integrate login page wih api and keycloak
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
<template>
|
||||
<div class="w-100 login-form-wrap">
|
||||
<div class="d-flex align-center mb-10">
|
||||
<img :src="Logoimg" alt="home" width="200" />
|
||||
</div>
|
||||
|
||||
<h1 class="text-h3 font-weight-bold text-grey-darken-4 mb-2">Selamat Datang</h1>
|
||||
<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>
|
||||
|
||||
<!-- 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-icon size="16" class="mr-2">mdi-key-variant</v-icon>
|
||||
Masuk dengan Keycloak
|
||||
</v-btn>
|
||||
|
||||
<div class="d-flex align-center mb-7">
|
||||
<v-divider />
|
||||
<div class="mx-3 text-caption text-medium-emphasis width-100">Atau</div>
|
||||
<v-divider />
|
||||
</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"
|
||||
/>
|
||||
|
||||
<div class="d-flex align-center justify-space-between mb-2">
|
||||
<label 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"
|
||||
>
|
||||
Masuk
|
||||
</v-btn>
|
||||
</v-form>
|
||||
|
||||
<p class="text-center text-body-2 text-medium-emphasis mb-0">
|
||||
© 2026 RSUD Dr. Saiful Anwar Provinsi Jawa Timur.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } 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;
|
||||
}
|
||||
}
|
||||
|
||||
const snackbarStore = useSnackbarStore();
|
||||
const { required } = useValidation();
|
||||
|
||||
const email = ref("");
|
||||
const password = ref("");
|
||||
const valid = ref(false);
|
||||
const showPassword = ref(false);
|
||||
const isLoading = ref(false);
|
||||
const errorMessage = ref("");
|
||||
const successMessage = ref("");
|
||||
|
||||
const emailRules = [
|
||||
required,
|
||||
(v : string) => /.+@.+\..+/.test(v) || 'Email tidak valid',
|
||||
];
|
||||
const passwordRules = [required];
|
||||
|
||||
const login = async () => {
|
||||
if (!valid.value) {
|
||||
snackbarStore.showSnackbar('Password dan email harus diisi', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading.value = true;
|
||||
|
||||
try {
|
||||
const response = await api.post<LoginResponse>('api/v1/auth/login', {
|
||||
email: email.value,
|
||||
password: password.value
|
||||
});
|
||||
|
||||
const loginData = response.data.data;
|
||||
console.log('Login response:', loginData);
|
||||
|
||||
if (!loginData?.access_token || !loginData?.refresh_token) {
|
||||
throw new Error('Token login tidak valid');
|
||||
}
|
||||
|
||||
await $fetch('/api/auth/sessionUserStore', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
accessToken: loginData.access_token,
|
||||
refreshToken: loginData.refresh_token,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
window.location.href = "/apps/dashboard";
|
||||
} catch (error: any) {
|
||||
const loginError = error?.response?.data?.message || error?.message || 'Terjadi kesalahan saat login';
|
||||
snackbarStore.showSnackbar(loginError, 'error');
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loginWithKeycloak = async (): Promise<void> => {
|
||||
isLoading.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'
|
||||
});
|
||||
|
||||
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;
|
||||
}, 500);
|
||||
} else {
|
||||
throw new Error('Failed to get authorization URL');
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Login error:', error);
|
||||
// Display the error message
|
||||
errorMessage.value = `Login failed: ${error.message || 'Please try again.'}`;
|
||||
} finally {
|
||||
// Only set isLoading back to false if no redirect is happening
|
||||
if (!successMessage.value.includes('Redirecting')) {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@@ -4,6 +4,8 @@ import { useDisplay } from "vuetify";
|
||||
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';
|
||||
|
||||
const sidebarMenu = shallowRef(sidebarItems);
|
||||
const customizer = useCustomizerStore();
|
||||
const { mdAndDown } = useDisplay();
|
||||
@@ -46,19 +48,13 @@ watch(mdAndDown, (val) => {
|
||||
<LazyLayoutFullVerticalHeader v-if="!customizer.setHorizontalLayout" />
|
||||
</div>
|
||||
|
||||
<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-container fluid class="page-wrapper">
|
||||
<div :class="customizer.boxed ? 'maxWidth' : ''">
|
||||
<RouterView />
|
||||
<AppSnackbar />
|
||||
</div>
|
||||
</v-container>
|
||||
</v-main>
|
||||
</v-app>
|
||||
</v-locale-provider>
|
||||
|
||||
@@ -1,105 +1,66 @@
|
||||
<!-- components/layout/ProfileDD.vue -->
|
||||
<script setup lang="ts">
|
||||
import { MailIcon } from "vue-tabler-icons";
|
||||
import { profileDD } from "~/_mockApis/headerData";
|
||||
import { PerfectScrollbar } from "vue3-perfect-scrollbar";
|
||||
import { useUserInfo } from "~/composables/useUserInfo";
|
||||
import { computed } from "vue";
|
||||
import { useAuth } from "~/composables/useAuth";
|
||||
import { computed, onMounted } from "vue";
|
||||
|
||||
const userInfo = useUserInfo();
|
||||
const auth = useAuth();
|
||||
|
||||
onMounted(async () => {
|
||||
if (!auth.isLoggedIn.value) {
|
||||
await auth.fetchUserSession();
|
||||
}
|
||||
});
|
||||
|
||||
// Enhanced logout with proper error handling
|
||||
const logout = async () => {
|
||||
try {
|
||||
// Use the updated logout method from useUserInfo that handles Keycloak logout and session clearing
|
||||
await userInfo.logout({
|
||||
reason: "idle",
|
||||
confirmDialog: false // Show confirmation dialog
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Logout from profile failed:", error);
|
||||
}
|
||||
await auth.logout();
|
||||
};
|
||||
|
||||
// **TAMBAHAN: Full logout function dengan konfirmasi**
|
||||
const fullLogout = async () => {
|
||||
try {
|
||||
// Tampilkan konfirmasi sebelum full logout
|
||||
const confirmed = confirm(
|
||||
"Apakah Anda yakin ingin keluar dari semua sesi? Ini akan menghapus semua data lokal dan sesi Keycloak."
|
||||
);
|
||||
const confirmed = confirm(
|
||||
"Apakah Anda yakin ingin keluar dari semua sesi? Ini akan menghapus semua data lokal dan sesi Keycloak."
|
||||
);
|
||||
|
||||
if (!confirmed) return;
|
||||
if (!confirmed) return;
|
||||
|
||||
console.log("Initiating full logout from ProfileDD...");
|
||||
|
||||
// Gunakan fullLogout dari useUserInfo composable
|
||||
await userInfo.fullLogout();
|
||||
} catch (error) {
|
||||
console.error("Full logout from profile failed:", error);
|
||||
|
||||
// Fallback jika fullLogout gagal
|
||||
try {
|
||||
console.log("Attempting fallback logout...");
|
||||
await userInfo.logout({
|
||||
reason: "manual",
|
||||
clearStorage: true
|
||||
});
|
||||
} catch (fallbackError) {
|
||||
console.error("Fallback logout also failed:", fallbackError);
|
||||
|
||||
// Last resort - force redirect
|
||||
if (process.client) {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
window.location.href = "/auth/login?reason=force";
|
||||
}
|
||||
}
|
||||
}
|
||||
await auth.fullLogout();
|
||||
};
|
||||
|
||||
// **TAMBAHAN: Logout dengan konfirmasi untuk UX yang lebih baik**
|
||||
const logoutWithConfirmation = async () => {
|
||||
try {
|
||||
const confirmed = confirm("Apakah Anda yakin ingin keluar?");
|
||||
const confirmed = confirm("Apakah Anda yakin ingin keluar?");
|
||||
|
||||
if (!confirmed) return;
|
||||
if (!confirmed) return;
|
||||
|
||||
await logout();
|
||||
} catch (error) {
|
||||
console.error("Logout with confirmation failed:", error);
|
||||
}
|
||||
await logout();
|
||||
};
|
||||
|
||||
// Get user display info from session
|
||||
const getUserDisplayInfo = () => {
|
||||
if (!userInfo.user.value && !userInfo.data.value?.user)
|
||||
if (!auth.user.value)
|
||||
return {
|
||||
name: "Guest User",
|
||||
email: "guest@example.com",
|
||||
role: "Guest"
|
||||
};
|
||||
|
||||
const user = userInfo.user.value || userInfo.data.value?.user || {};
|
||||
const user = auth.user.value || {};
|
||||
return {
|
||||
name: user.name || user.given_name || user.preferred_username || "User",
|
||||
name: user.name || "User",
|
||||
email: user.email || "No email",
|
||||
role: userInfo.userRoles.value[0] || "User"
|
||||
role: auth.userRoles.value[0] || user.role || "User",
|
||||
provider :user.auth_provider
|
||||
};
|
||||
};
|
||||
|
||||
const displayInfo = computed(() => getUserDisplayInfo());
|
||||
|
||||
// Computed properties for decodedToken and clientScopes
|
||||
// const decodedToken = computed(() => userInfo.decodedToken.value);
|
||||
// const clientScopes = computed(() => userInfo.clientScopes.value);
|
||||
|
||||
// **TAMBAHAN: Computed property untuk menampilkan status session**
|
||||
const sessionInfo = computed(() => {
|
||||
return {
|
||||
isAuthenticated: userInfo.isAuthenticated.value,
|
||||
sessionExpires: userInfo.sessionExpires.value,
|
||||
hasValidToken: !!userInfo.idToken.value
|
||||
isAuthenticated: auth.isLoggedIn.value,
|
||||
sessionExpires: auth.sessionData.value?.expiresAt || null,
|
||||
hasValidToken: !!auth.sessionData.value?.accessToken
|
||||
};
|
||||
});
|
||||
</script>
|
||||
@@ -127,10 +88,10 @@ const sessionInfo = computed(() => {
|
||||
</v-avatar>
|
||||
<div class="ml-3">
|
||||
<h6 class="text-h6 mb-n1">{{ displayInfo.name }}</h6>
|
||||
<span class="text-subtitle-1 font-weight-regular textSecondary">
|
||||
<!-- <span class="text-subtitle-1 font-weight-regular textSecondary">
|
||||
{{ displayInfo.role }}
|
||||
</span>
|
||||
<div class="d-flex align-center mt-1">
|
||||
</span> -->
|
||||
<div class="d-flex align-center mt-2">
|
||||
<MailIcon size="18" stroke-width="1.5" />
|
||||
<span
|
||||
class="text-subtitle-1 font-weight-regular textSecondary ml-2"
|
||||
@@ -151,46 +112,10 @@ const sessionInfo = computed(() => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<v-divider></v-divider>
|
||||
</div>
|
||||
|
||||
<PerfectScrollbar style="height: calc(100vh - 240px); max-height: 240px">
|
||||
<v-list class="py-0 theme-list" lines="two">
|
||||
<v-list-item
|
||||
v-for="item in profileDD"
|
||||
:key="item.title"
|
||||
class="py-4 px-8 custom-text-primary"
|
||||
:to="item.href"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<v-avatar
|
||||
size="48"
|
||||
color="lightprimary"
|
||||
class="mr-3"
|
||||
rounded="md"
|
||||
>
|
||||
<img
|
||||
:src="item.avatar"
|
||||
width="24"
|
||||
height="24"
|
||||
:alt="item.avatar"
|
||||
/>
|
||||
</v-avatar>
|
||||
</template>
|
||||
<div>
|
||||
<h6 class="text-subtitle-1 font-weight-bold mb-2 custom-title">
|
||||
{{ item.title }}
|
||||
</h6>
|
||||
</div>
|
||||
<p class="text-subtitle-1 font-weight-regular textSecondary">
|
||||
{{ item.subtitle }}
|
||||
</p>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</PerfectScrollbar>
|
||||
|
||||
<!-- **DIPERBAIKI: Logout buttons section dengan multiple options** -->
|
||||
<div class="pt-4 pb-6 px-8">
|
||||
<div class="pt-0 pb-6 px-8">
|
||||
<!-- Regular Logout Button -->
|
||||
<v-btn
|
||||
color="primary"
|
||||
@@ -205,6 +130,7 @@ const sessionInfo = computed(() => {
|
||||
|
||||
<!-- **TAMBAHAN: Full Logout Button** -->
|
||||
<v-btn
|
||||
v-if="displayInfo.provider === 'keycloak'"
|
||||
color="error"
|
||||
variant="outlined"
|
||||
block
|
||||
@@ -214,13 +140,6 @@ const sessionInfo = computed(() => {
|
||||
>
|
||||
Full Logout
|
||||
</v-btn>
|
||||
|
||||
<!-- **TAMBAHAN: Quick info text** -->
|
||||
<div class="text-center mt-2">
|
||||
<span class="text-caption textSecondary">
|
||||
Full logout akan menghapus semua sesi dan data lokal
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</v-sheet>
|
||||
</v-menu>
|
||||
|
||||
@@ -214,17 +214,17 @@ const sidebarItem: menu[] = [
|
||||
{
|
||||
title: 'Banners',
|
||||
icon: 'gallery-wide-line-duotone',
|
||||
to: '/widgets/banners'
|
||||
to: '/template/widgets/banners'
|
||||
},
|
||||
{
|
||||
title: 'Cards',
|
||||
icon: 'layers-minimalistic-line-duotone',
|
||||
to: '/widgets/cards'
|
||||
to: '/template/widgets/cards'
|
||||
},
|
||||
{
|
||||
title: 'Charts',
|
||||
icon: 'chart-line-duotone',
|
||||
to: '/widgets/charts'
|
||||
to: '/template/widgets/charts'
|
||||
},
|
||||
]
|
||||
},
|
||||
@@ -236,67 +236,67 @@ const sidebarItem: menu[] = [
|
||||
{
|
||||
title: 'Alerts',
|
||||
icon: 'danger-triangle-line-duotone',
|
||||
to: '/ui-components/alerts'
|
||||
to: '/template/ui-components/alerts'
|
||||
},
|
||||
{
|
||||
title: 'Avatar',
|
||||
icon: 'user-circle-line-duotone',
|
||||
to: '/ui-components/avatar'
|
||||
to: '/template/ui-components/avatar'
|
||||
},
|
||||
{
|
||||
title: 'Buttons',
|
||||
icon: 'ghost-line-duotone',
|
||||
to: '/ui-components/buttons'
|
||||
to: '/template/ui-components/buttons'
|
||||
},
|
||||
{
|
||||
title: 'Cards',
|
||||
icon: 'layers-minimalistic-line-duotone',
|
||||
to: '/ui-components/cards'
|
||||
to: '/template/ui-components/cards'
|
||||
},
|
||||
{
|
||||
title: 'Chip',
|
||||
icon: 'tag-horizontal-line-duotone',
|
||||
to: '/ui-components/chip'
|
||||
to: '/template/ui-components/chip'
|
||||
},
|
||||
{
|
||||
title: 'Dialogs',
|
||||
icon: 'window-frame-line-duotone',
|
||||
to: '/ui-components/dialogs'
|
||||
to: '/template/ui-components/dialogs'
|
||||
},
|
||||
{
|
||||
title: 'Expansion Panel',
|
||||
icon: 'hamburger-menu-line-duotone',
|
||||
to: '/ui-components/expansionPanel'
|
||||
to: '/template/ui-components/expansionPanel'
|
||||
},
|
||||
{
|
||||
title: 'List',
|
||||
icon: 'list-line-duotone',
|
||||
to: '/ui-components/list'
|
||||
to: '/template/ui-components/list'
|
||||
},
|
||||
{
|
||||
title: 'Menus',
|
||||
icon: 'menu-dots-line-duotone',
|
||||
to: '/ui-components/menus'
|
||||
to: '/template/ui-components/menus'
|
||||
},
|
||||
{
|
||||
title: 'Ratting',
|
||||
icon: 'star-line-duotone',
|
||||
to: '/ui-components/ratting'
|
||||
to: '/template/ui-components/ratting'
|
||||
},
|
||||
{
|
||||
title: 'Tables',
|
||||
icon: 'tablet-line-duotone',
|
||||
to: '/ui-components/tables'
|
||||
to: '/template/ui-components/tables'
|
||||
},
|
||||
{
|
||||
title: 'Tabs',
|
||||
icon: 'notebook-line-duotone',
|
||||
to: '/ui-components/tabs'
|
||||
to: '/template/ui-components/tabs'
|
||||
},
|
||||
{
|
||||
title: 'Tooltip',
|
||||
icon: 'chat-round-dots-line-duotone',
|
||||
to: '/ui-components/tooltip'
|
||||
to: '/template/ui-components/tooltip'
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -307,12 +307,12 @@ const sidebarItem: menu[] = [
|
||||
{
|
||||
title: 'Shadow',
|
||||
icon: 'copy-line-duotone',
|
||||
to: '/style-components/shadow'
|
||||
to: '/template/style-components/shadow'
|
||||
},
|
||||
{
|
||||
title: 'Typography',
|
||||
icon: 'text-bold-circle-line-duotone',
|
||||
to: '/style-components/typography'
|
||||
to: '/template/style-components/typography'
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -324,37 +324,37 @@ const sidebarItem: menu[] = [
|
||||
{
|
||||
title: 'Overview',
|
||||
icon: 'widget-5-line-duotone',
|
||||
to: '/shared-components'
|
||||
to: '/template/shared-components'
|
||||
},
|
||||
{
|
||||
title: 'UiParentCard & UiChildCard',
|
||||
icon: 'layers-minimalistic-line-duotone',
|
||||
to: '/shared-components/UiParentCard'
|
||||
to: '/template/shared-components/UiParentCard'
|
||||
},
|
||||
{
|
||||
title: 'WidgetCard & WidgetCardv2',
|
||||
icon: 'chart-square-line-duotone',
|
||||
to: '/shared-components/WidgetCards'
|
||||
to: '/template/shared-components/WidgetCards'
|
||||
},
|
||||
{
|
||||
title: 'Card Components',
|
||||
icon: 'card-2-line-duotone',
|
||||
to: '/shared-components/CardComponents'
|
||||
to: '/template/shared-components/CardComponents'
|
||||
},
|
||||
{
|
||||
title: 'BaseBreadcrumb',
|
||||
icon: 'route-line-duotone',
|
||||
to: '/shared-components/BaseBreadcrumb'
|
||||
to: '/template/shared-components/BaseBreadcrumb'
|
||||
},
|
||||
{
|
||||
title: 'UiTextfieldPrimary',
|
||||
icon: 'text-field-line-duotone',
|
||||
to: '/shared-components/UiTextfieldPrimary'
|
||||
to: '/template/shared-components/UiTextfieldPrimary'
|
||||
},
|
||||
{
|
||||
title: 'AppBaseCard',
|
||||
icon: 'sidebar-minimalistic-line-duotone',
|
||||
to: '/shared-components/AppBaseCard'
|
||||
to: '/template/shared-components/AppBaseCard'
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
<template>
|
||||
</template>
|
||||
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<v-snackbar
|
||||
v-model="snackbarStore.show"
|
||||
:color="snackbarStore.color"
|
||||
:timeout="snackbarStore.timeout"
|
||||
location="bottom center"
|
||||
>
|
||||
{{ snackbarStore.text }}
|
||||
<template v-slot:actions>
|
||||
<v-btn color="white" variant="text" @click="snackbarStore.show = false">
|
||||
Close
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-snackbar>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useSnackbarStore } from '~/store/snackbar';
|
||||
|
||||
const snackbarStore = useSnackbarStore();
|
||||
</script>
|
||||
@@ -5,11 +5,10 @@ const props = defineProps({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-card variant="outlined">
|
||||
<v-card elevation="0" >
|
||||
<v-card-item class="py-4 px-6">
|
||||
<v-card-title class="text-h5 mb-0">{{ title }}</v-card-title>
|
||||
</v-card-item>
|
||||
<v-divider />
|
||||
<v-card-text>
|
||||
<slot />
|
||||
</v-card-text>
|
||||
|
||||
Reference in New Issue
Block a user