feat: block inactive users from login and fix activity log on dev operators

- Block inactive users on email/password login (403)
- Block inactive users on Google OAuth (redirect to account_disabled)
- Auto-logout inactive users on session check (deleteMany sessions)
- Delete sessions when user is deactivated via PATCH /api/operators/:id
- Add account_disabled error message on login page
- Show inactive indicator on users table with reactivate button
- Add createSystemLog calls to /api/admin/users role and activate endpoints

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-29 11:58:31 +08:00
parent 73aa9729b8
commit 06794524fd
3 changed files with 91 additions and 17 deletions

View File

@@ -66,6 +66,7 @@ function LoginPage() {
invalid_state: 'Sesi OAuth tidak valid, silakan coba lagi.',
token_failed: 'Gagal menukar token Google, silakan coba lagi.',
userinfo_failed: 'Gagal mengambil info akun Google, silakan coba lagi.',
account_disabled: 'Akun Anda telah dinonaktifkan. Hubungi admin untuk informasi lebih lanjut.',
}[searchError ?? ''] ?? 'Login dengan Google gagal, silakan coba lagi.'
)}
</Alert>

View File

@@ -4,6 +4,7 @@ import {
ActionIcon,
Avatar,
Badge,
Box,
Button,
Card,
Container,
@@ -23,6 +24,7 @@ import {
TextInput,
ThemeIcon,
Title,
Tooltip,
} from '@mantine/core'
import { useDisclosure } from '@mantine/hooks'
import { notifications } from '@mantine/notifications'
@@ -37,7 +39,8 @@ import {
TbSearch,
TbShieldCheck,
TbTrash,
TbUserCheck
TbUserCheck,
TbUserPlus,
} from 'react-icons/tb'
import useSWR from 'swr'
import { API_URLS } from '../config/api'
@@ -229,6 +232,28 @@ function UsersPage() {
}
}
// ── Activate User ──
const handleActivateUser = async (user: any) => {
try {
const res = await fetch(`/api/operators/${user.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ active: true }),
})
if (res.ok) {
notifications.show({ title: 'Success', message: `${user.name} telah diaktifkan kembali.`, color: 'teal', icon: <TbCircleCheck size={18} /> })
mutateOperators()
mutateStats()
} else {
const err = await res.json()
throw new Error(err.error || 'Failed to activate user')
}
} catch (e: any) {
notifications.show({ title: 'Error', message: e.message || 'Something went wrong.', color: 'red', icon: <TbCircleX size={18} /> })
}
}
return (
<DashboardLayout>
<Container size="xl" py="lg">
@@ -306,33 +331,62 @@ function UsersPage() {
) : (
operators.map((user: any) => (
<Table.Tr key={user.id}>
<Table.Td>
<Table.Td style={{ opacity: user.active === false ? 0.45 : 1 }}>
<Group gap="sm">
<Avatar size="sm" radius="xl" color={getRoleColor(user.role)} src={user.image}>
{user.name.charAt(0)}
</Avatar>
<Box style={{ position: 'relative' }}>
<Avatar size="sm" radius="xl" color={user.active === false ? 'gray' : getRoleColor(user.role)} src={user.image}>
{user.name.charAt(0)}
</Avatar>
{user.active === false && (
<Box
style={{
position: 'absolute', bottom: -2, right: -2,
width: 10, height: 10, borderRadius: '50%',
background: 'var(--mantine-color-red-6)',
border: '1.5px solid var(--mantine-color-body)',
}}
/>
)}
</Box>
<Stack gap={0}>
<Text fw={600} size="sm">{user.name}</Text>
<Group gap={6}>
<Text fw={600} size="sm" c={user.active === false ? 'dimmed' : undefined}>{user.name}</Text>
{user.active === false && (
<Badge size="xs" color="red" variant="light">Inactive</Badge>
)}
</Group>
<Text size="xs" c="dimmed">{user.email}</Text>
</Stack>
</Group>
</Table.Td>
<Table.Td>
<Badge variant="light" color={getRoleColor(user.role)}>
<Table.Td style={{ opacity: user.active === false ? 0.45 : 1 }}>
<Badge variant="light" color={user.active === false ? 'gray' : getRoleColor(user.role)}>
{user.role}
</Badge>
</Table.Td>
<Table.Td>
<Text size="xs" fw={500}>{new Date(user.createdAt).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })}</Text>
<Table.Td style={{ opacity: user.active === false ? 0.45 : 1 }}>
<Text size="xs" fw={500} c={user.active === false ? 'dimmed' : undefined}>
{new Date(user.createdAt).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })}
</Text>
</Table.Td>
<Table.Td>
<Group gap="xs">
<ActionIcon disabled={!isDeveloper} variant="light" size="sm" color="blue" onClick={() => handleOpenEdit(user)}>
<TbPencil size={14} />
</ActionIcon>
<ActionIcon disabled={!isDeveloper} variant="light" size="sm" color="red" onClick={() => handleOpenDelete(user)}>
<TbTrash size={14} />
</ActionIcon>
{user.active === false ? (
<Tooltip label="Aktifkan user" withArrow>
<ActionIcon disabled={!isDeveloper} variant="light" size="sm" color="teal" onClick={() => handleActivateUser(user)}>
<TbUserPlus size={14} />
</ActionIcon>
</Tooltip>
) : (
<>
<ActionIcon disabled={!isDeveloper} variant="light" size="sm" color="blue" onClick={() => handleOpenEdit(user)}>
<TbPencil size={14} />
</ActionIcon>
<ActionIcon disabled={!isDeveloper} variant="light" size="sm" color="red" onClick={() => handleOpenDelete(user)}>
<TbTrash size={14} />
</ActionIcon>
</>
)}
</Group>
</Table.Td>
</Table.Tr>