feat: add authentication modal component and user authentication logic

- Implemented AuthModal.vue for user login and registration with form handling.
- Created useAuth composable for managing user state and authentication requests.
- Added admin route middleware to protect admin routes.
- Developed admin login page with authentication checks.
- Implemented API endpoints for user management (login, logout, fetch user, register).
- Created database migrations for users table and updated movies table.
- Added utility functions for password hashing and JWT token management.
This commit is contained in:
ngthanhvu
2026-07-22 04:26:58 -04:00
parent ad8d577493
commit d982449464
29 changed files with 1965 additions and 178 deletions
+245 -77
View File
@@ -1,137 +1,305 @@
# CineK
CineK is a Korean movie streaming web app built with Nuxt and Tailwind CSS. It uses NguonC movie data, normalizes the responses on the server, and presents the content with a blue Netflix-inspired interface.
CineK là website xem phim Hàn Quốc online với giao diện dark theme, hệ thống admin quản lý phim, đăng ký/đăng nhập thành viên, và đồng bộ dữ liệu từ nhiều nguồn API.
## Features
## Tính năng
- Korean-only movie catalogue from NguonC.
- Netflix-style home page with a large hero carousel.
- Floating movie preview cards on home page hover.
- Browse page with search, type filters, and pagination.
- Movie detail page with embedded player and episode selector.
- Shared responsive header with centered navigation and global search.
- Shared footer and scroll-to-top button.
- Tailwind CSS styling with the `Be Vietnam Pro` font.
- Lucide icons for UI actions.
### Người dùng
- Trang chủ với hero carousel phim Hàn Quốc
- Duyệt phim theo loại (phim bộ, phim lẻ), tìm kiếm, phân trang
- Trang chi tiết phim với trình phát HLS + Nhúng
- Đăng ký / Đăng nhập tài khoản thành viên (popup modal)
- Lịch sử xem, danh sách phim yêu thích (lưu localStorage)
### Admin (`/admin`)
- Dashboard tổng quan (tổng phim, đang hiển thị, chưa hiển thị)
- Đồng bộ phim từ API (chọn nguồn: OPhim, NguonC, KKPhim)
- Bật/tắt hiển thị từng phim
- Chỉnh sửa phim: mô tả, poster, thumbnail, tập phim tuỳ chỉnh
- Quản lý thành viên: đổi role, bật/tắt, xoá tài khoản
- Xoá toàn bộ phim
- Phân quyền: chỉ role `admin` mới truy cập được
## Tech Stack
- Nuxt 4
- Vue 3
- Tailwind CSS 4
- `@tailwindcss/vite`
- `lucide-vue-next`
- **Framework:** Nuxt 4 (Vue 3)
- **Styling:** Tailwind CSS 4
- **Database:** MySQL 8.0 + Drizzle ORM
- **Cache:** Redis 7
- **Auth:** JWT (bcryptjs + jsonwebtoken)
- **Icons:** Lucide Vue Next
- **Player:** HLS.js
- **Container:** Docker + Docker Compose
## Project Structure
## Cấu trúc thư mục
```text
app/
assets/css/main.css Global Tailwind and theme styles
components/ Shared UI components
pages/index.vue Home page
pages/phim/index.vue Browse, search, filter, and pagination page
pages/phim/[slug].vue Movie detail and player page
components/ Components dùng chung (AppHeader, AppLogo, AuthModal, AdminToggle...)
composables/ Composables (useAuth, useMovieLibrary, useWatchHistory)
layouts/
admin.vue Layout cho trang admin (sidebar + header)
middleware/
admin.global.ts Middleware chặn route /admin/* nếu chưa đăng nhập admin
pages/
index.vue Trang chủ
phim/ Trang chi tiết phim
xem/[slug].vue Trang xem phim (player)
thanh-vien.vue Trang cá nhân thành viên
yeu-thich.vue Phim yêu thích
lich-su.vue Lịch sử xem
dang-nhap.vue Đăng nhập
dang-ky.vue Đăng ký
admin/
login.vue Trang đăng nhập admin
index.vue Dashboard admin
phim/index.vue Quản lý phim
phim/[id].vue Chỉnh sửa phim
thanh-vien.vue Quản lý thành viên
cai-dat.vue Cài đặt hệ thống
server/
api/movies.get.ts Movie list API endpoint
api/movies/[slug].get.ts Movie detail API endpoint
utils/movies.ts API adapters and normalization helpers
API_nguonc.md NguonC API notes
api/
movies.get.ts API danh sách phim công khai (chỉ phim active)
movies/[slug].get.ts API chi tiết phim công khai
auth/ API xác thực (register, login, logout, me)
admin/ API admin (sync, movies, thanh-vien, stats)
database/
schema.ts Schema Drizzle (bảng users, movies)
migrations/ File migration SQL
middleware/
auth.ts Middleware chặn /api/admin/* nếu chưa đăng nhập admin
utils/
auth.ts Tiện ích auth (hash password, JWT sign/verify)
db.ts Kết nối MySQL
redis.ts Kết nối Redis
movies.ts Adapter API phim + chuẩn hoá dữ liệu
```
## Setup
## Setup & Chạy dự án
Install dependencies:
### Yêu cầu
- Docker & Docker Compose
- Node.js 22+ (nếu chạy ngoài Docker)
### 1. Clone & cài dependencies
```bash
git clone <repo-url>
cd kr-phim
npm install
```
## Development
### 2. Cấu hình biến môi trường
Start the development server:
Tạo file `.env`:
```env
DATABASE_URL=mysql://cinek:cinekpassword@localhost:3306/cinek
REDIS_URL=redis://localhost:6379
JWT_SECRET=your-secret-key-change-this
```
### 3. Chạy MySQL & Redis
```bash
# Chỉ bật MySQL và Redis
docker compose up -d mysql redis
```
Hoặc chạy tất cả (app + mysql + redis):
```bash
docker compose up -d
```
#### Thông tin kết nối mặc định
| Service | Host | Port | User | Password | Database |
|----------|-----------|------|---------|----------------|----------|
| MySQL | localhost | 3306 | cinek | cinekpassword | cinek |
| MySQL | localhost | 3306 | root | rootpassword | - |
| Redis | localhost | 6379 | - | - | - |
| App | localhost | 3002 | - | - | - |
### 4. Tạo bảng database (migration)
```bash
# Tạo file migration từ schema
npx drizzle-kit generate
# Áp dụng migration vào MySQL
npx drizzle-kit migrate
```
### 5. Chạy dev server
```bash
npm run dev
```
The app will usually be available at:
App chạy tại `http://localhost:3002`. Hot reload tự động khi sửa code.
```text
http://localhost:3002
```
### 6. Tạo tài khoản admin đầu tiên
## Production Build
Truy cập `http://localhost:3002` → bấm **Đăng nhập** → chuyển sang tab **Đăng ký**.
Build the app for production:
Tài khoản đầu tiên đăng ký sẽ tự động được gán role **admin**.
```bash
npm run build
```
Sau đó truy cập `http://localhost:3002/admin` để vào trang quản trị.
Preview the production build locally:
### 7. Đồng bộ phim
```bash
npm run preview
```
Vào **Admin → Dashboard** hoặc **Admin → Phim** → bấm **Đồng bộ từ API**.
Run the production server after building:
Chọn nguồn muốn đồng bộ (OPhim, NguonC, KKPhim) → bấm **Đồng bộ**.
```bash
npm run start
```
The production server listens on port `3002` by default.
Mặc định phim mới đồng bộ sẽ ở trạng thái **Ẩn**. Bật từng phim để hiển thị ra trang công khai.
## Docker
Build and run with Docker Compose:
### Dev mode (có hot reload)
```bash
docker compose up -d --build
```
The container exposes the app at:
File code được mount vào container qua volume, sửa file tự reload.
```text
http://localhost:3002
```
Stop the container:
### Production build
```bash
docker compose down
# Build image production
docker build -t cinek .
# Chạy
docker run -p 3002:3002 \
-e DATABASE_URL=mysql://cinek:cinekpassword@host.docker.internal:3306/cinek \
-e REDIS_URL=redis://host.docker.internal:6379 \
cinek
```
The Compose service is named `cinek` and maps host port `3002` to container port `3002`.
### Dừng & xoá containers
## Available Routes
```bash
docker compose down # Dừng containers
docker compose down -v # Dừng + xoá volumes (mất dữ liệu MySQL/Redis)
```
- `/` - Home page with hero carousel and movie rows.
- `/phim` - Browse page with search, filters, and pagination.
- `/phim?type=series` - Browse Korean series.
- `/phim?type=single` - Browse Korean movies.
- `/phim?q=keyword` - Search Korean movies.
- `/phim/[slug]` - Movie detail page from NguonC.
## Database
## API Notes
### Schema
The app does not call external movie APIs directly from the browser. Instead, Nuxt server endpoints request and normalize movie data:
**Bảng `users`:**
- NguonC list/detail data is handled in `server/utils/movies.ts`.
- Only Korean movies are kept in the normalized result.
| Cột | Kiểu | Mô tả |
|-----------|---------------|---------------------------|
| id | INT (PK) | Tự tăng |
| name | VARCHAR(200) | Tên hiển thị |
| email | VARCHAR(255) | Email (unique) |
| password | VARCHAR(255) | Mật khẩu (bcrypt hash) |
| role | VARCHAR(50) | admin / moderator / user |
| avatar | TEXT | URL ảnh đại diện |
| active | BOOLEAN | Trạng thái tài khoản |
| created_at| TIMESTAMP | Ngày tạo |
| updated_at| TIMESTAMP | Ngày cập nhật |
The public server endpoints use NguonC as the single movie API source.
**Bảng `movies`:**
| Cột | Kiểu | Mô tả |
|----------------|---------------|----------------------------------|
| id | INT (PK) | Tự tăng |
| source | VARCHAR(50) | Nguồn (ophim/nguonc/kkphim) |
| slug | VARCHAR(500) | Slug phim trên nguồn |
| name | VARCHAR(500) | Tên phim |
| origin_name | VARCHAR(500) | Tên gốc |
| thumb/poster | TEXT | URL ảnh |
| year | INT | Năm sản xuất |
| active | BOOLEAN | Hiển thị trên web công khai |
| custom_poster | TEXT | Poster admin tuỳ chỉnh |
| custom_thumb | TEXT | Thumbnail admin tuỳ chỉnh |
| custom_content | TEXT | Mô tả admin tuỳ chỉnh |
| custom_episodes| JSON | Tập phim admin tuỳ chỉnh |
| api_updated_at | TIMESTAMP | Thời gian API nguồn cập nhật |
| synced_at | TIMESTAMP | Thời gian đồng bộ vào DB |
### Migration
```bash
# Tạo migration mới (sau khi sửa schema.ts)
npx drizzle-kit generate
# Áp dụng migration
npx drizzle-kit migrate
```
### Kết nối MySQL trực tiếp
```bash
# Qua Docker
docker exec -it cinek-mysql mysql -ucinek -pcinekpassword cinek
# Hoặc từ host
mysql -h 127.0.0.1 -P 3306 -ucinek -pcinekpassword cinek
```
### Kết nối Redis trực tiếp
```bash
docker exec -it cinek-redis redis-cli
```
## API Endpoints
### Công khai
| Method | Endpoint | Mô tả |
|--------|-----------------------|---------------------------------|
| GET | /api/movies | Danh sách phim active (có cache)|
| GET | /api/movies/[slug] | Chi tiết phim active |
### Xác thực
| Method | Endpoint | Mô tả |
|--------|-----------------------|---------------------------------|
| POST | /api/auth/register | Đăng ký (tài khoản đầu = admin) |
| POST | /api/auth/login | Đăng nhập (trả JWT + cookie) |
| POST | /api/auth/logout | Đăng xuất (xoá cookie) |
| GET | /api/auth/me | Thông tin user hiện tại |
### Admin (cần role admin)
| Method | Endpoint | Mô tả |
|--------|-----------------------------|-------------------------------|
| GET | /api/admin/stats | Thống kê tổng phim |
| POST | /api/admin/sync | Đồng bộ phim từ API nguồn |
| GET | /api/admin/movies | Danh sách tất cả phim |
| GET | /api/admin/movies/:id | Chi tiết 1 phim |
| PATCH | /api/admin/movies/:id | Bật/tắt phim |
| PUT | /api/admin/movies/:id | Cập nhật custom (poster, tập) |
| DELETE | /api/admin/movies | Xoá tất cả phim |
| GET | /api/admin/thanh-vien | Danh sách thành viên |
| PATCH | /api/admin/thanh-vien/:id | Đổi role / bật/tắt |
| DELETE | /api/admin/thanh-vien/:id | Xoá thành viên |
## Scripts
```bash
npm run dev # Start Nuxt development server
npm run build # Build for production
npm run preview # Preview the production build
npm run start # Start the built Nitro server
npm run generate # Generate a static build when supported
npm run dev # Dev server (hot reload)
npm run build # Build production
npm run preview # Preview production build
npm run start # Chạy production server
npm run db:generate # Tạo migration từ schema
npm run db:migrate # Áp dụng migration
```
## Notes
## Nguồn phim
This project is intended as a movie browsing and streaming UI experiment using public movie API sources. Availability of movie metadata, images, and episode embeds depends on the upstream APIs.
Dữ liệu phim được lấy từ 3 API nguồn:
- **OPhim** - `https://ophim1.com`
- **NguonC** - `https://phim.nguonc.com`
- **KKPhim** - `https://phimapi.com`
Chỉ lấy phim quốc gia Hàn Quốc. Dữ liệu bao gồm: tên phim, ảnh, thể loại, tập phim, link embed, link HLS.
+90 -9
View File
@@ -2,10 +2,11 @@
import {
Heart,
History,
LogOut,
Menu,
Search,
Settings,
User,
UserRound,
X,
} from 'lucide-vue-next'
@@ -14,6 +15,17 @@ const router = useRouter()
const keyword = ref(typeof route.query.q === 'string' ? route.query.q : '')
const mobileMenuOpen = ref(false)
const memberMenuOpen = ref(false)
const authModalOpen = ref(false)
const { user, fetchUser, logout: doLogout } = useAuth()
await fetchUser()
const displayName = computed(() => {
if (!user.value) return ''
return user.value.name || user.value.email?.split('@')[0] || 'Thành viên'
})
const displayInitial = computed(() => displayName.value.charAt(0).toUpperCase())
const navItems = [
{ label: 'Trang chủ', to: '/' },
@@ -23,10 +35,17 @@ const navItems = [
{ label: 'Phim lẻ', to: '/phim?type=single' },
]
const memberMenuItems = [
const memberMenuItems = computed(() => {
const items = [
{ label: 'Trang cá nhân', icon: User, to: '/thanh-vien' },
{ label: 'Yêu thích', icon: Heart, to: '/yeu-thich' },
{ label: 'Lịch sử', icon: History, to: '/lich-su' },
]
if (user.value?.role === 'admin') {
items.push({ label: 'Trang quản trị', icon: Settings, to: '/admin' })
}
return items
})
function isActive(to: string) {
if (to === '/') return route.path === '/'
@@ -45,10 +64,24 @@ function closeMobileMenu() {
mobileMenuOpen.value = false
}
function openAuthModal() {
authModalOpen.value = true
}
function handleMemberClick() {
if (!user.value) {
openAuthModal()
return
}
memberMenuOpen.value = !memberMenuOpen.value
}
async function handleLogout() {
await doLogout()
memberMenuOpen.value = false
router.push('/')
}
watch(() => route.path, () => {
closeMobileMenu()
memberMenuOpen.value = false
@@ -56,7 +89,7 @@ watch(() => route.path, () => {
</script>
<template>
<header class="fixed inset-x-0 top-0 z-50 border-b border-white/10 bg-slate-950/78 backdrop-blur-xl">
<header class="fixed inset-x-0 top-0 z-50 bg-gradient-to-b from-black/80 via-black/40 to-transparent">
<nav class="mx-auto flex max-w-390 items-center gap-3 px-4 py-3 sm:px-6 lg:gap-6 lg:px-8 xl:px-10">
<AppLogo />
@@ -81,18 +114,37 @@ watch(() => route.path, () => {
<button type="button"
class="inline-flex h-9 shrink-0 cursor-pointer items-center gap-1.5 rounded-full bg-white px-2.5 pl-1 text-xs font-black text-slate-950 shadow-xl shadow-black/20 transition hover:bg-yellow-100"
aria-label="Tài khoản thành viên" @click="handleMemberClick">
<template v-if="user">
<span class="grid size-7 place-items-center rounded-full bg-yellow-400/20 text-xs font-black text-yellow-400">
{{ displayInitial }}
</span>
<span class="max-w-24 truncate">{{ displayName }}</span>
</template>
<template v-else>
<User class="ml-1 size-4 fill-current" />
<span>Thành viên</span>
<span>Đăng nhập</span>
</template>
</button>
<Transition name="member-menu">
<div v-if="memberMenuOpen"
class="absolute right-0 top-[calc(100%+0.75rem)] w-64 overflow-hidden rounded-lg border border-white/10 bg-[#101116] py-2 text-sm font-semibold text-slate-200 shadow-2xl shadow-black/40">
<div v-if="user && memberMenuOpen"
class="absolute right-0 top-[calc(100%+0.5rem)] w-52 overflow-hidden rounded-xl border border-white/8 bg-[#101116]/95 py-1.5 text-sm text-slate-300 shadow-xl shadow-black/50 backdrop-blur-xl">
<div class="border-b border-white/6 px-4 py-2.5">
<p class="truncate text-xs font-semibold text-white">{{ displayName }}</p>
<p class="truncate text-[11px] text-slate-500">{{ user.email }}</p>
</div>
<NuxtLink v-for="item in memberMenuItems" :key="item.label" :to="item.to"
class="flex h-12 w-full cursor-pointer items-center gap-3 px-5 text-left transition hover:bg-white/8 hover:text-white">
<component :is="item.icon" class="size-4 shrink-0" />
{{ item.label }}
class="flex h-9 w-full cursor-pointer items-center gap-2.5 px-4 transition hover:bg-white/6 hover:text-white">
<component :is="item.icon" class="size-3.5 shrink-0" />
<span class="text-[13px] font-medium">{{ item.label }}</span>
</NuxtLink>
<div class="my-1 border-t border-white/6" />
<button type="button"
class="flex h-9 w-full cursor-pointer items-center gap-2.5 px-4 text-[13px] font-medium text-red-400/80 transition hover:bg-red-400/8 hover:text-red-400"
@click="handleLogout">
<LogOut class="size-3.5 shrink-0" />
Đăng xuất
</button>
</div>
</Transition>
</div>
@@ -117,6 +169,17 @@ watch(() => route.path, () => {
<X class="size-5" />
</button>
</div>
<div v-if="user" class="flex items-center gap-3 border-b border-white/10 px-4 py-4">
<span class="grid size-9 place-items-center rounded-full bg-yellow-400/10 text-sm font-black text-yellow-400">
{{ displayInitial }}
</span>
<div class="min-w-0">
<p class="truncate text-sm font-semibold text-white">{{ displayName }}</p>
<p class="truncate text-xs text-slate-400">{{ user.email }}</p>
</div>
</div>
<nav class="flex flex-col gap-1 p-4">
<NuxtLink v-for="item in navItems" :key="item.to" :to="item.to"
class="rounded-lg px-4 py-3 text-base font-semibold text-slate-200 transition hover:bg-white/8 hover:text-yellow-200"
@@ -125,17 +188,35 @@ watch(() => route.path, () => {
</NuxtLink>
</nav>
<div class="border-t border-white/10 p-4">
<template v-if="user">
<NuxtLink v-for="item in memberMenuItems" :key="item.label" :to="item.to"
class="flex h-11 w-full cursor-pointer items-center gap-3 px-4 text-left text-sm font-semibold text-slate-200 transition hover:bg-white/8 hover:text-white"
@click="closeMobileMenu">
<component :is="item.icon" class="size-4 shrink-0" />
{{ item.label }}
</NuxtLink>
<div class="my-2 border-t border-white/10" />
<button type="button"
class="flex h-11 w-full cursor-pointer items-center gap-3 px-4 text-left text-sm font-black text-red-400 transition hover:bg-white/8 hover:text-red-300"
@click="handleLogout">
<LogOut class="size-4 shrink-0" />
Đăng xuất
</button>
</template>
<template v-else>
<button type="button"
class="flex h-11 w-full cursor-pointer items-center justify-center rounded-lg bg-yellow-400 px-4 text-sm font-black text-slate-950 transition hover:bg-yellow-300"
@click="closeMobileMenu(); openAuthModal()">
Đăng nhập
</button>
</template>
</div>
</div>
</div>
</Transition>
</Teleport>
<AuthModal v-model="authModalOpen" />
</header>
</template>
+173
View File
@@ -0,0 +1,173 @@
<script setup lang="ts">
import { Check, Eye, EyeOff, LockKeyhole, Mail, UserRound, X } from 'lucide-vue-next'
const open = defineModel<boolean>({ default: false })
const { fetchUser } = useAuth()
const mode = ref<'login' | 'register'>('login')
const name = ref('')
const email = ref('')
const password = ref('')
const error = ref('')
const success = ref('')
const loading = ref(false)
const showPassword = ref(false)
function switchMode(m: 'login' | 'register') {
mode.value = m
error.value = ''
success.value = ''
}
function resetForm() {
name.value = ''
email.value = ''
password.value = ''
error.value = ''
success.value = ''
showPassword.value = false
}
function closeModal() {
open.value = false
resetForm()
}
async function handleSubmit() {
error.value = ''
success.value = ''
loading.value = true
try {
if (mode.value === 'login') {
await $fetch('/api/auth/login', {
method: 'POST',
body: { email: email.value, password: password.value },
})
success.value = 'Đăng nhập thành công!'
} else {
await $fetch('/api/auth/register', {
method: 'POST',
body: { name: name.value, email: email.value, password: password.value },
})
success.value = 'Đăng ký thành công!'
}
await fetchUser()
setTimeout(() => closeModal(), 1000)
} catch (err: any) {
error.value = err?.data?.message || (mode.value === 'login' ? 'Đăng nhập thất bại' : 'Đăng ký thất bại')
} finally {
loading.value = false
}
}
watch(open, (val) => {
if (val) {
resetForm()
mode.value = 'login'
}
})
</script>
<template>
<Teleport to="body">
<Transition name="auth-modal">
<div v-if="open" class="fixed inset-0 z-70 grid place-items-center px-3">
<div class="absolute inset-0 bg-black/70 backdrop-blur-sm" @click="closeModal" />
<div class="relative w-full max-w-md overflow-hidden rounded-xl border border-white/10 bg-slate-900 shadow-2xl">
<button type="button"
class="absolute right-3 top-3 z-10 grid size-8 place-items-center rounded-full text-white transition hover:bg-white/10"
@click="closeModal">
<X class="size-5" />
</button>
<div class="border-b border-white/10 p-6 pb-5">
<h2 class="text-2xl font-black text-white">
{{ mode === 'login' ? 'Đăng nhập' : 'Đăng ký' }}
</h2>
<p class="mt-1 text-sm text-slate-400">
<template v-if="mode === 'login'">
Chào mừng bạn quay trở lại CineK
</template>
<template v-else>
Tạo tài khoản CineK miễn phí
</template>
</p>
<div class="mt-4 flex gap-1 rounded-lg bg-white/5 p-1">
<button type="button"
class="flex-1 rounded-md px-3 py-2 text-sm font-semibold transition"
:class="mode === 'login' ? 'bg-yellow-400 text-slate-950' : 'text-slate-400 hover:text-white'"
@click="switchMode('login')">
Đăng nhập
</button>
<button type="button"
class="flex-1 rounded-md px-3 py-2 text-sm font-semibold transition"
:class="mode === 'register' ? 'bg-yellow-400 text-slate-950' : 'text-slate-400 hover:text-white'"
@click="switchMode('register')">
Đăng
</button>
</div>
</div>
<form class="p-6 pt-5" @submit.prevent="handleSubmit">
<div class="space-y-3">
<div v-if="mode === 'register'">
<div class="relative">
<UserRound class="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-slate-400" />
<input v-model="name" type="text" placeholder="Tên hiển thị"
class="h-11 w-full rounded-lg border border-white/10 bg-white/5 pl-10 pr-4 text-sm text-white placeholder:text-slate-500 outline-none focus:border-yellow-400/50">
</div>
</div>
<div class="relative">
<Mail class="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-slate-400" />
<input v-model="email" type="email" required placeholder="Email"
class="h-11 w-full rounded-lg border border-white/10 bg-white/5 pl-10 pr-4 text-sm text-white placeholder:text-slate-500 outline-none focus:border-yellow-400/50">
</div>
<div class="relative">
<LockKeyhole class="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-slate-400" />
<input v-model="password" :type="showPassword ? 'text' : 'password'" required minlength="6"
:placeholder="mode === 'login' ? 'Mật khẩu' : 'Ít nhất 6 ký tự'"
class="h-11 w-full rounded-lg border border-white/10 bg-white/5 pl-10 pr-10 text-sm text-white placeholder:text-slate-500 outline-none focus:border-yellow-400/50">
<button type="button"
class="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 transition hover:text-white"
@click="showPassword = !showPassword">
<EyeOff v-if="showPassword" class="size-4" />
<Eye v-else class="size-4" />
</button>
</div>
</div>
<button type="submit"
class="mt-5 inline-flex h-11 w-full items-center justify-center rounded-lg bg-yellow-400 text-sm font-black text-slate-950 transition hover:bg-yellow-300 disabled:opacity-50"
:disabled="loading">
{{ loading ? 'Đang xử lý...' : (mode === 'login' ? 'Đăng nhập' : 'Đăng ký') }}
</button>
<p v-if="error" class="mt-3 rounded-lg bg-red-500/12 p-3 text-center text-sm text-red-300">
{{ error }}
</p>
<p v-if="success" class="mt-3 flex items-center justify-center gap-2 rounded-lg bg-green-500/12 p-3 text-center text-sm font-semibold text-green-300">
<Check class="size-4" />
{{ success }}
</p>
</form>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.auth-modal-enter-active,
.auth-modal-leave-active {
transition: opacity 0.18s ease;
}
.auth-modal-enter-from,
.auth-modal-leave-to {
opacity: 0;
}
</style>
+23
View File
@@ -0,0 +1,23 @@
export function useAuth() {
const user = useState<any>('auth-user', () => null)
const loading = useState('auth-loading', () => true)
async function fetchUser() {
try {
loading.value = true
const data = await $fetch('/api/auth/me')
user.value = data
} catch {
user.value = null
} finally {
loading.value = false
}
}
async function logout() {
await $fetch('/api/auth/logout', { method: 'POST' })
user.value = null
}
return { user, loading, fetchUser, logout }
}
+23 -7
View File
@@ -1,6 +1,5 @@
<script setup lang="ts">
import {
BarChart3,
Film,
Home,
LogOut,
@@ -11,8 +10,13 @@ import {
} from 'lucide-vue-next'
const route = useRoute()
const router = useRouter()
const sidebarOpen = ref(false)
const { data: user } = await useFetch('/api/auth/me', {
headers: useRequestHeaders(['cookie']),
})
const navItems = [
{ label: 'Dashboard', icon: Home, to: '/admin' },
{ label: 'Phim', icon: Film, to: '/admin/phim' },
@@ -28,6 +32,14 @@ function isActive(to: string) {
function closeSidebar() {
sidebarOpen.value = false
}
async function handleLogout() {
await $fetch('/api/auth/logout', { method: 'POST' })
await navigateTo('/admin/login')
}
const displayName = computed(() => user.value?.name || user.value?.email?.split('@')[0] || 'Admin')
const displayInitial = computed(() => displayName.value.charAt(0).toUpperCase())
</script>
<template>
@@ -59,12 +71,18 @@ function closeSidebar() {
</NuxtLink>
</nav>
<div class="absolute inset-x-0 bottom-0 border-t border-white/10 p-4">
<div class="absolute inset-x-0 bottom-0 space-y-1 border-t border-white/10 p-4">
<NuxtLink to="/"
class="flex items-center gap-3 rounded-lg px-4 py-3 text-sm font-semibold text-slate-300 transition hover:bg-white/5 hover:text-white">
<Home class="size-5" />
Về trang chủ
</NuxtLink>
<button type="button"
class="flex w-full items-center gap-3 rounded-lg px-4 py-3 text-sm font-semibold text-red-400 transition hover:bg-red-400/10"
@click="handleLogout">
<LogOut class="size-5" />
Đăng xuất
</button>
</div>
</aside>
@@ -77,14 +95,12 @@ function closeSidebar() {
</button>
<div class="ml-auto flex items-center gap-3">
<div class="flex items-center gap-3">
<div class="grid size-9 place-items-center rounded-full bg-yellow-400/10">
<span class="text-sm font-black text-yellow-400">A</span>
<span class="text-sm font-black text-yellow-400">{{ displayInitial }}</span>
</div>
<div class="hidden sm:block">
<p class="text-sm font-semibold text-white">Admin</p>
<p class="text-xs text-slate-400">Administrator</p>
</div>
<p class="text-sm font-semibold text-white">{{ displayName }}</p>
<p class="text-xs capitalize text-slate-400">{{ user?.role || 'admin' }}</p>
</div>
</div>
</header>
+16
View File
@@ -0,0 +1,16 @@
export default defineNuxtRouteMiddleware(async (to) => {
if (!to.path.startsWith('/admin')) return
if (to.path === '/admin/login') return
const { data: user, error } = await useFetch('/api/auth/me', {
headers: useRequestHeaders(['cookie']),
})
if (error.value?.statusCode === 401 || !user.value) {
return navigateTo('/admin/login')
}
if (user.value.role !== 'admin') {
return navigateTo('/')
}
})
+109
View File
@@ -0,0 +1,109 @@
<script setup lang="ts">
import { Eye, EyeOff, LockKeyhole, LogIn, Mail } from 'lucide-vue-next'
definePageMeta({
layout: false,
middleware: [],
})
useHead({
title: 'Đăng nhập Admin - CineK',
})
const { data: currentUser } = await useFetch('/api/auth/me', {
headers: useRequestHeaders(['cookie']),
})
if (currentUser.value?.role === 'admin') {
await navigateTo('/admin')
}
const email = ref('')
const password = ref('')
const error = ref('')
const loading = ref(false)
const showPassword = ref(false)
async function handleLogin() {
error.value = ''
loading.value = true
try {
const { data } = await useFetch('/api/auth/login', {
method: 'POST',
body: { email: email.value, password: password.value },
})
if (data.value?.user?.role === 'admin') {
await navigateTo('/admin')
} else {
error.value = 'Tài khoản không có quyền admin'
}
} catch (err: any) {
error.value = err?.data?.message || err?.message || 'Đăng nhập thất bại'
} finally {
loading.value = false
}
}
</script>
<template>
<div class="flex min-h-screen items-center justify-center bg-slate-950 p-4">
<div class="w-full max-w-md">
<div class="mb-8 text-center">
<div class="mb-4 inline-flex items-center gap-2">
<div class="grid size-10 place-items-center rounded-xl bg-yellow-400">
<span class="text-lg font-black text-slate-950">C</span>
</div>
<span class="text-2xl font-black text-white">CineK Admin</span>
</div>
<p class="text-sm text-slate-400">Đăng nhập để quản hệ thống</p>
</div>
<div class="rounded-xl border border-white/10 bg-slate-900/50 p-6">
<form @submit.prevent="handleLogin">
<div class="space-y-4">
<div>
<label class="mb-2 block text-sm font-semibold text-white">Email</label>
<div class="relative">
<Mail class="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-slate-400" />
<input v-model="email" type="email" required placeholder="admin@cinek.vn"
class="h-11 w-full rounded-lg border border-white/10 bg-white/5 pl-10 pr-4 text-sm text-white placeholder:text-slate-500 outline-none focus:border-yellow-400/50">
</div>
</div>
<div>
<label class="mb-2 block text-sm font-semibold text-white">Mật khẩu</label>
<div class="relative">
<LockKeyhole class="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-slate-400" />
<input v-model="password" :type="showPassword ? 'text' : 'password'" required minlength="6"
placeholder="Nhập mật khẩu"
class="h-11 w-full rounded-lg border border-white/10 bg-white/5 pl-10 pr-10 text-sm text-white placeholder:text-slate-500 outline-none focus:border-yellow-400/50">
<button type="button"
class="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 transition hover:text-white"
@click="showPassword = !showPassword">
<EyeOff v-if="showPassword" class="size-4" />
<Eye v-else class="size-4" />
</button>
</div>
</div>
</div>
<button type="submit"
class="mt-6 inline-flex h-11 w-full items-center justify-center gap-2 rounded-lg bg-yellow-400 text-sm font-black text-slate-950 transition hover:bg-yellow-300 disabled:opacity-50"
:disabled="loading">
<LogIn class="size-4" />
{{ loading ? 'Đang đăng nhập...' : 'Đăng nhập' }}
</button>
<p v-if="error" class="mt-4 rounded-lg bg-red-500/12 p-3 text-center text-sm text-red-300">
{{ error }}
</p>
</form>
</div>
<p class="mt-6 text-center text-xs text-slate-500">
Chỉ tài khoản admin mới quyền truy cập.
</p>
</div>
</div>
</template>
+63 -40
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { Edit, Search, Trash2 } from 'lucide-vue-next'
import { Edit, Mail, Search, Trash2, ToggleLeft, ToggleRight, UserRound } from 'lucide-vue-next'
definePageMeta({
layout: 'admin',
@@ -17,32 +17,52 @@ let searchTimeout: ReturnType<typeof setTimeout> | undefined
watch(searchInput, (val) => {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
debouncedKeyword.value = val.trim().toLowerCase()
debouncedKeyword.value = val.trim()
}, 300)
})
const allUsers = [
{ id: 1, name: 'Nguyễn Văn A', email: 'nguyenvana@example.com', role: 'admin', joined: '2024-01-01', status: 'active' },
{ id: 2, name: 'Trần Thị B', email: 'tranthib@example.com', role: 'user', joined: '2024-01-05', status: 'active' },
{ id: 3, name: 'Lê Văn C', email: 'levanc@example.com', role: 'user', joined: '2024-01-10', status: 'active' },
{ id: 4, name: 'Phạm Thị D', email: 'phamthid@example.com', role: 'user', joined: '2024-01-12', status: 'inactive' },
{ id: 5, name: 'Hoàng Văn E', email: 'hoangvane@example.com', role: 'moderator', joined: '2024-01-15', status: 'active' },
]
const filteredUsers = computed(() => {
if (!debouncedKeyword.value) return allUsers
const kw = debouncedKeyword.value
return allUsers.filter((u) =>
u.name.toLowerCase().includes(kw) || u.email.toLowerCase().includes(kw),
)
const { data, refresh } = await useFetch('/api/admin/thanh-vien', {
query: computed(() => ({
keyword: debouncedKeyword.value,
})),
})
const { data: currentUser } = await useFetch('/api/auth/me')
async function toggleActive(member: any) {
await $fetch(`/api/admin/thanh-vien/${member.id}`, {
method: 'PATCH',
body: { active: !member.active },
})
await refresh()
}
async function changeRole(member: any, role: string) {
await $fetch(`/api/admin/thanh-vien/${member.id}`, {
method: 'PATCH',
body: { role },
})
await refresh()
}
async function deleteMember(member: any) {
if (!confirm(`Xoá thành viên "${member.name || member.email}"?`)) return
try {
await $fetch(`/api/admin/thanh-vien/${member.id}`, { method: 'DELETE' })
await refresh()
} catch (err: any) {
alert(err?.data?.message || 'Xoá thất bại')
}
}
const members = computed(() => data.value?.items || [])
</script>
<template>
<div>
<div class="mb-6">
<h1 class="text-2xl font-black text-white">Quản thành viên</h1>
<p class="mt-1 text-sm text-slate-400">Quản tài khoản thành viên CineK</p>
<p class="mt-1 text-sm text-slate-400">Quản tài khoản thành viên CineK ({{ data?.total || 0 }} thành viên)</p>
</div>
<div class="rounded-xl border border-white/10 bg-slate-900/50">
@@ -67,45 +87,48 @@ const filteredUsers = computed(() => {
</tr>
</thead>
<tbody class="divide-y divide-white/5">
<tr v-for="user in filteredUsers" :key="user.id" class="transition hover:bg-white/5">
<td class="px-4 py-3 text-center text-sm text-slate-400">{{ filteredUsers.indexOf(user) + 1 }}</td>
<tr v-for="member in members" :key="member.id" class="transition hover:bg-white/5">
<td class="px-4 py-3 text-center text-sm text-slate-400">{{ members.indexOf(member) + 1 }}</td>
<td class="px-4 py-3 text-center">
<div class="min-w-0">
<p class="truncate text-sm font-semibold text-white">{{ user.name }}</p>
<p class="truncate text-xs text-slate-400">{{ user.email }}</p>
<p class="truncate text-sm font-semibold text-white">{{ member.name || '—' }}</p>
<p class="truncate text-xs text-slate-400">{{ member.email }}</p>
</div>
</td>
<td class="px-4 py-3 text-center">
<span class="rounded-full px-2.5 py-1 text-xs font-semibold"
<select :value="member.role"
class="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs font-semibold text-white outline-none"
:class="{
'bg-red-400/10 text-red-400': user.role === 'admin',
'bg-blue-400/10 text-blue-400': user.role === 'moderator',
'bg-slate-400/10 text-slate-400': user.role === 'user',
}">
{{ user.role === 'admin' ? 'Admin' : user.role === 'moderator' ? 'Moderator' : 'User' }}
</span>
'text-red-400': member.role === 'admin',
'text-blue-400': member.role === 'moderator',
'text-slate-400': member.role === 'user',
}"
@change="changeRole(member, ($event.target as HTMLSelectElement).value)">
<option value="user" class="bg-slate-900">User</option>
<option value="moderator" class="bg-slate-900">Moderator</option>
<option value="admin" class="bg-slate-900">Admin</option>
</select>
</td>
<td class="px-4 py-3 text-center">
<span class="rounded-full px-2.5 py-1 text-xs font-semibold"
:class="user.status === 'active' ? 'bg-green-400/10 text-green-400' : 'bg-slate-400/10 text-slate-400'">
{{ user.status === 'active' ? 'Hoạt động' : 'Khóa' }}
</span>
<AdminToggle :model-value="member.active"
@update:model-value="(val: boolean) => { member.active = val; toggleActive(member) }" />
</td>
<td class="px-4 py-3 text-center text-sm text-slate-400">
{{ new Date(member.createdAt).toLocaleDateString('vi-VN') }}
</td>
<td class="px-4 py-3 text-center text-sm text-slate-400">{{ user.joined }}</td>
<td class="px-4 py-3 text-center">
<div class="flex items-center justify-center gap-1">
<button type="button"
class="grid size-8 place-items-center rounded-lg text-slate-400 transition hover:bg-white/10 hover:text-white">
<Edit class="size-4" />
</button>
<button type="button"
class="grid size-8 place-items-center rounded-lg text-red-400 transition hover:bg-red-400/10">
class="grid size-8 place-items-center rounded-lg text-red-400 transition hover:bg-red-400/10"
:disabled="currentUser?.id === member.id"
:title="currentUser?.id === member.id ? 'Không thể xoá chính mình' : 'Xoá thành viên'"
@click="deleteMember(member)">
<Trash2 class="size-4" />
</button>
</div>
</td>
</tr>
<tr v-if="!filteredUsers.length">
<tr v-if="!members.length">
<td colspan="6" class="px-4 py-12 text-center text-sm text-slate-400">
{{ debouncedKeyword ? 'Không tìm thấy thành viên nào.' : 'Chưa có thành viên nào.' }}
</td>
@@ -115,7 +138,7 @@ const filteredUsers = computed(() => {
</div>
<div class="flex items-center justify-between border-t border-white/10 p-4">
<p class="text-sm text-slate-400">Hiển thị {{ filteredUsers.length }} / {{ allUsers.length }} thành viên</p>
<p class="text-sm text-slate-400">Hiển thị {{ members.length }} / {{ data?.total || 0 }} thành viên</p>
</div>
</div>
</div>
+63 -6
View File
@@ -1,12 +1,27 @@
<script setup lang="ts">
import { Clock3, Heart } from 'lucide-vue-next'
import { Clock3, Heart, Mail, UserRound } from 'lucide-vue-next'
const authOpen = ref(false)
const { user, logout: doLogout, fetchUser } = useAuth()
await fetchUser()
const memberName = computed(() => {
if (!user.value) return 'Thành viên'
return user.value.name || user.value.email?.split('@')[0] || 'Thành viên'
})
const memberInitial = computed(() => memberName.value.charAt(0).toUpperCase())
async function handleLogout() {
await doLogout()
}
useHead({
title: 'Thư vin - CineK',
title: 'Thành viên - CineK',
meta: [
{
name: 'description',
content: 'Quản lý lịch sử xem, danh sách yêu thích và các phim muốn xem sau trên CineK.',
content: 'Quản lý tài khoản CineK, lịch sử xem danh sách yêu thích.',
},
],
})
@@ -18,11 +33,35 @@ useHead({
<section class="mx-auto max-w-390 px-4 pb-16 pt-28 sm:px-6 lg:px-8 lg:pt-32 xl:px-10">
<div class="mb-6">
<p class="text-sm font-black uppercase text-yellow-300">T viện</p>
<h1 class="mt-2 text-3xl font-black sm:text-4xl">Của bạn</h1>
<p class="text-sm font-black uppercase text-yellow-300">Tài khoản</p>
<h1 class="mt-2 text-3xl font-black sm:text-4xl">Thành viên</h1>
</div>
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-1">
<div v-if="user" class="grid gap-5 lg:grid-cols-[minmax(0,1fr)_22rem]">
<section class="rounded-lg border border-white/10 bg-white/6 p-5 sm:p-6">
<div class="flex flex-col gap-5 sm:flex-row sm:items-center">
<div class="grid size-24 shrink-0 place-items-center rounded-full bg-yellow-400/10 ring-4 ring-yellow-300/30">
<span class="text-3xl font-black text-yellow-400">{{ memberInitial }}</span>
</div>
<div class="min-w-0">
<h2 class="truncate text-2xl font-black">{{ memberName }}</h2>
<p class="mt-2 inline-flex items-center gap-2 text-sm font-semibold text-slate-300">
<Mail class="size-4 text-yellow-300" />
{{ user.email }}
</p>
<p class="mt-4 max-w-2xl text-sm leading-6 text-slate-400">
Tài khoản CineK của bạn. Quản lịch sử xem các phim yêu thích.
</p>
<button type="button"
class="mt-4 inline-flex h-9 items-center gap-2 rounded-lg border border-red-400/20 bg-red-400/10 px-4 text-sm font-semibold text-red-400 transition hover:bg-red-400/20"
@click="handleLogout">
Đăng xuất
</button>
</div>
</div>
</section>
<aside class="grid gap-3 sm:grid-cols-2 lg:grid-cols-1">
<NuxtLink to="/lich-su"
class="flex items-center gap-3 rounded-lg border border-white/10 bg-white/6 p-4 transition hover:bg-white/10">
<span class="grid size-10 place-items-center rounded-md bg-yellow-300 text-slate-950">
@@ -43,7 +82,25 @@ useHead({
<span class="text-sm text-slate-400">Phim bạn đã lưu</span>
</span>
</NuxtLink>
</aside>
</div>
<div v-else class="rounded-lg border border-white/10 bg-white/6 p-6">
<UserRound class="size-10 text-yellow-300" />
<h2 class="mt-4 text-xl font-black">Bạn chưa đăng nhập</h2>
<p class="mt-2 text-sm leading-6 text-slate-300">
Đăng nhập hoặc đăng tài khoản để trải nghiệm đầy đủ các tính năng trên CineK.
</p>
<div class="mt-4">
<button type="button"
class="inline-flex h-10 items-center justify-center rounded-lg bg-yellow-300 px-5 text-sm font-black text-slate-950 transition hover:bg-white"
@click="authOpen = true">
Đăng nhập / Đăng
</button>
</div>
</div>
</section>
<AuthModal v-model="authOpen" />
</main>
</template>
+139 -3
View File
@@ -8,9 +8,11 @@
"hasInstallScript": true,
"dependencies": {
"@tailwindcss/vite": "^4.3.0",
"bcryptjs": "^3.0.3",
"drizzle-orm": "^0.45.2",
"hls.js": "^1.6.16",
"ioredis": "^5.11.1",
"jsonwebtoken": "^9.0.3",
"lucide-vue-next": "^1.0.0",
"mysql2": "^3.23.1",
"nuxt": "^4.4.5",
@@ -19,6 +21,8 @@
"vue-router": "^5.0.6"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/jsonwebtoken": "^9.0.10",
"drizzle-kit": "^0.31.10"
}
},
@@ -4109,6 +4113,13 @@
"tslib": "^2.4.0"
}
},
"node_modules/@types/bcryptjs": {
"version": "2.4.6",
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
"integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@@ -4121,12 +4132,29 @@
"integrity": "sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==",
"license": "MIT"
},
"node_modules/@types/jsonwebtoken": {
"version": "9.0.10",
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
"integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/ms": "*",
"@types/node": "*"
}
},
"node_modules/@types/ms": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "26.1.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz",
"integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==",
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~8.3.0"
}
@@ -4912,6 +4940,15 @@
"node": ">=6.0.0"
}
},
"node_modules/bcryptjs": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
"license": "BSD-3-Clause",
"bin": {
"bcrypt": "bin/bcrypt"
}
},
"node_modules/bindings": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
@@ -5026,6 +5063,12 @@
"node": ">=8.0.0"
}
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
@@ -6413,6 +6456,15 @@
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
"license": "MIT"
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -7439,6 +7491,49 @@
"node": ">=6"
}
},
"node_modules/jsonwebtoken": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
"integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
"license": "MIT",
"dependencies": {
"jws": "^4.0.1",
"lodash.includes": "^4.3.0",
"lodash.isboolean": "^3.0.3",
"lodash.isinteger": "^4.0.4",
"lodash.isnumber": "^3.0.3",
"lodash.isplainobject": "^4.0.6",
"lodash.isstring": "^4.0.1",
"lodash.once": "^4.0.0",
"ms": "^2.1.1",
"semver": "^7.5.4"
},
"engines": {
"node": ">=12",
"npm": ">=6"
}
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/kleur": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
@@ -7829,12 +7924,54 @@
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT"
},
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"license": "MIT"
},
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
"license": "MIT"
},
"node_modules/lodash.isinteger": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
"license": "MIT"
},
"node_modules/lodash.isnumber": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
"license": "MIT"
},
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
"license": "MIT"
},
"node_modules/lodash.isstring": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
"license": "MIT"
},
"node_modules/lodash.memoize": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz",
"integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==",
"license": "MIT"
},
"node_modules/lodash.once": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
"license": "MIT"
},
"node_modules/lodash.uniq": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz",
@@ -10515,8 +10652,7 @@
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/unenv": {
"version": "2.0.0-rc.24",
+4
View File
@@ -14,9 +14,11 @@
},
"dependencies": {
"@tailwindcss/vite": "^4.3.0",
"bcryptjs": "^3.0.3",
"drizzle-orm": "^0.45.2",
"hls.js": "^1.6.16",
"ioredis": "^5.11.1",
"jsonwebtoken": "^9.0.3",
"lucide-vue-next": "^1.0.0",
"mysql2": "^3.23.1",
"nuxt": "^4.4.5",
@@ -25,6 +27,8 @@
"vue-router": "^5.0.6"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/jsonwebtoken": "^9.0.10",
"drizzle-kit": "^0.31.10"
}
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { movies } from '../../database/schema'
import { desc, like, sql } from 'drizzle-orm'
import { desc, eq, like, sql } from 'drizzle-orm'
export default defineEventHandler(async (event) => {
const db = useDb()
@@ -26,7 +26,7 @@ export default defineEventHandler(async (event) => {
.select()
.from(movies)
.where(whereClause)
.orderBy(desc(movies.syncedAt))
.orderBy(desc(movies.apiUpdatedAt), desc(movies.year), desc(movies.syncedAt))
.limit(limit)
.offset(offset),
db
+2
View File
@@ -66,6 +66,7 @@ export default defineEventHandler(async (event) => {
categories: movie.categories || null,
countries: movie.countries || null,
sources: movie.sources || null,
apiUpdatedAt: movie.updatedAt ? new Date(movie.updatedAt) : null,
syncedAt: new Date(),
})
.where(eq(movies.id, existing[0].id))
@@ -88,6 +89,7 @@ export default defineEventHandler(async (event) => {
categories: movie.categories || null,
countries: movie.countries || null,
sources: movie.sources || null,
apiUpdatedAt: movie.updatedAt ? new Date(movie.updatedAt) : null,
active: false,
})
created++
+38
View File
@@ -0,0 +1,38 @@
import { users } from '../../database/schema'
import { desc, like, sql } from 'drizzle-orm'
export default defineEventHandler(async (event) => {
const db = useDb()
const query = getQuery(event)
const keyword = typeof query.keyword === 'string' ? query.keyword.trim() : ''
const whereClause = keyword
? like(users.name, `%${keyword}%`)
: undefined
const [items, countResult] = await Promise.all([
db
.select({
id: users.id,
name: users.name,
email: users.email,
role: users.role,
avatar: users.avatar,
active: users.active,
createdAt: users.createdAt,
})
.from(users)
.where(whereClause)
.orderBy(desc(users.createdAt))
.limit(100),
db
.select({ count: sql<number>`count(*)` })
.from(users)
.where(whereClause),
])
return {
items,
total: countResult[0]?.count ?? 0,
}
})
@@ -0,0 +1,25 @@
import { users } from '../../../database/schema'
import { eq } from 'drizzle-orm'
export default defineEventHandler(async (event) => {
const db = useDb()
const id = Number(getRouterParam(event, 'id'))
if (!id) {
throw createError({ statusCode: 400, message: 'Thiếu ID thành viên' })
}
const existing = await db.select().from(users).where(eq(users.id, id)).limit(1)
if (!existing.length) {
throw createError({ statusCode: 404, message: 'Không tìm thấy thành viên' })
}
const currentUser = event.context.user
if (currentUser?.id === id) {
throw createError({ statusCode: 400, message: 'Không thể xoá chính mình' })
}
await db.delete(users).where(eq(users.id, id))
return { success: true, id }
})
+35
View File
@@ -0,0 +1,35 @@
import { users } from '../../../database/schema'
import { eq } from 'drizzle-orm'
export default defineEventHandler(async (event) => {
const db = useDb()
const id = Number(getRouterParam(event, 'id'))
const body = await readBody(event)
if (!id) {
throw createError({ statusCode: 400, message: 'Thiếu ID thành viên' })
}
const existing = await db.select().from(users).where(eq(users.id, id)).limit(1)
if (!existing.length) {
throw createError({ statusCode: 404, message: 'Không tìm thấy thành viên' })
}
const updates: Record<string, any> = {}
if ('role' in body && ['admin', 'moderator', 'user'].includes(body.role)) {
updates.role = body.role
}
if (typeof body.active === 'boolean') {
updates.active = body.active
}
if (!Object.keys(updates).length) {
throw createError({ statusCode: 400, message: 'Không có dữ liệu cập nhật' })
}
await db.update(users).set(updates).where(eq(users.id, id))
return { success: true, id, ...updates }
})
+42
View File
@@ -0,0 +1,42 @@
import { users } from '../../database/schema'
import { eq, and } from 'drizzle-orm'
import { verifyPassword, signToken } from '../../utils/auth'
export default defineEventHandler(async (event) => {
const db = useDb()
const body = await readBody(event)
const email = typeof body.email === 'string' ? body.email.trim().toLowerCase() : ''
const password = typeof body.password === 'string' ? body.password : ''
if (!email || !password) {
throw createError({ statusCode: 400, message: 'Email và mật khẩu là bắt buộc' })
}
const result = await db.select().from(users).where(and(eq(users.email, email), eq(users.active, true))).limit(1)
if (!result.length) {
throw createError({ statusCode: 401, message: 'Email hoặc mật khẩu không đúng' })
}
const user = result[0]
const valid = await verifyPassword(password, user.password)
if (!valid) {
throw createError({ statusCode: 401, message: 'Email hoặc mật khẩu không đúng' })
}
const payload = { id: user.id, email: user.email, role: user.role }
const token = signToken(payload)
setCookie(event, 'token', token, {
httpOnly: true,
secure: false,
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7,
path: '/',
})
return {
user: { id: user.id, email: user.email, name: user.name, role: user.role, avatar: user.avatar },
token,
}
})
+4
View File
@@ -0,0 +1,4 @@
export default defineEventHandler((event) => {
deleteCookie(event, 'token', { path: '/' })
return { success: true }
})
+32
View File
@@ -0,0 +1,32 @@
import { users } from '../../database/schema'
import { eq } from 'drizzle-orm'
import { getTokenFromEvent, verifyToken } from '../../utils/auth'
export default defineEventHandler(async (event) => {
const token = getTokenFromEvent(event)
if (!token) {
throw createError({ statusCode: 401, message: 'Chưa đăng nhập' })
}
const payload = verifyToken(token)
if (!payload) {
deleteCookie(event, 'token', { path: '/' })
throw createError({ statusCode: 401, message: 'Phiên đăng nhập hết hạn' })
}
const db = useDb()
const result = await db.select().from(users).where(eq(users.id, payload.id)).limit(1)
if (!result.length || !result[0].active) {
deleteCookie(event, 'token', { path: '/' })
throw createError({ statusCode: 401, message: 'Tài khoản không tồn tại hoặc đã bị khóa' })
}
const user = result[0]
return {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
avatar: user.avatar,
}
})
+56
View File
@@ -0,0 +1,56 @@
import { users } from '../../database/schema'
import { eq, sql } from 'drizzle-orm'
import { hashPassword, signToken } from '../../utils/auth'
export default defineEventHandler(async (event) => {
const db = useDb()
const body = await readBody(event)
const email = typeof body.email === 'string' ? body.email.trim().toLowerCase() : ''
const password = typeof body.password === 'string' ? body.password : ''
const name = typeof body.name === 'string' ? body.name.trim() : ''
if (!email || !password) {
throw createError({ statusCode: 400, message: 'Email và mật khẩu là bắt buộc' })
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
throw createError({ statusCode: 400, message: 'Email không hợp lệ' })
}
if (password.length < 6) {
throw createError({ statusCode: 400, message: 'Mật khẩu phải có ít nhất 6 ký tự' })
}
const existing = await db.select().from(users).where(eq(users.email, email)).limit(1)
if (existing.length) {
throw createError({ statusCode: 409, message: 'Email đã được sử dụng' })
}
const hashedPassword = await hashPassword(password)
const isAdmin = (await db.select({ count: sql<number>`count(*)` }).from(users))[0]?.count === 0
const result = await db.insert(users).values({
email,
password: hashedPassword,
name: name || email.split('@')[0],
role: isAdmin ? 'admin' : 'user',
})
const userId = result[0].insertId
const user = { id: userId, email, role: isAdmin ? 'admin' : 'user' }
const token = signToken(user)
setCookie(event, 'token', token, {
httpOnly: true,
secure: false,
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7,
path: '/',
})
return {
user: { id: userId, email, name: name || email.split('@')[0], role: user.role },
token,
}
})
+1 -1
View File
@@ -31,7 +31,7 @@ export default defineEventHandler(async (event) => {
.select()
.from(movies)
.where(whereClause)
.orderBy(desc(movies.syncedAt))
.orderBy(desc(movies.apiUpdatedAt), desc(movies.year), desc(movies.syncedAt))
.limit(limit)
.offset(offset),
db
@@ -0,0 +1,13 @@
CREATE TABLE `users` (
`id` int AUTO_INCREMENT NOT NULL,
`name` varchar(200),
`email` varchar(255) NOT NULL,
`password` varchar(255) NOT NULL,
`role` varchar(50) NOT NULL DEFAULT 'user',
`avatar` text,
`active` boolean NOT NULL DEFAULT true,
`created_at` timestamp NOT NULL DEFAULT (now()),
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `users_id` PRIMARY KEY(`id`),
CONSTRAINT `users_email_unique` UNIQUE(`email`)
);
@@ -0,0 +1 @@
ALTER TABLE `movies` ADD `api_updated_at` timestamp;
@@ -0,0 +1,314 @@
{
"version": "5",
"dialect": "mysql",
"id": "ddff729d-56d9-4793-8d58-51855f8f3839",
"prevId": "b3692b95-4029-4e2c-bab4-ffd0696fa2a1",
"tables": {
"movies": {
"name": "movies",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"source": {
"name": "source",
"type": "varchar(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"slug": {
"name": "slug",
"type": "varchar(500)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "varchar(500)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"origin_name": {
"name": "origin_name",
"type": "varchar(500)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"thumb": {
"name": "thumb",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"poster": {
"name": "poster",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"year": {
"name": "year",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"time": {
"name": "time",
"type": "varchar(100)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"episode": {
"name": "episode",
"type": "varchar(100)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"quality": {
"name": "quality",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"lang": {
"name": "lang",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"type": {
"name": "type",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"rating": {
"name": "rating",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"categories": {
"name": "categories",
"type": "json",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"countries": {
"name": "countries",
"type": "json",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"sources": {
"name": "sources",
"type": "json",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"custom_poster": {
"name": "custom_poster",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"custom_thumb": {
"name": "custom_thumb",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"custom_content": {
"name": "custom_content",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"custom_episodes": {
"name": "custom_episodes",
"type": "json",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"active": {
"name": "active",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"synced_at": {
"name": "synced_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"movies_id": {
"name": "movies_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "varchar(200)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"email": {
"name": "email",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"password": {
"name": "password",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"role": {
"name": "role",
"type": "varchar(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'user'"
},
"avatar": {
"name": "avatar",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"active": {
"name": "active",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"users_id": {
"name": "users_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {
"users_email_unique": {
"name": "users_email_unique",
"columns": [
"email"
]
}
},
"checkConstraint": {}
}
},
"views": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"tables": {},
"indexes": {}
}
}
@@ -0,0 +1,321 @@
{
"version": "5",
"dialect": "mysql",
"id": "39aa52ed-af36-43ed-ab3e-2222785d1c00",
"prevId": "ddff729d-56d9-4793-8d58-51855f8f3839",
"tables": {
"movies": {
"name": "movies",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"source": {
"name": "source",
"type": "varchar(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"slug": {
"name": "slug",
"type": "varchar(500)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "varchar(500)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"origin_name": {
"name": "origin_name",
"type": "varchar(500)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"thumb": {
"name": "thumb",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"poster": {
"name": "poster",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"year": {
"name": "year",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"time": {
"name": "time",
"type": "varchar(100)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"episode": {
"name": "episode",
"type": "varchar(100)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"quality": {
"name": "quality",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"lang": {
"name": "lang",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"type": {
"name": "type",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"rating": {
"name": "rating",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"categories": {
"name": "categories",
"type": "json",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"countries": {
"name": "countries",
"type": "json",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"sources": {
"name": "sources",
"type": "json",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"custom_poster": {
"name": "custom_poster",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"custom_thumb": {
"name": "custom_thumb",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"custom_content": {
"name": "custom_content",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"custom_episodes": {
"name": "custom_episodes",
"type": "json",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"active": {
"name": "active",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"api_updated_at": {
"name": "api_updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"synced_at": {
"name": "synced_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"movies_id": {
"name": "movies_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "varchar(200)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"email": {
"name": "email",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"password": {
"name": "password",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"role": {
"name": "role",
"type": "varchar(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'user'"
},
"avatar": {
"name": "avatar",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"active": {
"name": "active",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"users_id": {
"name": "users_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {
"users_email_unique": {
"name": "users_email_unique",
"columns": [
"email"
]
}
},
"checkConstraint": {}
}
},
"views": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"tables": {},
"indexes": {}
}
}
@@ -22,6 +22,20 @@
"when": 1784694350192,
"tag": "0002_small_black_panther",
"breakpoints": true
},
{
"idx": 3,
"version": "5",
"when": 1784704595989,
"tag": "0003_slow_masked_marvel",
"breakpoints": true
},
{
"idx": 4,
"version": "5",
"when": 1784707891329,
"tag": "0004_opposite_sasquatch",
"breakpoints": true
}
]
}
+15
View File
@@ -1,5 +1,17 @@
import { mysqlTable, varchar, text, int, boolean, timestamp, json } from 'drizzle-orm/mysql-core'
export const users = mysqlTable('users', {
id: int('id').primaryKey().autoincrement(),
name: varchar('name', { length: 200 }),
email: varchar('email', { length: 255 }).notNull().unique(),
password: varchar('password', { length: 255 }).notNull(),
role: varchar('role', { length: 50 }).notNull().default('user'),
avatar: text('avatar'),
active: boolean('active').notNull().default(true),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow().onUpdateNow(),
})
export const movies = mysqlTable('movies', {
id: int('id').primaryKey().autoincrement(),
source: varchar('source', { length: 50 }).notNull(),
@@ -24,10 +36,13 @@ export const movies = mysqlTable('movies', {
customContent: text('custom_content'),
customEpisodes: json('custom_episodes').$type<{ name: string, linkEmbed?: string, linkM3u8?: string }[]>(),
active: boolean('active').notNull().default(false),
apiUpdatedAt: timestamp('api_updated_at'),
syncedAt: timestamp('synced_at').notNull().defaultNow(),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow().onUpdateNow(),
})
export type User = typeof users.$inferSelect
export type NewUser = typeof users.$inferInsert
export type Movie = typeof movies.$inferSelect
export type NewMovie = typeof movies.$inferInsert
+23
View File
@@ -0,0 +1,23 @@
import { getTokenFromEvent, verifyToken } from '../utils/auth'
export default defineEventHandler((event) => {
const path = event.path || event.node?.req?.url || ''
if (!path.startsWith('/api/admin')) return
const token = getTokenFromEvent(event)
if (!token) {
throw createError({ statusCode: 401, message: 'Chưa đăng nhập' })
}
const payload = verifyToken(token)
if (!payload) {
throw createError({ statusCode: 401, message: 'Phiên đăng nhập hết hạn' })
}
if (payload.role !== 'admin') {
throw createError({ statusCode: 403, message: 'Không có quyền truy cập' })
}
event.context.user = payload
})
+46
View File
@@ -0,0 +1,46 @@
import bcrypt from 'bcryptjs'
import jwt from 'jsonwebtoken'
const JWT_SECRET = process.env.JWT_SECRET || 'cinek-secret-key-change-in-production-2024'
const JWT_EXPIRES = '7d'
const SALT_ROUNDS = 10
export async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, SALT_ROUNDS)
}
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
return bcrypt.compare(password, hash)
}
export function signToken(payload: { id: number, email: string, role: string }): string {
return jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES })
}
export function verifyToken(token: string): { id: number, email: string, role: string } | null {
try {
return jwt.verify(token, JWT_SECRET) as { id: number, email: string, role: string }
} catch {
return null
}
}
export function getTokenFromEvent(event: any): string | null {
const authHeader = getHeader(event, 'authorization')
if (authHeader?.startsWith('Bearer ')) {
return authHeader.slice(7)
}
const cookies = parseCookies(event)
return cookies.token || null
}
function parseCookies(event: any): Record<string, string> {
const cookieHeader = getHeader(event, 'cookie') || ''
const cookies: Record<string, string> = {}
cookieHeader.split(';').forEach((pair: string) => {
const [key, ...vals] = pair.trim().split('=')
if (key) cookies[key] = decodeURIComponent(vals.join('='))
})
return cookies
}