Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,18 @@ client.interceptors.response.use(
// the global handler for those and let the caller deal with it.
const url = error?.config?.url ?? ''
const isAuthEndpoint = typeof url === 'string' && url.includes('/auth/')
if (error?.response?.status === 401 && !isAuthEndpoint) {
const status = error?.response?.status
if (!isAuthEndpoint && (status === 401 || status === 403)) {
const auth = useAuthStore()
auth.clear()
router.replace({ name: 'login' })
// 401 = unauthenticated. 403 with an expired token = the kit rejects a stale
// session as "forbidden" (the expired JWT falls back to anonymous). Either way
// the session is dead → sign out and bounce to login with an "expired" hint.
// A genuine 403 (valid token, missing permission) is left alone so the calling
// view can surface a "no permission" message instead of logging the user out.
if (status === 401 || auth.isExpired()) {
auth.clear()
router.replace({ name: 'login', query: { expired: '1' } })
}
}
return Promise.reject(error)
},
Expand Down
12 changes: 12 additions & 0 deletions src/components/EmptyState.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<script setup lang="ts">
// A friendly empty state for tables/lists: an icon + message instead of a bare
// "no results" line. Keeps every list page consistent.
withDefaults(defineProps<{ icon?: string; message: string }>(), { icon: 'pi-inbox' })
</script>

<template>
<div class="flex flex-col items-center justify-center gap-3 py-12 text-surface-400 dark:text-surface-500">
<i :class="['pi', icon, 'text-4xl opacity-70']" />
<p class="text-sm">{{ message }}</p>
</div>
</template>
1 change: 1 addition & 0 deletions src/i18n/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ export default {
password: 'Password',
submit: 'Sign in',
failed: 'Login failed',
sessionExpired: 'Your session expired. Please sign in again.',
},
changePassword: {
title: 'Change your password',
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ export default {
password: '비밀번호',
submit: '로그인',
failed: '로그인 실패',
sessionExpired: '세션이 만료되었습니다. 다시 로그인해 주세요.',
},
changePassword: {
title: '비밀번호 변경',
Expand Down
4 changes: 2 additions & 2 deletions src/layout/AppLayout.vue
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ function toggleLocale() {
text
rounded
severity="secondary"
:aria-label="t('app.toggleLocale')"
v-tooltip.top="t('app.toggleLocale')" :aria-label="t('app.toggleLocale')"
data-testid="locale-toggle"
@click="toggleLocale"
>
Expand All @@ -119,7 +119,7 @@ function toggleLocale() {
rounded
:icon="ui.theme === 'dark' ? 'pi pi-sun' : 'pi pi-moon'"
severity="secondary"
:aria-label="t('app.toggleTheme')"
v-tooltip.top="t('app.toggleTheme')" :aria-label="t('app.toggleTheme')"
@click="ui.toggleTheme()"
/>
<Button
Expand Down
2 changes: 2 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import PrimeVue from 'primevue/config'
import Aura from '@primevue/themes/aura'
import ToastService from 'primevue/toastservice'
import ConfirmationService from 'primevue/confirmationservice'
import Tooltip from 'primevue/tooltip'

import './style.css'

Expand All @@ -29,6 +30,7 @@ app.use(PrimeVue, {
})
app.use(ToastService)
app.use(ConfirmationService)
app.directive('tooltip', Tooltip)

// Apply the persisted theme before mount so the first paint matches.
useUiStore().applyTheme()
Expand Down
9 changes: 8 additions & 1 deletion src/router/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,8 +104,15 @@ const router = createRouter({

router.beforeEach((to) => {
const auth = useAuthStore()
// Treat an expired JWT as logged-out: drop it so the user is bounced to login
// (with an "expired" hint) instead of making doomed API calls from a dead session.
const expired = auth.token !== null && auth.isExpired()
if (expired) auth.clear()
if (!to.meta.public && !auth.isAuthenticated) {
return { name: 'login', query: { redirect: to.fullPath } }
return {
name: 'login',
query: { ...(expired ? { expired: '1' } : {}), redirect: to.fullPath },
}
}
if (to.name === 'login' && auth.isAuthenticated) {
return auth.mustChangePassword ? { name: 'change-password' } : { name: 'dashboard' }
Expand Down
23 changes: 22 additions & 1 deletion src/stores/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,19 @@ export interface CurrentUser {
mustChangePassword?: boolean
}

/** Epoch ms of the JWT's `exp`, or null if absent/unparseable. */
function tokenExpiryMs(token: string | null): number | null {
if (!token) return null
const parts = token.split('.')
if (parts.length < 2) return null
try {
const payload = JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/')))
return typeof payload.exp === 'number' ? payload.exp * 1000 : null
} catch {
return null
}
}

export const useAuthStore = defineStore('auth', () => {
const token = ref<string | null>(localStorage.getItem(TOKEN_KEY))
const userJson = localStorage.getItem(USER_KEY)
Expand All @@ -23,6 +36,14 @@ export const useAuthStore = defineStore('auth', () => {
const isAuthenticated = computed(() => token.value !== null)
const mustChangePassword = computed(() => user.value?.mustChangePassword === true)

// A function (not computed) so it re-reads the clock on every call — used by the
// router guard and the axios interceptor to detect an expired session and bounce
// the user to login instead of letting expired requests fail silently.
function isExpired(): boolean {
const exp = tokenExpiryMs(token.value)
return exp != null && exp <= Date.now()
}

function setSession(newToken: string, newUser: CurrentUser) {
token.value = newToken
user.value = newUser
Expand All @@ -37,5 +58,5 @@ export const useAuthStore = defineStore('auth', () => {
localStorage.removeItem(USER_KEY)
}

return { token, user, isAuthenticated, mustChangePassword, setSession, clear }
return { token, user, isAuthenticated, isExpired, mustChangePassword, setSession, clear }
})
4 changes: 2 additions & 2 deletions src/views/AuditLogsView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ onMounted(reload)
<h1 class="text-2xl font-semibold">{{ t('auditLogs.title') }}</h1>
<span class="text-sm text-surface-500">{{ totalRecords.toLocaleString() }}</span>
</div>
<Button icon="pi pi-refresh" severity="secondary" outlined :aria-label="t('common.ariaRefresh')" @click="reload" />
<Button icon="pi pi-refresh" severity="secondary" outlined v-tooltip.top="t('common.ariaRefresh')" :aria-label="t('common.ariaRefresh')" @click="reload" />
</div>

<div class="grid grid-cols-1 md:grid-cols-6 gap-3 p-3 rounded-md border border-surface-200 dark:border-surface-700">
Expand Down Expand Up @@ -168,7 +168,7 @@ onMounted(reload)
<Column field="ip" :header="t('auditLogs.columns.ip')" />
<Column header="" style="width: 5rem">
<template #body="{ data }">
<Button icon="pi pi-eye" text rounded :aria-label="t('auditLogs.ariaInspect')" @click="openDetail(data)" />
<Button icon="pi pi-eye" text rounded v-tooltip.top="t('auditLogs.ariaInspect')" :aria-label="t('auditLogs.ariaInspect')" @click="openDetail(data)" />
</template>
</Column>
</DataTable>
Expand Down
2 changes: 1 addition & 1 deletion src/views/DashboardView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ onMounted(refresh)
severity="secondary"
outlined
:loading="loading"
:aria-label="t('dashboard.refresh')"
v-tooltip.top="t('dashboard.refresh')" :aria-label="t('dashboard.refresh')"
@click="refresh"
/>
</div>
Expand Down
13 changes: 7 additions & 6 deletions src/views/GroupsView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import DataTable from 'primevue/datatable'
import Column from 'primevue/column'
import EmptyState from '@/components/EmptyState.vue'
import Button from 'primevue/button'
import Dialog from 'primevue/dialog'
import InputText from 'primevue/inputtext'
Expand Down Expand Up @@ -190,7 +191,7 @@ onMounted(reload)
<h1 class="text-xl font-semibold">{{ t('groups.title') }}</h1>
<div class="flex items-center gap-2">
<InputText v-model="search" :placeholder="t('common.search')" class="w-56" />
<Button icon="pi pi-refresh" severity="secondary" outlined :aria-label="t('common.ariaRefresh')" @click="reload" />
<Button icon="pi pi-refresh" severity="secondary" outlined v-tooltip.top="t('common.ariaRefresh')" :aria-label="t('common.ariaRefresh')" @click="reload" />
<Button icon="pi pi-plus" :label="t('common.create')" @click="openCreate" />
</div>
</div>
Expand All @@ -204,21 +205,21 @@ onMounted(reload)
data-key="id.value"
>
<template #empty>
<div class="py-6 text-center text-surface-500">{{ t('common.noResults') }}</div>
<EmptyState :message="t('common.noResults')" />
</template>
<Column field="code" :header="t('common.code')" sortable />
<Column field="name" :header="t('common.name')" sortable />
<Column header="" style="width: 10rem; text-align: right">
<template #body="{ data }">
<Button icon="pi pi-users" text rounded :aria-label="t('groups.manageMembers')" @click="openMembers(data)" />
<Button icon="pi pi-key" text rounded :aria-label="t('groups.manageRoles')" @click="openRoles(data)" />
<Button icon="pi pi-pencil" text rounded :aria-label="t('common.ariaRename')" @click="openRename(data)" />
<Button icon="pi pi-users" text rounded v-tooltip.top="t('groups.manageMembers')" :aria-label="t('groups.manageMembers')" @click="openMembers(data)" />
<Button icon="pi pi-key" text rounded v-tooltip.top="t('groups.manageRoles')" :aria-label="t('groups.manageRoles')" @click="openRoles(data)" />
<Button icon="pi pi-pencil" text rounded v-tooltip.top="t('common.ariaRename')" :aria-label="t('common.ariaRename')" @click="openRename(data)" />
<Button
icon="pi pi-trash"
text
rounded
severity="danger"
:aria-label="t('common.ariaDelete')"
v-tooltip.top="t('common.ariaDelete')" :aria-label="t('common.ariaDelete')"
@click="confirmDelete(data)"
/>
</template>
Expand Down
7 changes: 7 additions & 0 deletions src/views/LoginView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import Card from 'primevue/card'
import InputText from 'primevue/inputtext'
import Password from 'primevue/password'
import Button from 'primevue/button'
import Message from 'primevue/message'
import { useAuthStore } from '@/stores/auth'
import { authApi } from '@/api/auth'

Expand All @@ -16,6 +17,9 @@ const route = useRoute()
const toast = useToast()
const { t } = useI18n()

// Set by the router guard / axios interceptor when an expired session was bounced here.
const sessionExpired = route.query.expired === '1'

const tenantId = ref('default')
const loginId = ref('')
const rawPassword = ref('')
Expand Down Expand Up @@ -61,6 +65,9 @@ async function submit() {
</div>
</template>
<template #content>
<Message v-if="sessionExpired" severity="warn" :closable="false" class="mb-4">
{{ t('login.sessionExpired') }}
</Message>
<form class="flex flex-col gap-4" @submit.prevent="submit">
<div class="flex flex-col gap-1">
<label for="tenantId" class="text-sm font-medium">{{ t('login.tenant') }}</label>
Expand Down
11 changes: 6 additions & 5 deletions src/views/MenusView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import TreeTable from 'primevue/treetable'
import Column from 'primevue/column'
import EmptyState from '@/components/EmptyState.vue'
import Button from 'primevue/button'
import Dialog from 'primevue/dialog'
import InputText from 'primevue/inputtext'
Expand Down Expand Up @@ -172,14 +173,14 @@ onMounted(() => {
<div class="flex items-center justify-between">
<h1 class="text-xl font-semibold">{{ t('menus.title') }}</h1>
<div class="flex items-center gap-2">
<Button icon="pi pi-refresh" severity="secondary" outlined :aria-label="t('common.ariaRefresh')" @click="reload" />
<Button icon="pi pi-refresh" severity="secondary" outlined v-tooltip.top="t('common.ariaRefresh')" :aria-label="t('common.ariaRefresh')" @click="reload" />
<Button icon="pi pi-plus" :label="t('menus.addRoot')" @click="openCreate(null)" />
</div>
</div>

<TreeTable :value="nodes" :loading="loading" data-key="key" :indent-size="24">
<template #empty>
<div class="py-6 text-center text-surface-500">{{ t('common.noResults') }}</div>
<EmptyState :message="t('common.noResults')" />
</template>
<Column field="label" :header="t('menus.columns.label')" expander style="min-width: 16rem">
<template #body="{ node }">
Expand All @@ -203,14 +204,14 @@ onMounted(() => {
<Column field="displayOrder" :header="t('menus.columns.order')" style="width: 6rem" />
<Column header="" style="width: 12rem; text-align: right">
<template #body="{ node }">
<Button icon="pi pi-plus" text rounded :aria-label="t('menus.ariaAddChild')" @click="openCreate(node.data.id)" />
<Button icon="pi pi-pencil" text rounded :aria-label="t('common.ariaEdit')" @click="openEdit(node.data)" />
<Button icon="pi pi-plus" text rounded v-tooltip.top="t('menus.ariaAddChild')" :aria-label="t('menus.ariaAddChild')" @click="openCreate(node.data.id)" />
<Button icon="pi pi-pencil" text rounded v-tooltip.top="t('common.ariaEdit')" :aria-label="t('common.ariaEdit')" @click="openEdit(node.data)" />
<Button
icon="pi pi-trash"
text
rounded
severity="danger"
:aria-label="t('common.ariaDelete')"
v-tooltip.top="t('common.ariaDelete')" :aria-label="t('common.ariaDelete')"
@click="confirmDelete(node.data)"
/>
</template>
Expand Down
9 changes: 5 additions & 4 deletions src/views/PermissionsView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import DataTable from 'primevue/datatable'
import Column from 'primevue/column'
import EmptyState from '@/components/EmptyState.vue'
import Button from 'primevue/button'
import Dialog from 'primevue/dialog'
import InputText from 'primevue/inputtext'
Expand Down Expand Up @@ -138,7 +139,7 @@ onMounted(reload)
<h1 class="text-xl font-semibold">{{ t('permissions.title') }}</h1>
<div class="flex items-center gap-2">
<InputText v-model="search" :placeholder="t('common.search')" class="w-56" />
<Button icon="pi pi-refresh" severity="secondary" outlined :aria-label="t('common.ariaRefresh')" @click="reload" />
<Button icon="pi pi-refresh" severity="secondary" outlined v-tooltip.top="t('common.ariaRefresh')" :aria-label="t('common.ariaRefresh')" @click="reload" />
<Button icon="pi pi-plus" :label="t('common.create')" @click="openCreate" />
</div>
</div>
Expand All @@ -152,19 +153,19 @@ onMounted(reload)
data-key="id"
>
<template #empty>
<div class="py-6 text-center text-surface-500">{{ t('common.noResults') }}</div>
<EmptyState :message="t('common.noResults')" />
</template>
<Column field="code" :header="t('common.code')" sortable />
<Column field="description" :header="t('common.description')" />
<Column header="" style="width: 10rem; text-align: right">
<template #body="{ data }">
<Button icon="pi pi-pencil" text rounded :aria-label="t('common.ariaEdit')" @click="openEdit(data)" />
<Button icon="pi pi-pencil" text rounded v-tooltip.top="t('common.ariaEdit')" :aria-label="t('common.ariaEdit')" @click="openEdit(data)" />
<Button
icon="pi pi-trash"
text
rounded
severity="danger"
:aria-label="t('common.ariaDelete')"
v-tooltip.top="t('common.ariaDelete')" :aria-label="t('common.ariaDelete')"
@click="confirmDelete(data)"
/>
</template>
Expand Down
4 changes: 2 additions & 2 deletions src/views/PoliciesView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ onMounted(reload)
<div class="flex flex-col gap-4">
<div class="flex items-center justify-between">
<h1 class="text-xl font-semibold">{{ t('policies.title') }}</h1>
<Button icon="pi pi-refresh" severity="secondary" outlined :aria-label="t('common.ariaRefresh')" @click="reload" />
<Button icon="pi pi-refresh" severity="secondary" outlined v-tooltip.top="t('common.ariaRefresh')" :aria-label="t('common.ariaRefresh')" @click="reload" />
</div>

<Message severity="info" :closable="false">
Expand All @@ -141,7 +141,7 @@ onMounted(reload)
</Column>
<Column header="" style="width: 8rem; text-align: right">
<template #body="{ data }">
<Button icon="pi pi-play" text rounded :aria-label="t('policies.ariaTest')" @click="openTest(data)" />
<Button icon="pi pi-play" text rounded v-tooltip.top="t('policies.ariaTest')" :aria-label="t('policies.ariaTest')" @click="openTest(data)" />
</template>
</Column>
</DataTable>
Expand Down
11 changes: 6 additions & 5 deletions src/views/RolesView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import DataTable from 'primevue/datatable'
import Column from 'primevue/column'
import EmptyState from '@/components/EmptyState.vue'
import Button from 'primevue/button'
import Dialog from 'primevue/dialog'
import InputText from 'primevue/inputtext'
Expand Down Expand Up @@ -150,7 +151,7 @@ onMounted(reload)
<h1 class="text-xl font-semibold">{{ t('roles.title') }}</h1>
<div class="flex items-center gap-2">
<InputText v-model="search" :placeholder="t('common.search')" class="w-56" />
<Button icon="pi pi-refresh" severity="secondary" outlined :aria-label="t('common.ariaRefresh')" @click="reload" />
<Button icon="pi pi-refresh" severity="secondary" outlined v-tooltip.top="t('common.ariaRefresh')" :aria-label="t('common.ariaRefresh')" @click="reload" />
<Button icon="pi pi-plus" :label="t('common.create')" @click="openCreate" />
</div>
</div>
Expand All @@ -164,7 +165,7 @@ onMounted(reload)
data-key="id.value"
>
<template #empty>
<div class="py-6 text-center text-surface-500">{{ t('common.noResults') }}</div>
<EmptyState :message="t('common.noResults')" />
</template>
<Column field="code" :header="t('common.code')" sortable />
<Column field="name" :header="t('common.name')" sortable />
Expand All @@ -174,16 +175,16 @@ onMounted(reload)
icon="pi pi-key"
text
rounded
:aria-label="t('roles.managePermissions')"
v-tooltip.top="t('roles.managePermissions')" :aria-label="t('roles.managePermissions')"
@click="openPermissions(data)"
/>
<Button icon="pi pi-pencil" text rounded :aria-label="t('common.ariaRename')" @click="openRename(data)" />
<Button icon="pi pi-pencil" text rounded v-tooltip.top="t('common.ariaRename')" :aria-label="t('common.ariaRename')" @click="openRename(data)" />
<Button
icon="pi pi-trash"
text
rounded
severity="danger"
:aria-label="t('common.ariaDelete')"
v-tooltip.top="t('common.ariaDelete')" :aria-label="t('common.ariaDelete')"
@click="confirmDelete(data)"
/>
</template>
Expand Down
2 changes: 1 addition & 1 deletion src/views/SettingsView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ onMounted(reload)
severity="secondary"
outlined
:loading="loading"
:aria-label="t('common.ariaRefresh')"
v-tooltip.top="t('common.ariaRefresh')" :aria-label="t('common.ariaRefresh')"
@click="reload"
/>
</div>
Expand Down
Loading
Loading