Implement Keycloak authentication integration and unify user management

This commit is contained in:
2025-06-14 14:09:56 +02:00
parent 72ea543485
commit 5f8720bb63
11 changed files with 743 additions and 74 deletions

44
pages/auth/callback.vue Normal file
View File

@@ -0,0 +1,44 @@
<template>
<v-app>
<v-main>
<v-container class="fill-height">
<v-row align="center" justify="center">
<v-col cols="12" sm="8" md="4">
<v-card class="pa-6">
<v-card-text class="text-center">
<v-progress-circular
indeterminate
color="primary"
size="64"
class="mb-4"
/>
<h2 class="text-h5 mb-2">Authenticating...</h2>
<p class="text-body-2 text-medium-emphasis">
Please wait while we complete your login.
</p>
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-container>
</v-main>
</v-app>
</template>
<script setup lang="ts">
// This page handles the OAuth callback from Keycloak
// The nuxt-openid-connect module will automatically process the callback
// and redirect to the originally requested page or default redirect
definePageMeta({
auth: false, // This page should be accessible without authentication
layout: false // Use minimal layout for callback page
});
// The OIDC module handles the callback automatically
// If you need custom logic after successful authentication, you can add it here
onMounted(() => {
// Optional: Add any custom post-authentication logic here
console.log('OAuth callback page mounted');
});
</script>

View File

@@ -20,10 +20,16 @@
<v-list lines="two">
<v-list-item
v-if="user"
:title="`${user.first_name} ${user.last_name}`"
:title="user.name"
:subtitle="user.email"
prepend-icon="mdi-account"
/>
>
<template #append>
<v-chip v-if="user.tier && user.tier !== 'basic'" size="small" color="primary">
{{ user.tier }}
</v-chip>
</template>
</v-list-item>
<v-list-item
@click="logOut"
title="Log out"
@@ -65,8 +71,7 @@ definePageMeta({
});
const { mdAndDown } = useDisplay();
const { logout } = useDirectusAuth();
const user = useDirectusUser();
const { user, logout, authSource } = useUnifiedAuth();
const tags = usePortalTags();
const drawer = ref(false);

View File

@@ -0,0 +1,136 @@
<template>
<v-container>
<v-row>
<v-col cols="12">
<h1 class="text-h4 mb-4">Authentication Test</h1>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="6">
<v-card>
<v-card-title>Current Authentication Status</v-card-title>
<v-card-text>
<v-list>
<v-list-item>
<template #prepend>
<v-icon :color="isAuthenticated ? 'success' : 'error'">
{{ isAuthenticated ? 'mdi-check-circle' : 'mdi-close-circle' }}
</v-icon>
</template>
<v-list-item-title>Authentication Status</v-list-item-title>
<v-list-item-subtitle>{{ isAuthenticated ? 'Authenticated' : 'Not Authenticated' }}</v-list-item-subtitle>
</v-list-item>
<v-list-item v-if="authSource">
<template #prepend>
<v-icon>mdi-shield-account</v-icon>
</template>
<v-list-item-title>Auth Source</v-list-item-title>
<v-list-item-subtitle>{{ authSource }}</v-list-item-subtitle>
</v-list-item>
</v-list>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="6">
<v-card v-if="user">
<v-card-title>User Information</v-card-title>
<v-card-text>
<v-list>
<v-list-item>
<template #prepend>
<v-icon>mdi-identifier</v-icon>
</template>
<v-list-item-title>User ID</v-list-item-title>
<v-list-item-subtitle>{{ user.id }}</v-list-item-subtitle>
</v-list-item>
<v-list-item>
<template #prepend>
<v-icon>mdi-email</v-icon>
</template>
<v-list-item-title>Email</v-list-item-title>
<v-list-item-subtitle>{{ user.email }}</v-list-item-subtitle>
</v-list-item>
<v-list-item>
<template #prepend>
<v-icon>mdi-account</v-icon>
</template>
<v-list-item-title>Name</v-list-item-title>
<v-list-item-subtitle>{{ user.name }}</v-list-item-subtitle>
</v-list-item>
<v-list-item>
<template #prepend>
<v-icon>mdi-medal</v-icon>
</template>
<v-list-item-title>Tier</v-list-item-title>
<v-list-item-subtitle>
<v-chip :color="user.tier === 'admin' ? 'error' : 'primary'" size="small">
{{ user.tier || 'basic' }}
</v-chip>
</v-list-item-subtitle>
</v-list-item>
<v-list-item v-if="isAdmin">
<template #prepend>
<v-icon color="error">mdi-shield-crown</v-icon>
</template>
<v-list-item-title>Admin Status</v-list-item-title>
<v-list-item-subtitle>User has admin privileges</v-list-item-subtitle>
</v-list-item>
</v-list>
</v-card-text>
</v-card>
</v-col>
</v-row>
<v-row v-if="user">
<v-col cols="12">
<v-card>
<v-card-title>Raw User Data</v-card-title>
<v-card-text>
<v-expansion-panels>
<v-expansion-panel>
<v-expansion-panel-title>Click to view raw data</v-expansion-panel-title>
<v-expansion-panel-text>
<pre class="text-caption">{{ JSON.stringify(user.raw, null, 2) }}</pre>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</v-card-text>
</v-card>
</v-col>
</v-row>
<v-row class="mt-4">
<v-col cols="12">
<v-card>
<v-card-title>Test Actions</v-card-title>
<v-card-text>
<v-btn color="error" @click="testLogout" prepend-icon="mdi-logout">
Test Logout
</v-btn>
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-container>
</template>
<script setup lang="ts">
const { user, isAuthenticated, authSource, isAdmin, logout } = useUnifiedAuth();
const router = useRouter();
const testLogout = async () => {
await logout();
// The middleware should redirect to login automatically
};
useHead({
title: 'Authentication Test'
});
</script>

View File

@@ -4,70 +4,96 @@
<v-container class="fill-height" fluid>
<v-row align="center" justify="center" class="fill-height">
<v-col cols="12" class="d-flex flex-column align-center">
<v-card class="pa-6" rounded max-width="350" elevation="2">
<v-form @submit.prevent="submit" v-model="valid">
<v-row no-gutters>
<v-col cols="12">
<v-img src="/Port_Nimara_Logo_2_Colour_New_Transparent.png" width="200" class="mb-3 mx-auto" />
</v-col>
<v-scroll-y-transition>
<v-col v-if="errorThrown" cols="12" class="my-3">
<v-alert
text="Invalid email address or password"
color="error"
variant="tonal"
/>
</v-col>
</v-scroll-y-transition>
<v-col cols="12">
<v-row dense>
<v-col cols="12" class="mt-4">
<v-text-field
v-model="emailAddress"
placeholder="Email address"
:disabled="loading"
:rules="[
(value) => !!value || 'Must not be empty',
(value) =>
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) ||
'Invalid email address',
]"
variant="outlined"
type="email"
autofocus
/>
</v-col>
<v-card class="pa-6" rounded max-width="450" elevation="2">
<v-row no-gutters>
<v-col cols="12">
<v-img src="/Port_Nimara_Logo_2_Colour_New_Transparent.png" width="200" class="mb-3 mx-auto" />
</v-col>
<!-- Keycloak SSO Login -->
<v-col cols="12" class="mb-4">
<v-btn
color="primary"
size="large"
block
@click="loginWithKeycloak"
prepend-icon="mdi-shield-account"
>
Login with Single Sign-On
</v-btn>
</v-col>
<!-- Divider -->
<v-col cols="12">
<v-divider class="my-4">
<span class="text-caption">OR</span>
</v-divider>
</v-col>
<!-- Existing Directus Login Form -->
<v-col cols="12">
<v-form @submit.prevent="submit" v-model="valid">
<v-row no-gutters>
<v-scroll-y-transition>
<v-col v-if="errorThrown" cols="12" class="my-3">
<v-alert
text="Invalid email address or password"
color="error"
variant="tonal"
/>
</v-col>
</v-scroll-y-transition>
<v-col cols="12">
<v-text-field
@click:append-inner="passwordVisible = !passwordVisible"
v-model="password"
placeholder="Password"
:disabled="loading"
:type="passwordVisible ? 'text' : 'password'"
:append-inner-icon="
passwordVisible ? 'mdi-eye' : 'mdi-eye-off'
"
:rules="[(value) => !!value || 'Must not be empty']"
autocomplete="current-password"
variant="outlined"
/>
</v-col>
<v-col cols="12">
<v-btn
text="Log in"
:disabled="!valid"
:loading="loading"
type="submit"
variant="tonal"
color="primary"
size="large"
block
/>
<v-row dense>
<v-col cols="12" class="mt-4">
<v-text-field
v-model="emailAddress"
placeholder="Email address"
:disabled="loading"
:rules="[
(value) => !!value || 'Must not be empty',
(value) =>
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) ||
'Invalid email address',
]"
variant="outlined"
type="email"
autofocus
/>
</v-col>
<v-col cols="12">
<v-text-field
@click:append-inner="passwordVisible = !passwordVisible"
v-model="password"
placeholder="Password"
:disabled="loading"
:type="passwordVisible ? 'text' : 'password'"
:append-inner-icon="
passwordVisible ? 'mdi-eye' : 'mdi-eye-off'
"
:rules="[(value) => !!value || 'Must not be empty']"
autocomplete="current-password"
variant="outlined"
/>
</v-col>
<v-col cols="12">
<v-btn
text="Log in"
:disabled="!valid"
:loading="loading"
type="submit"
variant="tonal"
color="primary"
size="large"
block
/>
</v-col>
</v-row>
</v-col>
</v-row>
</v-col>
</v-row>
</v-form>
</v-form>
</v-col>
</v-row>
</v-card>
<!-- PWA Install Banner -->
@@ -80,18 +106,33 @@
</template>
<script lang="ts" setup>
// Define page meta for public access
definePageMeta({
auth: false
});
// Directus auth
const { login } = useDirectusAuth();
// OIDC auth for Keycloak
const oidc = useOidc();
const loading = ref(false);
const errorThrown = ref(false);
const emailAddress = ref();
const password = ref();
const passwordVisible = ref(false);
const valid = ref(false);
// Keycloak login function
const loginWithKeycloak = () => {
// Redirect to dashboard after login
oidc.login('/dashboard');
};
// Directus login function
const submit = async () => {
try {
loading.value = true;