Browse Source

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.
main
Bastian Masanek 2 months ago
parent
commit
cc35636d1a
  1. 11
      .claude/settings.local.json
  2. 124
      CLAUDE.md
  3. 5
      app/components/CommonFooter.vue
  4. 16
      app/components/CommonHeader.vue
  5. 90
      app/components/UserMenu.vue
  6. 22
      app/components/ui/avatar/Avatar.vue
  7. 12
      app/components/ui/avatar/AvatarFallback.vue
  8. 12
      app/components/ui/avatar/AvatarImage.vue
  9. 25
      app/components/ui/avatar/index.ts
  10. 15
      app/components/ui/dropdown-menu/DropdownMenu.vue
  11. 37
      app/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue
  12. 35
      app/components/ui/dropdown-menu/DropdownMenuContent.vue
  13. 12
      app/components/ui/dropdown-menu/DropdownMenuGroup.vue
  14. 26
      app/components/ui/dropdown-menu/DropdownMenuItem.vue
  15. 22
      app/components/ui/dropdown-menu/DropdownMenuLabel.vue
  16. 19
      app/components/ui/dropdown-menu/DropdownMenuRadioGroup.vue
  17. 38
      app/components/ui/dropdown-menu/DropdownMenuRadioItem.vue
  18. 20
      app/components/ui/dropdown-menu/DropdownMenuSeparator.vue
  19. 14
      app/components/ui/dropdown-menu/DropdownMenuShortcut.vue
  20. 19
      app/components/ui/dropdown-menu/DropdownMenuSub.vue
  21. 27
      app/components/ui/dropdown-menu/DropdownMenuSubContent.vue
  22. 31
      app/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue
  23. 14
      app/components/ui/dropdown-menu/DropdownMenuTrigger.vue
  24. 16
      app/components/ui/dropdown-menu/index.ts
  25. 16
      app/composables/useAuth.ts
  26. 33
      app/pages/index.vue
  27. 71
      app/pages/logout.vue
  28. 246
      docs/TESTING.md
  29. 8
      nuxt.config.ts
  30. 1
      package.json
  31. 29
      pnpm-lock.yaml
  32. 1
      server/api/auth/login.post.ts
  33. 35
      server/api/auth/logout.post.ts
  34. 18
      server/api/test/credentials.get.ts
  35. 117
      server/utils/cidaas.ts
  36. 103
      server/utils/test-helpers.ts
  37. 32
      tasks/00-PROGRESS.md
  38. 119
      tasks/03-authentication.md
  39. 302
      tests/README.md
  40. 81
      tests/e2e/auth-login.example.spec.ts

11
.claude/settings.local.json

@ -51,7 +51,16 @@
"WebFetch(domain:articles.cidaas.de)",
"WebFetch(domain:pre-release-docs.cidaas.com)",
"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": [],
"ask": []

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.
### 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
// composables/useAuth.ts - Client-side auth composable

5
app/components/CommonFooter.vue

@ -14,8 +14,9 @@ const currentYear = new Date().getFullYear()
</NuxtLink>
</div>
<p>
experimenta ist Deutschlands größtes Science Center. Mit über 275 Mitmachstationen, vier
Kreativstudios, neun Laboren und einer Sternwarte.
Die experimenta ist Deutschlands größtes Science Center mit interaktiven Experimenten zum
Anfassen, Forscherlaboren, Kreativwerkstätten und
dem weltweit einzigartigen Science Dome ein außerschulischer Lernort für alle Altersgruppen.
</p>
</div>

16
app/components/CommonHeader.vue

@ -1,13 +1,20 @@
<script setup lang="ts">
// experimenta header with branding
// experimenta header with branding and user menu
const { loggedIn } = useAuth()
</script>
<template>
<header class="header-wrapper">
<div class="header-content">
<!-- Logo (left side) -->
<NuxtLink to="/" class="logo">
<img src="/img/experimenta-logo-white.svg" alt="experimenta Logo" class="logo-svg" />
</NuxtLink>
<!-- User Menu (right side, only when logged in) -->
<div v-if="loggedIn" class="user-menu">
<UserMenu />
</div>
</div>
</header>
</template>
@ -26,7 +33,7 @@
margin: 0 auto;
padding: 0 20px;
display: flex;
justify-content: center;
justify-content: space-between;
align-items: center;
}
@ -60,4 +67,9 @@
width: 200px;
}
}
.user-menu {
display: flex;
align-items: center;
}
</style>

90
app/components/UserMenu.vue

@ -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

@ -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

@ -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

@ -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

@ -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

@ -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

@ -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

@ -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

@ -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

@ -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

@ -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

@ -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

@ -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

@ -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

@ -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

@ -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

@ -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

@ -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

@ -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

@ -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"

16
app/composables/useAuth.ts

@ -69,16 +69,24 @@ export function useAuth() {
/**
* 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() {
try {
// Call logout endpoint (performs Cidaas SSO + clears session)
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) {
console.error('Logout failed:', error)
throw error
// Even on error, clear local state and redirect
await clear()
navigateTo('/logout')
}
}

33
app/pages/index.vue

@ -1,5 +1,7 @@
<script setup lang="ts">
// Home page - MVP placeholder with shadcn-nuxt test
const { loggedIn, user } = useAuth()
// Sample button click handler
const handleClick = () => {
console.log('shadcn-nuxt Button clicked!')
@ -9,8 +11,35 @@ const handleClick = () => {
<template>
<NuxtLayout name="default">
<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>
<p class="text-lg mb-8 text-white/90">Your gateway to Makerspace annual passes and more.</p>
<!-- Welcome Section -->
<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">
<h2 class="text-xl font-semibold mb-2 text-experimenta-accent">MVP Development in Progress</h2>

71
app/pages/logout.vue

@ -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

@ -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

8
nuxt.config.ts

@ -65,6 +65,7 @@ export default defineNuxtConfig({
userinfoUrl: process.env.CIDAAS_USERINFO_URL,
jwksUrl: process.env.CIDAAS_JWKS_URL,
redirectUri: process.env.CIDAAS_REDIRECT_URI,
postLogoutRedirectUri: process.env.CIDAAS_POST_LOGOUT_REDIRECT_URI || process.env.APP_URL || 'http://localhost:3000',
},
// Session configuration
@ -74,6 +75,13 @@ export default defineNuxtConfig({
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: {
appUrl: process.env.APP_URL || 'http://localhost:3000',

1
package.json

@ -19,6 +19,7 @@
"dependencies": {
"@nuxtjs/i18n": "^10.1.2",
"@vee-validate/zod": "^4.15.1",
"@vueuse/core": "^14.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"drizzle-orm": "^0.44.7",

29
pnpm-lock.yaml

@ -14,6 +14,9 @@ importers:
'@vee-validate/zod':
specifier: ^4.15.1
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:
specifier: ^0.7.1
version: 0.7.1
@ -1926,12 +1929,25 @@ packages:
'@vueuse/core@12.8.2':
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':
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':
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:
resolution: {integrity: sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==}
engines: {node: ^18.17.0 || >=20.5.0}
@ -7170,14 +7186,27 @@ snapshots:
transitivePeerDependencies:
- 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@14.0.0': {}
'@vueuse/shared@12.8.2(typescript@5.9.3)':
dependencies:
vue: 3.5.22(typescript@5.9.3)
transitivePeerDependencies:
- 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: {}
abort-controller@3.0.0:

1
server/api/auth/login.post.ts

@ -82,6 +82,7 @@ export default defineEventHandler(async (event) => {
firstName: user.firstName,
lastName: user.lastName,
},
accessToken: tokens.access_token, // Store for logout
loggedInAt: new Date().toISOString(),
})

35
server/api/auth/logout.post.ts

@ -3,7 +3,7 @@
/**
* POST /api/auth/logout
*
* End user session and clear session cookie
* End user session and perform Single Sign-Out at Cidaas
*
* Response:
* {
@ -12,13 +12,34 @@
*/
export default defineEventHandler(async (event) => {
// Clear session (nuxt-auth-utils)
await clearUserSession(event)
try {
// 1. Get session to retrieve access token
const session = await getUserSession(event)
// Optional: Revoke Cidaas tokens (Single Sign-Out)
// This would require storing refresh_token in session and calling Cidaas revoke endpoint
// 2. If access token exists, logout from Cidaas (Single Sign-Out)
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 {
success: true,
// 3. Clear local session (nuxt-auth-utils)
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

@ -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()

117
server/utils/cidaas.ts

@ -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
* Uses cidaas:register scope (direct registration without invite)
*
* @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
*/
export async function registerUser(
data: CidaasRegistrationRequest
): Promise<{ success: boolean; message: string }> {
): Promise<{ success: boolean; message: string; redirect_uri?: string }> {
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`
try {
@ -171,6 +221,7 @@ export async function registerUser(
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({
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 {
success: true,
message: 'Registration successful. Please verify your email.',
redirect_uri: result.data?.redirect_uri,
}
} catch (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

@ -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',
})
}
})
}

32
tasks/00-PROGRESS.md

@ -2,9 +2,9 @@
## my.experimenta.science
**Last Updated:** 2025-10-30
**Last Updated:** 2025-11-01
**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
**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):**
- ✅ Install nuxt-auth-utils + jose
- ✅ Configure Cidaas environment variables in .env
- ✅ 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)
- ✅ Create Cidaas API client utility (server/utils/cidaas.ts) with `loginWithPassword()`
- ✅ Create JWT validation utility (server/utils/jwt.ts)
- ✅ Create /api/auth/login.post.ts endpoint
- ✅ Create /api/auth/callback.get.ts endpoint
- ✅ Create /api/auth/login.post.ts endpoint (Password Grant)
- ✅ Create /api/auth/register.post.ts endpoint
- ✅ Create /api/auth/logout.post.ts endpoint
- ✅ Create /api/auth/me.get.ts endpoint
@ -51,7 +62,9 @@
- ✅ Create auth page with tabs (pages/auth.vue)
- ✅ Create auth middleware (middleware/auth.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:**
@ -416,7 +429,8 @@ Tasks:
| 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-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 |
---

119
tasks/03-authentication.md

@ -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)
- **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

302
tests/README.md

@ -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

@ -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()
})
})
Loading…
Cancel
Save