feat: tambah profil akun dan ganti password
Tambahkan halaman /dashboard/profile beserta API ganti password untuk user yang sedang login. Rapikan AuthSeeder agar idempotent dan bisa ambil admin email/password dari env.
This commit is contained in:
@@ -26,6 +26,7 @@ $routes->delete('api/users/(:num)', '\App\Modules\Auth\Controllers\UserControlle
|
|||||||
$routes->get('login', 'LoginController::index');
|
$routes->get('login', 'LoginController::index');
|
||||||
$routes->post('logout', 'LogoutController::index');
|
$routes->post('logout', 'LogoutController::index');
|
||||||
$routes->get('dashboard', 'DashboardPageController::index', ['filter' => 'dashboard_page_auth']);
|
$routes->get('dashboard', 'DashboardPageController::index', ['filter' => 'dashboard_page_auth']);
|
||||||
|
$routes->get('dashboard/profile', 'DashboardPageController::profile', ['filter' => 'dashboard_page_auth']);
|
||||||
$routes->get('dashboard/attendance/reports', 'DashboardPageController::attendanceReports', ['filter' => 'dashboard_page_auth']);
|
$routes->get('dashboard/attendance/reports', 'DashboardPageController::attendanceReports', ['filter' => 'dashboard_page_auth']);
|
||||||
$routes->get('dashboard/attendance/report/(:num)', 'DashboardPageController::attendanceReport/$1', ['filter' => 'dashboard_page_auth']);
|
$routes->get('dashboard/attendance/report/(:num)', 'DashboardPageController::attendanceReport/$1', ['filter' => 'dashboard_page_auth']);
|
||||||
$routes->get('dashboard/schedule/today', 'DashboardPageController::scheduleToday', ['filter' => 'dashboard_page_auth']);
|
$routes->get('dashboard/schedule/today', 'DashboardPageController::scheduleToday', ['filter' => 'dashboard_page_auth']);
|
||||||
|
|||||||
@@ -25,6 +25,23 @@ class DashboardPageController extends BaseController
|
|||||||
return $this->response->setBody(view('layouts/main', $data));
|
return $this->response->setBody(view('layouts/main', $data));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /dashboard/profile
|
||||||
|
* Halaman profile user: info akun + form ganti password.
|
||||||
|
*/
|
||||||
|
public function profile(): ResponseInterface
|
||||||
|
{
|
||||||
|
$authService = new AuthService();
|
||||||
|
$user = $authService->currentUser();
|
||||||
|
|
||||||
|
$data = [
|
||||||
|
'user' => $user,
|
||||||
|
'content' => view('dashboard/profile'),
|
||||||
|
];
|
||||||
|
|
||||||
|
return $this->response->setBody(view('layouts/main', $data));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /dashboard/attendance/reports
|
* GET /dashboard/attendance/reports
|
||||||
* Attendance reports index: pick date, list schedules, link to report per schedule.
|
* Attendance reports index: pick date, list schedules, link to report per schedule.
|
||||||
|
|||||||
@@ -15,21 +15,37 @@ class AuthSeeder extends Seeder
|
|||||||
['role_code' => 'GURU_MAPEL', 'role_name' => 'Guru Mata Pelajaran'],
|
['role_code' => 'GURU_MAPEL', 'role_name' => 'Guru Mata Pelajaran'],
|
||||||
['role_code' => 'ORANG_TUA', 'role_name' => 'Orang Tua'],
|
['role_code' => 'ORANG_TUA', 'role_name' => 'Orang Tua'],
|
||||||
];
|
];
|
||||||
$this->db->table('roles')->insertBatch($roles);
|
|
||||||
|
foreach ($roles as $role) {
|
||||||
|
$exists = $this->db->table('roles')->where('role_code', $role['role_code'])->countAllResults();
|
||||||
|
if ($exists === 0) {
|
||||||
|
$this->db->table('roles')->insert($role);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$adminEmail = env('ADMIN_EMAIL', 'admin@example.com');
|
||||||
|
$adminPassword = env('ADMIN_PASSWORD', 'admin123');
|
||||||
|
|
||||||
|
$userExists = $this->db->table('users')->where('email', $adminEmail)->countAllResults();
|
||||||
|
if ($userExists > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$this->db->table('users')->insert([
|
$this->db->table('users')->insert([
|
||||||
'name' => 'Admin',
|
'name' => 'Admin',
|
||||||
'email' => 'admin@example.com',
|
'email' => $adminEmail,
|
||||||
'password_hash' => password_hash('admin123', PASSWORD_DEFAULT),
|
'password_hash' => password_hash($adminPassword, PASSWORD_DEFAULT),
|
||||||
'is_active' => 1,
|
'is_active' => 1,
|
||||||
'created_at' => date('Y-m-d H:i:s'),
|
'created_at' => date('Y-m-d H:i:s'),
|
||||||
'updated_at' => date('Y-m-d H:i:s'),
|
'updated_at' => date('Y-m-d H:i:s'),
|
||||||
]);
|
]);
|
||||||
$adminId = $this->db->insertID();
|
$adminId = $this->db->insertID();
|
||||||
$adminRoleId = $this->db->table('roles')->where('role_code', 'ADMIN')->get()->getRow()->id;
|
$adminRole = $this->db->table('roles')->where('role_code', 'ADMIN')->get()->getRow();
|
||||||
|
if ($adminRole) {
|
||||||
$this->db->table('user_roles')->insert([
|
$this->db->table('user_roles')->insert([
|
||||||
'user_id' => $adminId,
|
'user_id' => $adminId,
|
||||||
'role_id' => $adminRoleId,
|
'role_id' => $adminRole->id,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,4 +62,41 @@ class AuthController extends BaseApiController
|
|||||||
}
|
}
|
||||||
return $this->successResponse($user, 'Current user');
|
return $this->successResponse($user, 'Current user');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/auth/change-password
|
||||||
|
* Body: { "current_password": "", "new_password": "" }
|
||||||
|
* User can only change their own password.
|
||||||
|
*/
|
||||||
|
public function changePassword(): ResponseInterface
|
||||||
|
{
|
||||||
|
$user = $this->authService->currentUser();
|
||||||
|
if (!$user) {
|
||||||
|
return $this->errorResponse('Not authenticated', null, null, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
$input = $this->request->getJSON(true);
|
||||||
|
$currentPassword = $input['current_password'] ?? '';
|
||||||
|
$newPassword = $input['new_password'] ?? '';
|
||||||
|
|
||||||
|
if ($currentPassword === '' || $newPassword === '') {
|
||||||
|
return $this->errorResponse('Current password and new password are required', null, null, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strlen($newPassword) < 6) {
|
||||||
|
return $this->errorResponse('New password must be at least 6 characters', null, null, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$ok = $this->authService->changePassword(
|
||||||
|
(int) $user['id'],
|
||||||
|
$currentPassword,
|
||||||
|
$newPassword
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!$ok) {
|
||||||
|
return $this->errorResponse('Current password is incorrect', null, null, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->successResponse(null, 'Password changed successfully');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,4 +14,5 @@ $routes->group('api/auth', ['namespace' => 'App\Modules\Auth\Controllers'], func
|
|||||||
$routes->post('login', 'AuthController::login');
|
$routes->post('login', 'AuthController::login');
|
||||||
$routes->post('logout', 'AuthController::logout');
|
$routes->post('logout', 'AuthController::logout');
|
||||||
$routes->get('me', 'AuthController::me');
|
$routes->get('me', 'AuthController::me');
|
||||||
|
$routes->post('change-password', 'AuthController::changePassword');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -67,8 +67,9 @@ class AuthService
|
|||||||
public function currentUser(): ?array
|
public function currentUser(): ?array
|
||||||
{
|
{
|
||||||
$session = session();
|
$session = session();
|
||||||
$userId = $session->get(self::SESSION_USER_ID);
|
$rawUserId = $session->get(self::SESSION_USER_ID);
|
||||||
if (!$userId) {
|
$userId = (int) $rawUserId;
|
||||||
|
if ($userId <= 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,6 +82,31 @@ class AuthService
|
|||||||
return $this->userWithRoles($user);
|
return $this->userWithRoles($user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change password for the given user. Verifies current password first.
|
||||||
|
*
|
||||||
|
* @param int $userId
|
||||||
|
* @param string $currentPassword
|
||||||
|
* @param string $newPassword
|
||||||
|
* @return bool True on success, false if current password wrong or user not found
|
||||||
|
*/
|
||||||
|
public function changePassword(int $userId, string $currentPassword, string $newPassword): bool
|
||||||
|
{
|
||||||
|
$user = $this->userModel->find($userId);
|
||||||
|
if (!$user || !$user->isActive()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!password_verify($currentPassword, $user->password_hash)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$hash = password_hash($newPassword, PASSWORD_DEFAULT);
|
||||||
|
$this->userModel->update($userId, ['password_hash' => $hash]);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build user array with roles (no password).
|
* Build user array with roles (no password).
|
||||||
*/
|
*/
|
||||||
|
|||||||
130
app/Views/dashboard/profile.php
Normal file
130
app/Views/dashboard/profile.php
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
<?php
|
||||||
|
$user = $user ?? null;
|
||||||
|
$roles = $user && !empty($user['roles']) ? $user['roles'] : [];
|
||||||
|
$roleLabels = array_map(function ($r) { return $r['role_name'] ?? $r['role_code'] ?? ''; }, $roles);
|
||||||
|
?>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-xl font-semibold">Profil Akun</h1>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 mt-1">Informasi akun dan pengaturan ganti password.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Info Akun -->
|
||||||
|
<div class="rounded-2xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-sm p-6">
|
||||||
|
<h2 class="text-base font-semibold text-gray-900 dark:text-gray-100 mb-4">Informasi Akun</h2>
|
||||||
|
<dl class="space-y-3">
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm text-gray-500 dark:text-gray-400">Nama</dt>
|
||||||
|
<dd class="text-gray-900 dark:text-gray-100 font-medium"><?= esc($user['name'] ?? '-') ?></dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm text-gray-500 dark:text-gray-400">Email</dt>
|
||||||
|
<dd class="text-gray-900 dark:text-gray-100"><?= esc($user['email'] ?? '-') ?></dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm text-gray-500 dark:text-gray-400">Role</dt>
|
||||||
|
<dd class="text-gray-900 dark:text-gray-100"><?= esc(implode(', ', $roleLabels) ?: '-') ?></dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Form Ganti Password -->
|
||||||
|
<div class="rounded-2xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-sm p-6">
|
||||||
|
<h2 class="text-base font-semibold text-gray-900 dark:text-gray-100 mb-4">Ganti Password</h2>
|
||||||
|
<form id="form-change-password" class="space-y-4 max-w-md">
|
||||||
|
<div>
|
||||||
|
<label for="current-password" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Password Saat Ini <span class="text-red-500">*</span></label>
|
||||||
|
<input type="password" id="current-password" name="current_password" required autocomplete="current-password"
|
||||||
|
class="w-full px-4 py-2.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||||
|
placeholder="Masukkan password saat ini">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="new-password" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Password Baru <span class="text-red-500">*</span></label>
|
||||||
|
<input type="password" id="new-password" name="new_password" required minlength="6" autocomplete="new-password"
|
||||||
|
class="w-full px-4 py-2.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||||
|
placeholder="Minimal 6 karakter">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="new-password-confirm" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Konfirmasi Password Baru <span class="text-red-500">*</span></label>
|
||||||
|
<input type="password" id="new-password-confirm" name="new_password_confirm" required minlength="6" autocomplete="new-password"
|
||||||
|
class="w-full px-4 py-2.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||||
|
placeholder="Ulangi password baru">
|
||||||
|
<p id="password-match-error" class="mt-1 text-sm text-red-600 dark:text-red-400 hidden">Password baru dan konfirmasi tidak sama.</p>
|
||||||
|
</div>
|
||||||
|
<button type="submit" id="btn-submit" class="inline-flex items-center gap-2 px-5 py-2.5 rounded-lg bg-primary text-white font-medium hover:bg-primary-hover transition-colors">
|
||||||
|
<i class="bx bx-lock-alt"></i>
|
||||||
|
Ganti Password
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="toast-container" class="fixed bottom-6 right-6 z-[60] flex flex-col gap-2 pointer-events-none"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var baseUrl = '<?= base_url() ?>'.replace(/\/$/, '');
|
||||||
|
var apiChangePassword = baseUrl + '/api/auth/change-password';
|
||||||
|
|
||||||
|
var form = document.getElementById('form-change-password');
|
||||||
|
var btnSubmit = document.getElementById('btn-submit');
|
||||||
|
var currentPassword = document.getElementById('current-password');
|
||||||
|
var newPassword = document.getElementById('new-password');
|
||||||
|
var newPasswordConfirm = document.getElementById('new-password-confirm');
|
||||||
|
var passwordMatchError = document.getElementById('password-match-error');
|
||||||
|
var toastContainer = document.getElementById('toast-container');
|
||||||
|
|
||||||
|
function showToast(message, type) {
|
||||||
|
type = type || 'success';
|
||||||
|
var el = document.createElement('div');
|
||||||
|
el.className = 'pointer-events-auto px-4 py-3 rounded-lg shadow-lg text-sm font-medium ' + (type === 'success' ? 'bg-green-600 text-white' : 'bg-red-600 text-white');
|
||||||
|
el.textContent = message;
|
||||||
|
toastContainer.appendChild(el);
|
||||||
|
setTimeout(function() { if (el.parentNode) el.parentNode.removeChild(el); }, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function postOpts(body) {
|
||||||
|
return {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { 'X-Requested-With': 'XMLHttpRequest', 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
form.addEventListener('submit', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
passwordMatchError.classList.add('hidden');
|
||||||
|
|
||||||
|
if (newPassword.value !== newPasswordConfirm.value) {
|
||||||
|
passwordMatchError.classList.remove('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPassword.value.length < 6) {
|
||||||
|
showToast('Password baru minimal 6 karakter', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
btnSubmit.disabled = true;
|
||||||
|
fetch(apiChangePassword, postOpts({
|
||||||
|
current_password: currentPassword.value,
|
||||||
|
new_password: newPassword.value
|
||||||
|
}))
|
||||||
|
.then(function(res) { return res.json().then(function(j) { return { ok: res.ok, data: j }; }); })
|
||||||
|
.then(function(r) {
|
||||||
|
btnSubmit.disabled = false;
|
||||||
|
if (!r.ok) {
|
||||||
|
showToast(r.data && r.data.message ? r.data.message : 'Gagal mengganti password', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showToast('Password berhasil diubah');
|
||||||
|
form.reset();
|
||||||
|
})
|
||||||
|
.catch(function() {
|
||||||
|
btnSubmit.disabled = false;
|
||||||
|
showToast('Gagal mengganti password', 'error');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
@@ -21,10 +21,14 @@ function nav_is_active(string $currentUri, string $path, bool $exact = true): bo
|
|||||||
<a href="<?= base_url('dashboard') ?>" class="text-xl font-bold text-primary dark:text-primary-400">SMAN 1 Garut</a>
|
<a href="<?= base_url('dashboard') ?>" class="text-xl font-bold text-primary dark:text-primary-400">SMAN 1 Garut</a>
|
||||||
</div>
|
</div>
|
||||||
<nav class="flex-1 p-4 space-y-1 overflow-y-auto">
|
<nav class="flex-1 p-4 space-y-1 overflow-y-auto">
|
||||||
<a href="<?= base_url('dashboard') ?>" class="<?= nav_is_active($currentUri, 'dashboard') ? $navClassActive : $navClassInactive ?>">
|
<a href="<?= base_url('dashboard') ?>" class="<?= $currentUri === 'dashboard' ? $navClassActive : $navClassInactive ?>">
|
||||||
<i class="bx bx-home-alt text-xl"></i>
|
<i class="bx bx-home-alt text-xl"></i>
|
||||||
<span>Dashboard</span>
|
<span>Dashboard</span>
|
||||||
</a>
|
</a>
|
||||||
|
<a href="<?= base_url('dashboard/profile') ?>" class="<?= nav_is_active($currentUri, 'dashboard/profile') ? $navClassActive : $navClassInactive ?>">
|
||||||
|
<i class="bx bx-user text-xl"></i>
|
||||||
|
<span>Profil</span>
|
||||||
|
</a>
|
||||||
<a href="<?= base_url('dashboard/schedule/today') ?>" class="<?= nav_is_active($currentUri, 'dashboard/schedule/today') ? $navClassActive : $navClassInactive ?>">
|
<a href="<?= base_url('dashboard/schedule/today') ?>" class="<?= nav_is_active($currentUri, 'dashboard/schedule/today') ? $navClassActive : $navClassInactive ?>">
|
||||||
<i class="bx bx-calendar text-xl"></i>
|
<i class="bx bx-calendar text-xl"></i>
|
||||||
<span>Jadwal Hari Ini</span>
|
<span>Jadwal Hari Ini</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user