+
{
{
>
Full Logout
-
-
-
-
- Full logout akan menghapus semua sesi dan data lokal
-
-
diff --git a/components/layout/full/vertical-sidebar/sidebarItem.ts b/components/layout/full/vertical-sidebar/sidebarItem.ts
index e4f01a9..0350f09 100644
--- a/components/layout/full/vertical-sidebar/sidebarItem.ts
+++ b/components/layout/full/vertical-sidebar/sidebarItem.ts
@@ -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'
},
]
},
diff --git a/components/setting/pages/listPages.vue b/components/setting/pages/listPages.vue
new file mode 100644
index 0000000..ba53413
--- /dev/null
+++ b/components/setting/pages/listPages.vue
@@ -0,0 +1,2 @@
+
+
\ No newline at end of file
diff --git a/components/shared/AppSnackbar.vue b/components/shared/AppSnackbar.vue
new file mode 100644
index 0000000..dbdb8e1
--- /dev/null
+++ b/components/shared/AppSnackbar.vue
@@ -0,0 +1,21 @@
+
+
+ {{ snackbarStore.text }}
+
+
+ Close
+
+
+
+
+
+
diff --git a/components/shared/UiChildCard.vue b/components/shared/UiChildCard.vue
index a61b3e6..3271f12 100644
--- a/components/shared/UiChildCard.vue
+++ b/components/shared/UiChildCard.vue
@@ -5,11 +5,10 @@ const props = defineProps({
-
+
{{ title }}
-
diff --git a/composables/useAuth.ts b/composables/useAuth.ts
new file mode 100644
index 0000000..f3676d3
--- /dev/null
+++ b/composables/useAuth.ts
@@ -0,0 +1,89 @@
+// composables/useAuth.ts
+import { computed, ref } from 'vue';
+import type { SessionData as UserSession, LogoutResponse } from '~/types/auth';
+
+
+const sessionData = ref(null);
+const isLoggedIn = ref(false);
+const isLoading = ref(false);
+
+const clearLocalAuthStorage = () => {
+ if (!process.client) return;
+
+ localStorage.removeItem('accessToken');
+ localStorage.removeItem('refreshToken');
+ localStorage.removeItem('user-preferences');
+ localStorage.removeItem('app-state');
+ sessionStorage.clear();
+};
+
+export const useAuth = () => {
+ const fetchUserSession = async () => {
+ isLoading.value = true;
+
+ try {
+ const response = await $fetch('/api/auth/sessionUser', {
+ method: 'GET',
+ });
+
+ sessionData.value = response;
+ isLoggedIn.value = !!response;
+ } catch {
+ sessionData.value = null;
+ isLoggedIn.value = false;
+ } finally {
+ isLoading.value = false;
+ }
+ };
+
+ const logout = async (): Promise => {
+ try {
+ await $fetch('/api/auth/logout', {
+ method: 'POST',
+ });
+ } finally {
+ clearLocalAuthStorage();
+ sessionData.value = null;
+ isLoggedIn.value = false;
+ await navigateTo('/auth/login?logout=success');
+ }
+ };
+
+ const fullLogout = async (): Promise => {
+ try {
+ const response = await $fetch('/api/auth/logout', {
+ method: 'POST',
+ });
+
+ clearLocalAuthStorage();
+ sessionData.value = null;
+ isLoggedIn.value = false;
+
+ if (process.client && response?.success && response?.logoutUrl) {
+ window.location.href = response.logoutUrl;
+ return;
+ }
+
+ await navigateTo('/auth/login?logout=full');
+ } catch {
+ clearLocalAuthStorage();
+ sessionData.value = null;
+ isLoggedIn.value = false;
+ await navigateTo('/auth/login?logout=full');
+ }
+ };
+
+ const user = computed(() => sessionData.value?.user ?? null);
+ const userRoles = computed(() => sessionData.value?.user?.role ?? []);
+
+ return {
+ isLoggedIn,
+ isLoading,
+ sessionData,
+ user,
+ userRoles,
+ fetchUserSession,
+ logout,
+ fullLogout,
+ };
+};
diff --git a/composables/useAuthInfo.ts b/composables/useAuthInfo.ts
new file mode 100644
index 0000000..a34ea58
--- /dev/null
+++ b/composables/useAuthInfo.ts
@@ -0,0 +1,49 @@
+import { ref } from 'vue';
+import api from '~/utils/api';
+import type { AuthInfoResponse } from '~/types/auth';
+
+const authInfo = ref(null);
+const isLoading = ref(false);
+const errorMessage = ref(null);
+
+export const useAuthInfo = () => {
+ const fetchAuthInfo = async (accessToken: string) => {
+ if (!accessToken || !accessToken.trim()) {
+ throw new Error('accessToken is required');
+ }
+
+ isLoading.value = true;
+ errorMessage.value = null;
+
+ try {
+ const response = await api.get('/api/v1/auth/info', {
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${accessToken}`,
+ },
+ });
+
+ authInfo.value = response.data;
+ return response.data;
+ } catch (error) {
+ authInfo.value = null;
+ errorMessage.value = error instanceof Error ? error.message : 'Failed to fetch auth info';
+ throw error;
+ } finally {
+ isLoading.value = false;
+ }
+ };
+
+ const clearAuthInfo = () => {
+ authInfo.value = null;
+ errorMessage.value = null;
+ };
+
+ return {
+ authInfo,
+ isLoading,
+ errorMessage,
+ fetchAuthInfo,
+ clearAuthInfo,
+ };
+};
diff --git a/middleware/auth.ts b/middleware/auth.ts
new file mode 100644
index 0000000..a47de17
--- /dev/null
+++ b/middleware/auth.ts
@@ -0,0 +1,24 @@
+// middleware/auth.ts
+import { useAuth } from '~/composables/useAuth';
+
+export default defineNuxtRouteMiddleware(async (to) => {
+ // This middleware should only run on the client-side after the initial server-side render.
+ if (process.server) {
+ return;
+ }
+
+ const { isLoggedIn, fetchUserSession } = useAuth();
+
+ // Initial check
+ if (!isLoggedIn.value) {
+ await fetchUserSession();
+ }
+
+ // List of public routes that don't require authentication
+ const publicRoutes = ['/auth/login', '/auth/register'];
+
+ // If the route is not public and the user is not logged in, redirect to login
+ if (!publicRoutes.includes(to.path) && !isLoggedIn.value) {
+ return navigateTo('/auth/login');
+ }
+});
diff --git a/nuxt.config.ts b/nuxt.config.ts
index 23130e5..859c6c7 100644
--- a/nuxt.config.ts
+++ b/nuxt.config.ts
@@ -108,13 +108,15 @@ export default defineNuxtConfig({
},
runtimeConfig: {
authSecret: process.env.AUTH_SECRET,
- keycloakClientSecret: process.env.KEYCLOAK_CLIENT_SECRET,
- keycloakClientId: process.env.KEYCLOAK_CLIENT_ID,
- // Move keycloakIssuer to public to expose on client side
+ keycloakClientSecret: process.env.NUXT_KEYCLOAK_CLIENT_SECRET,
+ keycloakClientId: process.env.NUXT_KEYCLOAK_CLIENT_ID,
+ keycloakIssuer: process.env.NUXT_KEYCLOAK_ISSUER,
+ keycloakLogoutUri: process.env.NUXT_KEYCLOAK_LOGOUT_URI,
+ postLogoutRedirectUri: process.env.NUXT_POST_LOGOUT_REDIRECT_URI,
public: {
- authUrl: process.env.AUTH_ORIGIN || "http://meninjar.dev.rssa.id:3000",
- keycloakIssuer:
- process.env.KEYCLOAK_ISSUER || "https://auth.rssa.top/realms/sandbox"
+ baseUrl : process.env.NUXT_PUBLIC_BASE_URL,
+ authUrl: process.env.AUTH_ORIGIN,
+ postLogoutRedirectUri: process.env.NUXT_PUBLIC_POST_LOGOUT_REDIRECT_URI
}
},
// auth: {
diff --git a/pages/apps/dashboard/index.vue b/pages/apps/dashboard/index.vue
index acc8741..f39ca62 100644
--- a/pages/apps/dashboard/index.vue
+++ b/pages/apps/dashboard/index.vue
@@ -6,9 +6,10 @@ 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';
-// definePageMeta({
-// middleware: 'role',
-// });
+
+definePageMeta({
+ middleware: 'auth',
+});
diff --git a/pages/apps/setting/pages/index.vue b/pages/apps/setting/pages/index.vue
new file mode 100644
index 0000000..5a2513f
--- /dev/null
+++ b/pages/apps/setting/pages/index.vue
@@ -0,0 +1,70 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ page.name }}
+
{{ page.url }}
+
+
+
+
+
+
+
diff --git a/pages/auth/Login.vue b/pages/auth/Login.vue
index 26589a7..c694ba7 100644
--- a/pages/auth/Login.vue
+++ b/pages/auth/Login.vue
@@ -2,48 +2,7 @@
-
+
@@ -65,20 +24,12 @@
+
diff --git a/pages/index.vue b/pages/index.vue
index 340eaa3..f39ca62 100644
--- a/pages/index.vue
+++ b/pages/index.vue
@@ -6,6 +6,10 @@ 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';
+
+definePageMeta({
+ middleware: 'auth',
+});
diff --git a/pages/Icons.vue b/pages/template/Icons.vue
similarity index 100%
rename from pages/Icons.vue
rename to pages/template/Icons.vue
diff --git a/pages/Sample-Page.vue b/pages/template/Sample-Page.vue
similarity index 100%
rename from pages/Sample-Page.vue
rename to pages/template/Sample-Page.vue
diff --git a/pages/shared-components/AppBaseCard.vue b/pages/template/shared-components/AppBaseCard.vue
similarity index 100%
rename from pages/shared-components/AppBaseCard.vue
rename to pages/template/shared-components/AppBaseCard.vue
diff --git a/pages/shared-components/BaseBreadcrumb.vue b/pages/template/shared-components/BaseBreadcrumb.vue
similarity index 100%
rename from pages/shared-components/BaseBreadcrumb.vue
rename to pages/template/shared-components/BaseBreadcrumb.vue
diff --git a/pages/shared-components/CardComponents.vue b/pages/template/shared-components/CardComponents.vue
similarity index 100%
rename from pages/shared-components/CardComponents.vue
rename to pages/template/shared-components/CardComponents.vue
diff --git a/pages/shared-components/UiParentCard.vue b/pages/template/shared-components/UiParentCard.vue
similarity index 100%
rename from pages/shared-components/UiParentCard.vue
rename to pages/template/shared-components/UiParentCard.vue
diff --git a/pages/shared-components/UiTextfieldPrimary.vue b/pages/template/shared-components/UiTextfieldPrimary.vue
similarity index 100%
rename from pages/shared-components/UiTextfieldPrimary.vue
rename to pages/template/shared-components/UiTextfieldPrimary.vue
diff --git a/pages/shared-components/WidgetCards.vue b/pages/template/shared-components/WidgetCards.vue
similarity index 100%
rename from pages/shared-components/WidgetCards.vue
rename to pages/template/shared-components/WidgetCards.vue
diff --git a/pages/shared-components/index.vue b/pages/template/shared-components/index.vue
similarity index 100%
rename from pages/shared-components/index.vue
rename to pages/template/shared-components/index.vue
diff --git a/pages/style-components/Shadow.vue b/pages/template/style-components/Shadow.vue
similarity index 100%
rename from pages/style-components/Shadow.vue
rename to pages/template/style-components/Shadow.vue
diff --git a/pages/style-components/Typography.vue b/pages/template/style-components/Typography.vue
similarity index 100%
rename from pages/style-components/Typography.vue
rename to pages/template/style-components/Typography.vue
diff --git a/pages/ui-components/Alerts.vue b/pages/template/ui-components/Alerts.vue
similarity index 100%
rename from pages/ui-components/Alerts.vue
rename to pages/template/ui-components/Alerts.vue
diff --git a/pages/ui-components/Avatar.vue b/pages/template/ui-components/Avatar.vue
similarity index 100%
rename from pages/ui-components/Avatar.vue
rename to pages/template/ui-components/Avatar.vue
diff --git a/pages/ui-components/Buttons.vue b/pages/template/ui-components/Buttons.vue
similarity index 100%
rename from pages/ui-components/Buttons.vue
rename to pages/template/ui-components/Buttons.vue
diff --git a/pages/ui-components/Cards.vue b/pages/template/ui-components/Cards.vue
similarity index 100%
rename from pages/ui-components/Cards.vue
rename to pages/template/ui-components/Cards.vue
diff --git a/pages/ui-components/Chip.vue b/pages/template/ui-components/Chip.vue
similarity index 100%
rename from pages/ui-components/Chip.vue
rename to pages/template/ui-components/Chip.vue
diff --git a/pages/ui-components/Dialogs.vue b/pages/template/ui-components/Dialogs.vue
similarity index 100%
rename from pages/ui-components/Dialogs.vue
rename to pages/template/ui-components/Dialogs.vue
diff --git a/pages/ui-components/ExpansionPanel.vue b/pages/template/ui-components/ExpansionPanel.vue
similarity index 100%
rename from pages/ui-components/ExpansionPanel.vue
rename to pages/template/ui-components/ExpansionPanel.vue
diff --git a/pages/ui-components/List.vue b/pages/template/ui-components/List.vue
similarity index 100%
rename from pages/ui-components/List.vue
rename to pages/template/ui-components/List.vue
diff --git a/pages/ui-components/Menus.vue b/pages/template/ui-components/Menus.vue
similarity index 100%
rename from pages/ui-components/Menus.vue
rename to pages/template/ui-components/Menus.vue
diff --git a/pages/ui-components/Ratting.vue b/pages/template/ui-components/Ratting.vue
similarity index 100%
rename from pages/ui-components/Ratting.vue
rename to pages/template/ui-components/Ratting.vue
diff --git a/pages/ui-components/Tables.vue b/pages/template/ui-components/Tables.vue
similarity index 100%
rename from pages/ui-components/Tables.vue
rename to pages/template/ui-components/Tables.vue
diff --git a/pages/ui-components/Tabs.vue b/pages/template/ui-components/Tabs.vue
similarity index 100%
rename from pages/ui-components/Tabs.vue
rename to pages/template/ui-components/Tabs.vue
diff --git a/pages/ui-components/Tooltip.vue b/pages/template/ui-components/Tooltip.vue
similarity index 100%
rename from pages/ui-components/Tooltip.vue
rename to pages/template/ui-components/Tooltip.vue
diff --git a/pages/ui/Shadow.vue b/pages/template/ui/Shadow.vue
similarity index 100%
rename from pages/ui/Shadow.vue
rename to pages/template/ui/Shadow.vue
diff --git a/pages/ui/Typography.vue b/pages/template/ui/Typography.vue
similarity index 100%
rename from pages/ui/Typography.vue
rename to pages/template/ui/Typography.vue
diff --git a/pages/ui/tables/TableBasic.vue b/pages/template/ui/tables/TableBasic.vue
similarity index 100%
rename from pages/ui/tables/TableBasic.vue
rename to pages/template/ui/tables/TableBasic.vue
diff --git a/pages/ui/tables/TableDark.vue b/pages/template/ui/tables/TableDark.vue
similarity index 100%
rename from pages/ui/tables/TableDark.vue
rename to pages/template/ui/tables/TableDark.vue
diff --git a/pages/ui/tables/TableDensity.vue b/pages/template/ui/tables/TableDensity.vue
similarity index 100%
rename from pages/ui/tables/TableDensity.vue
rename to pages/template/ui/tables/TableDensity.vue
diff --git a/pages/ui/tables/TableEditable.vue b/pages/template/ui/tables/TableEditable.vue
similarity index 100%
rename from pages/ui/tables/TableEditable.vue
rename to pages/template/ui/tables/TableEditable.vue
diff --git a/pages/ui/tables/TableHeaderFixed.vue b/pages/template/ui/tables/TableHeaderFixed.vue
similarity index 100%
rename from pages/ui/tables/TableHeaderFixed.vue
rename to pages/template/ui/tables/TableHeaderFixed.vue
diff --git a/pages/ui/tables/TableHeight.vue b/pages/template/ui/tables/TableHeight.vue
similarity index 100%
rename from pages/ui/tables/TableHeight.vue
rename to pages/template/ui/tables/TableHeight.vue
diff --git a/pages/ui/tables/datatables/BasicTable.vue b/pages/template/ui/tables/datatables/BasicTable.vue
similarity index 100%
rename from pages/ui/tables/datatables/BasicTable.vue
rename to pages/template/ui/tables/datatables/BasicTable.vue
diff --git a/pages/ui/tables/datatables/CrudTable.vue b/pages/template/ui/tables/datatables/CrudTable.vue
similarity index 100%
rename from pages/ui/tables/datatables/CrudTable.vue
rename to pages/template/ui/tables/datatables/CrudTable.vue
diff --git a/pages/ui/tables/datatables/Filtering.vue b/pages/template/ui/tables/datatables/Filtering.vue
similarity index 100%
rename from pages/ui/tables/datatables/Filtering.vue
rename to pages/template/ui/tables/datatables/Filtering.vue
diff --git a/pages/ui/tables/datatables/Grouping.vue b/pages/template/ui/tables/datatables/Grouping.vue
similarity index 100%
rename from pages/ui/tables/datatables/Grouping.vue
rename to pages/template/ui/tables/datatables/Grouping.vue
diff --git a/pages/ui/tables/datatables/HeaderTables.vue b/pages/template/ui/tables/datatables/HeaderTables.vue
similarity index 100%
rename from pages/ui/tables/datatables/HeaderTables.vue
rename to pages/template/ui/tables/datatables/HeaderTables.vue
diff --git a/pages/ui/tables/datatables/Pagination.vue b/pages/template/ui/tables/datatables/Pagination.vue
similarity index 100%
rename from pages/ui/tables/datatables/Pagination.vue
rename to pages/template/ui/tables/datatables/Pagination.vue
diff --git a/pages/ui/tables/datatables/Selectable.vue b/pages/template/ui/tables/datatables/Selectable.vue
similarity index 100%
rename from pages/ui/tables/datatables/Selectable.vue
rename to pages/template/ui/tables/datatables/Selectable.vue
diff --git a/pages/ui/tables/datatables/Slots.vue b/pages/template/ui/tables/datatables/Slots.vue
similarity index 100%
rename from pages/ui/tables/datatables/Slots.vue
rename to pages/template/ui/tables/datatables/Slots.vue
diff --git a/pages/ui/tables/datatables/SortingTable.vue b/pages/template/ui/tables/datatables/SortingTable.vue
similarity index 100%
rename from pages/ui/tables/datatables/SortingTable.vue
rename to pages/template/ui/tables/datatables/SortingTable.vue
diff --git a/pages/widgets/Banners.vue b/pages/template/widgets/Banners.vue
similarity index 100%
rename from pages/widgets/Banners.vue
rename to pages/template/widgets/Banners.vue
diff --git a/pages/widgets/Cards.vue b/pages/template/widgets/Cards.vue
similarity index 100%
rename from pages/widgets/Cards.vue
rename to pages/template/widgets/Cards.vue
diff --git a/pages/widgets/Charts.vue b/pages/template/widgets/Charts.vue
similarity index 100%
rename from pages/widgets/Charts.vue
rename to pages/template/widgets/Charts.vue
diff --git a/server/api/auth/keycloak-callback.get.ts b/server/api/auth/keycloak-callback.get.ts
new file mode 100644
index 0000000..2163d14
--- /dev/null
+++ b/server/api/auth/keycloak-callback.get.ts
@@ -0,0 +1,203 @@
+// server/api/auth/keycloak-callback.ts - EXTENDED SESSION FIX
+import { createUserSession } from '~/server/utils/sessionStore';
+
+// Add this at the top of the file (after imports)
+const SESSION_DURATION = 1 * 60 * 60; // 1 hour in seconds (3600 seconds)
+// Or use one of these alternatives:
+// const SESSION_DURATION = 24 * 60 * 60; // 1 day
+// const SESSION_DURATION = 30 * 24 * 60 * 60; // 30 days
+// const SESSION_DURATION = 12 * 60 * 60; // 12 hours
+// const SESSION_DURATION = 7 * 24 * 60 * 60; // 7 days
+
+export default defineEventHandler(async (event) => {
+ try {
+ const config = useRuntimeConfig();
+ const query = getQuery(event);
+
+ const code = query.code as string;
+ const state = query.state as string;
+ const error = query.error as string;
+ const storedState = getCookie(event, 'oauth_state');
+
+ if (error) {
+ console.error('❌ OAuth error from Keycloak:', error);
+ const errorDescription = query.error_description as string;
+ console.error('❌ Error description:', errorDescription);
+
+ const errorMsg = encodeURIComponent(`Keycloak error: ${error} - ${errorDescription || 'Please try again'}`);
+ return sendRedirect(event, `/auth/login?error=${errorMsg}`);
+ }
+
+ if (!state || state !== storedState) {
+ console.error('❌ Invalid state parameter - possible CSRF attack');
+ console.error(' Expected:', storedState);
+ console.error(' Received:', state);
+
+ const errorMsg = encodeURIComponent('Security validation failed. Please try logging in again.');
+ return sendRedirect(event, `/auth/login?error=${errorMsg}`);
+ }
+
+ deleteCookie(event, 'oauth_state');
+
+ if (!code) {
+ console.error('❌ Authorization code not provided');
+ const errorMsg = encodeURIComponent('No authorization code received from Keycloak.');
+ return sendRedirect(event, `/auth/login?error=${errorMsg}`);
+ }
+
+ // Validate Keycloak configuration
+ if (!config.keycloakIssuer) {
+ console.error('❌ KEYCLOAK_ISSUER is not configured');
+ const errorMsg = encodeURIComponent('Keycloak server is not configured. Please contact administrator.');
+ return sendRedirect(event, `/auth/login?error=${errorMsg}`);
+ }
+
+ if (!config.keycloakClientId || !config.keycloakClientSecret) {
+ console.error('❌ Keycloak client credentials are not configured');
+ const errorMsg = encodeURIComponent('Keycloak client credentials are missing. Please contact administrator.');
+ return sendRedirect(event, `/auth/login?error=${errorMsg}`);
+ }
+
+ const tokenUrl = `${config.keycloakIssuer}/protocol/openid-connect/token`;
+ const redirectUri = `${config.public.authUrl}/api/auth/keycloak-callback`;
+
+ const tokenPayload = new URLSearchParams({
+ grant_type: 'authorization_code',
+ client_id: config.keycloakClientId,
+ client_secret: config.keycloakClientSecret,
+ code,
+ redirect_uri: redirectUri,
+ });
+
+ let tokenResponse;
+ try {
+ // Create abort controller for timeout (compatible with all Node.js versions)
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), 30000); // 30 second timeout
+
+ tokenResponse = await fetch(tokenUrl, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ },
+ body: tokenPayload,
+ signal: controller.signal,
+ });
+
+ clearTimeout(timeoutId);
+ } catch (fetchError: any) {
+ console.error('❌ Fetch error details:');
+ console.error(' - Error type:', fetchError.name);
+ console.error(' - Error message:', fetchError.message);
+ console.error(' - Token URL attempted:', tokenUrl);
+
+ // Provide more specific error messages
+ let errorMsg = 'Failed to connect to authentication server.';
+ if (fetchError.name === 'AbortError' || fetchError.message.includes('timeout')) {
+ errorMsg = 'Authentication server timeout. Please try again.';
+ } else if (fetchError.message.includes('ENOTFOUND') || fetchError.message.includes('getaddrinfo')) {
+ errorMsg = 'Cannot reach authentication server. Please check network connection.';
+ } else if (fetchError.message.includes('ECONNREFUSED')) {
+ errorMsg = 'Authentication server refused connection. Server may be down.';
+ } else if (fetchError.message.includes('certificate') || fetchError.message.includes('SSL')) {
+ errorMsg = 'SSL certificate error. Please contact administrator.';
+ }
+
+ const encodedError = encodeURIComponent(errorMsg);
+ return sendRedirect(event, `/auth/login?error=${encodedError}`);
+ }
+
+ if (!tokenResponse.ok) {
+ const errorText = await tokenResponse.text();
+ console.error('❌ Token exchange failed:', errorText);
+ const errorMsg = encodeURIComponent(`Token exchange failed: ${tokenResponse.status} - Please check Keycloak configuration`);
+ return sendRedirect(event, `/auth/login?error=${errorMsg}`);
+ }
+
+ const tokens = await tokenResponse.json();
+
+ let idTokenPayload;
+ let accessTokenPayload;
+ let refreshTokenPayload;
+ try {
+ idTokenPayload = JSON.parse(
+ Buffer.from(tokens.id_token.split('.')[1], 'base64').toString()
+ );
+ accessTokenPayload = JSON.parse(
+ Buffer.from(tokens.access_token.split('.')[1], 'base64').toString()
+ );
+ refreshTokenPayload = JSON.parse(
+ Buffer.from(tokens.refresh_token.split('.')[1], 'base64').toString()
+ );
+ } catch (decodeError) {
+ console.error('❌ Failed to decode token:', decodeError);
+ const errorMsg = encodeURIComponent('Invalid token format');
+ return sendRedirect(event, `/auth/login?error=${errorMsg}`);
+ }
+
+ const clientRoles = accessTokenPayload.resource_access?.[config.keycloakClientId]?.roles || [];
+ const nowInSeconds = Math.floor(Date.now() / 1000);
+ const refreshTokenExpiresInSeconds = Math.max(
+ refreshTokenPayload.exp - nowInSeconds,
+ 0
+ );
+ const sessionDurationSeconds = refreshTokenExpiresInSeconds || SESSION_DURATION;
+
+ const sessionData = {
+ user: {
+ auth_provider: 'keycloak',
+ email: idTokenPayload.email,
+ name: idTokenPayload.name || idTokenPayload.preferred_username,
+ role: clientRoles,
+ user_id: idTokenPayload.sub,
+ username: idTokenPayload.preferred_username,
+ },
+ accessToken: tokens.access_token,
+ refreshToken: tokens.refresh_token,
+ // idToken: tokens.id_token,
+ expiresAt: (nowInSeconds + sessionDurationSeconds) * 1000,
+ };
+
+ // Determine if we should use secure cookies
+ // For localhost, always use secure: false
+ const isSecure = process.env.NODE_ENV === 'production' &&
+ event.node.req.headers['x-forwarded-proto'] === 'https';
+
+ // Set cookie with proper settings for localhost
+ // For localhost HTTP, we need secure: false and sameSite: 'lax'
+ // IMPORTANT: Ensure domain is not set for localhost (allows cookie to work)
+ const cookieOptions: any = {
+ httpOnly: true,
+ secure: isSecure,
+ sameSite: 'lax' as const,
+ maxAge: sessionDurationSeconds,
+ path: '/',
+ // Explicitly don't set domain for localhost - this is important!
+ // Setting domain to 'localhost' can cause cookies to not work
+ };
+
+ // For localhost, don't set domain (allows cookie to work on localhost)
+ // Only set domain in production if needed
+ if (process.env.NODE_ENV === 'production' && !event.node.req.headers.host?.includes('localhost')) {
+ // Optionally set domain in production
+ // cookieOptions.domain = '.yourdomain.com';
+ }
+
+ // Store session in server-side store and use session ID in cookie
+ // This avoids cookie size limits (4KB)
+ const sessionId = createUserSession(sessionData);
+
+ // Store only the session ID in the cookie (much smaller)
+ setCookie(event, 'user_session', sessionId, cookieOptions);
+
+ return sendRedirect(event, '/apps/dashboard?authenticated=true', 302);
+ } catch (error: any) {
+ console.error('❌ === CALLBACK ERROR ===');
+ console.error('❌ Error message:', error.message);
+ console.error('❌ Error stack:', error.stack);
+ console.error('❌ ==================');
+
+ const errorMsg = encodeURIComponent(`Authentication failed: ${error.message}`);
+ return sendRedirect(event, `/auth/login?error=${errorMsg}`);
+ }
+});
\ No newline at end of file
diff --git a/server/api/auth/keycloak-login.ts b/server/api/auth/keycloak-login.ts
new file mode 100644
index 0000000..3eeb494
--- /dev/null
+++ b/server/api/auth/keycloak-login.ts
@@ -0,0 +1,70 @@
+// server/api/auth/keycloak-login.ts
+import { randomBytes } from 'crypto'
+
+export default defineEventHandler(async (event) => {
+ console.log('🔐 Keycloak Login Handler Called')
+ console.log('📍 Method:', getMethod(event))
+
+ try {
+ const config = useRuntimeConfig()
+
+ // Debug: Log runtime config (without secrets)
+ console.log('🔧 Runtime Config Check:')
+ console.log(' - Has keycloakIssuer:', !!config.keycloakIssuer)
+ console.log(' - Has keycloakClientId:', !!config.keycloakClientId)
+ console.log(' - Has keycloakSecret:', !!config.keycloakClientSecret)
+ console.log(' - Issuer value:', config.keycloakIssuer)
+
+ // Validate required configuration
+ if (!config.keycloakIssuer) {
+ throw new Error('KEYCLOAK_ISSUER is not configured')
+ }
+ if (!config.keycloakClientId) {
+ throw new Error('KEYCLOAK_CLIENT_ID is not configured')
+ }
+
+ // Generate state parameter for security
+ const state = randomBytes(32).toString('hex')
+ console.log('🎲 Generated state:', state.substring(0, 8) + '...')
+
+ // Store state in session cookie
+ setCookie(event, 'oauth_state', state, {
+ httpOnly: true,
+ secure: false,
+ sameSite: 'lax',
+ maxAge: 600 // 10 minutes
+ })
+
+ // Build Keycloak authorization URL
+ const redirectUri = `${config.public.authUrl}/api/auth/keycloak-callback`
+
+ // Debug: Log the redirect URI being used
+ console.log('🔧 AUTH_ORIGIN from config:', config.public.authUrl)
+ console.log('🔗 Redirect URI being sent to Keycloak:', redirectUri)
+
+ const authUrl = new URL(`${config.keycloakIssuer}/protocol/openid-connect/auth`)
+
+ authUrl.searchParams.set('client_id', config.keycloakClientId)
+ authUrl.searchParams.set('redirect_uri', redirectUri)
+ authUrl.searchParams.set('response_type', 'code')
+ authUrl.searchParams.set('scope', 'openid profile email')
+ authUrl.searchParams.set('state', state)
+
+ console.log('🏗️ Auth URL built:', authUrl.toString())
+
+ return {
+ success: true,
+ data: {
+ authUrl: authUrl.toString()
+ }
+ }
+
+ } catch (error: any) {
+ console.error('❌ Login Error:', error.message)
+
+ throw createError({
+ statusCode: 500,
+ statusMessage: `Failed to generate authorization URL: ${error.message}`
+ })
+ }
+})
\ No newline at end of file
diff --git a/server/api/auth/logout.post.ts b/server/api/auth/logout.post.ts
new file mode 100644
index 0000000..95ddf58
--- /dev/null
+++ b/server/api/auth/logout.post.ts
@@ -0,0 +1,82 @@
+// server/api/auth/logout.post.ts
+
+
+export default defineEventHandler(async (event) => {
+ const asString = (value: unknown): string => (typeof value === 'string' ? value : '');
+
+ const buildLogoutUrl = (idToken?: string) => {
+ const config = useRuntimeConfig();
+ const keycloakIssuer = asString(config.keycloakIssuer);
+ const keycloakClientId = asString(config.keycloakClientId);
+ const authUrl = asString(config.public.authUrl);
+ const baseUrl = asString(config.public.baseUrl);
+ const keycloakLogoutUri = asString(config.keycloakLogoutUri);
+ const postLogoutRedirectUriConfig = asString(config.postLogoutRedirectUri);
+
+ const logoutPath = keycloakLogoutUri || `${keycloakIssuer}/protocol/openid-connect/logout`;
+ const postLogoutRedirectUri = postLogoutRedirectUriConfig || baseUrl || authUrl;
+
+ const logoutUrl = new URL(logoutPath);
+ logoutUrl.searchParams.set('client_id', keycloakClientId);
+
+ if (postLogoutRedirectUri) {
+ logoutUrl.searchParams.set('post_logout_redirect_uri', postLogoutRedirectUri);
+ }
+
+ if (idToken) {
+ logoutUrl.searchParams.set('id_token_hint', idToken);
+ }
+
+ return logoutUrl.toString();
+ };
+
+ try {
+ // Retrieve token from the active session when available.
+ const sessionId = getCookie(event, 'user_session');
+ let idToken: string | undefined;
+
+ if (sessionId) {
+ try {
+ const { getUserSession, deleteUserSession } = await import('~/server/utils/sessionStore');
+ const session = getUserSession(sessionId);
+
+ if (session) {
+ idToken = session.idToken;
+ deleteUserSession(sessionId);
+ }
+ } catch {
+ // Ignore session-store retrieval failures; cookies are still cleared below.
+ }
+ }
+
+ // Always clear auth-related cookies.
+ deleteCookie(event, 'user_session');
+ deleteCookie(event, 'oauth_state');
+ deleteCookie(event, 'user_session', { path: '/' });
+ deleteCookie(event, 'oauth_state', { path: '/' });
+
+ const logoutUrl = buildLogoutUrl(idToken);
+
+ return {
+ success: true,
+ logoutUrl,
+ message: 'Session cleared successfully',
+ };
+
+ } catch {
+ let fallbackLogoutUrl = '';
+
+ try {
+ fallbackLogoutUrl = buildLogoutUrl();
+ } catch {
+ // Keep empty fallback URL if runtime config is not usable.
+ }
+
+ return {
+ success: false,
+ logoutUrl: fallbackLogoutUrl,
+ error: 'Logout encountered an error, but providing fallback logout URL',
+ message: 'Logout failed',
+ };
+ }
+});
\ No newline at end of file
diff --git a/server/api/auth/sessionUser.get.ts b/server/api/auth/sessionUser.get.ts
new file mode 100644
index 0000000..4b2a6cc
--- /dev/null
+++ b/server/api/auth/sessionUser.get.ts
@@ -0,0 +1,58 @@
+export default defineEventHandler(async (event) => {
+ const parseJwtPayload = (token: string): { exp?: number } | null => {
+ try {
+ const payload = token.split('.')[1];
+ if (!payload) return null;
+
+ const decoded = Buffer.from(payload, 'base64').toString();
+ return JSON.parse(decoded) as { exp?: number };
+ } catch {
+ return null;
+ }
+ };
+
+ const sessionId = getCookie(event, "user_session");
+
+ if (!sessionId) {
+ throw createError({
+ statusCode: 401,
+ statusMessage: "No session cookie found",
+ });
+ }
+
+ try {
+ // Get session from server-side store using session ID
+ const { getUserSession, deleteUserSession } = await import('~/server/utils/sessionStore');
+ const session = getUserSession(sessionId);
+
+ if (!session) {
+ throw createError({
+ statusCode: 401,
+ statusMessage: "Session expired or invalid",
+ });
+ }
+
+ const accessPayload = parseJwtPayload(session.accessToken);
+ const nowInSeconds = Math.floor(Date.now() / 1000);
+
+ if (!accessPayload?.exp || accessPayload.exp <= nowInSeconds) {
+ deleteUserSession(sessionId);
+ throw createError({
+ statusCode: 401,
+ statusMessage: "Access token expired or invalid",
+ });
+ }
+
+ return session;
+ } catch (error: any) {
+ if (error?.statusCode) {
+ throw error;
+ }
+
+ console.error("❌ Failed to validate session:", error);
+ throw createError({
+ statusCode: 401,
+ statusMessage: "Invalid session data",
+ });
+ }
+});
\ No newline at end of file
diff --git a/server/api/auth/sessionUserStore.post.ts b/server/api/auth/sessionUserStore.post.ts
new file mode 100644
index 0000000..5d45fc5
--- /dev/null
+++ b/server/api/auth/sessionUserStore.post.ts
@@ -0,0 +1,119 @@
+import { createError } from 'h3';
+import type { AuthInfoResponse } from '~/types/auth';
+import {
+ createUserSession,
+ deleteUserSession,
+ getUserSession,
+} from '~/server/utils/sessionStore';
+
+type SessionStoreRequest = {
+ accessToken?: string;
+ refreshToken?: string;
+};
+
+const DEFAULT_SESSION_SECONDS = 60 * 60; // 1 hour
+
+
+const parseJwtPayload = (token: string): { exp?: number } | null => {
+ try {
+ const payload = token.split('.')[1];
+ if (!payload) return null;
+
+ const normalized = payload.replace(/-/g, '+').replace(/_/g, '/');
+ const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=');
+ const decoded = Buffer.from(padded, 'base64').toString();
+
+ return JSON.parse(decoded) as { exp?: number };
+ } catch {
+ return null;
+ }
+};
+
+export default defineEventHandler(async (event) => {
+ const body = await readBody(event);
+
+ const accessToken = body?.accessToken;
+ const refreshToken = body?.refreshToken;
+
+ if (!accessToken || !refreshToken) {
+ throw createError({
+ statusCode: 400,
+ statusMessage: 'accessToken and refreshToken are required',
+ });
+ }
+
+ const config = useRuntimeConfig();
+ const baseUrl = config.public.baseUrl;
+ const authInfoUrl = `${baseUrl}/api/v1/auth/info`;
+
+ let authInfoResponse: AuthInfoResponse;
+ try {
+ authInfoResponse = await $fetch(authInfoUrl, {
+ method: 'GET',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${accessToken}`,
+ },
+ });
+ } catch (error: any) {
+ throw createError({
+ statusCode: 401,
+ statusMessage:
+ error?.data?.message || error?.message || 'Failed to fetch auth info',
+ });
+ }
+
+ if (!authInfoResponse?.data) {
+ throw createError({
+ statusCode: 502,
+ statusMessage: 'Invalid auth info response',
+ });
+ }
+
+ const now = Date.now();
+ const refreshPayload = parseJwtPayload(refreshToken);
+ const refreshExpSeconds = refreshPayload?.exp;
+
+ const expiresAt =
+ typeof refreshExpSeconds === 'number' && refreshExpSeconds > 0
+ ? refreshExpSeconds * 1000
+ : now + DEFAULT_SESSION_SECONDS * 1000;
+
+ const maxAge = Math.max(Math.floor((expiresAt - now) / 1000), 60);
+
+ const previousSessionId = getCookie(event, 'user_session');
+ if (previousSessionId && getUserSession(previousSessionId)) {
+ deleteUserSession(previousSessionId);
+ }
+
+ const sessionId = createUserSession({
+ user: {
+ auth_provider: authInfoResponse.data.auth_provider || 'jwt',
+ email: authInfoResponse.data.email,
+ name: authInfoResponse.data.name,
+ role: authInfoResponse.data.role,
+ user_id: authInfoResponse.data.user_id,
+ username: authInfoResponse.data.username,
+ },
+ accessToken,
+ refreshToken,
+ expiresAt,
+ });
+
+ const isSecure =
+ process.env.NODE_ENV === 'production' && event.node.req.headers['x-forwarded-proto'] === 'https';
+
+ setCookie(event, 'user_session', sessionId, {
+ httpOnly: true,
+ secure: isSecure,
+ sameSite: 'lax',
+ maxAge,
+ path: '/',
+ });
+
+ return {
+ success: true,
+ message: 'Session stored successfully',
+ expiresAt,
+ };
+});
diff --git a/server/utils/sessionStore.ts b/server/utils/sessionStore.ts
new file mode 100644
index 0000000..16e4532
--- /dev/null
+++ b/server/utils/sessionStore.ts
@@ -0,0 +1,75 @@
+// server/utils/sessionStore.ts
+// Simple in-memory session store (for development)
+// In production, use Redis or a database
+
+import { getCookie } from 'h3'
+import { randomBytes } from 'crypto'
+import type { SessionData } from '~/types/auth';
+
+
+
+const sessions = new Map();
+
+// Clean up expired sessions every 5 minutes
+setInterval(() => {
+ const now = Date.now();
+ for (const [sessionId, session] of sessions.entries()) {
+ if (session.expiresAt < now) {
+ sessions.delete(sessionId);
+ }
+ }
+}, 5 * 60 * 1000);
+
+export function createUserSession(data: Omit): string {
+ // Generate a secure random session ID
+ const sessionId = randomBytes(32).toString('hex');
+ const sessionData: SessionData = {
+ ...data
+ };
+ sessions.set(sessionId, sessionData);
+ return sessionId;
+}
+
+export function getUserSession(sessionId: string): SessionData | null {
+ const session = sessions.get(sessionId);
+ if (!session) {
+ return null;
+ }
+
+ if (session.expiresAt <= Date.now()) {
+ sessions.delete(sessionId);
+ return null;
+ }
+
+ return session;
+}
+
+export function deleteUserSession(sessionId: string): void {
+ sessions.delete(sessionId);
+}
+
+export function updateUserSession(sessionId: string, updates: Partial): boolean {
+ const session = sessions.get(sessionId);
+ if (!session) {
+ return false;
+ }
+
+ // Update the session with new data
+ const updatedSession = {
+ ...session,
+ ...updates,
+ };
+
+ sessions.set(sessionId, updatedSession);
+ return true;
+}
+
+// Helper function to get session from cookie (for use in API handlers)
+export async function getUserSessionFromCookie(event: any): Promise {
+ const sessionId = getCookie(event, 'user_session');
+ if (!sessionId) {
+ return null;
+ }
+ return getUserSession(sessionId);
+}
+
diff --git a/store/snackbar.ts b/store/snackbar.ts
new file mode 100644
index 0000000..87e43b8
--- /dev/null
+++ b/store/snackbar.ts
@@ -0,0 +1,32 @@
+import { defineStore } from 'pinia';
+
+export const useSnackbarStore = defineStore('snackbar', {
+/**
+ * The state of the snackbar store.
+ * It contains the following properties:
+ * - `show`: a boolean indicating whether the snackbar should be shown or not.
+ * - `text`: a string containing the text to be displayed in the snackbar.
+ * - `color`: a string containing the color of the snackbar.
+ * - `timeout`: a number containing the timeout of the snackbar in milliseconds.
+ */
+ state: () => ({
+ show: false,
+ text: '',
+ color: '',
+ timeout: 3000,
+ }),
+ actions: {
+ /**
+ * Show a snackbar with the given text, color and timeout.
+ * @param {string} text - The text to be displayed in the snackbar.
+ * @param {string} [color='error'] - The color of the snackbar.
+ * @param {number} [timeout=3000] - The timeout of the snackbar in milliseconds.
+ */
+ showSnackbar(text: string, color = 'error', timeout = 3000) {
+ this.text = text;
+ this.color = color;
+ this.timeout = timeout;
+ this.show = true;
+ },
+ },
+});
diff --git a/types/auth.ts b/types/auth.ts
index fd1e687..500e139 100644
--- a/types/auth.ts
+++ b/types/auth.ts
@@ -19,3 +19,42 @@ export interface ExtendedSession {
refreshToken: string;
expiresAt: number;
}
+
+export interface AuthInfoData {
+ auth_provider: string;
+ email: string;
+ name: string;
+ role: any;
+ user_id: string;
+ username: string;
+}
+
+export interface AuthInfoResponse {
+ data: AuthInfoData;
+ message: string;
+}
+
+
+
+export interface LogoutResponse {
+ success: boolean;
+ logoutUrl?: string;
+}
+
+export interface SessionData {
+ user: AuthInfoData;
+ accessToken: string;
+ idToken?: string;
+ refreshToken: string;
+ expiresAt: number;
+}
+
+export interface LoginResponse {
+ data : {
+ provider: string;
+ access_token: string;
+ refresh_token: string;
+ expires_in: number;
+ token_type: string;
+ }
+}
\ No newline at end of file
diff --git a/utils/api.ts b/utils/api.ts
new file mode 100644
index 0000000..123ef46
--- /dev/null
+++ b/utils/api.ts
@@ -0,0 +1,11 @@
+import axios from 'axios'
+
+const config = useRuntimeConfig()
+
+const api = axios.create({
+ baseURL: config.public.baseUrl,
+ timeout: 10000,
+})
+
+
+export default api
\ No newline at end of file