feat : middleware and refresh token

This commit is contained in:
Yusron alamsyah
2026-04-06 13:55:31 +07:00
parent d438fb0f5f
commit 4325bae76f
16 changed files with 1127 additions and 476 deletions
@@ -0,0 +1,359 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { Icon } from '@iconify/vue';
import api from '@/utils/api';
import { useSnackbarStore } from '~/store/snackbar';
import type {ModalMode,ParentPageOption} from '~/types/setting/menu';
import { useValidation } from '~/composables/useValidation';
const props = withDefaults(
defineProps<{
modelValue: boolean;
mode?: ModalMode;
pageId?: number | null;
}>(),
{
mode: 'create',
pageId: null,
},
);
const emit = defineEmits(['update:modelValue', 'saved']);
const dialog = ref(props.modelValue);
watch(() => props.modelValue, (newValue) => {
dialog.value = newValue;
});
watch(dialog, (newValue) => {
if (!newValue) {
emit('update:modelValue', false);
}
});
const entryType = ref('main');
const labelName = ref('');
const formRef = ref<any>(null);
const valid = ref(false);
const isSaving = ref(false);
const isLoadingDetail = ref(false);
const snackbarStore = useSnackbarStore();
const { required, isNumber } = useValidation();
const parentModule = ref<number | null>(null);
const parentPageItems = ref<ParentPageOption[]>([]);
const parentPageLoading = ref(false);
const parentPageSearch = ref('');
const url = ref('');
const sortOrder = ref<number | null>(null);
const icon = ref('');
const active = ref(true);
const isDetailMode = computed(() => props.mode === 'detail');
const isEditMode = computed(() => props.mode === 'edit');
const isCreateMode = computed(() => props.mode === 'create');
const isFormDisabled = computed(() => isDetailMode.value || isSaving.value || isLoadingDetail.value);
const titleText = computed(() => {
if (isCreateMode.value) return 'Tambah Menu';
if (isEditMode.value) return 'Edit Menu';
return 'Detail Menu';
});
const submitLabel = computed(() => (isEditMode.value ? 'Update' : 'Save'));
const resetForm = () => {
entryType.value = 'main';
labelName.value = '';
parentModule.value = null;
parentPageItems.value = [];
parentPageLoading.value = false;
parentPageSearch.value = '';
url.value = '';
sortOrder.value = null;
icon.value = '';
active.value = true;
valid.value = false;
formRef.value?.resetValidation?.();
};
const resolveParentLevel = async (parentId: number): Promise<number | null> => {
const fromItems = parentPageItems.value.find((p) => p.id === parentId)?.level;
if (fromItems != null) return fromItems;
try {
const response = await api.get(`/api/v1/roles/pages/${parentId}`);
const data = response.data?.data;
return typeof data?.level === 'number' ? data.level : null;
} catch {
return null;
}
};
const fetchParentPages = async (filterText: string) => {
parentPageLoading.value = true;
try {
const response = await api.get('/api/v1/roles/pages/search', {
params: {
name: filterText || '',
},
});
const data = response.data?.data;
parentPageItems.value = Array.isArray(data) ? data : [];
} catch (error) {
console.error('Error fetching parent pages:', error);
parentPageItems.value = [];
} finally {
parentPageLoading.value = false;
}
};
let parentPageSearchTimer: ReturnType<typeof setTimeout> | undefined;
watch(parentPageSearch, (value) => {
if (parentPageSearchTimer) clearTimeout(parentPageSearchTimer);
parentPageSearchTimer = setTimeout(() => {
if (!dialog.value || entryType.value !== 'sub') return;
fetchParentPages(value);
}, 300);
});
watch([dialog, entryType], ([isOpen, type]) => {
if (!isOpen) {
parentPageSearch.value = '';
parentPageItems.value = [];
parentPageLoading.value = false;
return;
}
if (type === 'sub') {
fetchParentPages(parentPageSearch.value);
} else {
parentModule.value = null;
}
});
let lastLoadedKey = '';
const fetchPageDetail = async () => {
if (!props.pageId) return;
const key = `${props.mode}:${props.pageId}`;
if (lastLoadedKey === key) return;
lastLoadedKey = key;
isLoadingDetail.value = true;
try {
const response = await api.get(`/api/v1/roles/pages/${props.pageId}`);
const data = response.data?.data;
if (!data) throw new Error('Data menu tidak ditemukan');
labelName.value = data.name ?? '';
icon.value = data.icon ?? '';
url.value = data.url ?? '';
sortOrder.value = typeof data.sort === 'number' ? data.sort : null;
active.value = typeof data.active === 'boolean' ? data.active : true;
const isSub = data.parent != null;
entryType.value = isSub ? 'sub' : 'main';
parentModule.value = isSub ? data.parent : null;
if (isSub) {
await fetchParentPages('');
}
} catch (error: any) {
const message = error?.response?.data?.message || error?.message || 'Gagal memuat detail menu';
snackbarStore.showSnackbar(message, 'error');
} finally {
isLoadingDetail.value = false;
}
};
watch(
[dialog, () => props.mode, () => props.pageId],
async ([isOpen, mode]) => {
if (!isOpen) return;
if (mode === 'create') {
lastLoadedKey = '';
resetForm();
return;
}
await fetchPageDetail();
},
{ immediate: false },
);
const save = () => {
const run = async () => {
if (isDetailMode.value) return;
if (isSaving.value) return;
isSaving.value = true;
try {
await formRef.value?.validate?.();
if (!valid.value) {
snackbarStore.showSnackbar('Lengkapi form terlebih dahulu', 'error');
return;
}
const isSubMenu = entryType.value === 'sub';
const parentId = isSubMenu ? parentModule.value : null;
let level = 1;
if (isSubMenu) {
if (!parentId) {
snackbarStore.showSnackbar('Menu induk tidak ditemukan, silakan pilih ulang', 'error');
return;
}
const parentLevel = await resolveParentLevel(parentId);
if (parentLevel == null) {
snackbarStore.showSnackbar('Menu induk tidak ditemukan, silakan pilih ulang', 'error');
return;
}
level = parentLevel + 1;
}
const payload: Record<string, any> = {
name: labelName.value,
icon: icon.value,
url: url.value,
level,
sort: sortOrder.value,
active: active.value,
};
if (isCreateMode.value) {
if (isSubMenu) payload.parent = parentId;
await api.post('/api/v1/roles/pages', payload);
snackbarStore.showSnackbar('Menu berhasil ditambahkan', 'success');
} else if (isEditMode.value) {
if (!props.pageId) throw new Error('ID menu tidak ditemukan');
payload.parent = parentId;
await api.put(`/api/v1/roles/pages/${props.pageId}`, payload);
snackbarStore.showSnackbar('Menu berhasil diperbarui', 'success');
}
emit('saved');
close();
} catch (error: any) {
const message = error?.response?.data?.message || error?.message || 'Gagal menambahkan menu';
snackbarStore.showSnackbar(message, 'error');
} finally {
isSaving.value = false;
}
};
run();
};
const close = () => {
emit('update:modelValue', false);
lastLoadedKey = '';
resetForm();
};
</script>
<template>
<v-dialog v-model="dialog" max-width="600px" persistent>
<v-card>
<v-form v-model="valid" ref="formRef" @submit.prevent="save">
<v-card-title class="d-flex align-center justify-space-between pa-3">
<div class="d-flex align-center">
<span class="ml-3 text-h6">{{ titleText }}</span>
</div>
<v-btn
icon
variant="text"
@click="close"
>
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-divider></v-divider>
<v-card-text>
<div>
<label class="font-weight-medium required">Tipe Menu</label>
<div class="d-flex gap-2 mt-2 rounded-lg">
<v-btn value="main" class="flex-grow-1" @click="entryType = 'main'" color="primary"
:readonly="isFormDisabled" :variant="entryType === 'main' ? 'flat' : 'outlined'" height="88" rounded="lg">
<div class="d-flex flex-column align-center">
<v-icon class="mb-1">mdi-file-document-outline</v-icon>
<span>Main Menu</span>
</div>
</v-btn>
<v-btn value="sub" class="flex-grow-1" @click="entryType = 'sub'" color="primary"
:readonly="isFormDisabled" :variant="entryType === 'sub' ? 'flat' : 'outlined'" height="88" rounded="lg">
<div class="d-flex flex-column align-center">
<v-icon class="mb-1">mdi-file-tree-outline</v-icon>
<span>Sub-Menu</span>
</div>
</v-btn>
</div>
</div>
<div class="mt-2">
<label class="font-weight-medium required">Nama Menu</label>
<v-text-field v-model="labelName" class="mt-2" variant="outlined" density="compact"
:readonly="isFormDisabled" :rules="[required]" />
</div>
<div v-if="entryType === 'sub'">
<label class="font-weight-medium required">Menu Induk</label>
<v-autocomplete v-model="parentModule" v-model:search="parentPageSearch" class="mt-2"
:items="parentPageItems" item-title="name" item-value="id" placeholder="Pilih menu induk"
variant="outlined" density="compact" :readonly="isFormDisabled" :loading="parentPageLoading" no-filter clearable
:rules="[required]" />
</div>
<div>
<label class="font-weight-medium required">URL</label>
<v-text-field v-model="url" class="mt-2" placeholder="/path/to/page" variant="outlined"
density="compact" :readonly="isFormDisabled" :rules="[required]" />
</div>
<v-row>
<v-col cols="6">
<label class="font-weight-medium required">Urutan</label>
<v-text-field v-model.number="sortOrder" class="mt-2" type="number" placeholder="urutan menu"
variant="outlined" density="compact" :readonly="isFormDisabled" :rules="[required, isNumber]" />
</v-col>
<v-col cols="6">
<label class="font-weight-medium required">Menu Icon</label>
<v-text-field v-model="icon" class="mt-2" placeholder="icon menu" variant="outlined"
density="compact" :readonly="isFormDisabled" :rules="[required]">
<template #append-inner>
<!-- <v-avatar color="primary" size="32"> -->
<Icon v-if="icon" class="text-primary" :icon="'solar:' + icon" width="20" />
<!-- </v-avatar> -->
</template>
</v-text-field>
</v-col>
</v-row>
<div>
<label class="font-weight-medium required">Status</label>
<v-switch v-model="active" class="mt-2" color="primary" inset :readonly="isFormDisabled" hide-details>
<template #label>
<span>{{ active ? 'Aktif' : 'Nonaktif' }}</span>
</template>
</v-switch>
</div>
</v-card-text>
<v-divider></v-divider>
<v-card-actions class="pa-4">
<v-spacer></v-spacer>
<v-btn v-if="!isDetailMode" color="primary" type="submit" variant="flat" :loading="isSaving" :disabled="isSaving">{{ submitLabel }}</v-btn>
</v-card-actions>
</v-form>
</v-card>
</v-dialog>
</template>
+90
View File
@@ -0,0 +1,90 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue";
import type { RolePage } from '~/types/setting/menu';
const props = defineProps<{
page: RolePage;
level: number;
}>();
const emit = defineEmits(['detail', 'edit', 'delete']);
const handleDetail = (page: RolePage) => emit('detail', page);
const handleEdit = (page: RolePage) => emit('edit', page);
const handleDelete = (page: RolePage) => emit('delete', page);
</script>
<template>
<div
:style="{ marginLeft: (level > 0 ? 40 : 0) + 'px' }"
:class="{ 'is-child': level > 0 }"
class="page-row-container"
>
<v-card class="mb-3 page-card" elevation="2" @click="handleDetail(page)">
<v-card-text class="pa-5">
<div class="d-flex align-center">
<v-avatar color="primary" size="40" class="me-3">
<Icon :icon="'solar:' + page.icon" width="24" />
</v-avatar>
<div class="flex-grow-1">
<div class="d-flex align-center">
<h6 class="text-subtitle-1 font-weight-semibold">{{ page.name }}</h6>
</div>
<p class="text-muted mb-0">{{ page.url }}</p>
</div>
<v-btn icon variant="text" class="ml-2" @click.stop="handleEdit(page)">
<v-icon>mdi-pencil</v-icon>
</v-btn>
<v-btn icon variant="text" class="ml-2" @click.stop="handleDelete(page)">
<v-icon>mdi-delete</v-icon>
</v-btn>
</div>
</v-card-text>
</v-card>
<div v-if="page.children && page.children.length > 0" class="children-container">
<PageRow
v-for="child in page.children"
:key="child.id"
:page="child"
:level="level + 1"
@detail="handleDetail"
@edit="handleEdit"
@delete="handleDelete"
/>
</div>
</div>
</template>
<style scoped>
.page-row-container {
position: relative;
}
.page-card {
cursor: pointer;
}
.children-container {
position: relative;
}
.children-container::before {
content: '';
position: absolute;
top: -12px;
bottom: 0;
left: 20px;
width: 3px;
background-color: #e0e0e0;
}
.children-container > .page-row-container::before {
content: '';
position: absolute;
top: 28px;
left: -20px;
width: 20px;
height: 3px;
background-color: #e0e0e0;
}
</style>
+63 -63
View File
@@ -8,17 +8,18 @@
<p class="text-body-1 text-grey mb-7">
Silakan masuk dengan akun yang telah terdaftar
</p>
<!-- Error Message -->
<v-alert v-if="errorMessage" type="error" closable class="mb-4" @click:close="errorMessage = ''">
{{ errorMessage }}
</v-alert>
<!-- Error Message -->
<v-alert v-if="errorMessage" type="error" closable class="mb-4" @click:close="errorMessage = ''">
{{ errorMessage }}
</v-alert>
<!-- Success Message -->
<v-alert v-if="successMessage" type="success" class="mb-4">
{{ successMessage }}
</v-alert>
<!-- Success Message -->
<v-alert v-if="successMessage" type="success" class="mb-4">
{{ successMessage }}
</v-alert>
<v-btn :loading="isLoading" :disabled="isLoading" block color="primary" size="large" rounded="pill" class="text-none mb-7 btn-login" @click="loginWithKeycloak">
<v-btn :loading="isLoadingKeycloak" :disabled="isLoadingKeycloak" block color="primary" size="large" rounded="pill"
class="text-none mb-7 btn-login" @click="loginWithKeycloak">
<v-icon size="16" class="mr-2">mdi-key-variant</v-icon>
Masuk dengan Keycloak
</v-btn>
@@ -30,41 +31,19 @@
</div>
<v-form v-model="valid" @submit.prevent="login">
<label class="text-subtitle-2 text-grey-darken-1 mb-2 d-inline-block">Email </label>
<v-text-field
v-model="email"
placeholder="Masukkan email"
density="comfortable"
variant="outlined"
rounded="pill"
class="mb-5"
:rules="emailRules"
/>
<label for="email" class="text-subtitle-2 text-grey-darken-1 mb-2 d-inline-block">Email </label>
<v-text-field v-model="email" id="email" placeholder="Masukkan email" density="comfortable" variant="outlined"
rounded="pill" class="mb-5" :rules="[required, emailRules]" />
<div class="d-flex align-center justify-space-between mb-2">
<label class="text-subtitle-2 text-grey-darken-1">Password</label>
<label for="password" class="text-subtitle-2 text-grey-darken-1">Password</label>
</div>
<v-text-field
v-model="password"
:type="showPassword ? 'text' : 'password'"
placeholder="Masukkan password"
density="comfortable"
variant="outlined"
rounded="pill"
class="mb-6"
:rules="passwordRules"
:append-inner-icon="showPassword ? 'mdi-eye' : 'mdi-eye-off'"
@click:append-inner="showPassword = !showPassword"
/>
<v-btn
block
type="submit"
color="primary"
size="large"
rounded="pill"
variant="tonal"
class="text-none mb-8 btn-login"
>
<v-text-field v-model="password" id="password" :type="showPassword ? 'text' : 'password'"
placeholder="Masukkan password" density="comfortable" variant="outlined" rounded="pill" class="mb-6"
:rules="passwordRules" :append-inner-icon="showPassword ? 'mdi-eye' : 'mdi-eye-off'"
@click:append-inner="showPassword = !showPassword" />
<v-btn block type="submit" color="primary" size="large" rounded="pill" variant="tonal"
class="text-none mb-8 btn-login" :loading="isLoadingKeycloak" :disabled="isLoadingKeycloak">
Masuk
</v-btn>
</v-form>
@@ -76,38 +55,57 @@
</template>
<script setup lang="ts">
import { ref } from "vue";
import { ref, onMounted } from "vue";
import Logoimg from "/images/logos/logo-farmasi.svg";
import { useValidation } from '~/composables/useValidation';
import api from '~/utils/api';
import { useSnackbarStore } from '~/store/snackbar';
interface LoginResponse {
data : {
provider: string;
access_token: string;
refresh_token: string;
expires_in: number;
token_type: string;
}
}
import type { LoginResponse } from '~/types/auth';
import { useAuth } from '~/composables/useAuth';
const snackbarStore = useSnackbarStore();
const { required } = useValidation();
const { required, emailRules } = useValidation();
const { fetchUserSession, sessionData } = useAuth();
const email = ref("");
const password = ref("");
const valid = ref(false);
const showPassword = ref(false);
const isLoadingKeycloak = ref(false);
const isLoading = ref(false);
const errorMessage = ref("");
const successMessage = ref("");
const emailRules = [
required,
(v : string) => /.+@.+\..+/.test(v) || 'Email tidak valid',
];
onMounted(async () => {
const urlParams = new URLSearchParams(window.location.search);
const authStatus = urlParams.get('auth');
if (authStatus === 'success') {
isLoadingKeycloak.value = true;
successMessage.value = 'Authentication successful. Redirecting to dashboard...';
}
const errorParam = urlParams.get('error');
if (errorParam) {
errorMessage.value = decodeURIComponent(errorParam);
}
await fetchUserSession();
if (sessionData.value?.accessToken && sessionData.value?.refreshToken) {
localStorage.setItem('accessToken', sessionData.value.accessToken);
localStorage.setItem('refreshToken', sessionData.value.refreshToken);
const newUrl = window.location.pathname;
window.history.replaceState({}, document.title, newUrl);
isLoadingKeycloak.value = false;
window.location.href = "/apps/dashboard";
}
});
const passwordRules = [required];
const login = async () => {
@@ -125,7 +123,6 @@ const login = async () => {
});
const loginData = response.data.data;
console.log('Login response:', loginData);
if (!loginData?.access_token || !loginData?.refresh_token) {
throw new Error('Token login tidak valid');
@@ -139,7 +136,10 @@ const login = async () => {
},
});
localStorage.setItem('accessToken', loginData.access_token);
localStorage.setItem('refreshToken', loginData.refresh_token);
window.location.href = "/apps/dashboard";
} catch (error: any) {
const loginError = error?.response?.data?.message || error?.message || 'Terjadi kesalahan saat login';
@@ -150,12 +150,12 @@ const login = async () => {
};
const loginWithKeycloak = async (): Promise<void> => {
isLoading.value = true;
isLoadingKeycloak.value = true;
errorMessage.value = '';
successMessage.value = '';
try {
// Call API route to initiate Keycloak login process
const response = await $fetch<any>('/api/auth/keycloak-login', {
method: 'POST'
@@ -163,7 +163,7 @@ const loginWithKeycloak = async (): Promise<void> => {
if (response?.success && response?.data?.authUrl) {
successMessage.value = 'Redirecting to Keycloak...';
// Redirect the user to the Keycloak authorization URL
setTimeout(() => {
window.location.href = response.data!.authUrl;
@@ -178,7 +178,7 @@ const loginWithKeycloak = async (): Promise<void> => {
} finally {
// Only set isLoading back to false if no redirect is happening
if (!successMessage.value.includes('Redirecting')) {
isLoading.value = false;
isLoadingKeycloak.value = false;
}
}
};
+13 -8
View File
@@ -5,6 +5,8 @@ import sidebarItems from "~/components/layout/full/vertical-sidebar/sidebarItem"
import { Menu2Icon } from "vue-tabler-icons";
import { useCustomizerStore } from "~/store/customizer";
import AppSnackbar from '~/components/shared/AppSnackbar.vue';
import Customizer from "~/components/layout/full/customizer/Customizer.vue";
const sidebarMenu = shallowRef(sidebarItems);
const customizer = useCustomizerStore();
@@ -21,6 +23,7 @@ watch(mdAndDown, (val) => {
<template>
<!------Sidebar-------->
<v-locale-provider>
<AppSnackbar />
<v-app
:theme="customizer.actTheme"
class="bg-containerBg"
@@ -47,14 +50,16 @@ watch(mdAndDown, (val) => {
<div :class="customizer.boxed ? 'maxWidth' : 'full-header'">
<LazyLayoutFullVerticalHeader v-if="!customizer.setHorizontalLayout" />
</div>
<v-main>
<v-container fluid class="page-wrapper">
<div :class="customizer.boxed ? 'maxWidth' : ''">
<RouterView />
<AppSnackbar />
</div>
</v-container>
<v-main class="ml-md-4">
<div class="rtl-lyt mb-3 hr-layout bg-containerBg">
<v-container fluid class="page-wrapper bg-background pt-md-8 rounded-xl">
<div>
<div class="">
<NuxtPage />
</div>
</div>
</v-container>
</div>
</v-main>
</v-app>
</v-locale-provider>
@@ -2,7 +2,8 @@
<script setup lang="ts">
import { MailIcon } from "vue-tabler-icons";
import { useAuth } from "~/composables/useAuth";
import { computed, onMounted } from "vue";
import { computed, onMounted, ref } from "vue";
import DialogConfrim from "~/components/shared/DialogConfrim.vue";
const auth = useAuth();
@@ -12,29 +13,39 @@ onMounted(async () => {
}
});
// Enhanced logout with proper error handling
const logout = async () => {
await auth.logout();
const isLogoutDialogOpen = ref(false);
const isFullLogoutDialogOpen = ref(false);
const isLoggingOut = ref(false);
const isFullLoggingOut = ref(false);
const openLogoutDialog = () => {
isLogoutDialogOpen.value = true;
};
// **TAMBAHAN: Full logout function dengan konfirmasi**
const fullLogout = async () => {
const confirmed = confirm(
"Apakah Anda yakin ingin keluar dari semua sesi? Ini akan menghapus semua data lokal dan sesi Keycloak."
);
if (!confirmed) return;
await auth.fullLogout();
const openFullLogoutDialog = () => {
isFullLogoutDialogOpen.value = true;
};
// **TAMBAHAN: Logout dengan konfirmasi untuk UX yang lebih baik**
const logoutWithConfirmation = async () => {
const confirmed = confirm("Apakah Anda yakin ingin keluar?");
const confirmLogout = async () => {
if (isLoggingOut.value) return;
isLoggingOut.value = true;
try {
await auth.logout();
isLogoutDialogOpen.value = false;
} finally {
isLoggingOut.value = false;
}
};
if (!confirmed) return;
await logout();
const confirmFullLogout = async () => {
if (isFullLoggingOut.value) return;
isFullLoggingOut.value = true;
try {
await auth.fullLogout();
isFullLogoutDialogOpen.value = false;
} finally {
isFullLoggingOut.value = false;
}
};
// Get user display info from session
@@ -121,7 +132,7 @@ const sessionInfo = computed(() => {
color="primary"
variant="outlined"
block
@click="logoutWithConfirmation"
@click="openLogoutDialog"
prepend-icon="mdi-logout"
class="mb-2"
>
@@ -134,13 +145,35 @@ const sessionInfo = computed(() => {
color="error"
variant="outlined"
block
@click="fullLogout"
@click="openFullLogoutDialog"
prepend-icon="mdi-logout-variant"
class="mb-2"
>
Full Logout
</v-btn>
</div>
<DialogConfrim
v-model="isLogoutDialogOpen"
title="Logout"
message="Apakah Anda yakin ingin keluar?"
confirm-text="Ya"
cancel-text="Tidak"
:loading="isLoggingOut"
:close-on-confirm="false"
@confirm="confirmLogout"
/>
<DialogConfrim
v-model="isFullLogoutDialogOpen"
title="Full Logout"
message="Apakah Anda yakin ingin keluar dari semua sesi? Ini akan menghapus semua data lokal dan sesi Keycloak."
confirm-text="Ya"
cancel-text="Tidak"
:loading="isFullLoggingOut"
:close-on-confirm="false"
@confirm="confirmFullLogout"
/>
</v-sheet>
</v-menu>
</template>
@@ -44,14 +44,14 @@ watch(priority, (newPriority) => {
<!-- ---------------------------------------------- -->
<!-- Search part -->
<!-- ---------------------------------------------- -->
<Searchbar />
<!-- <Searchbar /> -->
<!-- ---------------------------------------------- -->
<!-- Mega menu -->
<!-- ---------------------------------------------- -->
<div class="hidden-sm-and-down">
<!-- <div class="hidden-sm-and-down">
<Navigations />
</div>
</div> -->
<v-spacer class="hidden-sm-and-down" />
@@ -63,14 +63,14 @@ watch(priority, (newPriority) => {
</div>
<ThemeToggler />
<!-- <ThemeToggler /> -->
<!-- ---------------------------------------------- -->
<!-- translate -->
<!-- ---------------------------------------------- -->
<div class="hidden-sm-and-down">
<!-- <div class="hidden-sm-and-down">
<LanguageDD />
</div>
</div> -->
<!-- ---------------------------------------------- -->
<!-- ShoppingCart -->
@@ -84,9 +84,9 @@ watch(priority, (newPriority) => {
<!-- ---------------------------------------------- -->
<!-- Notification -->
<!-- ---------------------------------------------- -->
<div class="hidden-sm-and-down">
<!-- <div class="hidden-sm-and-down">
<NotificationDD />
</div>
</div> -->
<!-- ---------------------------------------------- -->
<!-- User Profile -->
@@ -17,349 +17,27 @@ export interface menu {
}
const sidebarItem: menu[] = [
{
header: 'dashboards',
header: 'Menu',
id: 1,
children: [
{
title: 'Dashboard1',
title: 'Dashboard',
icon: 'widget-add-line-duotone',
to: '/dashboards/dashboard1'
to: '/apps/dashboard'
},
{
title: 'Dashboard2',
icon: 'chart-line-duotone',
to: '/dashboards/dashboard2'
},
{
title: 'Dashboard3',
icon: 'screencast-2-line-duotone',
to: '/dashboards/dashboard3'
},
{
title: 'Front Pages',
icon: 'home-angle-linear',
title: 'Settings',
icon: 'settings-outline',
to: '/',
children: [
{
title: 'Homepage',
to: '/front-page/homepage'
},
{
title: 'About Us',
to: '/front-page/about-us'
},
{
title: 'Blog',
to: '/front-page/blog/posts'
},
{
title: 'Blog Details',
to: '/front-page/blog/early-black-friday-amazon-deals-cheap-tvs-headphones'
},
{
title: 'Contact Us',
to: '/front-page/contact-us'
},
{
title: 'Portfolio',
to: '/front-page/portfolio'
},
{
title: 'Pricing',
to: '/front-page/pricing'
title: 'Menu',
to: '/apps/setting/pages',
}
]
},
]
},
{
header: 'apps',
id: 1,
children: [
{
title: 'ECommerce',
icon: 'cart-3-line-duotone',
to: '/ecommerce/',
children: [
{
title: 'Shop',
to: '/ecommerce/products'
},
{
title: 'Detail',
to: '/ecommerce/product/detail/1'
},
{
title: 'List',
to: '/ecommerce/productlist'
},
{
title: 'Checkout',
to: '/ecommerce/checkout'
},
{
title: 'Add Product',
to: '/ecommerce/add-product'
},
{
title: 'Edit Product',
to: '/ecommerce/edit-product'
}
]
},
{
title: 'Blog',
icon: 'widget-4-line-duotone',
to: '/',
children: [
{
title: 'Blog Posts',
to: '/apps/blog/posts'
},
{
title: 'Blog Details',
to: '/apps/blog/early-black-friday-amazon-deals-cheap-tvs-headphones'
}
]
},
{
title: 'User Profile',
icon: 'shield-user-line-duotone',
to: '/',
children: [
{
title: 'Profile',
to: '/apps/user/profile'
},
{
title: 'Followers',
to: '/apps/user/profile/followers'
},
{
title: 'Friends',
to: '/apps/user/profile/friends'
},
{
title: 'Gallery',
to: '/apps/user/profile/gallery'
}
]
},
{
title: 'Invoice',
icon: 'bill-check-outline',
to: '/',
children: [
{
title: 'List',
to: '/apps/invoice'
},
{
title: 'Details',
to: '/apps/invoice/details/102'
},
{
title: 'Create',
to: '/apps/invoice/create'
},
{
title: 'Edit',
to: '/apps/invoice/edit/102'
}
]
},
{
title: 'Calendar',
icon: 'calendar-mark-line-duotone',
to: '/apps/calendar'
},
{
title: 'Email',
icon: 'letter-linear',
to: '/apps/email'
},
{
title: 'Chats',
icon: 'chat-round-line-line-duotone',
to: '/apps/chats'
},
{
title: 'Notes',
icon: 'document-text-line-duotone',
to: '/apps/notes'
},
{
title: 'Kanban',
icon: 'airbuds-case-minimalistic-line-duotone',
to: '/apps/kanban'
},
{
title: 'Contact',
icon: 'iphone-line-duotone',
to: '/apps/contacts'
},
{
title: 'Tickets',
icon: 'ticker-star-outline',
to: '/apps/tickets'
},
]
},
{
header: 'Widgets',
id: 2,
children: [
{
title: 'Banners',
icon: 'gallery-wide-line-duotone',
to: '/template/widgets/banners'
},
{
title: 'Cards',
icon: 'layers-minimalistic-line-duotone',
to: '/template/widgets/cards'
},
{
title: 'Charts',
icon: 'chart-line-duotone',
to: '/template/widgets/charts'
},
]
},
{
header: 'UI Components',
id: 3,
children: [
{
title: 'Alerts',
icon: 'danger-triangle-line-duotone',
to: '/template/ui-components/alerts'
},
{
title: 'Avatar',
icon: 'user-circle-line-duotone',
to: '/template/ui-components/avatar'
},
{
title: 'Buttons',
icon: 'ghost-line-duotone',
to: '/template/ui-components/buttons'
},
{
title: 'Cards',
icon: 'layers-minimalistic-line-duotone',
to: '/template/ui-components/cards'
},
{
title: 'Chip',
icon: 'tag-horizontal-line-duotone',
to: '/template/ui-components/chip'
},
{
title: 'Dialogs',
icon: 'window-frame-line-duotone',
to: '/template/ui-components/dialogs'
},
{
title: 'Expansion Panel',
icon: 'hamburger-menu-line-duotone',
to: '/template/ui-components/expansionPanel'
},
{
title: 'List',
icon: 'list-line-duotone',
to: '/template/ui-components/list'
},
{
title: 'Menus',
icon: 'menu-dots-line-duotone',
to: '/template/ui-components/menus'
},
{
title: 'Ratting',
icon: 'star-line-duotone',
to: '/template/ui-components/ratting'
},
{
title: 'Tables',
icon: 'tablet-line-duotone',
to: '/template/ui-components/tables'
},
{
title: 'Tabs',
icon: 'notebook-line-duotone',
to: '/template/ui-components/tabs'
},
{
title: 'Tooltip',
icon: 'chat-round-dots-line-duotone',
to: '/template/ui-components/tooltip'
}
]
},
{
header: 'Style Components',
id: 3,
children: [
{
title: 'Shadow',
icon: 'copy-line-duotone',
to: '/template/style-components/shadow'
},
{
title: 'Typography',
icon: 'text-bold-circle-line-duotone',
to: '/template/style-components/typography'
}
]
},
{
header: 'Shared Components',
id: 3,
children: [
{
title: 'Overview',
icon: 'widget-5-line-duotone',
to: '/template/shared-components'
},
{
title: 'UiParentCard & UiChildCard',
icon: 'layers-minimalistic-line-duotone',
to: '/template/shared-components/UiParentCard'
},
{
title: 'WidgetCard & WidgetCardv2',
icon: 'chart-square-line-duotone',
to: '/template/shared-components/WidgetCards'
},
{
title: 'Card Components',
icon: 'card-2-line-duotone',
to: '/template/shared-components/CardComponents'
},
{
title: 'BaseBreadcrumb',
icon: 'route-line-duotone',
to: '/template/shared-components/BaseBreadcrumb'
},
{
title: 'UiTextfieldPrimary',
icon: 'text-field-line-duotone',
to: '/template/shared-components/UiTextfieldPrimary'
},
{
title: 'AppBaseCard',
icon: 'sidebar-minimalistic-line-duotone',
to: '/template/shared-components/AppBaseCard'
},
]
},
}
];
export default sidebarItem;
+98
View File
@@ -0,0 +1,98 @@
<script setup lang="ts">
type ConfirmDialogMode = {
modelValue: boolean;
title?: string;
message?: string;
confirmText?: string;
cancelText?: string;
confirmColor?: string;
cancelColor?: string;
persistent?: boolean;
maxWidth?: number | string;
loading?: boolean;
// Kalau true: klik "Ya" langsung tutup.
// Kalau false: parent yang nutup (cocok untuk async delete/update).
closeOnConfirm?: boolean;
};
const props = withDefaults(defineProps<ConfirmDialogMode>(), {
title: 'Konfirmasi',
message: 'Apakah Anda yakin?',
confirmText: 'Ya',
cancelText: 'Tidak',
confirmColor: 'success',
cancelColor: 'error',
persistent: true,
maxWidth: 500,
loading: false,
closeOnConfirm: true,
});
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void;
(e: 'confirm'): void;
(e: 'cancel'): void;
}>();
const close = () => emit('update:modelValue', false);
const onCancel = () => {
emit('cancel');
close();
};
const onConfirm = () => {
emit('confirm');
if (props.closeOnConfirm) close();
};
</script>
<template>
<v-dialog
location="top"
transition="dialog-top-transition"
:model-value="props.modelValue"
@update:model-value="emit('update:modelValue', $event)"
:persistent="props.persistent"
:max-width="props.maxWidth"
>
<v-card class="pa-6">
<v-card-title class="text-h5">
<slot name="title">{{ props.title }}</slot>
</v-card-title>
<v-card-text>
<slot>{{ props.message }}</slot>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn
:color="props.cancelColor"
variant="tonal"
flat
:disabled="props.loading"
@click="onCancel"
>
{{ props.cancelText }}
</v-btn>
<v-btn
:color="props.confirmColor"
variant="tonal"
flat
:loading="props.loading"
:disabled="props.loading"
@click="onConfirm"
>
{{ props.confirmText }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
+127
View File
@@ -0,0 +1,127 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue';
interface Props {
loading?: boolean;
empty?: boolean;
error?: boolean;
loadingText?: string;
emptyText?: string;
emptyIcon?: string;
errorText?: string;
errorIcon?: string;
size?: 'small' | 'default' | 'large';
minHeight?: string;
}
const props = withDefaults(defineProps<Props>(), {
loading: false,
empty: false,
error: false,
loadingText: 'Memuat data...',
emptyText: 'Tidak ada data',
emptyIcon: 'solar:folder-open-outline',
errorText: 'Terjadi kesalahan saat memuat data',
errorIcon: 'solar:danger-circle-outline',
size: 'default',
minHeight: '200px'
});
const emit = defineEmits<{
(e: 'retry'): void;
}>();
const spinnerSize = computed(() => {
switch (props.size) {
case 'small': return 48;
case 'large': return 80;
default: return 64;
}
});
const iconSize = computed(() => {
switch (props.size) {
case 'small': return 48;
case 'large': return 80;
default: return 64;
}
});
const textClass = computed(() => {
switch (props.size) {
case 'small': return 'text-body-1';
case 'large': return 'text-h5';
default: return 'text-subtitle-1';
}
});
</script>
<template>
<!-- Loading State -->
<div v-if="loading" class="loading-state-container" :style="{ minHeight: minHeight }">
<div class="state-content">
<v-progress-circular
indeterminate
color="primary"
:size="spinnerSize"
></v-progress-circular>
<p :class="['mt-4', textClass, 'text-medium-emphasis']">
{{ loadingText }}
</p>
</div>
</div>
<!-- Empty State -->
<div v-else-if="empty" class="loading-state-container" :style="{ minHeight: minHeight }">
<div class="state-content">
<Icon :icon="emptyIcon" :height="iconSize" class="text-medium-emphasis" />
<p :class="['mt-4', textClass, 'text-medium-emphasis']">
{{ emptyText }}
</p>
<slot name="empty-action"></slot>
</div>
</div>
<!-- Error State -->
<div v-else-if="error" class="loading-state-container" :style="{ minHeight: minHeight }">
<div class="state-content">
<Icon :icon="errorIcon" :height="iconSize" class="text-error" />
<p :class="['mt-4', textClass, 'text-error']">
{{ errorText }}
</p>
<v-btn
color="primary"
variant="tonal"
class="mt-4"
@click="emit('retry')"
>
<Icon icon="solar:refresh-outline" height="20" class="mr-2" />
Coba Lagi
</v-btn>
<slot name="error-action"></slot>
</div>
</div>
<!-- Content Slot -->
<slot v-else></slot>
</template>
<style scoped>
.loading-state-container {
width: 100%;
min-height: 300px;
display: flex;
align-items: center;
justify-content: center;
}
.state-content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 2rem;
width: 100%;
}
</style>
-2
View File
@@ -12,8 +12,6 @@ const clearLocalAuthStorage = () => {
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
localStorage.removeItem('user-preferences');
localStorage.removeItem('app-state');
sessionStorage.clear();
};
+6
View File
@@ -8,6 +8,11 @@ export const useValidation = () => {
return (value !== null && value !== undefined && String(value).trim() !== '') || message;
};
const emailRules = (value, message = 'Email tidak valid.') => {
if (!value) return true;
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) || message;
};
const minNameLength = (value, minLength = 3) => {
if (!value) return true; // Lewati jika kosong (handle oleh 'required')
const length = value.trim().length; // Hitung panjang setelah menghapus spasi di awal/akhir
@@ -51,6 +56,7 @@ export const useValidation = () => {
return {
required,
emailRules,
isNumber,
phoneLength,
indonesianPhoneFormat,
+14
View File
@@ -6,10 +6,24 @@ import Totalincome from '@/components/dashboard/TotalIncome.vue';
import RevenueProduct from '@/components/dashboard/RevenueProducts.vue';
import DailyActivities from '@/components/dashboard/DailyActivities.vue';
import BlogCards from '@/components/dashboard/BlogCards.vue';
import { useAuth } from '~/composables/useAuth';
definePageMeta({
middleware: 'auth',
});
const { sessionData } = useAuth();
onMounted(async () => {
const urlParams = new URLSearchParams(window.location.search);
const authStatus = urlParams.get('authenticated');
if (authStatus === 'true') {
if (sessionData.value?.accessToken && sessionData.value?.refreshToken) {
localStorage.setItem('accessToken', sessionData.value.accessToken);
localStorage.setItem('refreshToken', sessionData.value.refreshToken);
}
}
});
</script>
<template>
+110 -42
View File
@@ -1,70 +1,138 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import api from '@/utils/api';
import { Icon } from "@iconify/vue";
interface RolePage {
id: number;
name: string;
icon: string;
url: string;
level: number;
sort: number;
active: boolean;
children?: RolePage[];
}
import PageRow from '@/components/apps/setting/pages/PageRow.vue';
import AddPageModal from '@/components/apps/setting/pages/AddPageModal.vue';
import DialogConfrim from '~/components/shared/DialogConfrim.vue';
import type { RolePage } from '~/types/setting/menu';
import { useSnackbarStore } from '~/store/snackbar';
const pages = ref<RolePage[]>([]);
const loading = ref(false);
const isModalOpen = ref(false);
const modalMode = ref<'create' | 'detail' | 'edit'>('create');
const selectedPageId = ref<number | null>(null);
const snackbarStore = useSnackbarStore();
const isDeleteDialogOpen = ref(false);
const isDeleting = ref(false);
const deleteTargetId = ref<number | null>(null);
const deleteTargetName = ref<string>('');
const fetchPages = async () => {
loading.value = true;
try {
const response = await api.get('/api/v1/roles/pages/tree');
pages.value = response.data?.data || [];
console.log('Pages loaded:', pages.value);
} catch (error) {
console.error('Error fetching pages:', error);
snackbarStore.showSnackbar('Gagal memuat data menu', 'error');
} finally {
loading.value = false;
}
};
const handleStatusChange = (page: RolePage) => {
console.log(`Page ${page.name} status changed to: ${page.active}`);
// Here you can add the API call to update the status on the server
// For example:
// api.put(`/api/v1/roles/pages/${page.id}`, { active: page.active })
// .catch(error => console.error('Error updating status:', error));
const handlePageSaved = async () => {
await fetchPages();
};
const openCreateModal = () => {
modalMode.value = 'create';
selectedPageId.value = null;
isModalOpen.value = true;
};
const openDetailModal = (page: RolePage) => {
modalMode.value = 'detail';
selectedPageId.value = page.id;
isModalOpen.value = true;
};
const openEditModal = (page: RolePage) => {
modalMode.value = 'edit';
selectedPageId.value = page.id;
isModalOpen.value = true;
};
const openDeleteDialog = (page: RolePage) => {
deleteTargetId.value = page.id;
deleteTargetName.value = page.name;
isDeleteDialogOpen.value = true;
};
const handleCancelDelete = () => {
deleteTargetId.value = null;
deleteTargetName.value = '';
};
const handleConfirmDelete = async () => {
if (!deleteTargetId.value) return;
if (isDeleting.value) return;
isDeleting.value = true;
try {
await api.delete(`/api/v1/roles/pages/${deleteTargetId.value}`);
snackbarStore.showSnackbar('Menu berhasil dihapus', 'success');
isDeleteDialogOpen.value = false;
await fetchPages();
} catch (error: any) {
const message = error?.response?.data?.message || error?.message || 'Gagal menghapus menu';
snackbarStore.showSnackbar(message, 'error');
} finally {
isDeleting.value = false;
}
};
onMounted(() => {
fetchPages();
});
definePageMeta({
middleware: 'auth',
});
</script>
<template>
<div v-if="loading" class="text-center py-4">
<v-progress-circular indeterminate color="primary"></v-progress-circular>
</div>
<div v-else-if="pages.length === 0" class="text-center py-4">
<p class="text-muted">No pages found</p>
</div>
<div v-else>
<v-card v-for="page in pages" :key="page.id" class="mb-2" elevation="10">
<v-card-text class="px-4 pb-0 pt-4">
<div class="d-flex align-top">
<Icon :icon="'solar:' + page.icon" width="18" />
<div class="flex-grow-1 ms-2">
<h4 class="mb-1">{{ page.name }}</h4>
<p class="text-muted mb-0">{{ page.url }}</p>
<v-card elevation="0" class="mb-5">
<v-card-title class="pa-4">
<div class="d-flex align-center">
<div>
<h4 class="text-h4">Menu</h4>
</div>
<v-spacer></v-spacer>
<v-btn color="primary" flat @click="openCreateModal">
<v-icon class="mr-2">mdi-plus</v-icon>
Tambah Menu
</v-btn>
</div>
<v-switch v-model="page.active" @change="handleStatusChange(page)"></v-switch>
</div>
</v-card-text>
</v-card>
</div>
</v-card-title>
</v-card>
<LoadingState :loading="loading" :empty="pages.length === 0 && !loading" loading-text="Memuat data pages..."
empty-text="Tidak ada data pages">
<div>
<PageRow
v-for="page in pages"
:key="page.id"
:page="page"
:level="0"
@detail="openDetailModal"
@edit="openEditModal"
@delete="openDeleteDialog"
/>
</div>
</LoadingState>
<AddPageModal v-model="isModalOpen" :mode="modalMode" :page-id="selectedPageId" @saved="handlePageSaved" />
<DialogConfrim
v-model="isDeleteDialogOpen"
title="Hapus menu"
:message="`Apakah anda yakin menghapus menu ${deleteTargetName} ?`"
:loading="isDeleting"
:close-on-confirm="false"
@confirm="handleConfirmDelete"
@cancel="handleCancelDelete"
/>
</template>
+5
View File
@@ -151,4 +151,9 @@ html {
.v-input__append {
display: none !important;
}
}
.required::after {
content: " *";
color: red;
}
+19
View File
@@ -0,0 +1,19 @@
export interface RolePage {
id: number;
name: string;
icon: string;
url: string;
level: number;
sort: number;
active: boolean;
children?: RolePage[];
}
export type ModalMode = 'create' | 'detail' | 'edit';
export type ParentPageOption = {
id: number;
name: string;
level: number;
};
+153 -2
View File
@@ -3,9 +3,160 @@ import axios from 'axios'
const config = useRuntimeConfig()
const api = axios.create({
baseURL: config.public.baseUrl,
timeout: 10000,
baseURL: config.public.baseUrl
})
// Flag to prevent multiple simultaneous refresh attempts
let isRefreshing = false
let failedQueue: any[] = []
const processQueue = (error: any, token: string | null = null) => {
failedQueue.forEach(prom => {
if (error) {
prom.reject(error)
} else {
prom.resolve(token)
}
})
failedQueue = []
}
const refreshAccessToken = async (): Promise<string | null> => {
try {
const refreshToken = localStorage.getItem('refreshToken')
if (!refreshToken) {
throw new Error('No refresh token available')
}
const response = await fetch(config.public.baseUrl+"/api/v1/auth/refresh", {
method: 'POST',
body: JSON.stringify({
refresh_token: refreshToken,
provider: 'keycloak',
}),
})
const responseBody = await response.json()
const data = responseBody.data
// Update tokens in localStorage
if (data.access_token) {
localStorage.setItem('accessToken', data.access_token)
}
if (data.refresh_token) {
localStorage.setItem('refreshToken', data.refresh_token)
}
// Update server-side session with new tokens
try {
await fetch('/api/auth/sessionUserStore', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
accessToken: data.access_token,
refreshToken: data.refresh_token,
// expiresAt: expiresAt
}),
})
console.log('✅ Server session updated with new tokens')
} catch (sessionError) {
console.error('⚠️ Failed to update server session:', sessionError)
// Don't throw error here - token refresh was successful, session update is secondary
}
console.log('✅ Token refreshed successfully')
return data.access_token
} catch (error) {
console.error('❌ Token refresh failed:', error)
return null
}
}
// Shared request interceptor
const requestInterceptor = (config: any) => {
// Only access localStorage on client-side (avoid SSR errors)
if (process.client) {
const accessToken = localStorage.getItem('accessToken')
if (accessToken) {
config.headers.Authorization = `Bearer ${accessToken}`
}
}
return config
}
// Shared response interceptor
const responseInterceptor = (response: any) => response
const responseErrorInterceptor = async (error: any) => {
const originalRequest = error.config
// centralized error
console.error('API Error:', error.response)
// Handle token expiration on client-side
if (process.client && error.response) {
const { status } = error.response
// Check if response is 401 (token expired or unauthorized)
if (status === 401 && !originalRequest._retry) {
if (isRefreshing) {
// If already refreshing, queue this request
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject })
})
.then(token => {
originalRequest.headers['Authorization'] = 'Bearer ' + token
return axios(originalRequest)
})
.catch(err => {
return Promise.reject(err)
})
}
originalRequest._retry = true
isRefreshing = true
// Try to refresh the token
const newToken = await refreshAccessToken()
if (newToken) {
// Token refresh successful, retry original request
isRefreshing = false
processQueue(null, newToken)
originalRequest.headers['Authorization'] = 'Bearer ' + newToken
return axios(originalRequest)
} else {
// Token refresh failed (refresh token expired or error), logout user
isRefreshing = false
processQueue(new Error('Token refresh failed'), null)
// Clear expired tokens
localStorage.removeItem('accessToken')
localStorage.removeItem('refreshToken')
// Call logout endpoint to clear session
fetch('/api/auth/logout', { method: 'POST' })
.catch(err => console.error('Logout failed:', err))
// Redirect to login with error message
const errorMessage = encodeURIComponent('Token is expired')
window.location.href = `/auth/login?error=${errorMessage}`
return Promise.reject(error)
}
}
}
return Promise.reject(error)
}
api.interceptors.request.use(requestInterceptor)
api.interceptors.response.use(responseInterceptor, responseErrorInterceptor)
export default api