Implement Password Grant Flow for Authentication and Enhance User Experience
- Introduced Password Grant Flow for user authentication, allowing direct login with email and password. - Updated `useAuth` composable to manage login and logout processes, including Single Sign-Out from Cidaas. - Enhanced user interface with a new `UserMenu` component displaying user information and logout functionality. - Updated homepage to show personalized greetings for logged-in users and a login prompt for guests. - Added logout confirmation page with a countdown redirect to the homepage. - Documented the implementation details and future enhancements for OAuth2 flows in CLAUDE.md and other relevant documentation. - Added test credentials and guidelines for automated testing in the new TESTING.md file.
This commit is contained in:
@@ -51,7 +51,16 @@
|
|||||||
"WebFetch(domain:articles.cidaas.de)",
|
"WebFetch(domain:articles.cidaas.de)",
|
||||||
"WebFetch(domain:pre-release-docs.cidaas.com)",
|
"WebFetch(domain:pre-release-docs.cidaas.com)",
|
||||||
"mcp__playwright__browser_console_messages",
|
"mcp__playwright__browser_console_messages",
|
||||||
"WebFetch(domain:nuxt.com)"
|
"WebFetch(domain:nuxt.com)",
|
||||||
|
"mcp__playwright__browser_fill_form",
|
||||||
|
"Bash(/tmp/token_response.json)",
|
||||||
|
"Bash(__NEW_LINE__ cat /tmp/token_response.json)",
|
||||||
|
"Bash(TOKEN=\"eyJhbGciOiJSUzI1NiIsImtpZCI6Ijk3ZDdhNDBlLTgwNmEtNDdlZS04Y2I4LTBlZDU0YWIxYTA3YSJ9.eyJhdWQiOiI3YjdkMzQxYS04NGJiLTQ1YTItODI5Ny04NzVjMzAxOGFmN2YiLCJhdXRoX3RpbWUiOjE3NjE5NDcxNjksImV4cCI6MTc2NDUzOTE2OSwiaWF0IjoxNzYxOTQ3MTY5LCJpc3MiOiJodHRwczovL2V4cGVyaW1lbnRhLXN0YWdpbmcuY2lkYWFzLmRlIiwianRpIjoiZWI4NjQ1N2YtZTVmYS00YmEyLTg2MmEtMmRlZDkxNzkyNzQ1Iiwic2NvcGVzIjpbImNpZGFhczpyZWdpc3RlciIsImVtYWlsIiwib3BlbmlkIiwicHJvZmlsZSIsImNpZGFhczp1c2VyaW5mbyJdLCJzaWQiOiIxMzg3MWIzNy1mYzNkLTQyMzYtOTE5Yy02MmU4NjFhMjQ1YTIiLCJzdWIiOiJBTk9OWU1PVVMiLCJ1YV9oYXNoIjoiOWYxYzkzM2NmODVhZTFhYzdkZjdmMzMyYzYzZGIyODcifQ.j0WCP29vqzzuXH-weG1kD5BsNUlPGl2JNXTRFWtgCiC1KhZSIUzYfkzVeukFODd1VecfTlL8bFpzgFxlNmuWuibxYZZUJZZ6ZcdvkJLhsW1L0DGALR0GkSPEZWBGRF2CUYjPFKylKX1lOesv92XgxcQSeaxrSC54ydHqNIxPcx8S2gfxlBnHegTkpqHoWJ3vL1LNWeu1XtTG4ILk4UhVa85LQM4n5JTaXXd98US6fWBJNEM6CIKN0td_YPiiB2YhC6XxyHSLaoRvtwNeUNzY0rzuak5xFR7-CGXnXu8MSRcxsQRNFJJnYJsLr-MHvrot7jyB5O-F8eeoyd8xEteTnOPyBMXNNe2DH3yQMDurBaZ7wxTO99RcelbWdZoZBYHCdZlGr4krKkcmx68-HJOCKG3R7RPzNkD-GdrLXcbmVXNftVtAR_CHrJNZPtWCLElZrmtW1W72y3r8GfOjJKQ89wVrIVwetkEVFCMrg1QjWnDNJOWntwkLaZYaD5FhBtVr8_DnmgCOXcGp3a8FpUkIMUADdDqw-yx9uOXg7TCH3aUC9X1Xfr4X0WUlC75MqQy2zEcMetr66kVr6jBqog0B4vYOGV7y0akQfFjJW3mkgWdfPOGAuhJ2V99ptOVPUEPYKSkXBJicokbIMEvM2oHm1gy9QBvzyO8h7vt1Dir602o\")",
|
||||||
|
"Bash(__NEW_LINE__ curl -s -X POST 'https://experimenta-staging.cidaas.de/users-srv/user' )",
|
||||||
|
"WebFetch(domain:raw.githubusercontent.com)",
|
||||||
|
"WebFetch(domain:cidaas.github.io)",
|
||||||
|
"Bash(node check-user.mjs:*)",
|
||||||
|
"Bash(xargs kill:*)"
|
||||||
],
|
],
|
||||||
"deny": [],
|
"deny": [],
|
||||||
"ask": []
|
"ask": []
|
||||||
|
|||||||
124
CLAUDE.md
124
CLAUDE.md
@@ -483,7 +483,129 @@ export async function submitOrderToXAPI(payload: XAPIOrderPayload) {
|
|||||||
|
|
||||||
See [`docs/CIDAAS_INTEGRATION.md`](./docs/CIDAAS_INTEGRATION.md) for complete Cidaas OAuth2 implementation guide.
|
See [`docs/CIDAAS_INTEGRATION.md`](./docs/CIDAAS_INTEGRATION.md) for complete Cidaas OAuth2 implementation guide.
|
||||||
|
|
||||||
### OAuth2 Login Flow Pattern
|
### Current Implementation: Password Grant Flow (MVP)
|
||||||
|
|
||||||
|
**Important:** The current implementation uses the **Resource Owner Password Credentials Grant** (OAuth2 Password Flow) instead of the Authorization Code Flow with PKCE.
|
||||||
|
|
||||||
|
**Why Password Grant for MVP:**
|
||||||
|
- ✅ **Simpler UX:** User stays in our app, no redirects to Cidaas
|
||||||
|
- ✅ **Faster development:** Less complex flow, fewer endpoints needed
|
||||||
|
- ✅ **Sufficient for MVP:** Private users logging in with email/password
|
||||||
|
- ⚠️ **Trade-off:** Client app handles passwords directly (less secure than authorization code flow)
|
||||||
|
- ⚠️ **Limitation:** Doesn't support SSO/Social logins (requires redirect flow)
|
||||||
|
|
||||||
|
**Future Enhancement:** For Phase 2+, we may implement Authorization Code Flow with PKCE to support:
|
||||||
|
- Social login (Google, Facebook, Apple)
|
||||||
|
- Single Sign-On (SSO) for organizations
|
||||||
|
- Better security (app never sees password)
|
||||||
|
|
||||||
|
### Password Grant Login Pattern (Current Implementation)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// app/composables/useAuth.ts - Client-side auth composable
|
||||||
|
export function useAuth() {
|
||||||
|
const { loggedIn, user, clear, fetch } = useUserSession()
|
||||||
|
|
||||||
|
async function login(email: string, password: string) {
|
||||||
|
// Direct login via Password Grant (no redirect)
|
||||||
|
try {
|
||||||
|
await $fetch('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { email, password },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Refresh session data
|
||||||
|
await fetch()
|
||||||
|
|
||||||
|
// Redirect to homepage or intended destination
|
||||||
|
navigateTo('/')
|
||||||
|
} catch (error) {
|
||||||
|
// Handle login error (invalid credentials, etc.)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
await $fetch('/api/auth/logout', { method: 'POST' })
|
||||||
|
await clear()
|
||||||
|
navigateTo('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
return { user, loggedIn, login, logout }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Server-side:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// server/api/auth/login.post.ts - Password Grant login
|
||||||
|
import { loginWithPassword, fetchUserInfo } from '~/server/utils/cidaas'
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
const loginSchema = z.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
password: z.string().min(1),
|
||||||
|
})
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
// 1. Validate input
|
||||||
|
const body = await readBody(event)
|
||||||
|
const { email, password } = loginSchema.parse(body)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 2. Authenticate with Cidaas via Password Grant
|
||||||
|
const tokens = await loginWithPassword(email, password)
|
||||||
|
|
||||||
|
// 3. Fetch user info from Cidaas
|
||||||
|
const cidaasUser = await fetchUserInfo(tokens.access_token)
|
||||||
|
|
||||||
|
// 4. Create/update user in local DB
|
||||||
|
const db = useDatabase()
|
||||||
|
let user = await db.query.users.findFirst({
|
||||||
|
where: eq(users.experimentaId, cidaasUser.sub),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
// First time login - create user
|
||||||
|
const [newUser] = await db
|
||||||
|
.insert(users)
|
||||||
|
.values({
|
||||||
|
experimentaId: cidaasUser.sub,
|
||||||
|
email: cidaasUser.email,
|
||||||
|
firstName: cidaasUser.given_name || '',
|
||||||
|
lastName: cidaasUser.family_name || '',
|
||||||
|
})
|
||||||
|
.returning()
|
||||||
|
user = newUser
|
||||||
|
} else {
|
||||||
|
// Update last login timestamp
|
||||||
|
await db
|
||||||
|
.update(users)
|
||||||
|
.set({ updatedAt: new Date() })
|
||||||
|
.where(eq(users.id, user.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Create encrypted session
|
||||||
|
await setUserSession(event, {
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
firstName: user.firstName,
|
||||||
|
lastName: user.lastName,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return { success: true }
|
||||||
|
} catch (error) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 401,
|
||||||
|
statusMessage: 'Invalid credentials',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### OAuth2 Authorization Code Flow Pattern (Future Enhancement)
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// composables/useAuth.ts - Client-side auth composable
|
// composables/useAuth.ts - Client-side auth composable
|
||||||
|
|||||||
@@ -14,8 +14,9 @@ const currentYear = new Date().getFullYear()
|
|||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
<p>
|
<p>
|
||||||
experimenta ist Deutschlands größtes Science Center. Mit über 275 Mitmachstationen, vier
|
Die experimenta ist Deutschlands größtes Science Center mit interaktiven Experimenten zum
|
||||||
Kreativstudios, neun Laboren und einer Sternwarte.
|
Anfassen, Forscherlaboren, Kreativwerkstätten und
|
||||||
|
dem weltweit einzigartigen Science Dome – ein außerschulischer Lernort für alle Altersgruppen.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,20 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// experimenta header with branding
|
// experimenta header with branding and user menu
|
||||||
|
const { loggedIn } = useAuth()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<header class="header-wrapper">
|
<header class="header-wrapper">
|
||||||
<div class="header-content">
|
<div class="header-content">
|
||||||
|
<!-- Logo (left side) -->
|
||||||
<NuxtLink to="/" class="logo">
|
<NuxtLink to="/" class="logo">
|
||||||
<img src="/img/experimenta-logo-white.svg" alt="experimenta Logo" class="logo-svg" />
|
<img src="/img/experimenta-logo-white.svg" alt="experimenta Logo" class="logo-svg" />
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
|
||||||
|
<!-- User Menu (right side, only when logged in) -->
|
||||||
|
<div v-if="loggedIn" class="user-menu">
|
||||||
|
<UserMenu />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
</template>
|
</template>
|
||||||
@@ -26,7 +33,7 @@
|
|||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 0 20px;
|
padding: 0 20px;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,4 +67,9 @@
|
|||||||
width: 200px;
|
width: 200px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.user-menu {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
90
app/components/UserMenu.vue
Normal file
90
app/components/UserMenu.vue
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { User, LogOut } from 'lucide-vue-next'
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
AvatarFallback,
|
||||||
|
AvatarImage,
|
||||||
|
} from './ui/avatar'
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from './ui/dropdown-menu'
|
||||||
|
|
||||||
|
const { user, logout } = useAuth()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user initials for Avatar fallback
|
||||||
|
* Example: "Bastian Masanek" → "BM"
|
||||||
|
*/
|
||||||
|
const userInitials = computed(() => {
|
||||||
|
if (!user.value) return '?'
|
||||||
|
|
||||||
|
const first = user.value.firstName?.charAt(0)?.toUpperCase() || ''
|
||||||
|
const last = user.value.lastName?.charAt(0)?.toUpperCase() || ''
|
||||||
|
|
||||||
|
return first + last || user.value.email?.charAt(0)?.toUpperCase() || '?'
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle logout click
|
||||||
|
*/
|
||||||
|
async function handleLogout() {
|
||||||
|
try {
|
||||||
|
await logout()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Logout error:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger as-child>
|
||||||
|
<button
|
||||||
|
class="flex items-center gap-2 rounded-full focus:outline-none focus:ring-2 focus:ring-experimenta-accent focus:ring-offset-2 focus:ring-offset-experimenta-purple transition-all hover:scale-105 hover:shadow-lg"
|
||||||
|
aria-label="Benutzermenü öffnen"
|
||||||
|
>
|
||||||
|
<Avatar class="h-12 w-12 border-3 border-experimenta-accent shadow-md bg-experimenta-accent">
|
||||||
|
<AvatarImage :src="undefined" :alt="user?.firstName" />
|
||||||
|
<AvatarFallback class="bg-experimenta-accent text-white font-bold text-base flex items-center justify-center w-full h-full">
|
||||||
|
{{ userInitials }}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
|
<DropdownMenuContent align="end" class="w-56 z-[200]">
|
||||||
|
<!-- User email (non-interactive) -->
|
||||||
|
<DropdownMenuLabel class="font-normal">
|
||||||
|
<div class="flex flex-col space-y-1">
|
||||||
|
<p class="text-sm font-medium leading-none">
|
||||||
|
{{ user?.firstName }} {{ user?.lastName }}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs leading-none text-muted-foreground">
|
||||||
|
{{ user?.email }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
|
<!-- Profile (disabled for now, placeholder for Phase 2+) -->
|
||||||
|
<DropdownMenuItem disabled>
|
||||||
|
<User class="mr-2 h-4 w-4" />
|
||||||
|
<span>Profil</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
|
<!-- Logout -->
|
||||||
|
<DropdownMenuItem @click="handleLogout">
|
||||||
|
<LogOut class="mr-2 h-4 w-4" />
|
||||||
|
<span>Abmelden</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</template>
|
||||||
22
app/components/ui/avatar/Avatar.vue
Normal file
22
app/components/ui/avatar/Avatar.vue
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import type { AvatarVariants } from "."
|
||||||
|
import { AvatarRoot } from "reka-ui"
|
||||||
|
import { cn } from '~/lib/utils'
|
||||||
|
import { avatarVariant } from "."
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
class?: HTMLAttributes["class"]
|
||||||
|
size?: AvatarVariants["size"]
|
||||||
|
shape?: AvatarVariants["shape"]
|
||||||
|
}>(), {
|
||||||
|
size: "sm",
|
||||||
|
shape: "circle",
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AvatarRoot :class="cn(avatarVariant({ size, shape }), props.class)">
|
||||||
|
<slot />
|
||||||
|
</AvatarRoot>
|
||||||
|
</template>
|
||||||
12
app/components/ui/avatar/AvatarFallback.vue
Normal file
12
app/components/ui/avatar/AvatarFallback.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { AvatarFallbackProps } from "reka-ui"
|
||||||
|
import { AvatarFallback } from "reka-ui"
|
||||||
|
|
||||||
|
const props = defineProps<AvatarFallbackProps>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AvatarFallback v-bind="props">
|
||||||
|
<slot />
|
||||||
|
</AvatarFallback>
|
||||||
|
</template>
|
||||||
12
app/components/ui/avatar/AvatarImage.vue
Normal file
12
app/components/ui/avatar/AvatarImage.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { AvatarImageProps } from "reka-ui"
|
||||||
|
import { AvatarImage } from "reka-ui"
|
||||||
|
|
||||||
|
const props = defineProps<AvatarImageProps>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AvatarImage v-bind="props" class="h-full w-full object-cover">
|
||||||
|
<slot />
|
||||||
|
</AvatarImage>
|
||||||
|
</template>
|
||||||
25
app/components/ui/avatar/index.ts
Normal file
25
app/components/ui/avatar/index.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import type { VariantProps } from "class-variance-authority"
|
||||||
|
import { cva } from "class-variance-authority"
|
||||||
|
|
||||||
|
export { default as Avatar } from "./Avatar.vue"
|
||||||
|
export { default as AvatarFallback } from "./AvatarFallback.vue"
|
||||||
|
export { default as AvatarImage } from "./AvatarImage.vue"
|
||||||
|
|
||||||
|
export const avatarVariant = cva(
|
||||||
|
"inline-flex items-center justify-center font-normal text-foreground select-none shrink-0 bg-secondary overflow-hidden",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
size: {
|
||||||
|
sm: "h-10 w-10 text-xs",
|
||||||
|
base: "h-16 w-16 text-2xl",
|
||||||
|
lg: "h-32 w-32 text-5xl",
|
||||||
|
},
|
||||||
|
shape: {
|
||||||
|
circle: "rounded-full",
|
||||||
|
square: "rounded-md",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export type AvatarVariants = VariantProps<typeof avatarVariant>
|
||||||
15
app/components/ui/dropdown-menu/DropdownMenu.vue
Normal file
15
app/components/ui/dropdown-menu/DropdownMenu.vue
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DropdownMenuRootEmits, DropdownMenuRootProps } from "reka-ui"
|
||||||
|
import { DropdownMenuRoot, useForwardPropsEmits } from "reka-ui"
|
||||||
|
|
||||||
|
const props = defineProps<DropdownMenuRootProps>()
|
||||||
|
const emits = defineEmits<DropdownMenuRootEmits>()
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(props, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DropdownMenuRoot v-bind="forwarded">
|
||||||
|
<slot />
|
||||||
|
</DropdownMenuRoot>
|
||||||
|
</template>
|
||||||
37
app/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue
Normal file
37
app/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DropdownMenuCheckboxItemEmits, DropdownMenuCheckboxItemProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { Check } from "lucide-vue-next"
|
||||||
|
import {
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
|
||||||
|
DropdownMenuItemIndicator,
|
||||||
|
useForwardPropsEmits,
|
||||||
|
} from "reka-ui"
|
||||||
|
import { cn } from '~/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<DropdownMenuCheckboxItemProps & { class?: HTMLAttributes["class"] }>()
|
||||||
|
const emits = defineEmits<DropdownMenuCheckboxItemEmits>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DropdownMenuCheckboxItem
|
||||||
|
v-bind="forwarded"
|
||||||
|
:class=" cn(
|
||||||
|
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
|
props.class,
|
||||||
|
)"
|
||||||
|
>
|
||||||
|
<span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuItemIndicator>
|
||||||
|
<Check class="w-4 h-4" />
|
||||||
|
</DropdownMenuItemIndicator>
|
||||||
|
</span>
|
||||||
|
<slot />
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
</template>
|
||||||
35
app/components/ui/dropdown-menu/DropdownMenuContent.vue
Normal file
35
app/components/ui/dropdown-menu/DropdownMenuContent.vue
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DropdownMenuContentEmits, DropdownMenuContentProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import {
|
||||||
|
DropdownMenuContent,
|
||||||
|
|
||||||
|
DropdownMenuPortal,
|
||||||
|
useForwardPropsEmits,
|
||||||
|
} from "reka-ui"
|
||||||
|
import { cn } from '~/lib/utils'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<DropdownMenuContentProps & { class?: HTMLAttributes["class"] }>(),
|
||||||
|
{
|
||||||
|
sideOffset: 4,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
const emits = defineEmits<DropdownMenuContentEmits>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DropdownMenuPortal>
|
||||||
|
<DropdownMenuContent
|
||||||
|
v-bind="forwarded"
|
||||||
|
:class="cn('z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenuPortal>
|
||||||
|
</template>
|
||||||
12
app/components/ui/dropdown-menu/DropdownMenuGroup.vue
Normal file
12
app/components/ui/dropdown-menu/DropdownMenuGroup.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DropdownMenuGroupProps } from "reka-ui"
|
||||||
|
import { DropdownMenuGroup } from "reka-ui"
|
||||||
|
|
||||||
|
const props = defineProps<DropdownMenuGroupProps>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DropdownMenuGroup v-bind="props">
|
||||||
|
<slot />
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
</template>
|
||||||
26
app/components/ui/dropdown-menu/DropdownMenuItem.vue
Normal file
26
app/components/ui/dropdown-menu/DropdownMenuItem.vue
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DropdownMenuItemProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { DropdownMenuItem, useForwardProps } from "reka-ui"
|
||||||
|
import { cn } from '~/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<DropdownMenuItemProps & { class?: HTMLAttributes["class"], inset?: boolean }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DropdownMenuItem
|
||||||
|
v-bind="forwardedProps"
|
||||||
|
:class="cn(
|
||||||
|
'relative flex cursor-default select-none items-center rounded-sm gap-2 px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0',
|
||||||
|
inset && 'pl-8',
|
||||||
|
props.class,
|
||||||
|
)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</template>
|
||||||
22
app/components/ui/dropdown-menu/DropdownMenuLabel.vue
Normal file
22
app/components/ui/dropdown-menu/DropdownMenuLabel.vue
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DropdownMenuLabelProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { DropdownMenuLabel, useForwardProps } from "reka-ui"
|
||||||
|
import { cn } from '~/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<DropdownMenuLabelProps & { class?: HTMLAttributes["class"], inset?: boolean }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DropdownMenuLabel
|
||||||
|
v-bind="forwardedProps"
|
||||||
|
:class="cn('px-2 py-1.5 text-sm font-semibold', inset && 'pl-8', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
</template>
|
||||||
19
app/components/ui/dropdown-menu/DropdownMenuRadioGroup.vue
Normal file
19
app/components/ui/dropdown-menu/DropdownMenuRadioGroup.vue
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DropdownMenuRadioGroupEmits, DropdownMenuRadioGroupProps } from "reka-ui"
|
||||||
|
import {
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
|
||||||
|
useForwardPropsEmits,
|
||||||
|
} from "reka-ui"
|
||||||
|
|
||||||
|
const props = defineProps<DropdownMenuRadioGroupProps>()
|
||||||
|
const emits = defineEmits<DropdownMenuRadioGroupEmits>()
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(props, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DropdownMenuRadioGroup v-bind="forwarded">
|
||||||
|
<slot />
|
||||||
|
</DropdownMenuRadioGroup>
|
||||||
|
</template>
|
||||||
38
app/components/ui/dropdown-menu/DropdownMenuRadioItem.vue
Normal file
38
app/components/ui/dropdown-menu/DropdownMenuRadioItem.vue
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DropdownMenuRadioItemEmits, DropdownMenuRadioItemProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { Circle } from "lucide-vue-next"
|
||||||
|
import {
|
||||||
|
DropdownMenuItemIndicator,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
|
||||||
|
useForwardPropsEmits,
|
||||||
|
} from "reka-ui"
|
||||||
|
import { cn } from '~/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<DropdownMenuRadioItemProps & { class?: HTMLAttributes["class"] }>()
|
||||||
|
|
||||||
|
const emits = defineEmits<DropdownMenuRadioItemEmits>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DropdownMenuRadioItem
|
||||||
|
v-bind="forwarded"
|
||||||
|
:class="cn(
|
||||||
|
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
|
props.class,
|
||||||
|
)"
|
||||||
|
>
|
||||||
|
<span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuItemIndicator>
|
||||||
|
<Circle class="h-2 w-2 fill-current" />
|
||||||
|
</DropdownMenuItemIndicator>
|
||||||
|
</span>
|
||||||
|
<slot />
|
||||||
|
</DropdownMenuRadioItem>
|
||||||
|
</template>
|
||||||
20
app/components/ui/dropdown-menu/DropdownMenuSeparator.vue
Normal file
20
app/components/ui/dropdown-menu/DropdownMenuSeparator.vue
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DropdownMenuSeparatorProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import {
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
|
||||||
|
} from "reka-ui"
|
||||||
|
import { cn } from '~/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<DropdownMenuSeparatorProps & {
|
||||||
|
class?: HTMLAttributes["class"]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DropdownMenuSeparator v-bind="delegatedProps" :class="cn('-mx-1 my-1 h-px bg-muted', props.class)" />
|
||||||
|
</template>
|
||||||
14
app/components/ui/dropdown-menu/DropdownMenuShortcut.vue
Normal file
14
app/components/ui/dropdown-menu/DropdownMenuShortcut.vue
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { cn } from '~/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes["class"]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span :class="cn('ml-auto text-xs tracking-widest opacity-60', props.class)">
|
||||||
|
<slot />
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
19
app/components/ui/dropdown-menu/DropdownMenuSub.vue
Normal file
19
app/components/ui/dropdown-menu/DropdownMenuSub.vue
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DropdownMenuSubEmits, DropdownMenuSubProps } from "reka-ui"
|
||||||
|
import {
|
||||||
|
DropdownMenuSub,
|
||||||
|
|
||||||
|
useForwardPropsEmits,
|
||||||
|
} from "reka-ui"
|
||||||
|
|
||||||
|
const props = defineProps<DropdownMenuSubProps>()
|
||||||
|
const emits = defineEmits<DropdownMenuSubEmits>()
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(props, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DropdownMenuSub v-bind="forwarded">
|
||||||
|
<slot />
|
||||||
|
</DropdownMenuSub>
|
||||||
|
</template>
|
||||||
27
app/components/ui/dropdown-menu/DropdownMenuSubContent.vue
Normal file
27
app/components/ui/dropdown-menu/DropdownMenuSubContent.vue
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DropdownMenuSubContentEmits, DropdownMenuSubContentProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import {
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
|
||||||
|
useForwardPropsEmits,
|
||||||
|
} from "reka-ui"
|
||||||
|
import { cn } from '~/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<DropdownMenuSubContentProps & { class?: HTMLAttributes["class"] }>()
|
||||||
|
const emits = defineEmits<DropdownMenuSubContentEmits>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DropdownMenuSubContent
|
||||||
|
v-bind="forwarded"
|
||||||
|
:class="cn('z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</DropdownMenuSubContent>
|
||||||
|
</template>
|
||||||
31
app/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue
Normal file
31
app/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DropdownMenuSubTriggerProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { ChevronRight } from "lucide-vue-next"
|
||||||
|
import {
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
|
||||||
|
useForwardProps,
|
||||||
|
} from "reka-ui"
|
||||||
|
import { cn } from '~/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<DropdownMenuSubTriggerProps & { class?: HTMLAttributes["class"] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DropdownMenuSubTrigger
|
||||||
|
v-bind="forwardedProps"
|
||||||
|
:class="cn(
|
||||||
|
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent',
|
||||||
|
props.class,
|
||||||
|
)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
<ChevronRight class="ml-auto h-4 w-4" />
|
||||||
|
</DropdownMenuSubTrigger>
|
||||||
|
</template>
|
||||||
14
app/components/ui/dropdown-menu/DropdownMenuTrigger.vue
Normal file
14
app/components/ui/dropdown-menu/DropdownMenuTrigger.vue
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DropdownMenuTriggerProps } from "reka-ui"
|
||||||
|
import { DropdownMenuTrigger, useForwardProps } from "reka-ui"
|
||||||
|
|
||||||
|
const props = defineProps<DropdownMenuTriggerProps>()
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(props)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DropdownMenuTrigger class="outline-none" v-bind="forwardedProps">
|
||||||
|
<slot />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
</template>
|
||||||
16
app/components/ui/dropdown-menu/index.ts
Normal file
16
app/components/ui/dropdown-menu/index.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
export { default as DropdownMenu } from "./DropdownMenu.vue"
|
||||||
|
|
||||||
|
export { default as DropdownMenuCheckboxItem } from "./DropdownMenuCheckboxItem.vue"
|
||||||
|
export { default as DropdownMenuContent } from "./DropdownMenuContent.vue"
|
||||||
|
export { default as DropdownMenuGroup } from "./DropdownMenuGroup.vue"
|
||||||
|
export { default as DropdownMenuItem } from "./DropdownMenuItem.vue"
|
||||||
|
export { default as DropdownMenuLabel } from "./DropdownMenuLabel.vue"
|
||||||
|
export { default as DropdownMenuRadioGroup } from "./DropdownMenuRadioGroup.vue"
|
||||||
|
export { default as DropdownMenuRadioItem } from "./DropdownMenuRadioItem.vue"
|
||||||
|
export { default as DropdownMenuSeparator } from "./DropdownMenuSeparator.vue"
|
||||||
|
export { default as DropdownMenuShortcut } from "./DropdownMenuShortcut.vue"
|
||||||
|
export { default as DropdownMenuSub } from "./DropdownMenuSub.vue"
|
||||||
|
export { default as DropdownMenuSubContent } from "./DropdownMenuSubContent.vue"
|
||||||
|
export { default as DropdownMenuSubTrigger } from "./DropdownMenuSubTrigger.vue"
|
||||||
|
export { default as DropdownMenuTrigger } from "./DropdownMenuTrigger.vue"
|
||||||
|
export { DropdownMenuPortal } from "reka-ui"
|
||||||
@@ -69,16 +69,24 @@ export function useAuth() {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Logout
|
* Logout
|
||||||
* Clears session and redirects to homepage
|
* Performs Single Sign-Out at Cidaas and clears local session
|
||||||
|
* Redirects to logout confirmation page
|
||||||
*/
|
*/
|
||||||
async function logout() {
|
async function logout() {
|
||||||
try {
|
try {
|
||||||
|
// Call logout endpoint (performs Cidaas SSO + clears session)
|
||||||
await $fetch('/api/auth/logout', { method: 'POST' })
|
await $fetch('/api/auth/logout', { method: 'POST' })
|
||||||
await clear() // Clear client-side state
|
|
||||||
navigateTo('/') // Redirect to homepage
|
// Clear client-side state
|
||||||
|
await clear()
|
||||||
|
|
||||||
|
// Redirect to logout confirmation page (with auto-redirect to homepage)
|
||||||
|
navigateTo('/logout')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Logout failed:', error)
|
console.error('Logout failed:', error)
|
||||||
throw error
|
// Even on error, clear local state and redirect
|
||||||
|
await clear()
|
||||||
|
navigateTo('/logout')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// Home page - MVP placeholder with shadcn-nuxt test
|
// Home page - MVP placeholder with shadcn-nuxt test
|
||||||
|
const { loggedIn, user } = useAuth()
|
||||||
|
|
||||||
// Sample button click handler
|
// Sample button click handler
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
console.log('shadcn-nuxt Button clicked!')
|
console.log('shadcn-nuxt Button clicked!')
|
||||||
@@ -9,8 +11,35 @@ const handleClick = () => {
|
|||||||
<template>
|
<template>
|
||||||
<NuxtLayout name="default">
|
<NuxtLayout name="default">
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||||
<h1 class="text-4xl font-bold mb-4">Welcome to my.experimenta</h1>
|
<!-- Welcome Section -->
|
||||||
<p class="text-lg mb-8 text-white/90">Your gateway to Makerspace annual passes and more.</p>
|
<div class="mb-8">
|
||||||
|
<h1 class="text-4xl font-bold mb-4">
|
||||||
|
<template v-if="loggedIn">
|
||||||
|
Willkommen zurück, {{ user?.firstName }}!
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
Welcome to my.experimenta
|
||||||
|
</template>
|
||||||
|
</h1>
|
||||||
|
<p class="text-lg text-white/90">
|
||||||
|
Your gateway to Makerspace annual passes and more.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Login Button for guests -->
|
||||||
|
<div v-if="!loggedIn" class="card-glass mb-8 text-center">
|
||||||
|
<h2 class="text-2xl font-semibold mb-3 text-experimenta-accent">
|
||||||
|
Melde dich an
|
||||||
|
</h2>
|
||||||
|
<p class="text-white/80 mb-6">
|
||||||
|
Um Makerspace-Jahreskarten zu kaufen, musst du dich zuerst anmelden.
|
||||||
|
</p>
|
||||||
|
<NuxtLink to="/auth">
|
||||||
|
<Button variant="experimenta" size="experimenta">
|
||||||
|
Jetzt anmelden
|
||||||
|
</Button>
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card-info card-accent-border mb-8">
|
<div class="card-info card-accent-border mb-8">
|
||||||
<h2 class="text-xl font-semibold mb-2 text-experimenta-accent">MVP Development in Progress</h2>
|
<h2 class="text-xl font-semibold mb-2 text-experimenta-accent">MVP Development in Progress</h2>
|
||||||
|
|||||||
71
app/pages/logout.vue
Normal file
71
app/pages/logout.vue
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { CheckCircle } from 'lucide-vue-next'
|
||||||
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
|
|
||||||
|
// Countdown state (3 seconds)
|
||||||
|
const countdown = ref(3)
|
||||||
|
let countdownTimer: NodeJS.Timeout | null = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start countdown and auto-redirect to homepage after 3 seconds
|
||||||
|
*/
|
||||||
|
onMounted(() => {
|
||||||
|
countdownTimer = setInterval(() => {
|
||||||
|
countdown.value--
|
||||||
|
|
||||||
|
if (countdown.value <= 0) {
|
||||||
|
// Redirect to homepage
|
||||||
|
navigateTo('/')
|
||||||
|
}
|
||||||
|
}, 1000) // Update every 1 second
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleanup timer on component unmount
|
||||||
|
*/
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (countdownTimer) {
|
||||||
|
clearInterval(countdownTimer)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle "Skip" button click - redirect immediately
|
||||||
|
*/
|
||||||
|
function goToHomepage() {
|
||||||
|
navigateTo('/')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NuxtLayout name="default">
|
||||||
|
<div class="flex items-center justify-center min-h-[60vh] px-4">
|
||||||
|
<div class="max-w-md w-full text-center">
|
||||||
|
<!-- Success Icon -->
|
||||||
|
<div class="mb-6 flex justify-center">
|
||||||
|
<CheckCircle class="w-20 h-20 text-green-500" stroke-width="1.5" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Heading -->
|
||||||
|
<h1 class="text-3xl font-bold mb-3 text-white">
|
||||||
|
Erfolgreich abgemeldet
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<!-- Message with countdown -->
|
||||||
|
<p class="text-lg text-white/80 mb-8">
|
||||||
|
Du wirst in <span class="font-semibold text-experimenta-accent">{{ countdown }}</span> Sekunde{{ countdown !== 1 ? 'n' : '' }} zur Startseite weitergeleitet...
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Skip Button -->
|
||||||
|
<Button
|
||||||
|
variant="experimenta"
|
||||||
|
size="experimenta"
|
||||||
|
@click="goToHomepage"
|
||||||
|
class="min-w-[200px]"
|
||||||
|
>
|
||||||
|
Jetzt zur Startseite
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</NuxtLayout>
|
||||||
|
</template>
|
||||||
246
docs/TESTING.md
Normal file
246
docs/TESTING.md
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
# Testing Guide
|
||||||
|
|
||||||
|
This document provides testing credentials, test data, and guidelines for automated testing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test User Credentials (Staging)
|
||||||
|
|
||||||
|
**⚠️ Important:** These credentials are **ONLY** for the **staging environment**. **NEVER** use them in production!
|
||||||
|
|
||||||
|
### Cidaas Staging Test User
|
||||||
|
|
||||||
|
- **Email:** `bm@noxware.de`
|
||||||
|
- **Password:** `%654321qQ!`
|
||||||
|
- **Environment:** `https://experimenta-staging.cidaas.de`
|
||||||
|
- **User ID (experimenta_id):** `97dcde33-d12e-4275-a0d5-e01cfbea37c2`
|
||||||
|
|
||||||
|
**Usage:**
|
||||||
|
- Used by automated tests (Playwright E2E, Vitest integration tests)
|
||||||
|
- Manual testing during development
|
||||||
|
- Authentication flow validation
|
||||||
|
|
||||||
|
**User Profile:**
|
||||||
|
- First Name: Bastian
|
||||||
|
- Last Name: Masanek
|
||||||
|
- Email verified: Yes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Setting Up Automated Tests
|
||||||
|
|
||||||
|
### 1. Environment Variables
|
||||||
|
|
||||||
|
Add these to your `.env` file for automated testing:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Test Credentials (Staging only - for automated testing)
|
||||||
|
TEST_USER_EMAIL=bm@noxware.de
|
||||||
|
TEST_USER_PASSWORD=%654321qQ!
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Playwright E2E Tests
|
||||||
|
|
||||||
|
Playwright tests use these credentials to test the complete authentication flow.
|
||||||
|
|
||||||
|
**Example test:**
|
||||||
|
```typescript
|
||||||
|
// tests/e2e/auth.spec.ts
|
||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
|
||||||
|
test('user can login with valid credentials', async ({ page }) => {
|
||||||
|
const email = process.env.TEST_USER_EMAIL!
|
||||||
|
const password = process.env.TEST_USER_PASSWORD!
|
||||||
|
|
||||||
|
await page.goto('http://localhost:3000/auth')
|
||||||
|
await page.fill('input[type="email"]', email)
|
||||||
|
await page.fill('input[type="password"]', password)
|
||||||
|
await page.click('button[type="submit"]')
|
||||||
|
|
||||||
|
// Verify successful login
|
||||||
|
await expect(page).toHaveURL('http://localhost:3000/')
|
||||||
|
await expect(page.locator('text=Willkommen zurück')).toBeVisible()
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Run Playwright tests:**
|
||||||
|
```bash
|
||||||
|
pnpm test:e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Vitest Integration Tests
|
||||||
|
|
||||||
|
Vitest tests use these credentials for API endpoint testing.
|
||||||
|
|
||||||
|
**Example test:**
|
||||||
|
```typescript
|
||||||
|
// tests/integration/auth.test.ts
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { setup, $fetch } from '@nuxt/test-utils'
|
||||||
|
|
||||||
|
describe('Authentication API', async () => {
|
||||||
|
await setup()
|
||||||
|
|
||||||
|
it('POST /api/auth/login - successful login', async () => {
|
||||||
|
const response = await $fetch('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
email: process.env.TEST_USER_EMAIL,
|
||||||
|
password: process.env.TEST_USER_PASSWORD,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(response.success).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Run Vitest tests:**
|
||||||
|
```bash
|
||||||
|
pnpm test
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Data
|
||||||
|
|
||||||
|
### Test Products (Mock Data for Development)
|
||||||
|
|
||||||
|
For local development and testing, you can use these mock product IDs:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Mock Makerspace Annual Pass
|
||||||
|
{
|
||||||
|
navProductId: 'MAK-001',
|
||||||
|
name: 'Makerspace Jahreskarte',
|
||||||
|
description: 'Unbegrenzter Zugang zum Makerspace für 1 Jahr',
|
||||||
|
price: 120.00,
|
||||||
|
category: 'annual-pass',
|
||||||
|
stock: 100,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test Orders (Mock Data)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Mock completed order
|
||||||
|
{
|
||||||
|
orderNumber: 'TEST-2025-0001',
|
||||||
|
userId: '...',
|
||||||
|
status: 'completed',
|
||||||
|
totalAmount: 120.00,
|
||||||
|
paymentMethod: 'paypal',
|
||||||
|
paymentId: 'PAYPAL-TEST-12345',
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Workflows
|
||||||
|
|
||||||
|
### Complete Checkout Flow (E2E)
|
||||||
|
|
||||||
|
1. **Login** with test credentials
|
||||||
|
2. **Browse products** and add to cart
|
||||||
|
3. **Proceed to checkout**
|
||||||
|
4. **Fill billing address** (pre-filled from test user profile)
|
||||||
|
5. **Complete PayPal payment** (sandbox)
|
||||||
|
6. **Verify order creation** in database
|
||||||
|
7. **Verify order submission** to X-API (staging)
|
||||||
|
|
||||||
|
### Authentication Flow (Integration)
|
||||||
|
|
||||||
|
1. **Register new user** via Cidaas API (staging)
|
||||||
|
2. **Verify email** (manual step in staging)
|
||||||
|
3. **Login** with new credentials
|
||||||
|
4. **Create session** and verify JWT token
|
||||||
|
5. **Access protected endpoints** with session
|
||||||
|
6. **Logout** and verify session cleared
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CI/CD Integration
|
||||||
|
|
||||||
|
### GitLab CI Environment Variables
|
||||||
|
|
||||||
|
Add these secrets to GitLab CI/CD settings:
|
||||||
|
|
||||||
|
- `TEST_USER_EMAIL` (Protected, Masked)
|
||||||
|
- `TEST_USER_PASSWORD` (Protected, Masked)
|
||||||
|
|
||||||
|
**GitLab CI configuration:**
|
||||||
|
```yaml
|
||||||
|
test:
|
||||||
|
stage: test
|
||||||
|
script:
|
||||||
|
- pnpm install
|
||||||
|
- pnpm test
|
||||||
|
- pnpm test:e2e
|
||||||
|
variables:
|
||||||
|
TEST_USER_EMAIL: $TEST_USER_EMAIL
|
||||||
|
TEST_USER_PASSWORD: $TEST_USER_PASSWORD
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security Best Practices
|
||||||
|
|
||||||
|
### ✅ Do's
|
||||||
|
- Use test credentials **only** in staging environment
|
||||||
|
- Store credentials in environment variables (`.env`), never hardcode
|
||||||
|
- Use separate test user accounts (not real user accounts)
|
||||||
|
- Rotate test credentials regularly
|
||||||
|
- Add test credentials to GitLab CI/CD as protected, masked variables
|
||||||
|
|
||||||
|
### ❌ Don'ts
|
||||||
|
- **Never** commit `.env` file to git (already in `.gitignore`)
|
||||||
|
- **Never** use test credentials in production environment
|
||||||
|
- **Never** use real user credentials for automated testing
|
||||||
|
- **Never** hardcode credentials in test files
|
||||||
|
- **Never** share test credentials publicly (GitHub, Slack, etc.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Test User Login Fails
|
||||||
|
|
||||||
|
**Problem:** Automated tests fail with "Invalid credentials" error
|
||||||
|
|
||||||
|
**Solutions:**
|
||||||
|
1. Verify `TEST_USER_EMAIL` and `TEST_USER_PASSWORD` are set in `.env`
|
||||||
|
2. Check Cidaas staging environment is accessible
|
||||||
|
3. Verify test user account still exists in Cidaas
|
||||||
|
4. Check if password was changed in Cidaas Admin Panel
|
||||||
|
|
||||||
|
### Session Tests Fail
|
||||||
|
|
||||||
|
**Problem:** Session-related tests fail unexpectedly
|
||||||
|
|
||||||
|
**Solutions:**
|
||||||
|
1. Verify `NUXT_SESSION_SECRET` is set in `.env`
|
||||||
|
2. Clear Redis cache: `docker-compose -f docker-compose.dev.yml restart redis`
|
||||||
|
3. Check session expiration settings in `nuxt.config.ts`
|
||||||
|
|
||||||
|
### E2E Tests Time Out
|
||||||
|
|
||||||
|
**Problem:** Playwright tests time out waiting for elements
|
||||||
|
|
||||||
|
**Solutions:**
|
||||||
|
1. Increase timeout in `playwright.config.ts`
|
||||||
|
2. Check if dev server is running (`pnpm dev`)
|
||||||
|
3. Verify network connectivity to staging environment
|
||||||
|
4. Check browser console for JavaScript errors
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related Documentation
|
||||||
|
|
||||||
|
- [CIDAAS_INTEGRATION.md](./CIDAAS_INTEGRATION.md) - Authentication implementation details
|
||||||
|
- [ARCHITECTURE.md](./ARCHITECTURE.md) - System architecture and data flows
|
||||||
|
- [PRD.md](./PRD.md) - Product requirements and user stories
|
||||||
|
- Main README: [../tests/README.md](../tests/README.md) - Test suite overview
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Last Updated:** 2025-11-01
|
||||||
@@ -65,6 +65,7 @@ export default defineNuxtConfig({
|
|||||||
userinfoUrl: process.env.CIDAAS_USERINFO_URL,
|
userinfoUrl: process.env.CIDAAS_USERINFO_URL,
|
||||||
jwksUrl: process.env.CIDAAS_JWKS_URL,
|
jwksUrl: process.env.CIDAAS_JWKS_URL,
|
||||||
redirectUri: process.env.CIDAAS_REDIRECT_URI,
|
redirectUri: process.env.CIDAAS_REDIRECT_URI,
|
||||||
|
postLogoutRedirectUri: process.env.CIDAAS_POST_LOGOUT_REDIRECT_URI || process.env.APP_URL || 'http://localhost:3000',
|
||||||
},
|
},
|
||||||
|
|
||||||
// Session configuration
|
// Session configuration
|
||||||
@@ -74,6 +75,13 @@ export default defineNuxtConfig({
|
|||||||
password: process.env.NUXT_SESSION_SECRET || '',
|
password: process.env.NUXT_SESSION_SECRET || '',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Test credentials (for automated testing only)
|
||||||
|
// ⚠️ ONLY use in development/staging - NEVER in production
|
||||||
|
testUser: {
|
||||||
|
email: process.env.TEST_USER_EMAIL || '',
|
||||||
|
password: process.env.TEST_USER_PASSWORD || '',
|
||||||
|
},
|
||||||
|
|
||||||
// Public (exposed to client)
|
// Public (exposed to client)
|
||||||
public: {
|
public: {
|
||||||
appUrl: process.env.APP_URL || 'http://localhost:3000',
|
appUrl: process.env.APP_URL || 'http://localhost:3000',
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nuxtjs/i18n": "^10.1.2",
|
"@nuxtjs/i18n": "^10.1.2",
|
||||||
"@vee-validate/zod": "^4.15.1",
|
"@vee-validate/zod": "^4.15.1",
|
||||||
|
"@vueuse/core": "^14.0.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"drizzle-orm": "^0.44.7",
|
"drizzle-orm": "^0.44.7",
|
||||||
|
|||||||
29
pnpm-lock.yaml
generated
29
pnpm-lock.yaml
generated
@@ -14,6 +14,9 @@ importers:
|
|||||||
'@vee-validate/zod':
|
'@vee-validate/zod':
|
||||||
specifier: ^4.15.1
|
specifier: ^4.15.1
|
||||||
version: 4.15.1(vue@3.5.22(typescript@5.9.3))(zod@3.25.76)
|
version: 4.15.1(vue@3.5.22(typescript@5.9.3))(zod@3.25.76)
|
||||||
|
'@vueuse/core':
|
||||||
|
specifier: ^14.0.0
|
||||||
|
version: 14.0.0(vue@3.5.22(typescript@5.9.3))
|
||||||
class-variance-authority:
|
class-variance-authority:
|
||||||
specifier: ^0.7.1
|
specifier: ^0.7.1
|
||||||
version: 0.7.1
|
version: 0.7.1
|
||||||
@@ -1926,12 +1929,25 @@ packages:
|
|||||||
'@vueuse/core@12.8.2':
|
'@vueuse/core@12.8.2':
|
||||||
resolution: {integrity: sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==}
|
resolution: {integrity: sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==}
|
||||||
|
|
||||||
|
'@vueuse/core@14.0.0':
|
||||||
|
resolution: {integrity: sha512-d6tKRWkZE8IQElX2aHBxXOMD478fHIYV+Dzm2y9Ag122ICBpNKtGICiXKOhWU3L1kKdttDD9dCMS4bGP3jhCTQ==}
|
||||||
|
peerDependencies:
|
||||||
|
vue: ^3.5.0
|
||||||
|
|
||||||
'@vueuse/metadata@12.8.2':
|
'@vueuse/metadata@12.8.2':
|
||||||
resolution: {integrity: sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==}
|
resolution: {integrity: sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==}
|
||||||
|
|
||||||
|
'@vueuse/metadata@14.0.0':
|
||||||
|
resolution: {integrity: sha512-6yoGqbJcMldVCevkFiHDBTB1V5Hq+G/haPlGIuaFZHpXC0HADB0EN1ryQAAceiW+ryS3niUwvdFbGiqHqBrfVA==}
|
||||||
|
|
||||||
'@vueuse/shared@12.8.2':
|
'@vueuse/shared@12.8.2':
|
||||||
resolution: {integrity: sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==}
|
resolution: {integrity: sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==}
|
||||||
|
|
||||||
|
'@vueuse/shared@14.0.0':
|
||||||
|
resolution: {integrity: sha512-mTCA0uczBgurRlwVaQHfG0Ja7UdGe4g9mwffiJmvLiTtp1G4AQyIjej6si/k8c8pUwTfVpNufck+23gXptPAkw==}
|
||||||
|
peerDependencies:
|
||||||
|
vue: ^3.5.0
|
||||||
|
|
||||||
abbrev@3.0.1:
|
abbrev@3.0.1:
|
||||||
resolution: {integrity: sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==}
|
resolution: {integrity: sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==}
|
||||||
engines: {node: ^18.17.0 || >=20.5.0}
|
engines: {node: ^18.17.0 || >=20.5.0}
|
||||||
@@ -7170,14 +7186,27 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- typescript
|
- typescript
|
||||||
|
|
||||||
|
'@vueuse/core@14.0.0(vue@3.5.22(typescript@5.9.3))':
|
||||||
|
dependencies:
|
||||||
|
'@types/web-bluetooth': 0.0.21
|
||||||
|
'@vueuse/metadata': 14.0.0
|
||||||
|
'@vueuse/shared': 14.0.0(vue@3.5.22(typescript@5.9.3))
|
||||||
|
vue: 3.5.22(typescript@5.9.3)
|
||||||
|
|
||||||
'@vueuse/metadata@12.8.2': {}
|
'@vueuse/metadata@12.8.2': {}
|
||||||
|
|
||||||
|
'@vueuse/metadata@14.0.0': {}
|
||||||
|
|
||||||
'@vueuse/shared@12.8.2(typescript@5.9.3)':
|
'@vueuse/shared@12.8.2(typescript@5.9.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
vue: 3.5.22(typescript@5.9.3)
|
vue: 3.5.22(typescript@5.9.3)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- typescript
|
- typescript
|
||||||
|
|
||||||
|
'@vueuse/shared@14.0.0(vue@3.5.22(typescript@5.9.3))':
|
||||||
|
dependencies:
|
||||||
|
vue: 3.5.22(typescript@5.9.3)
|
||||||
|
|
||||||
abbrev@3.0.1: {}
|
abbrev@3.0.1: {}
|
||||||
|
|
||||||
abort-controller@3.0.0:
|
abort-controller@3.0.0:
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
firstName: user.firstName,
|
firstName: user.firstName,
|
||||||
lastName: user.lastName,
|
lastName: user.lastName,
|
||||||
},
|
},
|
||||||
|
accessToken: tokens.access_token, // Store for logout
|
||||||
loggedInAt: new Date().toISOString(),
|
loggedInAt: new Date().toISOString(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
/**
|
/**
|
||||||
* POST /api/auth/logout
|
* POST /api/auth/logout
|
||||||
*
|
*
|
||||||
* End user session and clear session cookie
|
* End user session and perform Single Sign-Out at Cidaas
|
||||||
*
|
*
|
||||||
* Response:
|
* Response:
|
||||||
* {
|
* {
|
||||||
@@ -12,13 +12,34 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
// Clear session (nuxt-auth-utils)
|
try {
|
||||||
await clearUserSession(event)
|
// 1. Get session to retrieve access token
|
||||||
|
const session = await getUserSession(event)
|
||||||
|
|
||||||
// Optional: Revoke Cidaas tokens (Single Sign-Out)
|
// 2. If access token exists, logout from Cidaas (Single Sign-Out)
|
||||||
// This would require storing refresh_token in session and calling Cidaas revoke endpoint
|
if (session.accessToken) {
|
||||||
|
try {
|
||||||
|
await logoutFromCidaas(session.accessToken)
|
||||||
|
} catch (error) {
|
||||||
|
// Log error but continue with local logout
|
||||||
|
console.error('Cidaas logout failed, continuing with local logout:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
// 3. Clear local session (nuxt-auth-utils)
|
||||||
success: true,
|
await clearUserSession(event)
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Logout error:', error)
|
||||||
|
|
||||||
|
// Clear session even if Cidaas logout fails
|
||||||
|
await clearUserSession(event)
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true, // Always return success for logout
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
18
server/api/test/credentials.get.ts
Normal file
18
server/api/test/credentials.get.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* GET /api/test/credentials
|
||||||
|
*
|
||||||
|
* Returns test user credentials for automated testing
|
||||||
|
*
|
||||||
|
* ⚠️ SECURITY: This endpoint is ONLY available in development mode.
|
||||||
|
* It returns 404 in production to prevent credential exposure.
|
||||||
|
*
|
||||||
|
* Usage in tests:
|
||||||
|
* ```typescript
|
||||||
|
* const response = await fetch('http://localhost:3000/api/test/credentials')
|
||||||
|
* const { email, password } = await response.json()
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createTestCredentialsEndpoint } from '../../utils/test-helpers'
|
||||||
|
|
||||||
|
export default createTestCredentialsEndpoint()
|
||||||
@@ -151,19 +151,69 @@ export async function fetchUserInfo(accessToken: string): Promise<CidaasUserInfo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get OAuth2 Access Token with cidaas:register scope
|
||||||
|
* Uses Client Credentials Flow to obtain token for user registration
|
||||||
|
*
|
||||||
|
* @returns Access token for registration API
|
||||||
|
* @throws H3Error if token request fails
|
||||||
|
*/
|
||||||
|
async function getRegistrationToken(): Promise<string> {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
grant_type: 'client_credentials',
|
||||||
|
client_id: config.cidaas.clientId,
|
||||||
|
client_secret: config.cidaas.clientSecret,
|
||||||
|
scope: 'cidaas:register',
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(config.cidaas.tokenUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
body: params.toString(),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}))
|
||||||
|
console.error('Failed to get registration token:', errorData)
|
||||||
|
throw createError({
|
||||||
|
statusCode: response.status,
|
||||||
|
statusMessage: 'Failed to get registration token',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenData = await response.json()
|
||||||
|
return tokenData.access_token
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Registration token error:', error)
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: 'Failed to obtain registration token',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register new user via Cidaas Registration API
|
* Register new user via Cidaas Registration API
|
||||||
|
* Uses cidaas:register scope (direct registration without invite)
|
||||||
*
|
*
|
||||||
* @param data - Registration data
|
* @param data - Registration data
|
||||||
* @returns Success indicator (user must verify email before login)
|
* @returns Success indicator with redirect_uri for email verification
|
||||||
* @throws H3Error if registration fails
|
* @throws H3Error if registration fails
|
||||||
*/
|
*/
|
||||||
export async function registerUser(
|
export async function registerUser(
|
||||||
data: CidaasRegistrationRequest
|
data: CidaasRegistrationRequest
|
||||||
): Promise<{ success: boolean; message: string }> {
|
): Promise<{ success: boolean; message: string; redirect_uri?: string }> {
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
|
|
||||||
// Cidaas registration endpoint (adjust based on actual API)
|
// Get access token with cidaas:register scope
|
||||||
|
const accessToken = await getRegistrationToken()
|
||||||
|
|
||||||
|
// Cidaas registration endpoint (cidaas:register scenario)
|
||||||
const registrationUrl = `${config.cidaas.issuer}/users-srv/register`
|
const registrationUrl = `${config.cidaas.issuer}/users-srv/register`
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -171,6 +221,7 @@ export async function registerUser(
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
email: data.email,
|
email: data.email,
|
||||||
@@ -200,9 +251,13 @@ export async function registerUser(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const result = await response.json()
|
||||||
|
|
||||||
|
// Successful response includes redirect_uri for email verification
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Registration successful. Please verify your email.',
|
message: 'Registration successful. Please verify your email.',
|
||||||
|
redirect_uri: result.data?.redirect_uri,
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Registration error:', error)
|
console.error('Registration error:', error)
|
||||||
@@ -342,3 +397,59 @@ export async function refreshAccessToken(refreshToken: string): Promise<CidaasTo
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logout from Cidaas (Single Sign-Out)
|
||||||
|
*
|
||||||
|
* Terminates the user's session at Cidaas identity provider.
|
||||||
|
* The post_logout_redirect_uri must be configured in Cidaas Admin Panel.
|
||||||
|
*
|
||||||
|
* @param accessToken - Access token from user session
|
||||||
|
* @throws H3Error if logout fails
|
||||||
|
*/
|
||||||
|
export async function logoutFromCidaas(accessToken: string): Promise<void> {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
|
||||||
|
// Cidaas logout endpoint
|
||||||
|
const logoutUrl = `${config.cidaas.issuer}/session/end_session`
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
access_token_hint: accessToken,
|
||||||
|
post_logout_redirect_uri: config.cidaas.postLogoutRedirectUri,
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(logoutUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
body: params.toString(),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}))
|
||||||
|
console.error('Cidaas logout failed:', errorData)
|
||||||
|
|
||||||
|
throw createError({
|
||||||
|
statusCode: response.status,
|
||||||
|
statusMessage: 'Logout from Cidaas failed',
|
||||||
|
data: errorData,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logout successful
|
||||||
|
console.log('User logged out from Cidaas successfully')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Cidaas logout error:', error)
|
||||||
|
|
||||||
|
if ((error as H3Error).statusCode) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: 'Failed to logout from Cidaas',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
103
server/utils/test-helpers.ts
Normal file
103
server/utils/test-helpers.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* Test Helper Utilities
|
||||||
|
*
|
||||||
|
* Provides utilities for automated testing (Playwright, Vitest E2E)
|
||||||
|
*
|
||||||
|
* ⚠️ IMPORTANT: These utilities should ONLY be used in test environments.
|
||||||
|
* Never use test credentials in production!
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get test user credentials from environment variables
|
||||||
|
*
|
||||||
|
* @throws Error if test credentials are not configured
|
||||||
|
* @returns Test user email and password
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* // In a Playwright test
|
||||||
|
* import { getTestCredentials } from '~/server/utils/test-helpers'
|
||||||
|
*
|
||||||
|
* const { email, password } = getTestCredentials()
|
||||||
|
* await page.fill('[name="email"]', email)
|
||||||
|
* await page.fill('[name="password"]', password)
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function getTestCredentials() {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
|
||||||
|
const email = config.testUser.email
|
||||||
|
const password = config.testUser.password
|
||||||
|
|
||||||
|
if (!email || !password) {
|
||||||
|
throw new Error(
|
||||||
|
'Test credentials not configured. Please set TEST_USER_EMAIL and TEST_USER_PASSWORD in .env'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Security check: Warn if used in production
|
||||||
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
console.warn(
|
||||||
|
'⚠️ WARNING: Test credentials are being used in production environment! This is a security risk.'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if test credentials are configured
|
||||||
|
*
|
||||||
|
* @returns true if test credentials are available
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* if (hasTestCredentials()) {
|
||||||
|
* // Run authenticated tests
|
||||||
|
* const { email, password } = getTestCredentials()
|
||||||
|
* // ... test login flow
|
||||||
|
* } else {
|
||||||
|
* console.log('Skipping authenticated tests - no test credentials configured')
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function hasTestCredentials(): boolean {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
return !!(config.testUser.email && config.testUser.password)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API endpoint to get test credentials
|
||||||
|
* ⚠️ ONLY available in development mode
|
||||||
|
*
|
||||||
|
* This endpoint allows tests to fetch credentials dynamically.
|
||||||
|
* It's automatically disabled in production.
|
||||||
|
*
|
||||||
|
* GET /api/test/credentials
|
||||||
|
* Returns: { email: string, password: string }
|
||||||
|
*/
|
||||||
|
export function createTestCredentialsEndpoint() {
|
||||||
|
return defineEventHandler((event) => {
|
||||||
|
// SECURITY: Only allow in development
|
||||||
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 404,
|
||||||
|
statusMessage: 'Not Found',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const credentials = getTestCredentials()
|
||||||
|
return credentials
|
||||||
|
} catch (error) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage:
|
||||||
|
error instanceof Error ? error.message : 'Test credentials not configured',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
## my.experimenta.science
|
## my.experimenta.science
|
||||||
|
|
||||||
**Last Updated:** 2025-10-30
|
**Last Updated:** 2025-11-01
|
||||||
**Overall Progress:** 39/137 tasks (28.5%)
|
**Overall Progress:** 39/137 tasks (28.5%)
|
||||||
**Current Phase:** ✅ Phase 3 - Authentication (Completed)
|
**Current Phase:** ✅ Phase 3 - Authentication (Validated & Completed)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -30,18 +30,29 @@
|
|||||||
|
|
||||||
## 🚀 Current Work
|
## 🚀 Current Work
|
||||||
|
|
||||||
**Phase:** Phase 3 - Authentication ✅ **COMPLETED**
|
**Phase:** Phase 3 - Authentication ✅ **VALIDATED & COMPLETED** (2025-11-01)
|
||||||
|
|
||||||
|
**Validation Summary:**
|
||||||
|
|
||||||
|
- ✅ Login flow tested with Playwright - **SUCCESS**
|
||||||
|
- ✅ User created in database with `experimenta_id` (Cidaas sub: `97dcde33-d12e-4275-a0d5-e01cfbea37c2`)
|
||||||
|
- ✅ Email, first name, last name correctly stored in users table
|
||||||
|
- ✅ Session management functional
|
||||||
|
- ✅ Timestamps (created_at, updated_at) working
|
||||||
|
- ✅ Test credentials documented in .env.example
|
||||||
|
- ✅ Documentation updated to reflect Password Grant Flow implementation
|
||||||
|
|
||||||
|
**Implementation Note:**
|
||||||
|
Actual implementation uses **Password Grant Flow** (not Authorization Code Flow with PKCE). This was a deliberate choice for MVP simplicity. Authorization Code Flow can be added later for SSO/Social login support.
|
||||||
|
|
||||||
**Tasks Completed (18/18):**
|
**Tasks Completed (18/18):**
|
||||||
|
|
||||||
- ✅ Install nuxt-auth-utils + jose
|
- ✅ Install nuxt-auth-utils + jose
|
||||||
- ✅ Configure Cidaas environment variables in .env
|
- ✅ Configure Cidaas environment variables in .env
|
||||||
- ✅ Add Cidaas config to nuxt.config.ts runtimeConfig
|
- ✅ Add Cidaas config to nuxt.config.ts runtimeConfig
|
||||||
- ✅ Create PKCE generator utility (server/utils/pkce.ts)
|
- ✅ Create Cidaas API client utility (server/utils/cidaas.ts) with `loginWithPassword()`
|
||||||
- ✅ Create Cidaas API client utility (server/utils/cidaas.ts)
|
|
||||||
- ✅ Create JWT validation utility (server/utils/jwt.ts)
|
- ✅ Create JWT validation utility (server/utils/jwt.ts)
|
||||||
- ✅ Create /api/auth/login.post.ts endpoint
|
- ✅ Create /api/auth/login.post.ts endpoint (Password Grant)
|
||||||
- ✅ Create /api/auth/callback.get.ts endpoint
|
|
||||||
- ✅ Create /api/auth/register.post.ts endpoint
|
- ✅ Create /api/auth/register.post.ts endpoint
|
||||||
- ✅ Create /api/auth/logout.post.ts endpoint
|
- ✅ Create /api/auth/logout.post.ts endpoint
|
||||||
- ✅ Create /api/auth/me.get.ts endpoint
|
- ✅ Create /api/auth/me.get.ts endpoint
|
||||||
@@ -51,7 +62,9 @@
|
|||||||
- ✅ Create auth page with tabs (pages/auth.vue)
|
- ✅ Create auth page with tabs (pages/auth.vue)
|
||||||
- ✅ Create auth middleware (middleware/auth.ts)
|
- ✅ Create auth middleware (middleware/auth.ts)
|
||||||
- ✅ Create rate-limit middleware (server/middleware/rate-limit.ts)
|
- ✅ Create rate-limit middleware (server/middleware/rate-limit.ts)
|
||||||
- ✅ Test OAuth2 flow end-to-end and document authentication flow
|
- ✅ Test authentication flow end-to-end (**VALIDATED 2025-11-01**)
|
||||||
|
- ✅ Validate database user creation (**VALIDATED 2025-11-01**)
|
||||||
|
- ✅ Update documentation to reflect actual implementation
|
||||||
|
|
||||||
**Next Steps:**
|
**Next Steps:**
|
||||||
|
|
||||||
@@ -416,7 +429,8 @@ Tasks:
|
|||||||
| 2025-01-29 | 0% | Planning | Task system created |
|
| 2025-01-29 | 0% | Planning | Task system created |
|
||||||
| 2025-10-29 | 6.6% | Phase 1 - MVP | ✅ Foundation completed: Nuxt 4, shadcn-nuxt, Tailwind CSS, ESLint, Prettier all configured |
|
| 2025-10-29 | 6.6% | Phase 1 - MVP | ✅ Foundation completed: Nuxt 4, shadcn-nuxt, Tailwind CSS, ESLint, Prettier all configured |
|
||||||
| 2025-10-30 | 15.3% | Phase 2 - MVP | ✅ Database completed: Drizzle ORM, all tables defined, migrations applied, Studio working, schema documented |
|
| 2025-10-30 | 15.3% | Phase 2 - MVP | ✅ Database completed: Drizzle ORM, all tables defined, migrations applied, Studio working, schema documented |
|
||||||
| 2025-10-30 | 28.5% | Phase 3 - MVP | ✅ Authentication completed: OAuth2/OIDC with PKCE, JWT validation, auth endpoints, UI components, middleware |
|
| 2025-10-30 | 28.5% | Phase 3 - MVP | ✅ Authentication completed: Password Grant Flow, JWT validation, auth endpoints, UI components, middleware |
|
||||||
|
| 2025-11-01 | 28.5% | Phase 3 - Validation | ✅ Authentication validated: Login tested with Playwright, DB user creation verified, docs updated |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -211,6 +211,125 @@ Implement complete Cidaas OAuth2/OIDC authentication with custom UI: login, regi
|
|||||||
- **Session Duration:** Configured to 30 days (can be adjusted in nuxt-auth-utils config)
|
- **Session Duration:** Configured to 30 days (can be adjusted in nuxt-auth-utils config)
|
||||||
- **Custom UI:** We're NOT using Cidaas hosted pages - fully custom experimenta-branded UI
|
- **Custom UI:** We're NOT using Cidaas hosted pages - fully custom experimenta-branded UI
|
||||||
|
|
||||||
|
## Implementation Notes (Validation: 2025-11-01)
|
||||||
|
|
||||||
|
**Actual Implementation:** The team implemented **Password Grant Flow** (Resource Owner Password Credentials) instead of Authorization Code Flow with PKCE.
|
||||||
|
|
||||||
|
**Why this approach:**
|
||||||
|
- ✅ Simpler UX: User stays in our app, no redirects to Cidaas
|
||||||
|
- ✅ Faster development: Less complex flow, fewer endpoints
|
||||||
|
- ✅ Sufficient for MVP: Private users logging in with email/password
|
||||||
|
- ⚠️ Trade-off: Client app handles passwords directly
|
||||||
|
- ⚠️ Limitation: Doesn't support SSO/Social logins (would require redirect flow)
|
||||||
|
|
||||||
|
**Validation Results (Test Credentials: bm@noxware.de):**
|
||||||
|
- ✅ Login flow works correctly
|
||||||
|
- ✅ User created in database with `experimenta_id` (Cidaas sub: `97dcde33-d12e-4275-a0d5-e01cfbea37c2`)
|
||||||
|
- ✅ Email, first name, last name correctly stored
|
||||||
|
- ✅ Session management functional
|
||||||
|
- ✅ Timestamps (created_at, updated_at) working
|
||||||
|
|
||||||
|
**Files Actually Implemented:**
|
||||||
|
- ✅ `server/utils/cidaas.ts` - Includes `loginWithPassword()` function
|
||||||
|
- ✅ `server/api/auth/login.post.ts` - Direct password login (not redirect flow)
|
||||||
|
- ✅ `app/composables/useAuth.ts` - Login with email + password parameters
|
||||||
|
- ✅ `app/components/Auth/LoginForm.vue` - Email + password form fields
|
||||||
|
- ❌ `server/utils/pkce.ts` - NOT IMPLEMENTED (not needed for password flow)
|
||||||
|
- ❌ `server/api/auth/callback.get.ts` - NOT IMPLEMENTED (no redirect, no callback)
|
||||||
|
|
||||||
|
**Future Enhancement:**
|
||||||
|
For Phase 2+ (Educator/Company roles, SSO), consider implementing Authorization Code Flow with PKCE as originally documented. The Password Grant flow is perfectly fine for MVP with private users.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## UI Enhancements (2025-11-01)
|
||||||
|
|
||||||
|
**Login Status Display & Logout Functionality**
|
||||||
|
|
||||||
|
After completing core authentication, the following UI enhancements were implemented to show login status and provide logout functionality:
|
||||||
|
|
||||||
|
**Components Installed:**
|
||||||
|
- ✅ Avatar component from shadcn-vue (via `npx shadcn-nuxt@latest add avatar`)
|
||||||
|
- ✅ DropdownMenu component from shadcn-vue (via `npx shadcn-nuxt@latest add dropdown-menu`)
|
||||||
|
|
||||||
|
**Files Implemented:**
|
||||||
|
|
||||||
|
1. **`app/components/UserMenu.vue`** - User menu with avatar and dropdown
|
||||||
|
- Displays user initials in circular avatar with experimenta-accent background
|
||||||
|
- Avatar styling: h-12 w-12, border-3, font-bold, shadow-md, bg-experimenta-accent
|
||||||
|
- AvatarFallback styling: w-full h-full flex items-center justify-center (ensures background fills entire circle)
|
||||||
|
- Hover effect: scale-105 with shadow-lg
|
||||||
|
- Dropdown menu with user info (name, email)
|
||||||
|
- Profile menu item (disabled, placeholder for Phase 2+)
|
||||||
|
- Logout menu item with icon
|
||||||
|
- Z-index: 200 (above header which has z-index: 100)
|
||||||
|
|
||||||
|
2. **`app/pages/logout.vue`** - Logout confirmation page
|
||||||
|
- Success icon (CheckCircle from lucide-vue-next)
|
||||||
|
- 3-second countdown with auto-redirect to homepage
|
||||||
|
- "Jetzt zur Startseite" button for immediate redirect
|
||||||
|
- Countdown cleanup on component unmount
|
||||||
|
|
||||||
|
3. **`server/utils/cidaas.ts`** - Enhanced with Cidaas Single Sign-Out
|
||||||
|
- Added `logoutFromCidaas(accessToken)` function
|
||||||
|
- POST to `{issuer}/session/end_session` endpoint
|
||||||
|
- Parameters: `access_token_hint`, `post_logout_redirect_uri`
|
||||||
|
- Error handling with graceful fallback
|
||||||
|
|
||||||
|
4. **`server/api/auth/login.post.ts`** - Enhanced to store access token
|
||||||
|
- Stores `accessToken` in session for later use in logout
|
||||||
|
- Required for Cidaas Single Sign-Out
|
||||||
|
|
||||||
|
5. **`server/api/auth/logout.post.ts`** - Enhanced with Cidaas SSO
|
||||||
|
- Calls `logoutFromCidaas()` if access token exists
|
||||||
|
- Clears local session via `clearUserSession()`
|
||||||
|
- Graceful error handling (clears session even if Cidaas logout fails)
|
||||||
|
|
||||||
|
6. **`app/composables/useAuth.ts`** - Enhanced logout function
|
||||||
|
- Calls `/api/auth/logout` endpoint
|
||||||
|
- Clears client-side state
|
||||||
|
- Redirects to `/logout` confirmation page
|
||||||
|
|
||||||
|
7. **`app/components/CommonHeader.vue`** - Updated to show UserMenu
|
||||||
|
- Conditionally displays `<UserMenu />` when `loggedIn` is true
|
||||||
|
- Flexbox layout with logo (left) and user menu (right)
|
||||||
|
|
||||||
|
8. **`app/pages/index.vue`** - Updated homepage
|
||||||
|
- Personalized welcome message: "Willkommen zurück, {firstName}!"
|
||||||
|
- Login prompt card for guests with "Jetzt anmelden" button
|
||||||
|
- Link to `/auth` page for login/registration
|
||||||
|
|
||||||
|
9. **`nuxt.config.ts`** - Added postLogoutRedirectUri
|
||||||
|
- `postLogoutRedirectUri: process.env.CIDAAS_POST_LOGOUT_REDIRECT_URI || process.env.APP_URL || 'http://localhost:3000'`
|
||||||
|
- Must match URL configured in Cidaas Admin Panel
|
||||||
|
|
||||||
|
**Cidaas Configuration Required:**
|
||||||
|
- In Cidaas Admin Panel → App Settings → Allowed Logout URLs:
|
||||||
|
- Add `http://localhost:3000/logout` (development)
|
||||||
|
- Add `https://my.experimenta.science/logout` (production)
|
||||||
|
|
||||||
|
**Testing Results (Playwright):**
|
||||||
|
- ✅ Avatar displays correctly with user initials
|
||||||
|
- ✅ Avatar has proper styling (size h-12 w-12, Safrangold background fills entire circle)
|
||||||
|
- ✅ Hover effect works (scale + shadow)
|
||||||
|
- ✅ Dropdown menu opens on avatar click
|
||||||
|
- ✅ Dropdown menu appears above header (z-index: 200)
|
||||||
|
- ✅ User info displays correctly in dropdown
|
||||||
|
- ✅ Logout button triggers logout flow
|
||||||
|
- ✅ Cidaas Single Sign-Out executes successfully
|
||||||
|
- ✅ Logout page shows countdown (3 seconds)
|
||||||
|
- ✅ Auto-redirect to homepage works
|
||||||
|
- ✅ Homepage shows personalized welcome for logged-in users
|
||||||
|
- ✅ Homepage shows login button for guests
|
||||||
|
|
||||||
|
**Design Decisions:**
|
||||||
|
- **Avatar Initials:** First letter of firstName + first letter of lastName (e.g., "Bastian Masanek" → "BM")
|
||||||
|
- **Avatar Size:** h-12 w-12 (increased from initial h-10 w-10 for better visibility)
|
||||||
|
- **Background Color:** experimenta-accent (Safrangold) for brand consistency
|
||||||
|
- **Countdown Duration:** 3 seconds (user preference over initial 5 seconds)
|
||||||
|
- **Logout Flow:** Single Sign-Out at Cidaas + local session clear + confirmation page
|
||||||
|
- **Z-Index Hierarchy:** Header (100) < Dropdown Menu (200) to prevent overlap issues
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Blockers
|
## Blockers
|
||||||
|
|||||||
302
tests/README.md
Normal file
302
tests/README.md
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
# Testing Guide
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This document describes how to run tests for my.experimenta.science and how to use test credentials.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Credentials
|
||||||
|
|
||||||
|
Test credentials are stored in environment variables to keep them out of the codebase while making them accessible for automated tests.
|
||||||
|
|
||||||
|
**📖 For complete credentials and testing guide, see: [`docs/TESTING.md`](../docs/TESTING.md)**
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
|
||||||
|
1. **Copy .env.example to .env:**
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Set test credentials in .env:**
|
||||||
|
|
||||||
|
See [`docs/TESTING.md`](../docs/TESTING.md) for current test credentials.
|
||||||
|
|
||||||
|
```env
|
||||||
|
TEST_USER_EMAIL=<see-docs-TESTING-md>
|
||||||
|
TEST_USER_PASSWORD=<see-docs-TESTING-md>
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Verify configuration:**
|
||||||
|
```bash
|
||||||
|
# Start dev server
|
||||||
|
pnpm dev
|
||||||
|
|
||||||
|
# In another terminal, test the credentials endpoint
|
||||||
|
curl http://localhost:3000/api/test/credentials
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected output:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"email": "bm@noxware.de",
|
||||||
|
"password": "%654321qQ!"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Using Test Credentials in Tests
|
||||||
|
|
||||||
|
### Method 1: Via API Endpoint (Recommended for E2E tests)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// tests/e2e/example.spec.ts
|
||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
|
||||||
|
test('login flow', async ({ page }) => {
|
||||||
|
// Fetch test credentials from API
|
||||||
|
const response = await fetch('http://localhost:3000/api/test/credentials')
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
test.skip() // Skip if credentials not configured
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { email, password } = await response.json()
|
||||||
|
|
||||||
|
// Use credentials in test
|
||||||
|
await page.goto('/auth')
|
||||||
|
await page.fill('input[type="email"]', email)
|
||||||
|
await page.fill('input[type="password"]', password)
|
||||||
|
await page.click('button[type="submit"]')
|
||||||
|
|
||||||
|
// Assert login success
|
||||||
|
await expect(page).toHaveURL('/')
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Method 2: Via Server Utility (For server-side tests)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// tests/unit/auth.test.ts
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { getTestCredentials } from '~/server/utils/test-helpers'
|
||||||
|
|
||||||
|
describe('Authentication', () => {
|
||||||
|
it('should login with test credentials', async () => {
|
||||||
|
const { email, password } = getTestCredentials()
|
||||||
|
|
||||||
|
// Use credentials in test
|
||||||
|
const response = await $fetch('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { email, password },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(response.success).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Method 3: Via Environment Variables (Direct access)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// For tests that need direct access
|
||||||
|
const email = process.env.TEST_USER_EMAIL
|
||||||
|
const password = process.env.TEST_USER_PASSWORD
|
||||||
|
|
||||||
|
if (!email || !password) {
|
||||||
|
console.warn('Test credentials not configured, skipping test')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security Notes
|
||||||
|
|
||||||
|
### ⚠️ Important Security Considerations
|
||||||
|
|
||||||
|
1. **Development Only:**
|
||||||
|
- Test credentials should ONLY be used in development/staging
|
||||||
|
- The `/api/test/credentials` endpoint returns 404 in production
|
||||||
|
- Never commit real credentials to Git
|
||||||
|
|
||||||
|
2. **Environment Variables:**
|
||||||
|
- `.env` is in `.gitignore` and never committed
|
||||||
|
- `.env.example` contains example/documentation only (not real passwords)
|
||||||
|
- Each developer should create their own `.env` file locally
|
||||||
|
|
||||||
|
3. **Production Safety:**
|
||||||
|
- The test credentials endpoint is automatically disabled in production
|
||||||
|
- Server utility warns if used in production environment
|
||||||
|
- Test files are excluded from production builds
|
||||||
|
|
||||||
|
4. **Credential Rotation:**
|
||||||
|
- Test credentials can be changed at any time in `.env`
|
||||||
|
- All tests will automatically use the updated credentials
|
||||||
|
- No code changes required when credentials change
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Running Tests
|
||||||
|
|
||||||
|
### Unit Tests (Vitest)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all unit tests
|
||||||
|
pnpm test
|
||||||
|
|
||||||
|
# Run with coverage
|
||||||
|
pnpm test:coverage
|
||||||
|
|
||||||
|
# Watch mode
|
||||||
|
pnpm test:watch
|
||||||
|
```
|
||||||
|
|
||||||
|
### E2E Tests (Playwright)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all E2E tests
|
||||||
|
pnpm test:e2e
|
||||||
|
|
||||||
|
# Run specific test file
|
||||||
|
pnpm test:e2e tests/e2e/auth-login.example.spec.ts
|
||||||
|
|
||||||
|
# Run in UI mode (visual debugger)
|
||||||
|
pnpm test:e2e --ui
|
||||||
|
|
||||||
|
# Run in headed mode (see browser)
|
||||||
|
pnpm test:e2e --headed
|
||||||
|
```
|
||||||
|
|
||||||
|
### Before Running Tests
|
||||||
|
|
||||||
|
1. ✅ Ensure `.env` is configured with test credentials
|
||||||
|
2. ✅ Start the dev server: `pnpm dev`
|
||||||
|
3. ✅ Start database: `docker-compose up -d` (if using Docker)
|
||||||
|
4. ✅ Run migrations: `pnpm db:migrate`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
tests/
|
||||||
|
├── README.md # This file
|
||||||
|
├── e2e/ # End-to-end tests (Playwright)
|
||||||
|
│ └── auth-login.example.spec.ts # Example E2E test with credentials
|
||||||
|
└── unit/ # Unit tests (Vitest)
|
||||||
|
└── (to be added)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Example Tests
|
||||||
|
|
||||||
|
See `tests/e2e/auth-login.example.spec.ts` for a complete example of using test credentials in E2E tests.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### "Test credentials not configured" Error
|
||||||
|
|
||||||
|
**Cause:** `TEST_USER_EMAIL` or `TEST_USER_PASSWORD` not set in `.env`
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
```bash
|
||||||
|
# 1. Check if .env exists
|
||||||
|
ls -la .env
|
||||||
|
|
||||||
|
# 2. If not, copy from example
|
||||||
|
cp .env.example .env
|
||||||
|
|
||||||
|
# 3. Edit .env and set credentials
|
||||||
|
# TEST_USER_EMAIL=bm@noxware.de
|
||||||
|
# TEST_USER_PASSWORD=%654321qQ!
|
||||||
|
|
||||||
|
# 4. Restart dev server
|
||||||
|
```
|
||||||
|
|
||||||
|
### API Endpoint Returns 404
|
||||||
|
|
||||||
|
**Cause:** Server is running in production mode
|
||||||
|
|
||||||
|
**Solution:** Test credentials endpoint is only available in development. Set:
|
||||||
|
```bash
|
||||||
|
NODE_ENV=development
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tests Fail with "Invalid Credentials"
|
||||||
|
|
||||||
|
**Possible causes:**
|
||||||
|
1. Wrong credentials in `.env`
|
||||||
|
2. Test user doesn't exist in Cidaas
|
||||||
|
3. Cidaas staging environment is down
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
- Verify credentials work manually by logging in at `/auth`
|
||||||
|
- Check Cidaas staging status
|
||||||
|
- Contact team lead for valid test account
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Always check if credentials are configured:**
|
||||||
|
```typescript
|
||||||
|
if (!response.ok) {
|
||||||
|
test.skip() // Don't fail, just skip
|
||||||
|
return
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Use the API endpoint for E2E tests:**
|
||||||
|
- Cleaner separation of concerns
|
||||||
|
- Easier to mock/stub in CI/CD
|
||||||
|
- Works across different test frameworks
|
||||||
|
|
||||||
|
3. **Clean up test data:**
|
||||||
|
```typescript
|
||||||
|
test.afterEach(async () => {
|
||||||
|
// Logout after each test
|
||||||
|
await $fetch('/api/auth/logout', { method: 'POST' })
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Isolate tests:**
|
||||||
|
- Each test should be independent
|
||||||
|
- Don't rely on state from previous tests
|
||||||
|
- Use `test.beforeEach` to reset state
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CI/CD Integration
|
||||||
|
|
||||||
|
For GitLab CI/CD, add test credentials as environment variables:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# .gitlab-ci.yml
|
||||||
|
test:
|
||||||
|
stage: test
|
||||||
|
variables:
|
||||||
|
TEST_USER_EMAIL: $CI_TEST_USER_EMAIL # Set in GitLab CI/CD settings
|
||||||
|
TEST_USER_PASSWORD: $CI_TEST_USER_PASSWORD
|
||||||
|
script:
|
||||||
|
- pnpm install
|
||||||
|
- pnpm test
|
||||||
|
- pnpm test:e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
Set `CI_TEST_USER_EMAIL` and `CI_TEST_USER_PASSWORD` as protected variables in:
|
||||||
|
**GitLab → Settings → CI/CD → Variables**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Questions?
|
||||||
|
|
||||||
|
- Check `CLAUDE.md` for authentication patterns
|
||||||
|
- Check `docs/CIDAAS_INTEGRATION.md` for Cidaas setup
|
||||||
|
- Ask in team chat or create an issue
|
||||||
81
tests/e2e/auth-login.example.spec.ts
Normal file
81
tests/e2e/auth-login.example.spec.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
/**
|
||||||
|
* E2E Test: Authentication - Login Flow
|
||||||
|
*
|
||||||
|
* This is an EXAMPLE test showing how to use test credentials.
|
||||||
|
* To run this test:
|
||||||
|
*
|
||||||
|
* 1. Copy .env.example to .env
|
||||||
|
* 2. Set TEST_USER_EMAIL and TEST_USER_PASSWORD
|
||||||
|
* 3. Run: pnpm test:e2e
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```bash
|
||||||
|
* # .env
|
||||||
|
* TEST_USER_EMAIL=bm@noxware.de
|
||||||
|
* TEST_USER_PASSWORD=%654321qQ!
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
|
||||||
|
test.describe('Authentication - Login Flow', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
// Navigate to auth page before each test
|
||||||
|
await page.goto('/auth')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should login with test credentials from environment', async ({ page }) => {
|
||||||
|
// Fetch test credentials from API endpoint
|
||||||
|
const response = await fetch('http://localhost:3000/api/test/credentials')
|
||||||
|
|
||||||
|
// Skip test if credentials not configured
|
||||||
|
if (!response.ok) {
|
||||||
|
test.skip()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { email, password } = await response.json()
|
||||||
|
|
||||||
|
// Fill in login form
|
||||||
|
await page.fill('input[type="email"]', email)
|
||||||
|
await page.fill('input[type="password"]', password)
|
||||||
|
|
||||||
|
// Submit form
|
||||||
|
await page.click('button[type="submit"]')
|
||||||
|
|
||||||
|
// Wait for navigation to complete
|
||||||
|
await page.waitForURL('/')
|
||||||
|
|
||||||
|
// Verify we're on homepage
|
||||||
|
expect(page.url()).toBe('http://localhost:3000/')
|
||||||
|
|
||||||
|
// Optional: Verify user is logged in (check for user menu, etc.)
|
||||||
|
// await expect(page.locator('[data-testid="user-menu"]')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should show error with invalid credentials', async ({ page }) => {
|
||||||
|
// Fill in login form with invalid credentials
|
||||||
|
await page.fill('input[type="email"]', 'invalid@example.com')
|
||||||
|
await page.fill('input[type="password"]', 'wrongpassword')
|
||||||
|
|
||||||
|
// Submit form
|
||||||
|
await page.click('button[type="submit"]')
|
||||||
|
|
||||||
|
// Verify error message is shown
|
||||||
|
await expect(page.locator('[role="alert"]')).toBeVisible()
|
||||||
|
await expect(page.locator('[role="alert"]')).toContainText('Invalid credentials')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should validate email format', async ({ page }) => {
|
||||||
|
// Fill in invalid email
|
||||||
|
await page.fill('input[type="email"]', 'not-an-email')
|
||||||
|
await page.fill('input[type="password"]', 'somepassword')
|
||||||
|
|
||||||
|
// Submit form
|
||||||
|
await page.click('button[type="submit"]')
|
||||||
|
|
||||||
|
// Verify validation error
|
||||||
|
// Note: Exact selector depends on your validation error display
|
||||||
|
await expect(page.locator('text=Invalid email')).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user