Studi Kasus #20: Aplikasi Wisata Indonesia – Proteksi Rute Admin dengan Autentikasi


Studi Kasus #20: Aplikasi Wisata Indonesia – Proteksi Rute Admin dengan Autentikasi

Halo, calon full-stack developer!

Di Studi Kasus #19, kita telah membuat halaman admin dengan CRUD sederhana. Namun, halaman tersebut masih bisa diakses oleh siapa saja tanpa login. Sekarang kita akan menambahkan sistem autentikasi untuk melindungi rute admin. Hanya pengguna yang sudah login dengan token JWT yang valid yang dapat mengakses halaman admin.

Fitur yang akan dibuat:

  • Halaman login dengan form email dan password.
  • Mengirim permintaan login ke backend, menerima token JWT.
  • Menyimpan token di localStorage.
  • Membuat komponen PrivateRoute untuk melindungi rute admin.
  • Menambahkan tombol logout untuk menghapus token.
  • Mengarahkan pengguna ke halaman login jika belum login.

Kita akan memanfaatkan interceptor axios yang sudah kita buat untuk secara otomatis menambahkan token ke setiap request. Mari kita mulai! 

Langkah 1: Memastikan Endpoint Login di Backend

Sebelum memulai frontend, pastikan backend sudah memiliki endpoint login yang mengembalikan token JWT. Kita sudah membuatnya di Studi Kasus #10. Endpointnya adalah POST /api/auth/login yang menerima email dan password dan mengembalikan token beserta data user.

Contoh respons sukses:

{
  "message": "Login berhasil",
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "user": {
    "id": 1,
    "username": "admin",
    "email": "admin@example.com",
    "role": "admin"
  }
}

Pastikan backend berjalan dan CORS sudah diatur.

Langkah 2: Membuat Halaman Login

Buat file src/pages/Login.jsx (jika belum ada) dengan form sederhana. Kita akan menggunakan state untuk mengelola input dan error.

import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import API from '../services/api';
import './Login.css';

const Login = () => {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState('');
  const [loading, setLoading] = useState(false);
  const navigate = useNavigate();

  const handleSubmit = async (e) => {
    e.preventDefault();
    setLoading(true);
    setError('');

    try {
      const response = await API.post('/auth/login', { email, password });
      const { token, user } = response.data;

      // Simpan token di localStorage
      localStorage.setItem('token', token);
      // Opsional: simpan data user
      localStorage.setItem('user', JSON.stringify(user));

      // Redirect ke halaman admin (misal daftar destinasi)
      navigate('/admin/destinations');
    } catch (err) {
      setError(err.response?.data?.error || 'Email atau password salah');
    } finally {
      setLoading(false);
    }
  };

  return (
    <div className="login-container">
      <h2>Login Admin</h2>
      {error & <p className="error">{error}</p>}
      <form onSubmit={handleSubmit}>
        <div className="form-group">
          <label>Email</label>
          <input
            type="email"
            value={email}
            onChange={(e) => setEmail(e.target.value)}
            required
          />
        </div>
        <div className="form-group">
          <label>Password</label>
          <input
            type="password"
            value={password}
            onChange={(e) => setPassword(e.target.value)}
            required
          />
        </div>
        <button type="submit" disabled={loading}>
          {loading ? 'Loading...' : 'Login'}
        </button>
      </form>
    </div>
  );
};

export default Login;

Buat file CSS src/pages/Login.css untuk styling:

.login-container {
  max-width: 400px;
  margin: 50px auto;
  padding: 30px;
  background: white;
  border-radius: 8px;
  box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}

.login-container h2 {
  text-align: center;
  margin-bottom: 20px;
  color: #333;
}

.form-group {
  margin-bottom: 15px;
}

.form-group label {
  display: block;
  margin-bottom: 5px;
  font-weight: bold;
}

.form-group input {
  width: 100%;
  padding: 10px;
  border: 1px solid #ddd;
  border-radius: 4px;
  font-size: 1em;
}

button {
  width: 100%;
  padding: 12px;
  background: #e67e22;
  color: white;
  border: none;
  border-radius: 4px;
  font-size: 1.1em;
  cursor: pointer;
}

button:disabled {
  background: #ccc;
  cursor: not-allowed;
}

.error {
  color: #e74c3c;
  text-align: center;
  margin-bottom: 15px;
}

Langkah 3: Membuat Komponen PrivateRoute

Komponen ini akan memeriksa keberadaan token di localStorage. Jika ada, tampilkan halaman yang dilindungi. Jika tidak, redirect ke halaman login. Buat file src/components/PrivateRoute.jsx:

import { Navigate } from 'react-router-dom';

const PrivateRoute = ({ children }) => {
  const token = localStorage.getItem('token');
  return token ? children : <Navigate to="/login" replace />;
};

export default PrivateRoute;

Komponen ini sangat sederhana: jika token ada, render children (halaman admin). Jika tidak, arahkan ke /login. Parameter replace mengganti entri history agar tidak bisa kembali ke halaman sebelumnya.

Langkah 4: Mengatur Rute dengan PrivateRoute

Sekarang kita akan mengatur rute di App.jsx untuk melindungi semua halaman di bawah /admin. Gunakan PrivateRoute sebagai pembungkus untuk elemen admin.

Contoh App.jsx yang sudah dimodifikasi:

import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import MainLayout from './layouts/MainLayout';
import AdminLayout from './layouts/AdminLayout';
import PrivateRoute from './components/PrivateRoute';
import Home from './pages/Home';
import Login from './pages/Login';
// Halaman admin
import AdminDestinations from './pages/Admin/AdminDestinations';
import AdminDestinationForm from './pages/Admin/AdminDestinationForm';
import AdminAccommodations from './pages/Admin/AdminAccommodations';
import AdminAccommodationForm from './pages/Admin/AdminAccommodationForm';
import AdminTransportations from './pages/Admin/AdminTransportations';
import AdminTransportationForm from './pages/Admin/AdminTransportationForm';
import AdminPackages from './pages/Admin/AdminPackages';
import AdminPackageForm from './pages/Admin/AdminPackageForm';

const router = createBrowserRouter([
  // Rute publik dengan MainLayout
  {
    element: <MainLayout />,
    children: [
      { path: '/', element: <Home /> },
      { path: '/destination/:id', element: <DestinationDetail /> },
      { path: '/accommodations', element: <Accommodations /> },
      { path: '/accommodation/:id', element: <AccommodationDetail /> },
      { path: '/transportations', element: <Transportations /> },
      { path: '/transportation/:id', element: <TransportationDetail /> },
    ],
  },
  // Rute login tanpa layout
  {
    path: '/login',
    element: <Login />,
  },
  // Rute admin dilindungi
  {
    path: '/admin',
    element: (
      <PrivateRoute>
        <AdminLayout />
      </PrivateRoute>
    ),
    children: [
      { path: 'destinations', element: <AdminDestinations /> },
      { path: 'destinations/new', element: <AdminDestinationForm /> },
      { path: 'destinations/edit/:id', element: <AdminDestinationForm /> },
      { path: 'accommodations', element: <AdminAccommodations /> },
      { path: 'accommodations/new', element: <AdminAccommodationForm /> },
      { path: 'accommodations/edit/:id', element: <AdminAccommodationForm /> },
      { path: 'transportations', element: <AdminTransportations /> },
      { path: 'transportations/new', element: <AdminTransportationForm /> },
      { path: 'transportations/edit/:id', element: <AdminTransportationForm /> },
      { path: 'packages', element: <AdminPackages /> },
      { path: 'packages/new', element: <AdminPackageForm /> },
      { path: 'packages/edit/:id', element: <AdminPackageForm /> },
    ],
  },
]);

function App() {
  return <RouterProvider router={router} />;
}

export default App;

Perhatikan bahwa PrivateRoute membungkus AdminLayout. Semua halaman di bawah /admin akan otomatis terproteksi.

Langkah 5: Menambahkan Logout

Tambahkan tombol logout di sidebar admin atau navbar. Fungsi logout cukup menghapus token dari localStorage dan mengarahkan ke halaman login.

Di komponen AdminLayout.jsx, tambahkan tombol logout:

import { Outlet, Link, useNavigate } from 'react-router-dom';
import './AdminLayout.css';

const AdminLayout = () => {
  const navigate = useNavigate();

  const handleLogout = () => {
    localStorage.removeItem('token');
    localStorage.removeItem('user');
    navigate('/login');
  };

  return (
    <div className="admin-layout">
      <aside className="sidebar">
        <h3>Admin Panel</h3>
        <nav>
          <ul>
            <li><Link to="/admin/destinations">Destinasi</Link></li>
            <li><Link to="/admin/accommodations">Penginapan</Link></li>
            <li><Link to="/admin/transportations">Transportasi</Link></li>
            <li><Link to="/admin/packages">Paket</Link></li>
          </ul>
        </nav>
        <button onClick={handleLogout} className="logout-btn">Logout</button>
      </aside>
      <main className="admin-content">
        <Outlet />
      </main>
    </div>
  );
};

export default AdminLayout;

Tambahkan CSS untuk tombol logout di AdminLayout.css:

.logout-btn {
  margin-top: 20px;
  padding: 10px;
  background: #e74c3c;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  width: 100%;
}

Langkah 6: Memastikan Token Dikirim dalam Request

Kita sudah membuat interceptor di services/api.js yang secara otomatis menambahkan token ke setiap request. Pastikan interceptor tersebut sudah ada:

import axios from 'axios';

const API = axios.create({
  baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3000/api',
});

API.interceptors.request.use((config) => {
  const token = localStorage.getItem('token');
  if (token) {
    config.headers.Authorization = `Bearer ${token}`;
  }
  return config;
});

export default API;

Dengan ini, setiap panggilan ke API akan menyertakan token jika ada. Jika token tidak valid atau kadaluarsa, backend akan mengembalikan 401, dan kita bisa menangani di frontend (misal redirect ke login).

Langkah 7: Menguji Alur Autentikasi

  1. Jalankan backend dan frontend.
  2. Coba akses http://localhost:5173/admin/destinations tanpa login. Seharusnya langsung diarahkan ke /login.
  3. Masukkan email dan password admin yang sudah didaftarkan (misal dari registrasi). Klik login.
  4. Jika berhasil, akan diarahkan ke halaman admin dan token tersimpan di localStorage.
  5. Coba refresh halaman admin, seharusnya tetap bisa mengakses karena token masih ada.
  6. Klik tombol logout, token dihapus dan kembali ke login.
  7. Coba akses admin lagi, akan redirect ke login.
💡 Tips: Untuk mengecek apakah token dikirim dengan benar, buka tab Network di devtools, lihat header Authorization pada request ke API. Harus ada "Bearer <token>".

Penanganan Token Kadaluarsa

Jika token kadaluarsa, backend akan mengembalikan status 401. Kita bisa menambahkan response interceptor untuk menangani hal ini, misalnya menghapus token dan redirect ke login. Contoh di api.js:

API.interceptors.response.use(
  response => response,
  error => {
    if (error.response?.status === 401) {
      localStorage.removeItem('token');
      localStorage.removeItem('user');
      window.location.href = '/login';
    }
    return Promise.reject(error);
  }
);

Tambahkan kode ini setelah request interceptor. Ini akan secara otomatis logout jika token tidak valid.

Langkah Selanjutnya

Di Studi Kasus #21, kita akan menyempurnakan halaman admin dengan menambahkan fitur untuk mengelola galeri foto (upload multiple) dan paket harga yang terintegrasi dengan entitas. Kita juga akan membahas cara menampilkan gambar di form edit.

Pastikan alur autentikasi berjalan dengan baik. Jika ada kendala, periksa kembali kode dan konsol browser.

Sampai jumpa di tutorial berikutnya!


Ditulis dengan ❤️ untuk para pengembang yang ingin membangun aplikasi wisata.

Lebih baru Lebih lama

نموذج الاتصال