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:
21
src/app.ts
21
src/app.ts
@@ -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') {
|
||||
user = await prisma.user.update({ where: { id: user.id }, data: { role: 'DEVELOPER' } })
|
||||
}
|
||||
@@ -233,6 +237,10 @@ export function createApp() {
|
||||
set.status = 401
|
||||
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
|
||||
if (env.SUPER_ADMIN_EMAILS.includes(user.email) && user.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 } }
|
||||
const session = await prisma.session.findUnique({
|
||||
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) await prisma.session.delete({ where: { id: session.id } })
|
||||
set.status = 401
|
||||
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 }
|
||||
}, {
|
||||
detail: {
|
||||
@@ -641,6 +654,10 @@ export function createApp() {
|
||||
},
|
||||
})
|
||||
|
||||
if (body.active === false) {
|
||||
await prisma.session.deleteMany({ where: { userId: id } })
|
||||
}
|
||||
|
||||
if (userId) {
|
||||
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 },
|
||||
})
|
||||
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 }
|
||||
})
|
||||
|
||||
@@ -1069,6 +1087,7 @@ export function createApp() {
|
||||
})
|
||||
if (!active) await prisma.session.deleteMany({ where: { userId: params.id } })
|
||||
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 }
|
||||
})
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user