diff --git a/frontend_management/composables/useAuthFetch.ts b/frontend_management/composables/useAuthFetch.ts new file mode 100644 index 00000000..d48924d3 --- /dev/null +++ b/frontend_management/composables/useAuthFetch.ts @@ -0,0 +1,80 @@ +import { authService } from '~/services/auth.service'; + +/** + * Custom fetch composable that handles automatic token refresh + * + * Flow: + * 1. Get token from cookie + * 2. Make API request with token + * 3. If 401 error (token expired): + * - Try to refresh token using refreshToken + * - If refresh successful, retry original request + * - If refresh fails, redirect to login + */ +export const useAuthFetch = () => { + const config = useRuntimeConfig(); + const router = useRouter(); + + const authFetch = async ( + url: string, + options: { + method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; + body?: any; + headers?: Record; + } = {} + ): Promise => { + const tokenCookie = useCookie('token'); + const refreshTokenCookie = useCookie('refreshToken'); + + const makeRequest = async (token: string | null) => { + return await $fetch(url, { + baseURL: config.public.apiBaseUrl as string, + method: options.method || 'GET', + headers: { + ...options.headers, + ...(token ? { Authorization: `Bearer ${token}` } : {}) + }, + body: options.body + }); + }; + + try { + // Try request with current token + return await makeRequest(tokenCookie.value ?? null); + } catch (error: any) { + // If 401 and we have refresh token, try to refresh + if (error.response?.status === 401 && refreshTokenCookie.value) { + console.log('Token expired, attempting refresh...'); + + try { + // Refresh token + const newTokens = await authService.refreshToken(refreshTokenCookie.value); + console.log('Token refreshed successfully'); + + // Update cookies + tokenCookie.value = newTokens.token; + refreshTokenCookie.value = newTokens.refreshToken; + + // Retry original request with new token + return await makeRequest(newTokens.token); + } catch (refreshError) { + console.error('Token refresh failed, redirecting to login'); + + // Clear cookies + tokenCookie.value = null; + refreshTokenCookie.value = null; + useCookie('user').value = null; + + // Redirect to login + router.push('/login'); + throw new Error('Session expired. Please login again.'); + } + } + + // Other errors, just throw + throw error; + } + }; + + return { authFetch }; +}; diff --git a/frontend_management/pages/instructor/courses/[id]/index.vue b/frontend_management/pages/instructor/courses/[id]/index.vue index 55994a30..dc355a93 100644 --- a/frontend_management/pages/instructor/courses/[id]/index.vue +++ b/frontend_management/pages/instructor/courses/[id]/index.vue @@ -103,7 +103,7 @@
@@ -204,6 +204,11 @@ const totalLessons = computed(() => { return course.value.chapters.reduce((sum, ch) => sum + ch.lessons.length, 0); }); +const sortedChapters = computed(() => { + if (!course.value) return []; + return course.value.chapters.slice().sort((a, b) => a.sort_order - b.sort_order); +}); + // Methods const fetchCourse = async () => { loading.value = true; @@ -246,6 +251,10 @@ const getChapterDuration = (chapter: ChapterResponse) => { return chapter.lessons.reduce((sum, l) => sum + l.duration_minutes, 0); }; +const getSortedLessons = (chapter: ChapterResponse) => { + return chapter.lessons.slice().sort((a, b) => a.sort_order - b.sort_order); +}; + const getLessonIcon = (type: string) => { const icons: Record = { VIDEO: 'play_circle', diff --git a/frontend_management/pages/instructor/courses/[id]/structure.vue b/frontend_management/pages/instructor/courses/[id]/structure.vue new file mode 100644 index 00000000..14ce4124 --- /dev/null +++ b/frontend_management/pages/instructor/courses/[id]/structure.vue @@ -0,0 +1,571 @@ + + + diff --git a/frontend_management/pages/instructor/index.vue b/frontend_management/pages/instructor/index.vue index d6e06970..380387d5 100644 --- a/frontend_management/pages/instructor/index.vue +++ b/frontend_management/pages/instructor/index.vue @@ -112,8 +112,14 @@ >
-
- {{ course.icon }} +
+ + {{ course.icon }}
{{ course.title }}
diff --git a/frontend_management/pages/login.vue b/frontend_management/pages/login.vue index c7603bd8..47df8928 100644 --- a/frontend_management/pages/login.vue +++ b/frontend_management/pages/login.vue @@ -124,6 +124,18 @@ const $q = useQuasar(); const authStore = useAuthStore(); const router = useRouter(); +// Check if already logged in, redirect to appropriate dashboard +onMounted(() => { + if (authStore.isAuthenticated && authStore.user) { + const role = authStore.user.role; + if (role === 'ADMIN') { + navigateTo('/admin'); + } else if (role === 'INSTRUCTOR') { + navigateTo('/instructor'); + } + } +}); + // Login form const email = ref(''); const password = ref(''); diff --git a/frontend_management/plugins/01.auth.ts b/frontend_management/plugins/01.auth.ts index 791d2631..40ca3de5 100644 --- a/frontend_management/plugins/01.auth.ts +++ b/frontend_management/plugins/01.auth.ts @@ -1,11 +1,11 @@ export default defineNuxtPlugin({ name: 'auth-restore', parallel: false, - setup() { + async setup() { const authStore = useAuthStore(); // Restore auth state from cookies on app initialization - // useCookie works on both server and client - authStore.checkAuth(); + // Now async - will attempt to refresh token if needed + await authStore.checkAuth(); } }); diff --git a/frontend_management/services/auth.service.ts b/frontend_management/services/auth.service.ts index bd3b0011..f1669f8c 100644 --- a/frontend_management/services/auth.service.ts +++ b/frontend_management/services/auth.service.ts @@ -211,6 +211,33 @@ export const authService = { baseURL: config.public.apiBaseUrl as string, body: data }); + }, + + async refreshToken(currentRefreshToken: string): Promise<{ token: string; refreshToken: string }> { + const config = useRuntimeConfig(); + const useMockData = config.public.useMockData as boolean; + + if (useMockData) { + // Mock: return new tokens + await new Promise(resolve => setTimeout(resolve, 500)); + return { + token: 'mock-new-jwt-token-' + Date.now(), + refreshToken: 'mock-new-refresh-token-' + Date.now() + }; + } + + if (!currentRefreshToken) { + throw new Error('No refresh token available'); + } + + // Real API + const response = await $fetch<{ token: string; refreshToken: string }>('/api/auth/refresh', { + method: 'POST', + baseURL: config.public.apiBaseUrl as string, + body: { refreshToken: currentRefreshToken } + }); + + return response; } }; diff --git a/frontend_management/services/instructor.service.ts b/frontend_management/services/instructor.service.ts index 512b0918..1b15b29b 100644 --- a/frontend_management/services/instructor.service.ts +++ b/frontend_management/services/instructor.service.ts @@ -38,6 +38,45 @@ const getAuthToken = (): string => { return tokenCookie.value || ''; }; +// Helper function for making authenticated requests with auto refresh +const authRequest = async ( + url: string, + options: { + method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; + body?: any; + } = {} +): Promise => { + const config = useRuntimeConfig(); + + const makeRequest = async (token: string) => { + return await $fetch(url, { + baseURL: config.public.apiBaseUrl as string, + method: options.method || 'GET', + headers: { + Authorization: `Bearer ${token}` + }, + body: options.body + }); + }; + + try { + const token = getAuthToken(); + return await makeRequest(token); + } catch (error: any) { + // If 401, try to refresh token + if (error.response?.status === 401) { + const { handleUnauthorized } = await import('~/utils/authFetch'); + const newToken = await handleUnauthorized(); + if (newToken) { + return await makeRequest(newToken); + } + // Redirect to login + navigateTo('/login'); + } + throw error; + } +}; + // Mock courses data const MOCK_COURSES: CourseResponse[] = [ { @@ -118,14 +157,7 @@ export const instructorService = { return MOCK_COURSES; } - const token = getAuthToken(); - const response = await $fetch('/api/instructors/courses', { - baseURL: config.public.apiBaseUrl as string, - headers: { - Authorization: `Bearer ${token}` - } - }); - + const response = await authRequest('/api/instructors/courses'); return response.data; }, @@ -146,27 +178,16 @@ export const instructorService = { } as CourseResponse; } - const token = getAuthToken(); - // Clean data - remove empty thumbnail_url const cleanedData = { ...data }; if (!cleanedData.thumbnail_url) { delete cleanedData.thumbnail_url; } - console.log('=== CREATE COURSE DEBUG ==='); - console.log('Body:', JSON.stringify({ data: cleanedData }, null, 2)); - console.log('==========================='); - - const response = await $fetch<{ code: number; data: CourseResponse }>('/api/instructors/courses', { + const response = await authRequest<{ code: number; data: CourseResponse }>('/api/instructors/courses', { method: 'POST', - baseURL: config.public.apiBaseUrl as string, - headers: { - Authorization: `Bearer ${token}` - }, body: { data: cleanedData } }); - return response.data; }, @@ -179,14 +200,7 @@ export const instructorService = { return MOCK_COURSE_DETAIL; } - const token = getAuthToken(); - const response = await $fetch<{ code: number; data: CourseDetailResponse }>(`/api/instructors/courses/${courseId}`, { - baseURL: config.public.apiBaseUrl as string, - headers: { - Authorization: `Bearer ${token}` - } - }); - + const response = await authRequest<{ code: number; data: CourseDetailResponse }>(`/api/instructors/courses/${courseId}`); return response.data; }, @@ -204,23 +218,10 @@ export const instructorService = { } as CourseResponse; } - const token = getAuthToken(); - - // Debug log - console.log('=== UPDATE COURSE DEBUG ==='); - console.log('URL:', `${config.public.apiBaseUrl}/api/instructors/courses/${courseId}`); - console.log('Body:', JSON.stringify({ data }, null, 2)); - console.log('==========================='); - - const response = await $fetch<{ code: number; data: CourseResponse }>(`/api/instructors/courses/${courseId}`, { + const response = await authRequest<{ code: number; data: CourseResponse }>(`/api/instructors/courses/${courseId}`, { method: 'PUT', - baseURL: config.public.apiBaseUrl as string, - headers: { - Authorization: `Bearer ${token}` - }, body: { data } }); - return response.data; }, @@ -233,14 +234,7 @@ export const instructorService = { return; } - const token = getAuthToken(); - await $fetch(`/api/instructors/courses/${courseId}`, { - method: 'DELETE', - baseURL: config.public.apiBaseUrl as string, - headers: { - Authorization: `Bearer ${token}` - } - }); + await authRequest(`/api/instructors/courses/${courseId}`, { method: 'DELETE' }); }, async sendForReview(courseId: number): Promise { @@ -252,14 +246,93 @@ export const instructorService = { return; } - const token = getAuthToken(); - await $fetch(`/api/instructors/courses/send-review/${courseId}`, { - method: 'POST', - baseURL: config.public.apiBaseUrl as string, - headers: { - Authorization: `Bearer ${token}` - } - }); + await authRequest(`/api/instructors/courses/send-review/${courseId}`, { method: 'POST' }); + }, + + async getChapters(courseId: number): Promise { + const config = useRuntimeConfig(); + const useMockData = config.public.useMockData as boolean; + + if (useMockData) { + await new Promise(resolve => setTimeout(resolve, 500)); + return MOCK_COURSE_DETAIL.chapters; + } + + const response = await authRequest<{ code: number; data: ChapterResponse[]; total: number }>( + `/api/instructors/courses/${courseId}/chapters` + ); + return response.data; + }, + + async createChapter(courseId: number, data: CreateChapterRequest): Promise { + const config = useRuntimeConfig(); + const useMockData = config.public.useMockData as boolean; + + if (useMockData) { + await new Promise(resolve => setTimeout(resolve, 500)); + return { + ...MOCK_COURSE_DETAIL.chapters[0], + id: Date.now(), + ...data, + lessons: [] + }; + } + + const response = await authRequest<{ code: number; data: ChapterResponse }>( + `/api/instructors/courses/${courseId}/chapters`, + { method: 'POST', body: data } + ); + return response.data; + }, + + async updateChapter(courseId: number, chapterId: number, data: CreateChapterRequest): Promise { + const config = useRuntimeConfig(); + const useMockData = config.public.useMockData as boolean; + + if (useMockData) { + await new Promise(resolve => setTimeout(resolve, 500)); + return { + ...MOCK_COURSE_DETAIL.chapters[0], + id: chapterId, + ...data + }; + } + + const response = await authRequest<{ code: number; data: ChapterResponse }>( + `/api/instructors/courses/${courseId}/chapters/${chapterId}`, + { method: 'PUT', body: data } + ); + return response.data; + }, + + async deleteChapter(courseId: number, chapterId: number): Promise { + const config = useRuntimeConfig(); + const useMockData = config.public.useMockData as boolean; + + if (useMockData) { + await new Promise(resolve => setTimeout(resolve, 500)); + return; + } + + await authRequest( + `/api/instructors/courses/${courseId}/chapters/${chapterId}`, + { method: 'DELETE' } + ); + }, + + async reorderChapter(courseId: number, chapterId: number, sortOrder: number): Promise { + const config = useRuntimeConfig(); + const useMockData = config.public.useMockData as boolean; + + if (useMockData) { + await new Promise(resolve => setTimeout(resolve, 300)); + return; + } + + await authRequest( + `/api/instructors/courses/${courseId}/chapters/${chapterId}/reorder`, + { method: 'PUT', body: { sort_order: sortOrder } } + ); } }; @@ -327,6 +400,13 @@ export interface QuizResponse { show_answers_after_completion: boolean; } +export interface CreateChapterRequest { + title: { th: string; en: string }; + description: { th: string; en: string }; + sort_order?: number; + is_published?: boolean; +} + // Mock course detail const MOCK_COURSE_DETAIL: CourseDetailResponse = { ...MOCK_COURSES[0], diff --git a/frontend_management/stores/auth.ts b/frontend_management/stores/auth.ts index bba3ca1e..754a9f68 100644 --- a/frontend_management/stores/auth.ts +++ b/frontend_management/stores/auth.ts @@ -70,10 +70,12 @@ export const useAuthStore = defineStore('auth', { userCookie.value = null; }, - checkAuth() { + async checkAuth() { const tokenCookie = useCookie('token'); const userCookie = useCookie('user'); + const refreshTokenCookie = useCookie('refreshToken'); + // Case 1: Have token and user - restore auth state if (tokenCookie.value && userCookie.value) { this.token = tokenCookie.value; try { @@ -81,11 +83,45 @@ export const useAuthStore = defineStore('auth', { ? JSON.parse(userCookie.value) : userCookie.value; this.isAuthenticated = true; + return; } catch (e) { // Invalid user data this.logout(); } } + + // Case 2: No token but have refresh token - try to refresh + if (!tokenCookie.value && refreshTokenCookie.value && userCookie.value) { + // Get cookie refs with options BEFORE await to maintain Nuxt context + const tokenCookieWithOptions = useCookie('token', { + maxAge: 60 * 60 * 24, // 24 hours + sameSite: 'strict' + }); + const refreshTokenCookieWithOptions = useCookie('refreshToken', { + maxAge: 60 * 60 * 24 * 7, // 7 days + sameSite: 'strict' + }); + const currentRefreshToken = refreshTokenCookie.value; + + try { + console.log('Token missing, attempting refresh...'); + const newTokens = await authService.refreshToken(currentRefreshToken); + + // Update cookies with new tokens + tokenCookieWithOptions.value = newTokens.token; + refreshTokenCookieWithOptions.value = newTokens.refreshToken; + + this.token = newTokens.token; + this.user = typeof userCookie.value === 'string' + ? JSON.parse(userCookie.value) + : userCookie.value; + this.isAuthenticated = true; + console.log('Token refreshed successfully'); + } catch (e) { + console.error('Token refresh failed'); + this.logout(); + } + } } } }); diff --git a/frontend_management/stores/instructor.ts b/frontend_management/stores/instructor.ts index f503ffdd..7a98cbf1 100644 --- a/frontend_management/stores/instructor.ts +++ b/frontend_management/stores/instructor.ts @@ -1,11 +1,13 @@ import { defineStore } from 'pinia'; +import { instructorService } from '~/services/instructor.service'; interface Course { - id: string; + id: number; title: string; students: number; lessons: number; icon: string; + thumbnail: string | null; } interface DashboardStats { @@ -17,27 +19,13 @@ interface DashboardStats { export const useInstructorStore = defineStore('instructor', { state: () => ({ stats: { - totalCourses: 5, - totalStudents: 125, - completedStudents: 45 + totalCourses: 0, + totalStudents: 0, + completedStudents: 0 } as DashboardStats, - recentCourses: [ - { - id: '1', - title: 'Python เบื้องต้น', - students: 45, - lessons: 8, - icon: '📘' - }, - { - id: '2', - title: 'JavaScript สำหรับเว็บ', - students: 32, - lessons: 12, - icon: '📗' - } - ] as Course[] + recentCourses: [] as Course[], + loading: false }), getters: { @@ -47,14 +35,31 @@ export const useInstructorStore = defineStore('instructor', { actions: { async fetchDashboardData() { - // TODO: Replace with real API call - // const { $api } = useNuxtApp(); - // const data = await $api('/instructor/dashboard'); - // this.stats = data.stats; - // this.recentCourses = data.recentCourses; + this.loading = true; + try { + // Fetch real courses from API + const courses = await instructorService.getCourses(); - // Using mock data for now - console.log('Using mock data for instructor dashboard'); + // Update stats + this.stats.totalCourses = courses.length; + // TODO: Get real student counts from API when available + this.stats.totalStudents = 0; + this.stats.completedStudents = 0; + + // Map to recent courses format (take first 5) + this.recentCourses = courses.slice(0, 3).map((course, index) => ({ + id: course.id, + title: course.title.th, + students: 0, // TODO: Get from API + lessons: 0, // TODO: Get from course detail API + icon: ['📘', '📗', '📙', '📕', '📒'][index % 5], + thumbnail: course.thumbnail_url || null + })); + } catch (error) { + console.error('Failed to fetch dashboard data:', error); + } finally { + this.loading = false; + } } } }); diff --git a/frontend_management/utils/authFetch.ts b/frontend_management/utils/authFetch.ts new file mode 100644 index 00000000..1f7bdc90 --- /dev/null +++ b/frontend_management/utils/authFetch.ts @@ -0,0 +1,134 @@ +import { authService } from '~/services/auth.service'; + +let isRefreshing = false; +let refreshPromise: Promise | null = null; + +/** + * Get token or refresh if needed + * Handles concurrent requests by sharing same refresh promise + */ +export const getValidToken = async (): Promise => { + const tokenCookie = useCookie('token'); + const refreshTokenCookie = useCookie('refreshToken'); + + // If we have token, return it + if (tokenCookie.value) { + return tokenCookie.value; + } + + // No token but have refresh token, try to refresh + if (refreshTokenCookie.value) { + // If already refreshing, wait for that promise + if (isRefreshing && refreshPromise) { + return refreshPromise; + } + + try { + isRefreshing = true; + const refreshToken = refreshTokenCookie.value; + refreshPromise = authService.refreshToken(refreshToken).then(res => { + // Update cookies + useCookie('token').value = res.token; + useCookie('refreshToken').value = res.refreshToken; + return res.token; + }); + const newToken = await refreshPromise; + return newToken; + } catch (error) { + console.error('Token refresh failed'); + return null; + } finally { + isRefreshing = false; + refreshPromise = null; + } + } + + return null; +}; + +/** + * Handle 401 error - try to refresh and return new token + * Returns null if refresh fails + */ +export const handleUnauthorized = async (): Promise => { + const refreshTokenCookie = useCookie('refreshToken'); + + if (!refreshTokenCookie.value) { + return null; + } + + // If already refreshing, wait for that promise + if (isRefreshing && refreshPromise) { + return refreshPromise; + } + + try { + isRefreshing = true; + const refreshToken = refreshTokenCookie.value; + refreshPromise = authService.refreshToken(refreshToken).then(res => { + // Update cookies + useCookie('token').value = res.token; + useCookie('refreshToken').value = res.refreshToken; + return res.token; + }); + const newToken = await refreshPromise; + console.log('Token refreshed successfully'); + return newToken; + } catch (error) { + console.error('Token refresh failed, need to login again'); + // Clear all auth cookies + useCookie('token').value = null; + useCookie('refreshToken').value = null; + useCookie('user').value = null; + return null; + } finally { + isRefreshing = false; + refreshPromise = null; + } +}; + +/** + * Create authenticated fetch with auto token refresh + */ +export const createAuthFetch = () => { + const config = useRuntimeConfig(); + + return async ( + url: string, + options: { + method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; + body?: any; + headers?: Record; + } = {} + ): Promise => { + const token = await getValidToken(); + + const makeRequest = async (authToken: string | null) => { + return await $fetch(url, { + baseURL: config.public.apiBaseUrl as string, + method: options.method || 'GET', + headers: { + ...options.headers, + ...(authToken ? { Authorization: `Bearer ${authToken}` } : {}) + }, + body: options.body + }); + }; + + try { + return await makeRequest(token); + } catch (error: any) { + // If 401, try to refresh + if (error.response?.status === 401) { + const newToken = await handleUnauthorized(); + if (newToken) { + // Retry with new token + return await makeRequest(newToken); + } + // Redirect to login + navigateTo('/login'); + } + throw error; + } + }; +};