feat(frontend): Add roles
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
102
legalconsenthub/composables/usePermissions.ts
Normal file
102
legalconsenthub/composables/usePermissions.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
|
||||
55
legalconsenthub/server/utils/permissions.ts
Normal file
55
legalconsenthub/server/utils/permissions.ts
Normal 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]
|
||||
3
legalconsenthub/types/betterAuth.ts
Normal file
3
legalconsenthub/types/betterAuth.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
const { client } = useAuth()
|
||||
|
||||
export type ActiveOrganization = typeof client.$Infer.ActiveOrganization
|
||||
Reference in New Issue
Block a user