feat : middleware and refresh token
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -12,8 +12,6 @@ const clearLocalAuthStorage = () => {
|
||||
|
||||
localStorage.removeItem('accessToken');
|
||||
localStorage.removeItem('refreshToken');
|
||||
localStorage.removeItem('user-preferences');
|
||||
localStorage.removeItem('app-state');
|
||||
sessionStorage.clear();
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -151,4 +151,9 @@ html {
|
||||
.v-input__append {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.required::after {
|
||||
content: " *";
|
||||
color: red;
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
Reference in New Issue
Block a user