feat: add Logs
This commit is contained in:
@@ -1,31 +0,0 @@
|
|||||||
import { ref, watch } from 'vue'
|
|
||||||
|
|
||||||
const STORAGE_KEY = 'mws_theme'
|
|
||||||
const DARK_CLASS = 'app-dark'
|
|
||||||
|
|
||||||
const isDark = ref(loadInitial())
|
|
||||||
|
|
||||||
function loadInitial(): boolean {
|
|
||||||
const stored = localStorage.getItem(STORAGE_KEY)
|
|
||||||
if (stored === 'dark') return true
|
|
||||||
if (stored === 'light') return false
|
|
||||||
return window.matchMedia('(prefers-color-scheme: dark)').matches
|
|
||||||
}
|
|
||||||
|
|
||||||
function apply(value: boolean) {
|
|
||||||
document.documentElement.classList.toggle(DARK_CLASS, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
apply(isDark.value)
|
|
||||||
|
|
||||||
watch(isDark, (value) => {
|
|
||||||
apply(value)
|
|
||||||
localStorage.setItem(STORAGE_KEY, value ? 'dark' : 'light')
|
|
||||||
})
|
|
||||||
|
|
||||||
export function useTheme() {
|
|
||||||
function toggle() {
|
|
||||||
isDark.value = !isDark.value
|
|
||||||
}
|
|
||||||
return { isDark, toggle }
|
|
||||||
}
|
|
||||||
@@ -46,7 +46,7 @@
|
|||||||
<i class="pi pi-users"></i> Users
|
<i class="pi pi-users"></i> Users
|
||||||
</router-link>
|
</router-link>
|
||||||
|
|
||||||
<div v-if="auth.canView('masterdata') || auth.canView('permissions')">
|
<div v-if="auth.canView('masterdata') || auth.canView('permissions') || auth.canView('actionlogs') || auth.canView('loginlogs')">
|
||||||
<button
|
<button
|
||||||
class="nav-link w-full"
|
class="nav-link w-full"
|
||||||
:class="{ 'router-link-active': isSettingsActive }"
|
:class="{ 'router-link-active': isSettingsActive }"
|
||||||
@@ -65,6 +65,12 @@
|
|||||||
<router-link v-if="auth.canView('permissions')" class="nav-link nav-link-sub submenu-link" to="/settings/permissions" @click="handleNavClick">
|
<router-link v-if="auth.canView('permissions')" class="nav-link nav-link-sub submenu-link" to="/settings/permissions" @click="handleNavClick">
|
||||||
Permissions
|
Permissions
|
||||||
</router-link>
|
</router-link>
|
||||||
|
<router-link v-if="auth.canView('actionlogs')" class="nav-link nav-link-sub submenu-link" to="/settings/actionlogs" @click="handleNavClick">
|
||||||
|
Action Logs
|
||||||
|
</router-link>
|
||||||
|
<router-link v-if="auth.canView('loginlogs')" class="nav-link nav-link-sub submenu-link" to="/settings/loginlogs" @click="handleNavClick">
|
||||||
|
Login Logs
|
||||||
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -88,20 +94,12 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex min-w-0 items-center gap-2">
|
<div class="flex min-w-0 items-center gap-2">
|
||||||
<Button
|
|
||||||
:icon="theme.isDark.value ? 'pi pi-sun' : 'pi pi-moon'"
|
|
||||||
rounded
|
|
||||||
text
|
|
||||||
severity="secondary"
|
|
||||||
:aria-label="theme.isDark.value ? 'Switch to light mode' : 'Switch to dark mode'"
|
|
||||||
@click="theme.toggle"
|
|
||||||
/>
|
|
||||||
<UserMenu />
|
<UserMenu />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="flex-1 overflow-auto">
|
<main class="flex-1 overflow-auto">
|
||||||
<div class="mx-auto h-full w-full max-w-[1200px] p-5 sm:p-7 lg:px-9 lg:py-8">
|
<div class="mx-auto h-full w-full p-5 sm:p-7 lg:px-9 lg:py-8">
|
||||||
<router-view />
|
<router-view />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
@@ -112,12 +110,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
import { useTheme } from '../composables/useTheme'
|
|
||||||
import UserMenu from '../components/UserMenu.vue'
|
import UserMenu from '../components/UserMenu.vue'
|
||||||
|
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const theme = useTheme()
|
|
||||||
|
|
||||||
const desktopMq = window.matchMedia('(min-width: 1024px)')
|
const desktopMq = window.matchMedia('(min-width: 1024px)')
|
||||||
const isDesktop = ref(desktopMq.matches)
|
const isDesktop = ref(desktopMq.matches)
|
||||||
|
|||||||
@@ -95,20 +95,12 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex min-w-0 items-center gap-2">
|
<div class="flex min-w-0 items-center gap-2">
|
||||||
<Button
|
|
||||||
:icon="theme.isDark.value ? 'pi pi-sun' : 'pi pi-moon'"
|
|
||||||
rounded
|
|
||||||
text
|
|
||||||
severity="secondary"
|
|
||||||
:aria-label="theme.isDark.value ? 'Switch to light mode' : 'Switch to dark mode'"
|
|
||||||
@click="theme.toggle"
|
|
||||||
/>
|
|
||||||
<UserMenu />
|
<UserMenu />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="flex-1 overflow-auto">
|
<main class="flex-1 overflow-auto">
|
||||||
<div class="mx-auto h-full w-full max-w-[1200px] p-5 sm:p-7 lg:px-9 lg:py-8">
|
<div class="mx-auto h-full w-full p-5 sm:p-7 lg:px-9 lg:py-8">
|
||||||
<router-view />
|
<router-view />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
@@ -118,7 +110,6 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
import { useTheme } from '../composables/useTheme'
|
|
||||||
import { getProject, getProjects } from '../services/backend'
|
import { getProject, getProjects } from '../services/backend'
|
||||||
import UserMenu from '../components/UserMenu.vue'
|
import UserMenu from '../components/UserMenu.vue'
|
||||||
import type { Project } from '../types'
|
import type { Project } from '../types'
|
||||||
@@ -126,7 +117,6 @@ import type { Project } from '../types'
|
|||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const theme = useTheme()
|
|
||||||
|
|
||||||
const desktopMq = window.matchMedia('(min-width: 1024px)')
|
const desktopMq = window.matchMedia('(min-width: 1024px)')
|
||||||
const isDesktop = ref(desktopMq.matches)
|
const isDesktop = ref(desktopMq.matches)
|
||||||
|
|||||||
+1
-1
@@ -18,7 +18,7 @@ app.use(router)
|
|||||||
app.use(PrimeVue, {
|
app.use(PrimeVue, {
|
||||||
theme: {
|
theme: {
|
||||||
preset: KrakenPreset,
|
preset: KrakenPreset,
|
||||||
options: { darkModeSelector: '.app-dark' },
|
options: { darkModeSelector: false },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
app.use(ToastService)
|
app.use(ToastService)
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ const router = createRouter({
|
|||||||
{ path: 'masterdata', name: 'settings-masterdata', component: () => import('../views/settings/masterdata/MasterDataView.vue'), meta: { title: 'Master Data', screenKey: 'masterdata' } },
|
{ path: 'masterdata', name: 'settings-masterdata', component: () => import('../views/settings/masterdata/MasterDataView.vue'), meta: { title: 'Master Data', screenKey: 'masterdata' } },
|
||||||
{ path: 'roles', name: 'settings-roles', component: () => import('../views/settings/roles/RolesView.vue'), meta: { title: 'Roles', screenKey: 'permissions' } },
|
{ path: 'roles', name: 'settings-roles', component: () => import('../views/settings/roles/RolesView.vue'), meta: { title: 'Roles', screenKey: 'permissions' } },
|
||||||
{ path: 'permissions', name: 'settings-permissions', component: () => import('../views/settings/permissions/PermissionsView.vue'), meta: { title: 'Permissions', screenKey: 'permissions' } },
|
{ path: 'permissions', name: 'settings-permissions', component: () => import('../views/settings/permissions/PermissionsView.vue'), meta: { title: 'Permissions', screenKey: 'permissions' } },
|
||||||
|
{ path: 'actionlogs', name: 'settings-actionlogs', component: () => import('../views/settings/actionlogs/ActionLogsView.vue'), meta: { title: 'Action Logs', screenKey: 'actionlogs' } },
|
||||||
|
{ path: 'loginlogs', name: 'settings-loginlogs', component: () => import('../views/settings/loginlogs/LoginLogsView.vue'), meta: { title: 'Login Logs', screenKey: 'loginlogs' } },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ import type {
|
|||||||
PagedResult,
|
PagedResult,
|
||||||
UserRoleDetail,
|
UserRoleDetail,
|
||||||
User,
|
User,
|
||||||
|
ActionLog,
|
||||||
|
LoginLog,
|
||||||
|
ActionLogFilters,
|
||||||
|
LoginLogFilters,
|
||||||
} from '../types'
|
} from '../types'
|
||||||
|
|
||||||
export async function login(username: string, password: string): Promise<LoginResponse> {
|
export async function login(username: string, password: string): Promise<LoginResponse> {
|
||||||
@@ -221,3 +225,13 @@ export async function getUserPermissions(userId: string): Promise<MenuItem[]> {
|
|||||||
const { data } = await api.get<MenuItem[]>(`/api/users/${userId}/permissions`)
|
const { data } = await api.get<MenuItem[]>(`/api/users/${userId}/permissions`)
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getActionLogs(params: ActionLogFilters = {}): Promise<PagedResult<ActionLog>> {
|
||||||
|
const { data } = await api.get<PagedResult<ActionLog>>('/api/audit/actions', { params })
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLoginLogs(params: LoginLogFilters = {}): Promise<PagedResult<LoginLog>> {
|
||||||
|
const { data } = await api.get<PagedResult<LoginLog>>('/api/audit/logins', { params })
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|||||||
@@ -155,3 +155,46 @@ export interface UserRoleDetail {
|
|||||||
export interface TaskCounts {
|
export interface TaskCounts {
|
||||||
[status: string]: number
|
[status: string]: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ActionLog {
|
||||||
|
id: string
|
||||||
|
actorUserId: string
|
||||||
|
actorUsername: string
|
||||||
|
action: string
|
||||||
|
entityType: string
|
||||||
|
entityId: string | null
|
||||||
|
entityName: string | null
|
||||||
|
ipAddress: string | null
|
||||||
|
userAgent: string | null
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginLog {
|
||||||
|
id: string
|
||||||
|
username: string
|
||||||
|
userId: string | null
|
||||||
|
success: boolean
|
||||||
|
failureReason: string | null
|
||||||
|
ipAddress: string | null
|
||||||
|
userAgent: string | null
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActionLogFilters {
|
||||||
|
from?: string
|
||||||
|
to?: string
|
||||||
|
actor?: string
|
||||||
|
action?: string
|
||||||
|
entityType?: string
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginLogFilters {
|
||||||
|
from?: string
|
||||||
|
to?: string
|
||||||
|
username?: string
|
||||||
|
success?: boolean
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,15 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="grid min-h-dvh lg:grid-cols-[1.05fr_1fr]">
|
<div class="grid min-h-dvh lg:grid-cols-[1.05fr_1fr]">
|
||||||
<Button
|
|
||||||
:icon="theme.isDark.value ? 'pi pi-sun' : 'pi pi-moon'"
|
|
||||||
rounded
|
|
||||||
text
|
|
||||||
severity="secondary"
|
|
||||||
class="!fixed !right-4 !top-4 z-10"
|
|
||||||
:aria-label="theme.isDark.value ? 'Switch to light mode' : 'Switch to dark mode'"
|
|
||||||
@click="theme.toggle"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Left panel: login image -->
|
<!-- Left panel: login image -->
|
||||||
<section class="brand-panel relative hidden overflow-hidden lg:flex lg:items-center lg:justify-center">
|
<section class="brand-panel relative hidden overflow-hidden lg:flex lg:items-center lg:justify-center">
|
||||||
<img src="/login.png" alt="Login" class="absolute inset-0 h-full w-full object-cover" />
|
<img src="/login.png" alt="Login" class="absolute inset-0 h-full w-full object-cover" />
|
||||||
@@ -52,10 +42,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useAuthStore } from '../../stores/auth'
|
import { useAuthStore } from '../../stores/auth'
|
||||||
import { errorMessage } from '../../services/api'
|
import { errorMessage } from '../../services/api'
|
||||||
import { useTheme } from '../../composables/useTheme'
|
|
||||||
|
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const theme = useTheme()
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex h-full flex-col">
|
||||||
|
<AppTableShell title="Action Logs">
|
||||||
|
<template #filters>
|
||||||
|
<DatePicker v-model="dateRange" selectionMode="range" :manualInput="false" placeholder="Date range" showClear class="w-[260px]" @change="onFilterChange" />
|
||||||
|
<IconField class="sm:w-[240px]">
|
||||||
|
<InputIcon class="pi pi-search" />
|
||||||
|
<InputText v-model.trim="filters.actor" placeholder="Actor username" class="search-input w-full" @input="onTextFilter" />
|
||||||
|
</IconField>
|
||||||
|
<InputText v-model.trim="filters.action" placeholder="Action" class="search-input w-[180px]" @input="onTextFilter" />
|
||||||
|
<InputText v-model.trim="filters.entityType" placeholder="Entity type" class="search-input w-[160px]" @input="onTextFilter" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<AppDataTable
|
||||||
|
:value="logs"
|
||||||
|
:loading="loading"
|
||||||
|
:lazy="true"
|
||||||
|
:paginator="true"
|
||||||
|
:rows="pageSize"
|
||||||
|
:totalRecords="totalCount"
|
||||||
|
:first="first"
|
||||||
|
@page="onPageChange"
|
||||||
|
emptyMessage="No action logs"
|
||||||
|
class="min-h-0 flex-1 min-w-[900px]"
|
||||||
|
>
|
||||||
|
<Column header="Time" style="width: 15%">
|
||||||
|
<template #body="{ data }">{{ formatDate(data.createdAt) }}</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="actorUsername" header="Actor" style="width: 12%" />
|
||||||
|
<Column field="action" header="Action" style="width: 16%" />
|
||||||
|
<Column field="entityType" header="Entity" style="width: 12%" />
|
||||||
|
<Column header="Entity name" style="width: 20%">
|
||||||
|
<template #body="{ data }">{{ data.entityName ?? '-' }}</template>
|
||||||
|
</Column>
|
||||||
|
<Column header="Entity id" style="width: 15%">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<span class="font-mono text-xs">{{ data.entityId ? data.entityId.slice(0, 8) : '-' }}</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column header="IP" style="width: 10%">
|
||||||
|
<template #body="{ data }">{{ data.ipAddress ?? '-' }}</template>
|
||||||
|
</Column>
|
||||||
|
</AppDataTable>
|
||||||
|
</AppTableShell>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { getActionLogs } from '../../../services/backend'
|
||||||
|
import { errorMessage } from '../../../services/api'
|
||||||
|
import AppDataTable from '../../../components/AppDataTable.vue'
|
||||||
|
import AppTableShell from '../../../components/AppTableShell.vue'
|
||||||
|
import type { ActionLog } from '../../../types'
|
||||||
|
import type { DataTablePageEvent } from 'primevue/datatable'
|
||||||
|
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
const logs = ref<ActionLog[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const dateRange = ref<Date[] | null>(null)
|
||||||
|
const filters = reactive<{ actor: string; action: string; entityType: string }>({ actor: '', action: '', entityType: '' })
|
||||||
|
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = ref(10)
|
||||||
|
const totalCount = ref(0)
|
||||||
|
const first = computed(() => (page.value - 1) * pageSize.value)
|
||||||
|
|
||||||
|
async function loadLogs() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getActionLogs({
|
||||||
|
from: dateRange.value?.[0]?.toISOString(),
|
||||||
|
to: endOfDay(dateRange.value?.[1]),
|
||||||
|
actor: filters.actor || undefined,
|
||||||
|
action: filters.action || undefined,
|
||||||
|
entityType: filters.entityType || undefined,
|
||||||
|
page: page.value,
|
||||||
|
pageSize: pageSize.value,
|
||||||
|
})
|
||||||
|
logs.value = res.items
|
||||||
|
totalCount.value = res.totalCount
|
||||||
|
} catch (e) {
|
||||||
|
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPageChange(event: DataTablePageEvent) {
|
||||||
|
page.value = event.page + 1
|
||||||
|
pageSize.value = event.rows
|
||||||
|
void loadLogs()
|
||||||
|
}
|
||||||
|
|
||||||
|
let textTimer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
|
||||||
|
function onTextFilter() {
|
||||||
|
clearTimeout(textTimer)
|
||||||
|
textTimer = setTimeout(() => {
|
||||||
|
page.value = 1
|
||||||
|
void loadLogs()
|
||||||
|
}, 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFilterChange() {
|
||||||
|
page.value = 1
|
||||||
|
void loadLogs()
|
||||||
|
}
|
||||||
|
|
||||||
|
function endOfDay(date?: Date): string | undefined {
|
||||||
|
if (!date) return undefined
|
||||||
|
const end = new Date(date)
|
||||||
|
end.setHours(23, 59, 59, 999)
|
||||||
|
return end.toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value: string): string {
|
||||||
|
return new Date(value).toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadLogs)
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex h-full flex-col">
|
||||||
|
<AppTableShell title="Login Logs">
|
||||||
|
<template #filters>
|
||||||
|
<DatePicker v-model="dateRange" selectionMode="range" :manualInput="false" placeholder="Date range" showClear class="w-[260px]" @change="onFilterChange" />
|
||||||
|
<IconField class="sm:w-[240px]">
|
||||||
|
<InputIcon class="pi pi-user" />
|
||||||
|
<InputText v-model.trim="filters.username" placeholder="Username" class="search-input w-full" @input="onTextFilter" />
|
||||||
|
</IconField>
|
||||||
|
<Select v-model="filters.success" :options="statusOptions" optionLabel="label" optionValue="value" placeholder="Result" showClear class="w-[160px]" @change="onFilterChange" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<AppDataTable
|
||||||
|
:value="logs"
|
||||||
|
:loading="loading"
|
||||||
|
:lazy="true"
|
||||||
|
:paginator="true"
|
||||||
|
:rows="pageSize"
|
||||||
|
:totalRecords="totalCount"
|
||||||
|
:first="first"
|
||||||
|
@page="onPageChange"
|
||||||
|
emptyMessage="No login logs"
|
||||||
|
class="min-h-0 flex-1 min-w-[800px]"
|
||||||
|
>
|
||||||
|
<Column header="Time" style="width: 15%">
|
||||||
|
<template #body="{ data }">{{ formatDate(data.createdAt) }}</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="username" header="Username" style="width: 15%" />
|
||||||
|
<Column header="Result" style="width: 12%">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<Tag :value="data.success ? 'Success' : 'Failed'" :severity="data.success ? 'success' : 'danger'" />
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column header="Reason" style="width: 20%">
|
||||||
|
<template #body="{ data }">{{ data.failureReason ?? '-' }}</template>
|
||||||
|
</Column>
|
||||||
|
<Column header="IP" style="width: 15%">
|
||||||
|
<template #body="{ data }">{{ data.ipAddress ?? '-' }}</template>
|
||||||
|
</Column>
|
||||||
|
<Column header="User agent" style="width: 23%">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<span class="block truncate" :title="data.userAgent ?? ''">{{ data.userAgent ?? '-' }}</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
</AppDataTable>
|
||||||
|
</AppTableShell>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { getLoginLogs } from '../../../services/backend'
|
||||||
|
import { errorMessage } from '../../../services/api'
|
||||||
|
import AppDataTable from '../../../components/AppDataTable.vue'
|
||||||
|
import AppTableShell from '../../../components/AppTableShell.vue'
|
||||||
|
import type { LoginLog } from '../../../types'
|
||||||
|
import type { DataTablePageEvent } from 'primevue/datatable'
|
||||||
|
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
const logs = ref<LoginLog[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const dateRange = ref<Date[] | null>(null)
|
||||||
|
const filters = reactive<{ username: string; success: boolean | null }>({ username: '', success: null })
|
||||||
|
const statusOptions = [
|
||||||
|
{ label: 'Success', value: true },
|
||||||
|
{ label: 'Failed', value: false },
|
||||||
|
]
|
||||||
|
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = ref(10)
|
||||||
|
const totalCount = ref(0)
|
||||||
|
const first = computed(() => (page.value - 1) * pageSize.value)
|
||||||
|
|
||||||
|
async function loadLogs() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getLoginLogs({
|
||||||
|
from: dateRange.value?.[0]?.toISOString(),
|
||||||
|
to: endOfDay(dateRange.value?.[1]),
|
||||||
|
username: filters.username || undefined,
|
||||||
|
success: filters.success ?? undefined,
|
||||||
|
page: page.value,
|
||||||
|
pageSize: pageSize.value,
|
||||||
|
})
|
||||||
|
logs.value = res.items
|
||||||
|
totalCount.value = res.totalCount
|
||||||
|
} catch (e) {
|
||||||
|
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPageChange(event: DataTablePageEvent) {
|
||||||
|
page.value = event.page + 1
|
||||||
|
pageSize.value = event.rows
|
||||||
|
void loadLogs()
|
||||||
|
}
|
||||||
|
|
||||||
|
let textTimer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
|
||||||
|
function onTextFilter() {
|
||||||
|
clearTimeout(textTimer)
|
||||||
|
textTimer = setTimeout(() => {
|
||||||
|
page.value = 1
|
||||||
|
void loadLogs()
|
||||||
|
}, 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFilterChange() {
|
||||||
|
page.value = 1
|
||||||
|
void loadLogs()
|
||||||
|
}
|
||||||
|
|
||||||
|
function endOfDay(date?: Date): string | undefined {
|
||||||
|
if (!date) return undefined
|
||||||
|
const end = new Date(date)
|
||||||
|
end.setHours(23, 59, 59, 999)
|
||||||
|
return end.toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value: string): string {
|
||||||
|
return new Date(value).toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadLogs)
|
||||||
|
</script>
|
||||||
@@ -117,6 +117,8 @@ const ALL_SCREENS = [
|
|||||||
{ key: 'users', label: 'Users' },
|
{ key: 'users', label: 'Users' },
|
||||||
{ key: 'permissions', label: 'Permissions' },
|
{ key: 'permissions', label: 'Permissions' },
|
||||||
{ key: 'masterdata', label: 'Master Data' },
|
{ key: 'masterdata', label: 'Master Data' },
|
||||||
|
{ key: 'actionlogs', label: 'Action Logs' },
|
||||||
|
{ key: 'loginlogs', label: 'Login Logs' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const filteredRoles = computed(() => {
|
const filteredRoles = computed(() => {
|
||||||
|
|||||||
@@ -54,14 +54,15 @@
|
|||||||
<span v-if="isMine(task)" class="mine-badge">My task</span>
|
<span v-if="isMine(task)" class="mine-badge">My task</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="task.description" class="muted-note mt-1 line-clamp-2 text-[0.85rem]">{{ task.description }}</div>
|
<div v-if="task.description" class="muted-note mt-1 line-clamp-2 text-[0.85rem]">{{ task.description }}</div>
|
||||||
<div class="mt-2 text-[0.8rem]" v-if="task.assigneeName">
|
<div class="mt-2 flex flex-col gap-1.5 text-[0.8rem]">
|
||||||
<span class="flex items-center gap-1 muted-note">
|
<span v-if="task.assigneeName" class="meta-chip meta-chip-user self-start">
|
||||||
<i class="pi pi-user text-[0.7rem]"></i> {{ task.assigneeName }}
|
<i class="pi pi-user text-[0.7rem]"></i> {{ task.assigneeName }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
<span
|
||||||
<div class="text-[0.8rem]" v-if="task.dueDate">
|
class="meta-chip self-start"
|
||||||
<span class="flex items-center gap-1 muted-note">
|
:class="task.dueDate ? 'meta-chip-due' : 'meta-chip-nodue'"
|
||||||
<i class="pi pi-calendar text-[0.7rem]"></i> {{ formatDate(task.dueDate) }}
|
>
|
||||||
|
<i class="pi pi-calendar text-[0.7rem]"></i> {{ task.dueDate ? formatDate(task.dueDate) : 'No Due date' }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -275,6 +276,32 @@ watch(
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
.meta-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
max-width: 100%;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.meta-chip-user {
|
||||||
|
background: var(--primary-soft);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
.meta-chip-due {
|
||||||
|
background: rgba(245, 158, 11, 0.14);
|
||||||
|
color: #b45309;
|
||||||
|
}
|
||||||
|
.meta-chip-nodue {
|
||||||
|
background: rgba(110, 107, 102, 0.1);
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
.board-col.drag-over {
|
.board-col.drag-over {
|
||||||
background: color-mix(in srgb, var(--primary) 6%, transparent);
|
background: color-mix(in srgb, var(--primary) 6%, transparent);
|
||||||
box-shadow: inset 0 0 0 2px var(--primary);
|
box-shadow: inset 0 0 0 2px var(--primary);
|
||||||
|
|||||||
Reference in New Issue
Block a user