feat: add Logs

This commit is contained in:
2026-09-20 16:17:33 +07:00
parent 4a25557cfe
commit a01cd6f1bf
12 changed files with 353 additions and 73 deletions
-31
View File
@@ -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 }
}
+8 -12
View File
@@ -46,7 +46,7 @@
<i class="pi pi-users"></i> Users
</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
class="nav-link w-full"
: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">
Permissions
</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>
</nav>
@@ -88,20 +94,12 @@
/>
</div>
<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 />
</div>
</header>
<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 />
</div>
</main>
@@ -112,12 +110,10 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useAuthStore } from '../stores/auth'
import { useTheme } from '../composables/useTheme'
import UserMenu from '../components/UserMenu.vue'
const auth = useAuthStore()
const route = useRoute()
const theme = useTheme()
const desktopMq = window.matchMedia('(min-width: 1024px)')
const isDesktop = ref(desktopMq.matches)
+1 -11
View File
@@ -95,20 +95,12 @@
/>
</div>
<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 />
</div>
</header>
<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 />
</div>
</main>
@@ -118,7 +110,6 @@
<script setup lang="ts">
import { useAuthStore } from '../stores/auth'
import { useTheme } from '../composables/useTheme'
import { getProject, getProjects } from '../services/backend'
import UserMenu from '../components/UserMenu.vue'
import type { Project } from '../types'
@@ -126,7 +117,6 @@ import type { Project } from '../types'
const auth = useAuthStore()
const route = useRoute()
const router = useRouter()
const theme = useTheme()
const desktopMq = window.matchMedia('(min-width: 1024px)')
const isDesktop = ref(desktopMq.matches)
+1 -1
View File
@@ -18,7 +18,7 @@ app.use(router)
app.use(PrimeVue, {
theme: {
preset: KrakenPreset,
options: { darkModeSelector: '.app-dark' },
options: { darkModeSelector: false },
},
})
app.use(ToastService)
+2
View File
@@ -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: '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: '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' } },
],
},
],
+14
View File
@@ -13,6 +13,10 @@ import type {
PagedResult,
UserRoleDetail,
User,
ActionLog,
LoginLog,
ActionLogFilters,
LoginLogFilters,
} from '../types'
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`)
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
}
+43
View File
@@ -155,3 +155,46 @@ export interface UserRoleDetail {
export interface TaskCounts {
[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
}
-12
View File
@@ -1,15 +1,5 @@
<template>
<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 -->
<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" />
@@ -52,10 +42,8 @@
<script setup lang="ts">
import { useAuthStore } from '../../stores/auth'
import { errorMessage } from '../../services/api'
import { useTheme } from '../../composables/useTheme'
const auth = useAuthStore()
const theme = useTheme()
const router = useRouter()
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: 'permissions', label: 'Permissions' },
{ key: 'masterdata', label: 'Master Data' },
{ key: 'actionlogs', label: 'Action Logs' },
{ key: 'loginlogs', label: 'Login Logs' },
]
const filteredRoles = computed(() => {
+33 -6
View File
@@ -54,14 +54,15 @@
<span v-if="isMine(task)" class="mine-badge">My task</span>
</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">
<span class="flex items-center gap-1 muted-note">
<div class="mt-2 flex flex-col gap-1.5 text-[0.8rem]">
<span v-if="task.assigneeName" class="meta-chip meta-chip-user self-start">
<i class="pi pi-user text-[0.7rem]"></i> {{ task.assigneeName }}
</span>
</div>
<div class="text-[0.8rem]" v-if="task.dueDate">
<span class="flex items-center gap-1 muted-note">
<i class="pi pi-calendar text-[0.7rem]"></i> {{ formatDate(task.dueDate) }}
<span
class="meta-chip self-start"
:class="task.dueDate ? 'meta-chip-due' : 'meta-chip-nodue'"
>
<i class="pi pi-calendar text-[0.7rem]"></i> {{ task.dueDate ? formatDate(task.dueDate) : 'No Due date' }}
</span>
</div>
</div>
@@ -275,6 +276,32 @@ watch(
font-weight: 600;
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 {
background: color-mix(in srgb, var(--primary) 6%, transparent);
box-shadow: inset 0 0 0 2px var(--primary);