- 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>
118 lines
3.6 KiB
TypeScript
118 lines
3.6 KiB
TypeScript
import { useLogin } from '@/frontend/hooks/useAuth'
|
|
import {
|
|
Alert,
|
|
Button,
|
|
Center,
|
|
Divider,
|
|
Paper,
|
|
PasswordInput,
|
|
Stack,
|
|
Text,
|
|
TextInput,
|
|
Title,
|
|
} from '@mantine/core'
|
|
import { createFileRoute, redirect } from '@tanstack/react-router'
|
|
import { useState } from 'react'
|
|
import { FcGoogle } from 'react-icons/fc'
|
|
import { TbAlertCircle, TbLock, TbLogin, TbMail } from 'react-icons/tb'
|
|
|
|
export const Route = createFileRoute('/login')({
|
|
validateSearch: (search: Record<string, unknown>): { error?: string } => ({
|
|
error: (search.error as string) || undefined,
|
|
}),
|
|
beforeLoad: async ({ context }) => {
|
|
try {
|
|
const data = await context.queryClient.ensureQueryData({
|
|
queryKey: ['auth', 'session'],
|
|
queryFn: () => fetch('/api/auth/session', { credentials: 'include' }).then((r) => r.json()),
|
|
})
|
|
if (data?.user) {
|
|
const dest = data.user.role === 'DEVELOPER' ? '/dev' : data.user.role === 'USER' ? '/profile' : '/dashboard'
|
|
throw redirect({ to: dest })
|
|
}
|
|
} catch (e) {
|
|
if (e instanceof Error) return
|
|
throw e
|
|
}
|
|
},
|
|
component: LoginPage,
|
|
})
|
|
|
|
function LoginPage() {
|
|
const login = useLogin()
|
|
const { error: searchError } = Route.useSearch()
|
|
const [email, setEmail] = useState('')
|
|
const [password, setPassword] = useState('')
|
|
|
|
const handleSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault()
|
|
login.mutate({ email, password })
|
|
}
|
|
|
|
return (
|
|
<Center mih="100vh">
|
|
<Paper shadow="md" p="xl" radius="md" w={400} withBorder>
|
|
<form onSubmit={handleSubmit}>
|
|
<Stack gap="md">
|
|
<Title order={2} ta="center">
|
|
Login
|
|
</Title>
|
|
|
|
{(login.isError || searchError) && (
|
|
<Alert icon={<TbAlertCircle size={16} />} color="red" variant="light">
|
|
{login.isError ? login.error.message : (
|
|
{
|
|
google_denied: 'Login dengan Google dibatalkan.',
|
|
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>
|
|
)}
|
|
|
|
<TextInput
|
|
label="Email"
|
|
placeholder="email@example.com"
|
|
leftSection={<TbMail size={16} />}
|
|
value={email}
|
|
onChange={(e) => setEmail(e.currentTarget.value)}
|
|
required
|
|
/>
|
|
|
|
<PasswordInput
|
|
label="Password"
|
|
placeholder="Password"
|
|
leftSection={<TbLock size={16} />}
|
|
value={password}
|
|
onChange={(e) => setPassword(e.currentTarget.value)}
|
|
required
|
|
/>
|
|
|
|
<Button
|
|
type="submit"
|
|
fullWidth
|
|
leftSection={<TbLogin size={18} />}
|
|
loading={login.isPending}
|
|
>
|
|
Sign in
|
|
</Button>
|
|
|
|
<Divider label="or" labelPosition="center" />
|
|
|
|
<Button
|
|
variant="default"
|
|
fullWidth
|
|
leftSection={<FcGoogle size={18} />}
|
|
onClick={() => { window.location.href = '/api/auth/google' }}
|
|
>
|
|
Continue with Google
|
|
</Button>
|
|
</Stack>
|
|
</form>
|
|
</Paper>
|
|
</Center>
|
|
)
|
|
}
|