major: Migration from better-auth to keycloak

This commit is contained in:
2025-10-28 10:40:38 +01:00
parent e5e063bbde
commit 36364a7977
77 changed files with 1444 additions and 2930 deletions

View File

@@ -1,92 +0,0 @@
<template>
<UDashboardPanel>
<template #header>
<UDashboardNavbar title="Accept Invitation" :ui="{ right: 'gap-3' }">
<template #leading>
<UDashboardSidebarCollapse />
</template>
<template #right />
</UDashboardNavbar>
<UDashboardToolbar>
<template #left />
</UDashboardToolbar>
</template>
<template #body>
<div class="flex flex-col gap-4 sm:gap-6 lg:gap-12 w-full lg:max-w-2xl mx-auto">
<UPageCard title="Accept Invitation" description="Accept or decline the invitation." class="mb-6">
<div v-if="invitation && !error">
<div v-if="invitationStatus === 'pending'" class="space-y-4">
<p>
<strong>{{ invitation.inviterEmail }}</strong> has invited you to join
<strong>{{ invitation.organizationName }}</strong
>.
</p>
<p>
This invitation was sent to <strong>{{ invitation.email }}</strong
>.
</p>
</div>
<div v-if="invitationStatus === 'accepted'" class="space-y-4 text-center">
<div class="flex items-center justify-center w-16 h-16 mx-auto bg-green-100 rounded-full">
<UIcon name="i-lucide-check" class="w-8 h-8 text-green-600" />
</div>
<h2 class="text-2xl font-bold">Welcome to {{ invitation.organizationName }}!</h2>
<p>You've successfully joined the organization. We're excited to have you on board!</p>
</div>
<div v-if="invitationStatus === 'rejected'" class="space-y-4 text-center">
<div class="flex items-center justify-center w-16 h-16 mx-auto bg-red-100 rounded-full">
<UIcon name="i-lucide-x" class="w-8 h-8 text-red-600" />
</div>
<h2 class="text-2xl font-bold">Invitation Declined</h2>
<p>You've declined the invitation to join {{ invitation.organizationName }}.</p>
</div>
<div v-if="invitationStatus === 'pending'" class="flex justify-between mt-6">
<UButton variant="soft" color="neutral" @click="handleReject">Decline</UButton>
<UButton color="primary" @click="handleAccept">Accept Invitation</UButton>
</div>
</div>
<div v-else-if="!invitation && !error" class="space-y-4">
<USkeleton class="w-1/3 h-6" />
<USkeleton class="w-full h-4" />
<USkeleton class="w-2/3 h-4" />
<USkeleton class="w-24 h-10 ml-auto" />
</div>
<div v-else>
<p class="text-red-600 font-medium">{{ error }}</p>
</div>
</UPageCard>
</div>
</template>
</UDashboardPanel>
</template>
<script setup lang="ts">
import type { CustomInvitation } from '~/types/auth'
const invitationId = useRoute().params.id as string
const { acceptInvitation, rejectInvitation, getInvitation } = useOrganizationStore()
const invitation = ref<CustomInvitation>(null)
const invitationStatus = ref<'pending' | 'accepted' | 'rejected'>('pending')
const error = ref<string | null>(null)
async function handleAccept() {
await acceptInvitation(invitationId)
}
async function handleReject() {
await rejectInvitation(invitationId)
}
onMounted(async () => {
invitation.value = await getInvitation(invitationId)
})
</script>

View File

@@ -1,191 +0,0 @@
<template>
<UDashboardPanel>
<template #header>
<UDashboardNavbar title="Administration" :ui="{ right: 'gap-3' }">
<template #leading>
<UDashboardSidebarCollapse />
</template>
<template #right />
</UDashboardNavbar>
<UDashboardToolbar>
<template #left />
</UDashboardToolbar>
</template>
<template #body>
<div class="flex flex-col gap-4 sm:gap-6 lg:gap-12 w-full lg:max-w-2xl mx-auto">
<UPageCard
title="Organization Selection"
description="Choose an organization or create a new one."
class="mb-6"
>
<div class="flex justify-between items-center">
<USelect
v-model="selectedOrganizationId"
:items="labeledOrganizations"
value-key="value"
placeholder="Select organization"
class="w-64"
/>
<CreateOrganizationModal />
</div>
</UPageCard>
<UPageCard
title="Organization Overview"
description="View your current organization and its details."
class="mb-6"
>
<div class="flex gap-4 items-center">
<UAvatar :src="activeOrganization?.logo || undefined" size="md" alt="Org Logo" class="rounded" />
<div>
<p class="font-semibold">{{ activeOrganization?.name }}</p>
<p class="text-xs text-gray-500">{{ activeOrganization?.members?.length || 1 }} members</p>
</div>
</div>
<div class="flex justify-end mt-4">
<UButton color="error" icon="i-lucide-trash" @click="deleteOrganization"> Delete Organization </UButton>
</div>
</UPageCard>
<UPageCard title="Members & Invitations" description="Manage team members and pending invites.">
<div class="flex flex-col md:flex-row gap-8">
<!-- Members -->
<div class="flex-1">
<p class="font-medium mb-2">Members</p>
<div v-if="activeOrganizationMembers" class="space-y-2">
<div
v-for="member in activeOrganizationMembers"
:key="member.id"
class="flex justify-between items-center"
>
<div class="flex items-center gap-2">
<UAvatar :src="member.user.image || undefined" size="sm" />
<div>
<p class="text-sm">{{ member.user.name }}</p>
<p class="text-xs text-gray-500">{{ member.role }}</p>
</div>
</div>
<div v-if="user && canRemove({ role: 'owner' }, member)">
<UButton size="xs" color="error" @click="organizationStore.removeMember(member.id)">
{{ member.user.id === user.id ? 'Leave' : 'Remove' }}
</UButton>
</div>
</div>
<div v-if="!activeOrganization?.id" class="flex items-center gap-2">
<UAvatar :src="user?.image ?? undefined" />
<div>
<p class="text-sm">{{ user?.name }}</p>
<p class="text-xs text-gray-500">Owner</p>
</div>
</div>
</div>
</div>
<!-- Invitations -->
<div class="flex-1">
<p class="font-medium mb-2">Invites</p>
<div class="space-y-2">
<template v-if="invitations.length > 0">
<div
v-for="invitation in invitations.filter((i: Invitation) => i.status === 'pending')"
:key="invitation.id"
class="flex justify-between items-center"
>
<div>
<p class="text-sm">{{ invitation.email }}</p>
<p class="text-xs text-gray-500">{{ invitation.role }}</p>
</div>
<div class="flex items-center gap-2">
<UButton
size="xs"
color="error"
:loading="isRevoking.includes(invitation.id)"
@click="() => handleInvitationCancellation(invitation.id)"
>
Revoke
</UButton>
<UButton icon="i-lucide-copy" size="xs" @click="copy(getInviteLink(invitation.id))">
{{ copied ? 'Copied!' : 'Copy' }}
</UButton>
</div>
</div>
</template>
<p v-else class="text-sm text-gray-500">No active invitations</p>
<p v-if="!activeOrganization?.id" class="text-xs text-gray-500">
You can't invite members to your personal workspace.
</p>
</div>
</div>
</div>
<div class="flex justify-end mt-6">
<InviteMemberModal
v-if="activeOrganization?.id"
:organization="activeOrganization"
@update="activeOrganization = $event"
/>
</div>
</UPageCard>
</div>
</template>
</UDashboardPanel>
</template>
<script setup lang="ts">
import { useClipboard } from '@vueuse/core'
import type { Invitation } from 'better-auth/plugins'
const { copy, copied } = useClipboard()
const { user } = useAuth()
const organizationStore = useOrganizationStore()
const { activeOrganization, activeOrganizationMembers, organizations, invitations } = storeToRefs(organizationStore)
const isRevoking = ref<string[]>([])
onMounted(async () => {
await organizationStore.loadOrganizations()
})
const labeledOrganizations = computed(() => organizations.value.map((org) => ({ label: org.name, value: org.id })))
const selectedOrganizationId = computed({
get() {
return activeOrganization.value?.id
},
set(id: string) {
organizationStore.setActiveOrganization(id)
}
})
function isAdminOrOwner(member: { role: string }) {
return member.role === 'owner' || member.role === 'admin'
}
function canRemove(current: { role: string }, target: { role: string }) {
return target.role !== 'owner' && isAdminOrOwner(current)
}
async function handleInvitationCancellation(invitationId: string) {
isRevoking.value.push(invitationId)
await organizationStore.cancelSentInvitation(invitationId)
}
function getInviteLink(inviteId: string): string {
return `${window.location.origin}/accept-invitation/${inviteId}`
}
async function deleteOrganization() {
if (!activeOrganization.value?.id) return
const confirmed = confirm(
`Are you sure you want to delete the organization "${activeOrganization.value.name}"? This cannot be undone.`
)
if (!confirmed) return
await organizationStore.deleteOrganization()
}
</script>

View File

@@ -80,7 +80,9 @@ import type { StepperItem } from '@nuxt/ui'
const { getApplicationFormById, updateApplicationForm, submitApplicationForm } = useApplicationForm()
const route = useRoute()
const { user } = useAuth()
const userStore = useUserStore()
const { user } = storeToRefs(userStore)
const toast = useToast()
definePageMeta({

View File

@@ -0,0 +1,19 @@
<template>
<h1>Authentication callback processing...</h1>
</template>
<script setup lang="ts">
import { useKeycloak } from '~/composables/useKeycloak'
const { userManager } = useKeycloak()
onMounted(async () => {
try {
const user = await userManager.signinRedirectCallback()
console.log('User logged in', user)
await navigateTo('/')
} catch (e) {
console.error('Error during login', e)
}
})
</script>

View File

@@ -16,7 +16,7 @@
<template #body>
<div class="flex flex-col gap-4 sm:gap-6 lg:gap-12 w-full lg:max-w-4xl mx-auto">
<div v-if="!canCreateApplicationForm" class="text-center py-12">
<div v-if="!true" class="text-center py-12">
<UIcon name="i-lucide-shield-x" class="w-16 h-16 mx-auto text-red-400 mb-4" />
<h2 class="text-2xl font-semibold text-gray-700 mb-2">Keine Berechtigung</h2>
<p class="text-gray-500 mb-4">Sie haben keine Berechtigung zum Erstellen von Anträgen.</p>
@@ -83,15 +83,19 @@ import { useApplicationFormValidator } from '~/composables/useApplicationFormVal
import type { FormElementId } from '~/types/formElement'
import type { StepperItem } from '@nuxt/ui'
const { getAllApplicationFormTemplates } = useApplicationFormTemplate()
const { getAllApplicationFormTemplates } = await useApplicationFormTemplate()
const { createApplicationForm, submitApplicationForm } = useApplicationForm()
const { validateFormElements, getHighestComplianceStatus } = useApplicationFormValidator()
const { selectedOrganization } = useAuth()
const { canCreateApplicationForm, getCurrentRoleInfo } = usePermissions()
const userStore = useUserStore()
const { selectedOrganization } = storeToRefs(userStore)
const toast = useToast()
// Get current role information for display
const currentRoleInfo = computed(() => getCurrentRoleInfo())
const currentRoleInfo = {
name: 'Mitarbeiter',
description: 'Sie können Anträge erstellen und bearbeiten.',
color: 'info'
}
const stepper = useTemplateRef('stepper')
const activeStepperItemIndex = ref<number>(0)
@@ -196,6 +200,7 @@ async function prepareAndCreateApplicationForm() {
return null
}
console.log('selectedOrganization', selectedOrganization.value)
applicationFormTemplate.value.organizationId = selectedOrganization.value?.id ?? ''
return await createApplicationForm(applicationFormTemplate.value)

View File

@@ -78,10 +78,12 @@
<script setup lang="ts">
import type { ApplicationFormDto, PagedApplicationFormDto } from '~/.api-client'
import type { Organization } from '~/types/keycloak'
const { getAllApplicationForms, deleteApplicationFormById } = useApplicationForm()
const route = useRoute()
const { organizations, selectedOrganization } = useAuth()
const userStore = useUserStore()
const { organizations, selectedOrganization } = storeToRefs(userStore)
// Inject notification state from layout
const { isNotificationsSlideoverOpen, unreadCount } = inject('notificationState', {
@@ -117,7 +119,7 @@ const selectedOrganizationId = computed({
},
set(item) {
// TODO: USelect triggers multiple times after single selection
selectedOrganization.value = organizations.value.find((i) => i.id === item) ?? null
selectedOrganization.value = organizations.value.find((i: Organization) => i.id === item) ?? null
}
})

View File

@@ -1,80 +1,42 @@
<template>
<UAuthForm
:fields="fields"
:schema="signInSchema"
:providers="providers"
title="Welcome back"
icon="i-lucide-lock"
@submit="onLoginSubmit"
>
<template #description>
Don't have an account? <ULink to="/signup" class="text-primary-500 font-medium">Sign up</ULink>.
</template>
<UCard variant="subtle">
<template #header>
<div class="text-center">
<UIcon name="i-lucide-lock" class="mx-auto h-16 w-16 text-primary-500 mb-6" />
<h1 class="text-3xl font-bold text-gray-900 mb-2">
Welcome
</h1>
<p class="text-gray-600">
You will be redirected to Keycloak to authenticate
</p>
</div>
</template>
<template #password-hint>
<ULink to="/" class="text-primary-500 font-medium">Forgot password?</ULink>
</template>
<div class="text-center">
<UButton
color="primary"
size="xl"
icon="i-lucide-log-in"
@click="handleSignIn"
>
Sign in with Keycloak
</UButton>
</div>
<template #footer>
By signing in, you agree to our <ULink to="/" class="text-primary-500 font-medium">Terms of Service</ULink>.
</template>
</UAuthForm>
<template #footer>
<div class="text-center text-xs text-gray-500">
By signing in, you agree to our terms of service
</div>
</template>
</UCard>
</template>
<script setup lang="ts">
import type { FormSubmitEvent } from '@nuxt/ui'
import { signInSchema, type SignInSchema } from '~/types/schemas'
definePageMeta({ layout: 'auth' })
definePageMeta({ auth: false, layout: 'auth' })
useSeoMeta({ title: 'Login' })
const toast = useToast()
const { signIn } = useAuth()
const fields = [
{
name: 'email',
type: 'text' as const,
label: 'Email',
placeholder: 'Enter your email',
required: true
},
{
name: 'password',
label: 'Password',
type: 'password' as const,
placeholder: 'Enter your password'
},
{
name: 'remember',
label: 'Remember me',
type: 'checkbox' as const
}
]
const providers = [
{
label: 'Google',
icon: 'i-simple-icons-google',
onClick: () => {
toast.add({ title: 'Google', description: 'Login with Google' })
}
},
{
label: 'GitHub',
icon: 'i-simple-icons-github',
onClick: () => {
toast.add({ title: 'GitHub', description: 'Login with GitHub' })
}
}
]
function onLoginSubmit(payload: FormSubmitEvent<SignInSchema>) {
if (!payload.data.email || !payload.data.password) {
alert('Bitte alle Felder ausfüllen')
return
}
signIn(payload)
function handleSignIn() {
navigateTo('/auth/keycloak', { external: true })
}
</script>

View File

@@ -1,72 +0,0 @@
<template>
<UAuthForm
:fields="fields"
:schema="signUpSchema"
:providers="providers"
title="Create an account"
:submit="{ label: 'Create account' }"
@submit="onSignUpSubmit"
>
<template #description>
Already have an account? <ULink to="/login" class="text-primary-500 font-medium">Login</ULink>.
</template>
<template #footer>
By signing up, you agree to our <ULink to="/" class="text-primary-500 font-medium">Terms of Service</ULink>.
</template>
</UAuthForm>
</template>
<script setup lang="ts">
import type { FormSubmitEvent } from '@nuxt/ui'
import { signUpSchema, type SignUpSchema } from '~/types/schemas'
definePageMeta({ layout: 'auth' })
useSeoMeta({ title: 'Sign up' })
const toast = useToast()
const { signUp } = useAuth()
const fields = [
{
name: 'name',
type: 'text' as const,
label: 'Name',
placeholder: 'Enter your name'
},
{
name: 'email',
type: 'text' as const,
label: 'Email',
placeholder: 'Enter your email'
},
{
name: 'password',
label: 'Password',
type: 'password' as const,
placeholder: 'Enter your password'
}
]
const providers = [
{
label: 'Google',
icon: 'i-simple-icons-google',
onClick: () => {
toast.add({ title: 'Google', description: 'Login with Google' })
}
},
{
label: 'GitHub',
icon: 'i-simple-icons-github',
onClick: () => {
toast.add({ title: 'GitHub', description: 'Login with GitHub' })
}
}
]
function onSignUpSubmit(payload: FormSubmitEvent<SignUpSchema>) {
signUp(payload)
}
</script>