feat(frontend): Add roles

This commit is contained in:
2025-07-28 06:46:31 +02:00
parent 115a12bbf5
commit 7b5a1a3bda
9 changed files with 359 additions and 32 deletions

View File

@@ -1,26 +1,44 @@
<template>
<UModal v-model:open="open" title="Invite Member" description="Invite a member to your organization">
<UButton icon="i-lucide-mail-plus" variant="outline" size="sm" @click="open = true"> Invite Member </UButton>
<UModal v-model:open="open" title="Mitglied einladen" description="Laden Sie ein Mitglied zu Ihrer Organisation ein">
<UButton
v-if="canInviteMembers"
icon="i-lucide-mail-plus"
variant="outline"
size="sm"
@click="open = true"
>
Mitglied einladen
</UButton>
<template #body>
<UForm :state="form" class="space-y-4" @submit="handleSubmit">
<UFormField label="Email" name="email">
<UInput v-model="form.email" type="email" placeholder="Email" class="w-full" />
<UFormField label="E-Mail" name="email">
<UInput v-model="form.email" type="email" placeholder="E-Mail" class="w-full" />
</UFormField>
<UFormField label="Role" name="role">
<UFormField label="Rolle" name="role">
<USelect
v-model="form.role"
:items="roleOptions"
placeholder="Select a role"
:items="availableRoles"
placeholder="Rolle auswählen"
value-key="value"
class="w-full"
/>
>
<template #option="{ option }">
<div class="flex items-center gap-2">
<UIcon :name="option.icon" :class="`text-${option.color}-500`" />
<div>
<div class="font-medium">{{ option.label }}</div>
<div class="text-xs text-gray-500">{{ option.description }}</div>
</div>
</div>
</template>
</USelect>
</UFormField>
<div class="flex justify-end">
<UButton type="submit" :loading="loading">
{{ loading ? 'Inviting...' : 'Invite' }}
{{ loading ? 'Einladen...' : 'Einladen' }}
</UButton>
</div>
</UForm>
@@ -29,10 +47,9 @@
</template>
<script setup lang="ts">
defineProps<{
organization: ActiveOrganization
}>()
import { ROLES, type LegalRole } from '~/server/utils/permissions'
const { canInviteMembers } = usePermissions()
const { inviteMember } = useBetterAuth()
const open = ref(false)
@@ -40,26 +57,68 @@ const loading = ref(false)
const form = ref({
email: '',
role: 'member'
role: ROLES.EMPLOYEE as LegalRole
})
const roleOptions = [
{ label: 'Admin', value: 'admin' },
{ label: 'Member', value: 'member' }
]
const availableRoles = computed(() => {
return Object.values(ROLES).map(role => {
const roleInfo = getRoleInfo(role)
return {
label: roleInfo.name,
value: role,
description: roleInfo.description,
color: roleInfo.color,
icon: roleInfo.icon
}
})
})
watch(open, (val) => {
function getRoleInfo(role: LegalRole) {
const roleInfo = {
[ROLES.EMPLOYER]: {
name: 'Arbeitgeber',
description: 'Kann Anträge genehmigen und Vereinbarungen unterzeichnen',
color: 'blue',
icon: 'i-lucide-briefcase'
},
[ROLES.EMPLOYEE]: {
name: 'Arbeitnehmer',
description: 'Kann eigene Anträge einsehen und kommentieren',
color: 'green',
icon: 'i-lucide-user'
},
[ROLES.WORKS_COUNCIL_MEMBER]: {
name: 'Betriebsrat',
description: 'Kann Anträge prüfen und Vereinbarungen unterzeichnen',
color: 'purple',
icon: 'i-lucide-users'
},
[ROLES.ADMIN]: {
name: 'Administrator',
description: 'Vollzugriff auf Organisationsverwaltung',
color: 'red',
icon: 'i-lucide-settings'
}
}
return roleInfo[role] || { name: role, description: '', color: 'gray', icon: 'i-lucide-circle' }
}
watch(open, (val: boolean) => {
if (val) {
form.value = { email: '', role: 'member' }
form.value = {
email: '',
role: ROLES.EMPLOYEE
}
}
})
async function handleSubmit() {
loading.value = true
try {
await inviteMember(form.value.email, form.value.role as 'member' | 'admin')
await inviteMember(form.value.email, form.value.role)
open.value = false
useToast().add({ title: 'Invitation sent', color: 'success' })
useToast().add({ title: 'Einladung gesendet', color: 'success' })
} finally {
loading.value = false
}

View File

@@ -7,12 +7,21 @@ import { organizationClient, jwtClient } from 'better-auth/client/plugins'
import type { RouteLocationRaw } from 'vue-router'
import type { UserDto } from '~/.api-client'
import type { RouteLocationNormalizedLoaded } from '#vue-router'
import {
accessControl,
employerRole,
worksCouncilMemberRole,
employeeRole,
adminRole,
ROLES
} from '~/server/utils/permissions'
interface RuntimeAuthConfig {
redirectUserTo: RouteLocationRaw | string
redirectGuestTo: RouteLocationRaw | string
}
// TODO: Move into pinia store
const session = ref<InferSessionFromClient<ClientOptions> | null>(null)
const user = ref<InferUserFromClient<ClientOptions> | null>(null)
const sessionFetching = import.meta.server ? ref(false) : ref(false)
@@ -28,6 +37,7 @@ const selectedOrganization = ref<{
metadata?: any
logo?: string | null
} | null>(null)
const activeMember = ref<{role: string} | null>(null)
export function useAuth() {
const url = useRequestURL()
@@ -39,7 +49,19 @@ export function useAuth() {
fetchOptions: {
headers
},
plugins: [organizationClient(), jwtClient()]
plugins: [
organizationClient({
// Pass the same access control instance and roles to client
ac: accessControl,
roles: {
[ROLES.EMPLOYER]: employerRole,
[ROLES.WORKS_COUNCIL_MEMBER]: worksCouncilMemberRole,
[ROLES.EMPLOYEE]: employeeRole,
[ROLES.ADMIN]: adminRole
}
}),
jwtClient()
]
})
const options = defu(useRuntimeConfig().public.auth as Partial<RuntimeAuthConfig>, {
@@ -86,6 +108,14 @@ export function useAuth() {
if (!selectedOrganization.value && organizations.value.length > 0) {
selectedOrganization.value = organizations.value[0]
}
// Fetch active member
const activeMemberResult = await client.organization.getActiveMember({
fetchOptions: {
headers
}
})
activeMember.value = activeMemberResult.data || null
}
watch(
@@ -147,6 +177,7 @@ export function useAuth() {
fetchJwtAndOrganizations,
client,
jwt,
isPublicRoute
isPublicRoute,
activeMember
}
}

View File

@@ -1,3 +1,6 @@
import type { ActiveOrganization } from '~/types/betterAuth'
import type { LegalRole } from '~/server/utils/permissions'
const activeOrganization = ref<ActiveOrganization | null>(null)
const selectedOrgId = ref<string | undefined>(undefined)
@@ -7,7 +10,7 @@ export function useBetterAuth() {
async function createOrganization(name: string, slug: string, logo?: string) {
const slugCheck = await organization.checkSlug({ slug })
if (!slugCheck.data.available) {
if (!slugCheck.data?.status) {
toast.add({ title: 'Slug bereits vergeben', description: 'Bitte wählen Sie einen anderen Slug', color: 'error' })
return Promise.reject()
}
@@ -62,7 +65,7 @@ export function useBetterAuth() {
})
}
async function inviteMember(email: string, role: 'member' | 'admin') {
async function inviteMember(email: string, role: LegalRole) {
await organization.inviteMember({
email,
role,

View File

@@ -0,0 +1,102 @@
import { ROLES, type LegalRole } from '~/server/utils/permissions'
export function usePermissions() {
const { organization, activeMember } = useAuth()
const currentRole = computed((): LegalRole | null => {
return (activeMember.value?.role as LegalRole) || null
})
const hasPermission = (permissions: Record<string, string[]>): boolean => {
if (!currentRole.value) return false
return organization.checkRolePermission({
permissions,
role: currentRole.value
})
}
// Specific permission helpers
const canCreateApplicationForm = computed(() =>
hasPermission({ application_form: ["create"] })
)
const canApproveApplicationForm = computed(() =>
hasPermission({ application_form: ["approve"] })
)
const canSignAgreement = computed(() =>
hasPermission({ agreement: ["sign"] })
)
const canInviteMembers = computed(() =>
hasPermission({ member: ["invite"] })
)
const canManageOrganization = computed(() =>
hasPermission({ organization: ["manage_settings"] })
)
// Role checks
const isEmployer = computed(() => currentRole.value === ROLES.EMPLOYER)
const isEmployee = computed(() => currentRole.value === ROLES.EMPLOYEE)
const isWorksCouncilMember = computed(() => currentRole.value === ROLES.WORKS_COUNCIL_MEMBER)
const isAdmin = computed(() => currentRole.value === ROLES.ADMIN)
const getCurrentRoleInfo = () => {
const roleInfo = {
[ROLES.EMPLOYER]: {
name: 'Arbeitgeber',
description: 'Kann Anträge genehmigen und Vereinbarungen unterzeichnen',
color: 'blue',
icon: 'i-lucide-briefcase'
},
[ROLES.EMPLOYEE]: {
name: 'Arbeitnehmer',
description: 'Kann eigene Anträge einsehen und kommentieren',
color: 'green',
icon: 'i-lucide-user'
},
[ROLES.WORKS_COUNCIL_MEMBER]: {
name: 'Betriebsrat',
description: 'Kann Anträge prüfen und Vereinbarungen unterzeichnen',
color: 'purple',
icon: 'i-lucide-users'
},
[ROLES.ADMIN]: {
name: 'Administrator',
description: 'Vollzugriff auf Organisationsverwaltung',
color: 'red',
icon: 'i-lucide-settings'
}
}
return currentRole.value && currentRole.value in roleInfo ? roleInfo[currentRole.value as LegalRole] : null
}
return {
// State
currentRole,
activeMember,
// Permission checks
hasPermission,
// Role checks
isEmployer,
isEmployee,
isWorksCouncilMember,
isAdmin,
// Computed permissions
canCreateApplicationForm,
canApproveApplicationForm,
canSignAgreement,
canInviteMembers,
canManageOrganization,
// Utilities
getCurrentRoleInfo,
ROLES
}
}

View File

@@ -16,10 +16,40 @@
<template #body>
<div class="flex flex-col gap-4 sm:gap-6 lg:gap-12 w-full lg:max-w-4xl mx-auto">
Erstelle Forumular für Organisation: {{ selectedOrganization?.name }}
<UPageCard title="Ampelstatus" variant="naked" orientation="horizontal" class="mb-4">
{{ ampelStatusEmoji }}
</UPageCard>
<!-- Permission Guard using Better Auth's native system -->
<div v-if="!canCreateApplicationForm" 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>
<UAlert
v-if="currentRoleInfo"
:title="`Ihre aktuelle Rolle: ${currentRoleInfo.name}`"
:description="currentRoleInfo.description"
:color="currentRoleInfo.color"
variant="soft"
class="max-w-md mx-auto"
/>
</div>
<div v-else>
Erstelle Formular für Organisation: {{ selectedOrganization?.name }}
<!-- Role Context Alert -->
<UAlert
v-if="currentRoleInfo"
:title="`Erstellen als: ${currentRoleInfo.name}`"
:description="`${currentRoleInfo.description} - Sie können Anträge erstellen und bearbeiten.`"
:color="currentRoleInfo.color"
variant="soft"
:icon="currentRoleInfo.icon"
class="mb-4"
/>
<UPageCard title="Ampelstatus" variant="naked" orientation="horizontal" class="mb-4">
{{ ampelStatusEmoji }}
</UPageCard>
<UPageCard variant="subtle">
<UForm class="space-y-4" :state="{}" @submit="onSubmit">
@@ -55,6 +85,7 @@
</div>
</UForm>
</UPageCard>
</div>
</div>
</template>
</UDashboardPanel>
@@ -70,6 +101,10 @@ const { getAllApplicationFormTemplates } = useApplicationFormTemplate()
const { createApplicationForm } = useApplicationForm()
const { validateFormElements, getHighestComplianceStatus } = useApplicationFormValidator()
const { userDto, selectedOrganization } = useAuth()
const { canCreateApplicationForm, getCurrentRoleInfo } = usePermissions()
// Get current role information for display
const currentRoleInfo = computed(() => getCurrentRoleInfo())
const stepper = useTemplateRef('stepper')
const activeStepperItemIndex = ref<number>(0)

View File

@@ -2,6 +2,15 @@ import { betterAuth } from 'better-auth'
import Database from 'better-sqlite3'
import { organization, jwt } from 'better-auth/plugins'
import { resend } from './mail'
import {
accessControl,
employerRole,
worksCouncilMemberRole,
employeeRole,
adminRole,
ROLES,
type LegalRole
} from './permissions'
export const auth = betterAuth({
database: new Database('./sqlite.db'),
@@ -21,16 +30,46 @@ export const auth = betterAuth({
}
}),
organization({
// Pass the access control instance and roles
ac: accessControl,
roles: {
[ROLES.EMPLOYER]: employerRole,
[ROLES.WORKS_COUNCIL_MEMBER]: worksCouncilMemberRole,
[ROLES.EMPLOYEE]: employeeRole,
[ROLES.ADMIN]: adminRole
},
// Creator gets admin role by default
creatorRole: ROLES.ADMIN,
async sendInvitationEmail(data) {
console.log('Sending invitation email', data)
const inviteLink = `http://192.168.178.114:3001/accept-invitation/${data.id}`
const roleDisplayNames = {
[ROLES.EMPLOYER]: 'Arbeitgeber',
[ROLES.EMPLOYEE]: 'Arbeitnehmer',
[ROLES.WORKS_COUNCIL_MEMBER]: 'Betriebsrat',
[ROLES.ADMIN]: 'Administrator'
}
const roleDisplayName = roleDisplayNames[data.role as LegalRole] || data.role
await resend.emails.send({
from: 'Acme <onboarding@resend.dev>',
from: 'Legal Consent Hub <noreply@legalconsenthub.com>',
to: data.email,
subject: 'Email Verification',
html: `You are invited to the Organization ${data.organization.name}! Click the link to verify your email: ${inviteLink}`
subject: `Einladung als ${roleDisplayName} - ${data.organization.name}`,
html: `
<h2>Einladung zur Organisation ${data.organization.name}</h2>
<p>Sie wurden als <strong>${roleDisplayName}</strong> eingeladen.</p>
<p><a href="${inviteLink}" style="background: #007bff; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px;">Einladung annehmen</a></p>
<p>Diese Einladung läuft ab am: ${new Date(data.invitation.expiresAt).toLocaleDateString('de-DE')}</p>
`
})
}
})
]
})
export { ROLES }
export type { LegalRole }

View File

@@ -0,0 +1,55 @@
import { createAccessControl } from 'better-auth/plugins/access'
export const statement = {
application_form: ['create', 'read', 'update', 'delete', 'approve', 'reject', 'submit'],
agreement: ['create', 'read', 'update', 'sign', 'approve', 'reject'],
organization: ['create', 'read', 'update', 'delete', 'manage_settings'],
member: ['create', 'read', 'update', 'delete', 'invite', 'remove'],
comment: ['create', 'read', 'update', 'delete'],
document: ['create', 'read', 'update', 'delete', 'download', 'upload']
} as const
export const accessControl = createAccessControl(statement)
// Roles with specific permissions
export const employerRole = accessControl.newRole({
application_form: ['create', 'read', 'approve', 'reject'],
agreement: ['create', 'read', 'sign', 'approve'],
member: ['invite', 'read'],
comment: ['create', 'read', 'update', 'delete'],
document: ['create', 'read', 'update', 'delete', 'download', 'upload']
})
export const worksCouncilMemberRole = accessControl.newRole({
application_form: ['create', 'read', 'update', 'submit'],
agreement: ['read', 'sign', 'approve'],
member: ['read'],
comment: ['create', 'read', 'update', 'delete'],
document: ['create', 'read', 'update', 'download', 'upload']
})
export const employeeRole = accessControl.newRole({
application_form: ['read'],
agreement: ['read'],
member: ['read'],
comment: ['create', 'read'],
document: ['read', 'download']
})
export const adminRole = accessControl.newRole({
application_form: ['create', 'read', 'update', 'delete', 'approve', 'reject'],
agreement: ['create', 'read', 'update', 'sign', 'approve', 'reject'],
organization: ['create', 'read', 'update', 'delete', 'manage_settings'],
member: ['create', 'read', 'update', 'delete', 'invite', 'remove'],
comment: ['create', 'read', 'update', 'delete'],
document: ['create', 'read', 'update', 'delete', 'download', 'upload']
})
export const ROLES = {
EMPLOYER: 'employer',
WORKS_COUNCIL_MEMBER: 'works_council_member',
EMPLOYEE: 'employee',
ADMIN: 'admin'
} as const
export type LegalRole = (typeof ROLES)[keyof typeof ROLES]

View File

@@ -0,0 +1,3 @@
const { client } = useAuth()
export type ActiveOrganization = typeof client.$Infer.ActiveOrganization