feat(frontend): Refactor organization, fixed several organization bugs

This commit is contained in:
2025-08-17 09:28:26 +02:00
parent b7b6d02cf2
commit 6090d543c1
14 changed files with 279 additions and 221 deletions

View File

@@ -34,13 +34,11 @@
<script setup lang="ts">
import * as z from 'zod'
import { useBetterAuth } from '~/composables/useBetterAuth'
const emit = defineEmits<{
(e: 'organizationCreated', id: string | undefined): void
}>()
const { createOrganization } = useBetterAuth()
const { createOrganization } = useOrganizationStore()
const open = ref(false)
const loading = ref(false)
const isSlugEdited = ref(false)

View File

@@ -51,7 +51,7 @@
<script setup lang="ts">
import type { CommentDto, FormElementDto, FormOptionDto } from '~/.api-client'
import { useComment } from '~/composables/comment/useComment'
import { useCommentTextarea } from '~/composables/comment/useCommentTextarea'
import { resolveComponent } from 'vue'
const props = defineProps<{
@@ -69,7 +69,7 @@ const commentStore = useCommentStore()
const { load: loadComments } = commentStore
const { comments } = storeToRefs(commentStore)
const commentActions = useComment(props.applicationFormId)
const commentActions = useCommentTextarea(props.applicationFormId)
const {
submitComment,
updateEditComment,

View File

@@ -44,7 +44,7 @@
import { ROLES, type LegalRole } from '~/server/utils/permissions'
const { canInviteMembers } = usePermissions()
const { inviteMember } = useBetterAuth()
const { inviteMember } = useOrganizationStore()
const open = ref(false)
const loading = ref(false)
@@ -114,7 +114,6 @@ async function handleSubmit() {
try {
await inviteMember(form.value.email, form.value.role)
open.value = false
useToast().add({ title: 'Einladung gesendet', color: 'success' })
} finally {
loading.value = false
}

View File

@@ -1,12 +1,13 @@
import type { CreateCommentDto, CommentDto } from '~/.api-client'
export function useComment(applicationFormId: string) {
export function useCommentTextarea(applicationFormId: string) {
const commentStore = useCommentStore()
const { createComment, updateComment } = commentStore
const { user } = useAuth()
const isEditingComment = ref(false)
const currentEditedComment = ref<CommentDto | null>(null)
const commentTextAreaValue = ref('')
const toast = useToast()
async function submitComment(formElementId: string) {
const newCommentDto: CreateCommentDto = {
@@ -15,9 +16,9 @@ export function useComment(applicationFormId: string) {
try {
await createComment(applicationFormId, formElementId, newCommentDto)
commentTextAreaValue.value = ''
useToast().add({ title: 'Comment created successfully', color: 'success' })
toast.add({ title: 'Comment created successfully', color: 'success' })
} catch (e) {
useToast().add({ title: 'Error creating comment', color: 'error' })
toast.add({ title: 'Error creating comment', color: 'error' })
console.error('Error creating comment:', e)
}
}
@@ -30,9 +31,9 @@ export function useComment(applicationFormId: string) {
commentTextAreaValue.value = ''
currentEditedComment.value = null
isEditingComment.value = false
useToast().add({ title: 'Comment updated successfully', color: 'success' })
toast.add({ title: 'Comment updated successfully', color: 'success' })
} catch (e) {
useToast().add({ title: 'Error updating comment', color: 'error' })
toast.add({ title: 'Error updating comment', color: 'error' })
console.error('Error updating comment:', e)
}
}

View File

@@ -0,0 +1,63 @@
import type { LegalRole } from '~/server/utils/permissions'
export function useOrganizationApi() {
const { organization } = useAuth()
async function createOrganization(name: string, slug: string, logo?: string) {
return organization.create({ name, slug, logo })
}
async function deleteOrganization(organizationId: string) {
return organization.delete({ organizationId })
}
async function getInvitation(invitationId: string) {
return organization.getInvitation({ query: { id: invitationId } })
}
async function listInvitations(organizationId?: string) {
return organization.listInvitations(organizationId ? { query: { organizationId: organizationId } } : undefined)
}
async function inviteMember(email: string, role: LegalRole) {
return organization.inviteMember({ email, role })
}
async function acceptInvitation(invitationId: string) {
return organization.acceptInvitation({ invitationId })
}
async function cancelSentInvitation(invitationId: string) {
return organization.cancelInvitation({ invitationId })
}
async function rejectInvitation(invitationId: string) {
return organization.rejectInvitation({ invitationId })
}
async function loadOrganizations() {
return organization.list()
}
async function checkSlugAvailability(slug: string) {
return organization.checkSlug({ slug })
}
async function setActiveOrganization(organizationId: string) {
return organization.setActive({ organizationId })
}
return {
createOrganization,
deleteOrganization,
getInvitation,
listInvitations,
inviteMember,
acceptInvitation,
cancelSentInvitation,
rejectInvitation,
loadOrganizations,
checkSlugAvailability,
setActiveOrganization
}
}

View File

@@ -5,7 +5,6 @@ import { createAuthClient } from 'better-auth/vue'
import type { InferSessionFromClient, InferUserFromClient, ClientOptions } from 'better-auth/client'
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,
@@ -22,6 +21,24 @@ interface RuntimeAuthConfig {
redirectGuestTo: RouteLocationRaw | string
}
// Types can be found here: https://github.com/better-auth/better-auth/blob/3f574ec70bb15c155a78673d42c5e25f7376ced3/packages/better-auth/src/plugins/organization/routes/crud-invites.ts#L531
type Client = ReturnType<typeof useAuth>['client']
export type Session = Client['$Infer']['Session']
export type User = Session['user']
export type ActiveOrganization = Client['$Infer']['ActiveOrganization']
export type Organization = Client['$Infer']['Organization']
export type Invitation = Client['$Infer']['Invitation']
// Extended invitation type with additional organization and inviter details
export type CustomInvitation =
| (Invitation & {
organizationName: string
organizationSlug: string
inviterEmail: string
})
| null
// TODO: Move into pinia store
const session = ref<InferSessionFromClient<ClientOptions> | null>(null)
const user = ref<InferUserFromClient<ClientOptions> | null>(null)

View File

@@ -1,130 +0,0 @@
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)
export function useBetterAuth() {
const toast = useToast()
const { organization } = useAuth()
async function createOrganization(name: string, slug: string, logo?: string) {
const slugCheck = await organization.checkSlug({ slug })
if (!slugCheck.data?.status) {
toast.add({ title: 'Slug bereits vergeben', description: 'Bitte wählen Sie einen anderen Slug', color: 'error' })
return Promise.reject()
}
return await organization.create(
{ name, slug, logo },
{
onSuccess: () => {
toast.add({ title: 'Organisation erfolgreich erstellt', color: 'success' })
return Promise.resolve()
},
onError: (ctx) => {
toast.add({
title: 'Fehler bei der Erstellung der Organisation',
description: ctx.error.message,
color: 'error'
})
return Promise.reject()
}
}
)
}
async function deleteOrganization() {
await organization.delete(
{ organizationId: activeOrganization.value?.id ?? '' },
{
onSuccess: () => {
toast.add({ title: 'Organization deleted', color: 'success' })
activeOrganization.value = null
selectedOrgId.value = undefined
},
onError: (ctx) => {
toast.add({ title: 'Error deleting organization', description: ctx.error.message, color: 'error' })
}
}
)
}
async function getInvitation(invitationId: string): Promise<CustomInvitation> {
return organization.getInvitation({
query: { id: invitationId },
fetchOptions: {
throw: true,
onSuccess: (ctx) => {
return ctx.data
},
onError: (error) => {
toast.add({ title: 'Fehler beim Einladen', description: error.error.message, color: 'error' })
}
}
})
}
async function inviteMember(email: string, role: LegalRole) {
await organization.inviteMember({
email,
role,
fetchOptions: {
throw: true,
onSuccess: (ctx) => {
if (activeOrganization.value) {
activeOrganization.value = {
...activeOrganization.value,
invitations: [...(activeOrganization.value?.invitations || []), ctx.data]
}
}
},
onError: (error) => {
toast.add({ title: 'Fehler beim Einladen', description: error.error.message, color: 'error' })
}
}
})
}
async function acceptInvitation(invitationId: string) {
await organization.acceptInvitation({
invitationId,
fetchOptions: {
throw: true,
onSuccess: async () => {
toast.add({ title: 'Invitation accepted', color: 'success' })
await navigateTo('/')
},
onError: (ctx) => {
toast.add({ title: 'Error when accepting invitation', description: ctx.error.message, color: 'error' })
}
}
})
}
async function rejectInvitation(invitationId: string) {
await organization.rejectInvitation({
invitationId,
fetchOptions: {
throw: true,
onSuccess: () => {
toast.add({ title: 'Invitation rejected', color: 'success' })
},
onError: (ctx) => {
toast.add({ title: 'Error when rejecting invitation', description: ctx.error.message, color: 'error' })
}
}
})
}
return {
activeOrganization,
selectedOrgId,
createOrganization,
deleteOrganization,
getInvitation,
inviteMember,
acceptInvitation,
rejectInvitation
}
}

View File

@@ -69,9 +69,11 @@
</template>
<script setup lang="ts">
import type { CustomInvitation } from '~/composables/useAuth'
const invitationId = useRoute().params.id as string
const { acceptInvitation, rejectInvitation, getInvitation } = useBetterAuth()
const { acceptInvitation, rejectInvitation, getInvitation } = useOrganizationStore()
const invitation = ref<CustomInvitation>(null)
const invitationStatus = ref<'pending' | 'accepted' | 'rejected'>('pending')
const error = ref<string | null>(null)

View File

@@ -23,13 +23,13 @@
>
<div class="flex justify-between items-center">
<USelect
v-model="selectedOrgId"
:items="availableOrganizations"
v-model="selectedOrganizationId"
:items="labeledOrganizations"
value-key="value"
placeholder="Select organization"
class="w-64"
/>
<CreateOrganizationModal @organization-created="setOrganization" />
<CreateOrganizationModal />
</div>
</UPageCard>
@@ -89,11 +89,9 @@
<div class="flex-1">
<p class="font-medium mb-2">Invites</p>
<div class="space-y-2">
<template v-if="activeOrganization?.invitations">
<template v-if="invitations.length > 0">
<div
v-for="invitation in activeOrganization?.invitations.filter(
(i: Invitation) => i.status === 'pending'
)"
v-for="invitation in invitations.filter((i: Invitation) => i.status === 'pending')"
:key="invitation.id"
class="flex justify-between items-center"
>
@@ -106,7 +104,7 @@
size="xs"
color="error"
:loading="isRevoking.includes(invitation.id)"
@click="() => handleRevokeInvitation(invitation.id)"
@click="() => handleInvitationCancellation(invitation.id)"
>
Revoke
</UButton>
@@ -116,9 +114,7 @@
</div>
</div>
</template>
<p v-if="!activeOrganization?.invitations?.length" class="text-sm text-gray-500">
No active invitations
</p>
<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>
@@ -144,41 +140,26 @@ import { useClipboard } from '@vueuse/core'
import type { Invitation } from 'better-auth/plugins'
const { copy, copied } = useClipboard()
const toast = useToast()
const { organization } = useAuth()
const { deleteOrganization: betterAuthDeleteOrganization, activeOrganization, selectedOrgId } = useBetterAuth()
const organizations = ref<
Array<{ id: string; name: string; slug: string; createdAt: Date; logo?: string | null; metadata?: unknown }>
>([])
const availableOrganizations = computed(() => organizations.value.map((org) => ({ label: org.name, value: org.id })))
const { user } = await useAuth()
const { organization, user } = useAuth()
const organizationStore = useOrganizationStore()
const { deleteOrganization: betterAuthDeleteOrganization, loadOrganizations } = organizationStore
const { activeOrganization, organizations, invitations } = storeToRefs(organizationStore)
const isRevoking = ref<string[]>([])
watch(selectedOrgId, async (newId) => {
const { data } = await organization.setActive({ organizationId: newId || null })
activeOrganization.value = data
onMounted(async () => {
await loadOrganizations()
})
await loadOrganizations()
async function loadOrganizations() {
try {
const response = await organization.list()
organizations.value = response.data || []
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to load organizations'
toast.add({ title: 'Error', description: errorMessage, color: 'error' })
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)
}
}
async function setOrganization(id: string | undefined) {
await loadOrganizations()
selectedOrgId.value = id
}
})
function isAdminOrOwner(member: { role: string }) {
return member.role === 'owner' || member.role === 'admin'
@@ -188,26 +169,9 @@ function canRemove(current: { role: string }, target: { role: string }) {
return target.role !== 'owner' && isAdminOrOwner(current)
}
async function handleRevokeInvitation(invitationId: string) {
async function handleInvitationCancellation(invitationId: string) {
isRevoking.value.push(invitationId)
await organization.cancelInvitation(
{ invitationId },
{
onSuccess: () => {
toast.add({ title: 'Success', description: 'Invitation revoked', color: 'success' })
isRevoking.value = isRevoking.value.filter((id) => id !== invitationId)
if (activeOrganization.value) {
activeOrganization.value.invitations = activeOrganization.value.invitations.filter(
(invitation: Invitation) => invitation.id !== invitationId
)
}
},
onError: (ctx) => {
toast.add({ title: 'Error', description: ctx.error.message, color: 'error' })
isRevoking.value = isRevoking.value.filter((id) => id !== invitationId)
}
}
)
await organizationStore.cancelSentInvitation(invitationId)
}
function getInviteLink(inviteId: string): string {

View File

@@ -14,6 +14,7 @@ import {
export const auth = betterAuth({
database: new Database('./sqlite.db'),
onAPIError: { throw: true },
emailAndPassword: { enabled: true, autoSignIn: false },
trustedOrigins: ['http://localhost:3001'],
plugins: [

View File

@@ -2,7 +2,7 @@ import { defineStore } from 'pinia'
import { type CreateCommentDto, type CommentDto, ResponseError } from '~/.api-client'
import { useCommentApi } from '~/composables/comment/useCommentApi'
export const useCommentStore = defineStore('comment', () => {
export const useCommentStore = defineStore('Comment', () => {
type FormElementId = string
const commentApi = useCommentApi()
const comments = ref<Record<FormElementId, CommentDto[]>>({})

View File

@@ -0,0 +1,160 @@
import type { ActiveOrganization, Organization, Invitation } from '~/composables/useAuth'
import { useOrganizationApi } from '~/composables/organization/useOrganizationApi'
import type { LegalRole } from '~/server/utils/permissions'
export const useOrganizationStore = defineStore('Organization', () => {
const activeOrganization = ref<ActiveOrganization | null>(null)
const organizations = ref<Organization[]>([])
const invitations = ref<Invitation[]>([])
const organizationApi = useOrganizationApi()
const toast = useToast()
async function createOrganization(name: string, slug: string, logo?: string) {
try {
const { data: slugCheck } = await organizationApi.checkSlugAvailability(slug)
if (!slugCheck?.status) {
toast.add({
title: 'Slug already taken',
description: 'Please choose a different slug',
color: 'error'
})
return Promise.reject()
}
const { data: createdOrganization } = await organizationApi.createOrganization(name, slug, logo)
if (createdOrganization) {
organizations.value.push(createdOrganization)
toast.add({ title: 'Organization created successfully', color: 'success' })
if (createdOrganization.id) {
await setActiveOrganization(createdOrganization.id)
}
return createdOrganization
}
} catch (e) {
toast.add({ title: 'Error creating organization', color: 'error' })
console.error('Error creating organization:', e)
}
}
async function deleteOrganization(organizationId?: string) {
try {
const idToDelete = organizationId ?? activeOrganization.value?.id
if (!idToDelete) {
throw Error(`No organization is selected for deletion`)
}
await organizationApi.deleteOrganization(idToDelete)
organizations.value = organizations.value.filter((org) => org.id !== organizationId)
toast.add({ title: 'Organization deleted successfully', color: 'success' })
} catch (e) {
toast.add({ title: 'Error deleting organization', color: 'error' })
console.error('Error deleting organization:', e)
}
}
async function getInvitation(invitationId: string): Promise<CustomInvitation> {
try {
const { data: invitation } = await organizationApi.getInvitation(invitationId)
return invitation
} catch (e) {
console.error('Error getInvitation:', e)
return null
}
}
async function loadInvitations(organizationId?: string) {
try {
const { data: loadedInvitations } = await organizationApi.listInvitations(organizationId)
if (loadedInvitations) {
invitations.value = loadedInvitations
}
} catch (e) {
toast.add({ title: 'Error loading invitations', color: 'error' })
console.error('Error loading invitations:', e)
}
}
async function inviteMember(email: string, role: LegalRole) {
try {
await organizationApi.inviteMember(email, role)
await loadInvitations()
toast.add({ title: 'Member invited successfully', color: 'success' })
} catch (e) {
toast.add({ title: 'Error inviting member', color: 'error' })
console.error('Error inviting member:', e)
}
}
async function acceptInvitation(invitationId: string) {
try {
await organizationApi.acceptInvitation(invitationId)
await navigateTo('/')
} catch (e) {
toast.add({ title: 'Error accepting invitation', color: 'error' })
console.error('Error accepting invitation:', e)
}
}
async function cancelSentInvitation(invitationId: string) {
try {
await organizationApi.cancelSentInvitation(invitationId)
invitations.value = invitations.value.filter((invitation) => invitation.id !== invitationId)
} catch (e) {
toast.add({ title: 'Error rejecting invitation', color: 'error' })
console.error('Error rejecting invitation:', e)
}
}
async function rejectInvitation(invitationId: string) {
try {
await organizationApi.rejectInvitation(invitationId)
invitations.value = invitations.value.filter((invitation) => invitation.id !== invitationId)
} catch (e) {
toast.add({ title: 'Error rejecting invitation', color: 'error' })
console.error('Error rejecting invitation:', e)
}
}
async function loadOrganizations() {
try {
const { data: loadedOrganizations } = await organizationApi.loadOrganizations()
if (loadedOrganizations) {
organizations.value = loadedOrganizations
}
} catch (e) {
toast.add({ title: 'Error loading organizations', color: 'error' })
console.error('Error loading organizations:', e)
}
}
async function setActiveOrganization(organizationId: string) {
try {
const { data: activeOrganizationToSet } = await organizationApi.setActiveOrganization(organizationId)
activeOrganization.value = activeOrganizationToSet
const { data: invitationsToSet } = await organizationApi.listInvitations(activeOrganizationToSet?.id)
invitations.value = invitationsToSet ?? []
} catch (e) {
toast.add({ title: 'Error setting active organizations', color: 'error' })
console.error('Error setting active organizations:', e)
}
}
return {
activeOrganization,
organizations,
createOrganization,
deleteOrganization,
invitations,
getInvitation,
loadInvitations,
inviteMember,
acceptInvitation,
rejectInvitation,
cancelSentInvitation,
loadOrganizations,
setActiveOrganization
}
})

View File

@@ -1,4 +0,0 @@
import type { useAuth } from '#imports'
type Client = ReturnType<typeof useAuth>['client']
export type ActiveOrganization = Client['$Infer']['ActiveOrganization']

View File

@@ -1,13 +0,0 @@
// Types can be found here: https://github.com/better-auth/better-auth/blob/3f574ec70bb15c155a78673d42c5e25f7376ced3/packages/better-auth/src/plugins/organization/routes/crud-invites.ts#L531
export type CustomInvitation = {
organizationName: string
organizationSlug: string
inviterEmail: string
id: string
status: 'pending' | 'accepted' | 'rejected' | 'canceled'
email: string
expiresAt: Date
organizationId: string
role: string
inviterId: string
} | null