บทที่ 19advanced|10 KB

บทที่ 19: Admin Dashboard & Analytics

บทที่ 19: Admin Dashboard & Analytics

🎯 เป้าหมายการเรียนรู้

หลังจากเรียนบทนี้แล้ว คุณจะสามารถ:

  • เข้าใจระบบ Role-Based Access Control (RBAC) (Admin vs User)
  • สร้าง Middleware ฝั่ง Backend เพื่อป้องกันคนนอกเข้าถึงหน้า Admin
  • สร้าง Dashboard แสดงสถิติเบื้องต้น (User Count, Total Notes)
  • จัดการผู้ใช้ (ดูรายชื่อ, แบน, ลบ)

📚 ทฤษฎีและแนวคิด

1. RBAC คืออะไร?

Role-Based Access Control คือการกำหนด "สิทธิ์" ตาม "บทบาท":

  • User: สร้าง/แก้ไข/ลบ Note ของตัวเองได้เท่านั้น
  • Admin: ดูข้อมูลคนอื่นได้, ลบ User ได้, ดูภาพรวมระบบได้

2. Security at Backend

ห้ามเชื่อ Frontend เด็ดขาด! แม้เราจะซ่อนปุ่ม Admin ไว้ แต่ Hacker สามารถยิง API ตรงๆ ได้ ดังนั้นเราต้องมี Middleware ดักจับที่หลังบ้านเสมอ:

  1. Check Token (Login หรือยัง?)
  2. Check Role (เป็น Admin หรือเปล่า?)

💻 การเขียนโค้ด (Implementation)

1. Backend: Admin Middleware

ไฟล์: backend/src/middleware/admin.js

/*
  Middleware นี้จะทำงาน "ต่อจาก" auth middleware
  แปลว่า req.user จะต้องมีค่าแล้ว (จากการ decode jwt)
*/

const adminCheck = (req, res, next) => {
  // 1. ตรวจสอบว่ามีข้อมูล user ไหม
  if (!req.user) {
    return res.status(401).json({ error: "Unauthorized" });
  }

  // 2. ตรวจสอบ Role
  if (req.user.role !== 'admin') {
    // 403 Forbidden = รู้ว่าคุณเป็นใคร (Login แล้ว) แต่ไม่มีสิทธิ์
    return res.status(403).json({ error: "Admin access required" });
  }

  // 3. ผ่าน! ไปทำงานต่อ
  next();
};

module.exports = adminCheck;

การนำไปใช้ใน Routes: backend/src/routes/adminRoutes.js

const express = require("express");
const router = express.Router();
const auth = require("../middleware/auth");
const admin = require("../middleware/admin");
const User = require("../models/User");

**อธิบายทีละบรรทัด (Imports):**

- `const express = require("express")`
  - นำเข้า Express Framework 

- `const router = express.Router()`
  - สร้าง Router ใหม่เพื่อจัดการ Admin routes โดยเฉพาะ

- `const auth / admin`
  - นำเข้า Middlewares ที่เราสร้างไว้ (ต้องผ่าน Auth ก่อน, แล้วค่อยผ่าน Admin Check)

**อธิบาย:**

// ต้องผ่านด่าน auth ก่อน -> แล้วค่อยผ่านด่าน admin
router.get("/users", auth, admin, async (req, res) => {
  try {
    // ดึงรายชื่อ User ทั้งหมด (ไม่เอา password)
    const users = await User.find().select("-passwordHash");
    res.json({ users });
  } catch (e) {
    res.status(500).json({ error: e.message });
  }
});

module.exports = router;

2. Frontend: Admin Service

ไฟล์: mobile/lib/services/admin_api.dart

import 'dart:convert';
import 'package:http/http.dart' as http;
import 'api_config.dart';
import 'session.dart';

class AdminApi {
  static Map<String, String> _headers() {
    return {
      "Content-Type": "application/json",
      "Authorization": "Bearer ${Session.token}",
    };
  }

  // ดึงสถิติรวม
  static Future<Map<String, dynamic>> getStats() async {
    final uri = Uri.parse("$kApiBase/api/admin/stats");
    final res = await http.get(uri, headers: _headers());
    
    if (res.statusCode != 200) throw Exception("Failed to load stats");
    
    return jsonDecode(res.body);
  }

  // ดึงรายชื่อ User
  static Future<List<dynamic>> getUsers() async {
    final uri = Uri.parse("$kApiBase/api/admin/users");
    final res = await http.get(uri, headers: _headers());
    
    if (res.statusCode != 200) throw Exception("Access Denied");
    
    final data = jsonDecode(res.body);
    return data['users'];
  }
}

3. Frontend: Protecting the UI

เราไม่อยากให้ปุ่ม "Admin Dashboard" รกตา User ธรรมดา เราจะซ่อนมันไว้

ไฟล์: mobile/lib/screens/settings_screen.dart

// ตรวจสอบ role จาก Session ที่เราเก็บไว้ตอน Login
bool isAdmin = Session.user?['role'] == 'admin';

if (isAdmin)
  ListTile(
    leading: Icon(Icons.dashboard, color: Colors.purple),
    title: Text("Admin Dashboard"),
    trailing: Icon(Icons.chevron_right),
    onTap: () {
      Navigator.push(
        context, 
        MaterialPageRoute(builder: (_) => AdminDashboardScreen())
      );
    },
  ),

4. Admin Dashboard Screen

ไฟล์: mobile/lib/screens/admin_dashboard_screen.dart

หน้าจอนี้จะแสดง Card สถิติ และรายชื่อ User

class AdminDashboardScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text("ผู้ดูแลระบบ")),
      body: FutureBuilder(
        future: AdminApi.getStats(), // เรียก API
        builder: (context, snapshot) {
          if (snapshot.connectionState == ConnectionState.waiting) {
            return Center(child: CircularProgressIndicator());
          }
          if (snapshot.hasError) {
            return Center(child: Text("Error: ${snapshot.error}"));
          }

          final stats = snapshot.data as Map<String, dynamic>;

          return ListView(
            padding: EdgeInsets.all(16),
            children: [
              // 1. Stat Cards
              Row(
                children: [
                   _StatCard(
                     title: "Users", 
                     value: "${stats['userCount']}", 
                     color: Colors.blue
                   ),
                   SizedBox(width: 16),
                   _StatCard(
                     title: "Notes", 
                     value: "${stats['noteCount']}", 
                     color: Colors.green
                   ),
                ],
              ),
              
              SizedBox(height: 24),
              Text("Recent Users", style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
              
              // 2. User List (ตัวอย่าง)
              // ในโค้ดจริงใช้ ListView.builder แยก
              ListTile(
                leading: CircleAvatar(child: Text("A")),
                title: Text("Admin User"),
                subtitle: Text("admin@example.com"),
                trailing: Chip(label: Text("Admin"), backgroundColor: Colors.purple[100]),
              ),
            ],
          );
        },
      ),
    );
  }
}

class _StatCard extends StatelessWidget {
  final String title;
  final String value;
  final Color color;

  const _StatCard({required this.title, required this.value, required this.color});

  @override
  Widget build(BuildContext context) {
    return Expanded(
      child: Container(
        padding: EdgeInsets.all(20),
        decoration: BoxDecoration(
          color: color.withOpacity(0.1),
          borderRadius: BorderRadius.circular(16),
          border: Border.all(color: color.withOpacity(0.3)),
        ),
        child: Column(
          children: [
            Text(value, style: TextStyle(fontSize: 32, fontWeight: FontWeight.bold, color: color)),
            Text(title, style: TextStyle(color: Colors.grey[700])),
          ],
        ),
      ),
    );
  }
}

📝 แบบฝึกหัด

โจทย์ที่ 1: Ban User Function

สิ่งที่ต้องทำ:

  1. Backend: เพิ่ม Route POST /api/admin/ban/:id (เปลี่ยน status user เป็น banned)
  2. Frontend: เพิ่มปุ่ม "แบน" สีแดงท้ายชื่อ user
  3. Middleware: แก้ auth.js ให้เช็คว่า if (user.status == 'banned') ห้ามเข้าระบบ

โจทย์ที่ 2: System Announcement

สิ่งที่ต้องทำ:

  1. Admin พิมพ์ข้อความประกาศ -> บันทึกลง DB
  2. User ทุกคนเปิดแอปมา -> เห็น Banner ประกาศด้านบนสุดของ Home

โจทย์ที่ 3: Charts

สิ่งที่ต้องทำ:

  1. ลง package fl_chart
  2. แสดงกราฟแท่งจำนวน Note ที่ถูกสร้างในรอบ 7 วัน

📌 สรุป

ระบบ Admin เป็นสิ่งที่ขาดไม่ได้สำหรับ Commercial App

  • Security First: อย่าไว้ใจ Frontend, ต้องเช็ค Permission ที่ Backend เสมอ
  • Monitoring: Dashboard ช่วยให้เรารู้สุขภาพของระบบ
  • Control: ช่วยให้เราจัดการปัญหา (เช่น User ทำผิดกฎ) ได้ทันท่วงที

ไฟล์ที่เกี่ยวข้อง


🔜 บทถัดไป

บทที่ 20: Export & Share Features เจาะลึกการทำรูประดับโปรและการแชร์ข้ามแพลตฟอร์ม