Implement Keycloak authentication integration and unify user management
This commit is contained in:
parent
72ea543485
commit
5f8720bb63
12
.env.example
12
.env.example
|
|
@ -20,3 +20,15 @@ NUXT_DOCUMENSO_BASE_URL=https://signatures.portnimara.dev
|
|||
|
||||
# Webhook Configuration for Embedded Signing
|
||||
WEBHOOK_SECRET_SIGNING=96BQQRiKkTIN2w0rHbqo7yHggV/sT8702HtHih3uNSY=
|
||||
|
||||
# Keycloak Configuration
|
||||
KEYCLOAK_ISSUER=https://auth.portnimara.dev/realms/client-portal
|
||||
KEYCLOAK_CLIENT_ID=client-portal
|
||||
KEYCLOAK_CLIENT_SECRET=your-keycloak-client-secret
|
||||
KEYCLOAK_CALLBACK_URL=https://client.portnimara.dev/auth/callback
|
||||
# For local development, use: http://localhost:3000/auth/callback
|
||||
|
||||
# OIDC Session Configuration
|
||||
OIDC_SESSION_SECRET=your-32-character-session-secret
|
||||
OIDC_ENCRYPT_KEY=your-32-character-encryption-key
|
||||
OIDC_ENCRYPT_IV=16-char-enc-iv!
|
||||
|
|
|
|||
|
|
@ -0,0 +1,91 @@
|
|||
export interface UnifiedUser {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
tier?: string;
|
||||
authSource: 'keycloak' | 'directus';
|
||||
raw: any;
|
||||
}
|
||||
|
||||
export const useUnifiedAuth = () => {
|
||||
// Get both auth systems
|
||||
const directusAuth = useDirectusAuth();
|
||||
const directusUser = useDirectusUser();
|
||||
const oidc = useOidc();
|
||||
|
||||
// Create unified user object
|
||||
const user = computed<UnifiedUser | null>(() => {
|
||||
// Check Keycloak user first
|
||||
if (oidc.user?.value) {
|
||||
const keycloakUser = oidc.user.value;
|
||||
// Construct name from firstName and lastName if available
|
||||
let name = keycloakUser.name;
|
||||
if (!name && (keycloakUser.given_name || keycloakUser.family_name)) {
|
||||
name = `${keycloakUser.given_name || ''} ${keycloakUser.family_name || ''}`.trim();
|
||||
} else if (!name && (keycloakUser.firstName || keycloakUser.lastName)) {
|
||||
name = `${keycloakUser.firstName || ''} ${keycloakUser.lastName || ''}`.trim();
|
||||
}
|
||||
if (!name) {
|
||||
name = keycloakUser.preferred_username || keycloakUser.email;
|
||||
}
|
||||
|
||||
return {
|
||||
id: keycloakUser.sub || keycloakUser.id,
|
||||
email: keycloakUser.email,
|
||||
name: name,
|
||||
tier: keycloakUser.tier || 'basic', // From custom Keycloak attribute
|
||||
authSource: 'keycloak',
|
||||
raw: keycloakUser
|
||||
};
|
||||
}
|
||||
|
||||
// Fall back to Directus user
|
||||
if (directusUser.value && directusUser.value.email) {
|
||||
return {
|
||||
id: directusUser.value.id,
|
||||
email: directusUser.value.email,
|
||||
name: `${directusUser.value.first_name || ''} ${directusUser.value.last_name || ''}`.trim() || directusUser.value.email,
|
||||
tier: directusUser.value.tier || 'basic', // If Directus has tier field
|
||||
authSource: 'directus',
|
||||
raw: directusUser.value
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
// Unified logout function
|
||||
const logout = async () => {
|
||||
if (user.value?.authSource === 'keycloak') {
|
||||
// Keycloak logout
|
||||
await oidc.logout();
|
||||
} else if (user.value?.authSource === 'directus') {
|
||||
// Directus logout
|
||||
await directusAuth.logout();
|
||||
await navigateTo('/login');
|
||||
}
|
||||
};
|
||||
|
||||
// Check if user is authenticated
|
||||
const isAuthenticated = computed(() => !!user.value);
|
||||
|
||||
// Get auth source
|
||||
const authSource = computed(() => user.value?.authSource);
|
||||
|
||||
// Check if user has specific tier
|
||||
const hasTier = (tier: string) => {
|
||||
return user.value?.tier === tier;
|
||||
};
|
||||
|
||||
// Check if user is admin
|
||||
const isAdmin = computed(() => hasTier('admin'));
|
||||
|
||||
return {
|
||||
user: readonly(user),
|
||||
logout,
|
||||
isAuthenticated: readonly(isAuthenticated),
|
||||
authSource: readonly(authSource),
|
||||
hasTier,
|
||||
isAdmin: readonly(isAdmin),
|
||||
};
|
||||
};
|
||||
|
|
@ -0,0 +1,203 @@
|
|||
# Keycloak Integration Documentation
|
||||
|
||||
## Overview
|
||||
|
||||
This document describes the dual authentication system implementation that supports both Keycloak (new) and Directus (existing) authentication methods running in parallel.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Authentication Flow
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[User visits Portal] --> B{Already Authenticated?}
|
||||
B -->|No| C[Login Page]
|
||||
B -->|Yes| D[Dashboard]
|
||||
|
||||
C --> E{Login Method}
|
||||
E -->|SSO Button| F[Redirect to Keycloak]
|
||||
E -->|Email/Password| G[Directus Authentication]
|
||||
|
||||
F --> H[Keycloak Login Page]
|
||||
H --> I[User Authenticates]
|
||||
I --> J[Redirect to /auth/callback]
|
||||
J --> D
|
||||
|
||||
G --> K[Validate with Directus]
|
||||
K --> D
|
||||
```
|
||||
|
||||
### Key Components
|
||||
|
||||
1. **Unified Authentication Composable** (`composables/useUnifiedAuth.ts`)
|
||||
- Provides a single interface for both auth systems
|
||||
- Maps user data to a common format
|
||||
- Handles logout for both systems
|
||||
|
||||
2. **Authentication Middleware** (`middleware/authentication.ts`)
|
||||
- Checks Keycloak auth first, then Directus
|
||||
- Supports public pages via `auth: false` page meta
|
||||
|
||||
3. **Login Page** (`pages/login.vue`)
|
||||
- Dual login options (SSO and traditional)
|
||||
- Public page (no auth required)
|
||||
|
||||
4. **OAuth Callback** (`pages/auth/callback.vue`)
|
||||
- Handles Keycloak OAuth redirects
|
||||
- Shows loading state during authentication
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
```env
|
||||
# Keycloak Configuration
|
||||
KEYCLOAK_ISSUER=https://auth.portnimara.dev/realms/client-portal
|
||||
KEYCLOAK_CLIENT_ID=client-portal
|
||||
KEYCLOAK_CLIENT_SECRET=your-client-secret
|
||||
KEYCLOAK_CALLBACK_URL=https://client.portnimara.dev/auth/callback
|
||||
|
||||
# OIDC Session Configuration
|
||||
OIDC_SESSION_SECRET=your-32-character-session-secret
|
||||
OIDC_ENCRYPT_KEY=your-32-character-encryption-key
|
||||
OIDC_ENCRYPT_IV=16-char-enc-iv!
|
||||
```
|
||||
|
||||
### Keycloak Client Configuration
|
||||
|
||||
1. **Client Type**: Confidential (uses client secret)
|
||||
2. **Authentication Flow**: Standard flow (Authorization Code)
|
||||
3. **Valid Redirect URIs**:
|
||||
- `https://client.portnimara.dev/*`
|
||||
- `https://client.portnimara.dev/auth/callback`
|
||||
- `http://localhost:3000/*` (development)
|
||||
- `http://localhost:3000/auth/callback` (development)
|
||||
4. **Web Origins**: `+` (all redirect URI origins)
|
||||
|
||||
### User Attributes
|
||||
|
||||
Custom Keycloak attributes:
|
||||
- `tier`: User tier level (basic, premium, admin)
|
||||
|
||||
Standard attributes used:
|
||||
- `email`: User email address
|
||||
- `given_name` / `firstName`: First name
|
||||
- `family_name` / `lastName`: Last name
|
||||
|
||||
## Usage
|
||||
|
||||
### For Components
|
||||
|
||||
Replace Directus-specific code with unified auth:
|
||||
|
||||
```typescript
|
||||
// Before
|
||||
const user = useDirectusUser();
|
||||
const { logout } = useDirectusAuth();
|
||||
|
||||
// After
|
||||
const { user, logout } = useUnifiedAuth();
|
||||
```
|
||||
|
||||
### User Object Structure
|
||||
|
||||
```typescript
|
||||
interface UnifiedUser {
|
||||
id: string; // User ID
|
||||
email: string; // Email address
|
||||
name: string; // Full name
|
||||
tier?: string; // User tier (basic, premium, admin)
|
||||
authSource: 'keycloak' | 'directus'; // Auth system used
|
||||
raw: any; // Original user object
|
||||
}
|
||||
```
|
||||
|
||||
### Checking Authentication
|
||||
|
||||
```typescript
|
||||
const { isAuthenticated, authSource, isAdmin } = useUnifiedAuth();
|
||||
|
||||
if (isAuthenticated.value) {
|
||||
console.log(`User logged in via ${authSource.value}`);
|
||||
|
||||
if (isAdmin.value) {
|
||||
// Show admin features
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Test Page
|
||||
|
||||
Visit `/dashboard/auth-test` to:
|
||||
- View current authentication status
|
||||
- See user information and tier
|
||||
- Test logout functionality
|
||||
- View raw user data
|
||||
|
||||
### Testing Both Auth Methods
|
||||
|
||||
1. **Directus Login**:
|
||||
- Use email/password form on login page
|
||||
- Existing users continue to work
|
||||
|
||||
2. **Keycloak Login**:
|
||||
- Click "Login with Single Sign-On"
|
||||
- Redirected to Keycloak
|
||||
- Returns to portal after authentication
|
||||
|
||||
## Migration Path
|
||||
|
||||
### Phase 1: Parallel Systems (Current)
|
||||
- Both authentication methods available
|
||||
- Users can choose their preferred method
|
||||
- No breaking changes
|
||||
|
||||
### Phase 2: Gradual Migration
|
||||
- Encourage SSO usage
|
||||
- Migrate users to Keycloak
|
||||
- Monitor adoption rates
|
||||
|
||||
### Phase 3: Keycloak Only
|
||||
- Remove Directus login option
|
||||
- All users on Keycloak
|
||||
- Simplified codebase
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **"Invalid redirect URI" error**
|
||||
- Check Keycloak client redirect URIs
|
||||
- Ensure callback URL matches environment
|
||||
|
||||
2. **User tier not showing**
|
||||
- Verify tier attribute in Keycloak
|
||||
- Check attribute mappers in client scope
|
||||
|
||||
3. **Name not displaying correctly**
|
||||
- Ensure firstName/lastName set in Keycloak
|
||||
- Check unified auth name construction logic
|
||||
|
||||
### Debug Mode
|
||||
|
||||
Enable debug logging in development:
|
||||
- OIDC debug mode automatically enabled
|
||||
- Check browser console for auth flow details
|
||||
- Review network tab for OAuth redirects
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **Client Secret**: Never expose in client-side code
|
||||
2. **Session Security**: Uses encrypted cookies
|
||||
3. **Token Validation**: Server-side APIs should validate JWTs
|
||||
4. **HTTPS Required**: OAuth flow requires secure connections
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- [ ] Social login providers (Google, Microsoft)
|
||||
- [ ] Multi-factor authentication
|
||||
- [ ] User self-service portal
|
||||
- [ ] Advanced role-based access control
|
||||
- [ ] Session management UI
|
||||
|
|
@ -1,14 +1,34 @@
|
|||
export default defineNuxtRouteMiddleware(async () => {
|
||||
export default defineNuxtRouteMiddleware(async (to) => {
|
||||
// Skip auth for SSR
|
||||
if (import.meta.server) return;
|
||||
|
||||
// Check if auth is required (default true unless explicitly set to false)
|
||||
const isAuthRequired = to.meta.auth !== false;
|
||||
|
||||
// Check Keycloak auth first
|
||||
const oidc = useOidc();
|
||||
if (oidc.isLoggedIn) {
|
||||
// User authenticated with Keycloak
|
||||
return;
|
||||
}
|
||||
|
||||
// Fall back to Directus auth
|
||||
const { fetchUser, setUser } = useDirectusAuth();
|
||||
|
||||
const user = useDirectusUser();
|
||||
|
||||
if (!user.value) {
|
||||
const directusUser = useDirectusUser();
|
||||
|
||||
if (!directusUser.value) {
|
||||
const user = await fetchUser();
|
||||
setUser(user.value);
|
||||
}
|
||||
|
||||
if (!user.value) {
|
||||
return navigateTo("/login");
|
||||
if (directusUser.value) {
|
||||
// User authenticated with Directus
|
||||
return;
|
||||
}
|
||||
|
||||
// No authentication found
|
||||
if (isAuthRequired) {
|
||||
// Redirect to login page
|
||||
return navigateTo('/login');
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ export default defineNuxtConfig({
|
|||
ssr: false,
|
||||
compatibilityDate: "2024-11-01",
|
||||
devtools: { enabled: true },
|
||||
modules: ["nuxt-directus", "vuetify-nuxt-module", "@vite-pwa/nuxt"],
|
||||
modules: ["nuxt-directus", "nuxt-openid-connect", "vuetify-nuxt-module", "@vite-pwa/nuxt"],
|
||||
app: {
|
||||
head: {
|
||||
titleTemplate: "%s • Port Nimara Portal",
|
||||
|
|
@ -124,6 +124,36 @@ export default defineNuxtConfig({
|
|||
},
|
||||
},
|
||||
},
|
||||
openidConnect: {
|
||||
addPlugin: true,
|
||||
op: {
|
||||
issuer: process.env.KEYCLOAK_ISSUER || "https://auth.portnimara.dev/realms/client-portal",
|
||||
clientId: process.env.KEYCLOAK_CLIENT_ID || "client-portal",
|
||||
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET || "",
|
||||
callbackUrl: process.env.KEYCLOAK_CALLBACK_URL || "",
|
||||
scope: ["openid", "email", "profile"],
|
||||
},
|
||||
config: {
|
||||
debug: process.env.NODE_ENV === 'development',
|
||||
response_type: "code",
|
||||
secret: process.env.OIDC_SESSION_SECRET || "default-session-secret-change-in-production",
|
||||
cookie: {
|
||||
loginName: "keycloak-login",
|
||||
},
|
||||
cookiePrefix: "keycloak._",
|
||||
cookieEncrypt: true,
|
||||
cookieEncryptKey: process.env.OIDC_ENCRYPT_KEY || "default-encrypt-key-change-in-prod",
|
||||
cookieEncryptIV: process.env.OIDC_ENCRYPT_IV || "default-iv-12345",
|
||||
cookieEncryptALGO: "aes-256-cbc",
|
||||
cookieMaxAge: 24 * 60 * 60, // 1 day
|
||||
cookieFlags: {
|
||||
access_token: {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
vuetify: {
|
||||
vuetifyOptions: {
|
||||
theme: {
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@
|
|||
"nodemailer": "^7.0.3",
|
||||
"nuxt": "^3.15.4",
|
||||
"nuxt-directus": "^5.7.0",
|
||||
"nuxt-openid-connect": "^0.8.1",
|
||||
"v-phone-input": "^4.4.2",
|
||||
"vue": "latest",
|
||||
"vue-router": "latest",
|
||||
|
|
@ -8570,6 +8571,15 @@
|
|||
"jiti": "bin/jiti.js"
|
||||
}
|
||||
},
|
||||
"node_modules/jose": {
|
||||
"version": "4.15.9",
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz",
|
||||
"integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/js-levenshtein": {
|
||||
"version": "1.1.6",
|
||||
"resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz",
|
||||
|
|
@ -9722,6 +9732,18 @@
|
|||
"@nuxt/kit": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/nuxt-openid-connect": {
|
||||
"version": "0.8.1",
|
||||
"resolved": "https://registry.npmjs.org/nuxt-openid-connect/-/nuxt-openid-connect-0.8.1.tgz",
|
||||
"integrity": "sha512-b/pMCZ4ZnY6VHqQ5clnjRnrz7OQ1wsydjcZIQ0c5VQq7BCgb5VBtp/9HETRqRArTjDiIqxc/Xu3hhddbHAfakA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@nuxt/kit": "^3.11.2",
|
||||
"defu": "^6.0.0",
|
||||
"openid-client": "^5.1.6",
|
||||
"uuid": "^10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/nypm": {
|
||||
"version": "0.5.2",
|
||||
"resolved": "https://registry.npmjs.org/nypm/-/nypm-0.5.2.tgz",
|
||||
|
|
@ -9742,6 +9764,15 @@
|
|||
"node": "^14.16.0 || >=16.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/object-hash": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz",
|
||||
"integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/object-inspect": {
|
||||
"version": "1.13.3",
|
||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.3.tgz",
|
||||
|
|
@ -9800,6 +9831,15 @@
|
|||
"integrity": "sha512-FlDryZAahJmEF3VR3w1KogSEdWX3WhA5GPakFx4J81kEAiHyLMpdLLElS8n8dfNadMgAne/MywcvmogzscVt4g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/oidc-token-hash": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.1.0.tgz",
|
||||
"integrity": "sha512-y0W+X7Ppo7oZX6eovsRkuzcSM40Bicg2JEJkDJ4irIt1wsYAP5MLSNv+QAogO8xivMffw/9OvV3um1pxXgt1uA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^10.13.0 || >=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/on-finished": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||
|
|
@ -9900,6 +9940,39 @@
|
|||
"typescript": "^5.x"
|
||||
}
|
||||
},
|
||||
"node_modules/openid-client": {
|
||||
"version": "5.7.1",
|
||||
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz",
|
||||
"integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jose": "^4.15.9",
|
||||
"lru-cache": "^6.0.0",
|
||||
"object-hash": "^2.2.0",
|
||||
"oidc-token-hash": "^5.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/openid-client/node_modules/lru-cache": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
|
||||
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"yallist": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/openid-client/node_modules/yallist": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
||||
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/own-keys": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz",
|
||||
|
|
@ -13541,6 +13614,19 @@
|
|||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
|
||||
"integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/v-phone-input": {
|
||||
"version": "4.4.2",
|
||||
"resolved": "https://registry.npmjs.org/v-phone-input/-/v-phone-input-4.4.2.tgz",
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@
|
|||
"nodemailer": "^7.0.3",
|
||||
"nuxt": "^3.15.4",
|
||||
"nuxt-directus": "^5.7.0",
|
||||
"nuxt-openid-connect": "^0.8.1",
|
||||
"v-phone-input": "^4.4.2",
|
||||
"vue": "latest",
|
||||
"vue-router": "latest",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
165
pages/login.vue
165
pages/login.vue
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue