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

@@ -203,6 +203,10 @@ export function createApp() {
}) })
} }
if (!user.active) {
return new Response(null, { status: 302, headers: { Location: '/login?error=account_disabled' } })
}
if (env.SUPER_ADMIN_EMAILS.includes(user.email) && user.role !== 'DEVELOPER') { if (env.SUPER_ADMIN_EMAILS.includes(user.email) && user.role !== 'DEVELOPER') {
user = await prisma.user.update({ where: { id: user.id }, data: { role: 'DEVELOPER' } }) user = await prisma.user.update({ where: { id: user.id }, data: { role: 'DEVELOPER' } })
} }
@@ -233,6 +237,10 @@ export function createApp() {
set.status = 401 set.status = 401
return { error: 'Email atau password salah' } return { error: 'Email atau password salah' }
} }
if (!user.active) {
set.status = 403
return { error: 'Akun Anda telah dinonaktifkan. Hubungi admin untuk informasi lebih lanjut.' }
}
// Auto-promote super admin from env // Auto-promote super admin from env
if (env.SUPER_ADMIN_EMAILS.includes(user.email) && user.role !== 'DEVELOPER') { if (env.SUPER_ADMIN_EMAILS.includes(user.email) && user.role !== 'DEVELOPER') {
user = await prisma.user.update({ where: { id: user.id }, data: { role: 'DEVELOPER' } }) user = await prisma.user.update({ where: { id: user.id }, data: { role: 'DEVELOPER' } })
@@ -281,13 +289,18 @@ export function createApp() {
if (!token) { set.status = 401; return { user: null } } if (!token) { set.status = 401; return { user: null } }
const session = await prisma.session.findUnique({ const session = await prisma.session.findUnique({
where: { token }, where: { token },
include: { user: { select: { id: true, name: true, email: true, role: true, image: true } } }, include: { user: { select: { id: true, name: true, email: true, role: true, image: true, active: true } } },
}) })
if (!session || session.expiresAt < new Date()) { if (!session || session.expiresAt < new Date()) {
if (session) await prisma.session.delete({ where: { id: session.id } }) if (session) await prisma.session.delete({ where: { id: session.id } })
set.status = 401 set.status = 401
return { user: null } return { user: null }
} }
if (!session.user.active) {
await prisma.session.deleteMany({ where: { userId: session.user.id } })
set.status = 401
return { user: null }
}
return { user: session.user } return { user: session.user }
}, { }, {
detail: { detail: {
@@ -641,6 +654,10 @@ export function createApp() {
}, },
}) })
if (body.active === false) {
await prisma.session.deleteMany({ where: { userId: id } })
}
if (userId) { if (userId) {
await createSystemLog(userId, 'UPDATE', `Updated user: ${user.name} (${user.email})`) await createSystemLog(userId, 'UPDATE', `Updated user: ${user.name} (${user.email})`)
} }
@@ -1054,6 +1071,7 @@ export function createApp() {
select: { id: true, name: true, email: true, role: true, active: true, createdAt: true }, select: { id: true, name: true, email: true, role: true, active: true, createdAt: true },
}) })
await appLog('info', `Role changed: ${user.email} ${target?.role}${role}`) await appLog('info', `Role changed: ${user.email} ${target?.role}${role}`)
await createSystemLog(auth.userId, 'UPDATE', `Role changed: ${user.name} (${user.email}) ${target?.role}${role}`)
return { user } return { user }
}) })
@@ -1069,6 +1087,7 @@ export function createApp() {
}) })
if (!active) await prisma.session.deleteMany({ where: { userId: params.id } }) if (!active) await prisma.session.deleteMany({ where: { userId: params.id } })
await appLog('info', `User ${active ? 'activated' : 'deactivated'}: ${user.email}`) await appLog('info', `User ${active ? 'activated' : 'deactivated'}: ${user.email}`)
await createSystemLog(auth.userId, active ? 'UPDATE' : 'DELETE', `User ${active ? 'activated' : 'deactivated'}: ${user.name} (${user.email})`)
return { user } return { user }
}) })

View File

@@ -66,6 +66,7 @@ function LoginPage() {
invalid_state: 'Sesi OAuth tidak valid, silakan coba lagi.', invalid_state: 'Sesi OAuth tidak valid, silakan coba lagi.',
token_failed: 'Gagal menukar token Google, silakan coba lagi.', token_failed: 'Gagal menukar token Google, silakan coba lagi.',
userinfo_failed: 'Gagal mengambil info akun 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.' }[searchError ?? ''] ?? 'Login dengan Google gagal, silakan coba lagi.'
)} )}
</Alert> </Alert>

View File

@@ -4,6 +4,7 @@ import {
ActionIcon, ActionIcon,
Avatar, Avatar,
Badge, Badge,
Box,
Button, Button,
Card, Card,
Container, Container,
@@ -23,6 +24,7 @@ import {
TextInput, TextInput,
ThemeIcon, ThemeIcon,
Title, Title,
Tooltip,
} from '@mantine/core' } from '@mantine/core'
import { useDisclosure } from '@mantine/hooks' import { useDisclosure } from '@mantine/hooks'
import { notifications } from '@mantine/notifications' import { notifications } from '@mantine/notifications'
@@ -37,7 +39,8 @@ import {
TbSearch, TbSearch,
TbShieldCheck, TbShieldCheck,
TbTrash, TbTrash,
TbUserCheck TbUserCheck,
TbUserPlus,
} from 'react-icons/tb' } from 'react-icons/tb'
import useSWR from 'swr' import useSWR from 'swr'
import { API_URLS } from '../config/api' 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 ( return (
<DashboardLayout> <DashboardLayout>
<Container size="xl" py="lg"> <Container size="xl" py="lg">
@@ -306,33 +331,62 @@ function UsersPage() {
) : ( ) : (
operators.map((user: any) => ( operators.map((user: any) => (
<Table.Tr key={user.id}> <Table.Tr key={user.id}>
<Table.Td> <Table.Td style={{ opacity: user.active === false ? 0.45 : 1 }}>
<Group gap="sm"> <Group gap="sm">
<Avatar size="sm" radius="xl" color={getRoleColor(user.role)} src={user.image}> <Box style={{ position: 'relative' }}>
{user.name.charAt(0)} <Avatar size="sm" radius="xl" color={user.active === false ? 'gray' : getRoleColor(user.role)} src={user.image}>
</Avatar> {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}> <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> <Text size="xs" c="dimmed">{user.email}</Text>
</Stack> </Stack>
</Group> </Group>
</Table.Td> </Table.Td>
<Table.Td> <Table.Td style={{ opacity: user.active === false ? 0.45 : 1 }}>
<Badge variant="light" color={getRoleColor(user.role)}> <Badge variant="light" color={user.active === false ? 'gray' : getRoleColor(user.role)}>
{user.role} {user.role}
</Badge> </Badge>
</Table.Td> </Table.Td>
<Table.Td> <Table.Td style={{ opacity: user.active === false ? 0.45 : 1 }}>
<Text size="xs" fw={500}>{new Date(user.createdAt).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })}</Text> <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>
<Table.Td> <Table.Td>
<Group gap="xs"> <Group gap="xs">
<ActionIcon disabled={!isDeveloper} variant="light" size="sm" color="blue" onClick={() => handleOpenEdit(user)}> {user.active === false ? (
<TbPencil size={14} /> <Tooltip label="Aktifkan user" withArrow>
</ActionIcon> <ActionIcon disabled={!isDeveloper} variant="light" size="sm" color="teal" onClick={() => handleActivateUser(user)}>
<ActionIcon disabled={!isDeveloper} variant="light" size="sm" color="red" onClick={() => handleOpenDelete(user)}> <TbUserPlus size={14} />
<TbTrash size={14} /> </ActionIcon>
</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> </Group>
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>