feat(frontend): Add organization creation, deletion, add better-auth organization plugin

This commit is contained in:
2025-04-06 09:35:15 +02:00
parent 99d3b28381
commit eed4b673c0
11 changed files with 496 additions and 14 deletions

View File

@@ -1,7 +0,0 @@
create table "better_auth_user" ("id" text not null primary key, "name" text not null, "email" text not null unique, "emailVerified" integer not null, "image" text, "createdAt" date not null, "updatedAt" date not null);
create table "better_auth_session" ("id" text not null primary key, "expiresAt" date not null, "token" text not null unique, "createdAt" date not null, "updatedAt" date not null, "ipAddress" text, "userAgent" text, "userId" text not null references "better_auth_user" ("id"));
create table "better_auth_account" ("id" text not null primary key, "accountId" text not null, "providerId" text not null, "userId" text not null references "better_auth_user" ("id"), "accessToken" text, "refreshToken" text, "idToken" text, "accessTokenExpiresAt" date, "refreshTokenExpiresAt" date, "scope" text, "password" text, "createdAt" date not null, "updatedAt" date not null);
create table "better_auth_verification" ("id" text not null primary key, "identifier" text not null, "value" text not null, "expiresAt" date not null, "createdAt" date, "updatedAt" date)

View File

@@ -0,0 +1,13 @@
create table "user" ("id" text not null primary key, "name" text not null, "email" text not null unique, "emailVerified" integer not null, "image" text, "createdAt" date not null, "updatedAt" date not null);
create table "session" ("id" text not null primary key, "expiresAt" date not null, "token" text not null unique, "createdAt" date not null, "updatedAt" date not null, "ipAddress" text, "userAgent" text, "userId" text not null references "user" ("id"), "activeOrganizationId" text);
create table "account" ("id" text not null primary key, "accountId" text not null, "providerId" text not null, "userId" text not null references "user" ("id"), "accessToken" text, "refreshToken" text, "idToken" text, "accessTokenExpiresAt" date, "refreshTokenExpiresAt" date, "scope" text, "password" text, "createdAt" date not null, "updatedAt" date not null);
create table "verification" ("id" text not null primary key, "identifier" text not null, "value" text not null, "expiresAt" date not null, "createdAt" date, "updatedAt" date);
create table "organization" ("id" text not null primary key, "name" text not null, "slug" text not null unique, "logo" text, "createdAt" date not null, "metadata" text);
create table "member" ("id" text not null primary key, "organizationId" text not null references "organization" ("id"), "userId" text not null references "user" ("id"), "role" text not null, "createdAt" date not null);
create table "invitation" ("id" text not null primary key, "organizationId" text not null references "organization" ("id"), "email" text not null, "role" text, "status" text not null, "expiresAt" date not null, "inviterId" text not null references "user" ("id"));

View File

@@ -0,0 +1,117 @@
<template>
<UModal
v-model:open="open"
title="New Organization"
description="Create a new organization to collaborate with your team"
>
<template #default>
<UButton icon="i-heroicons-plus" @click="open = true"> Organisation erstellen </UButton>
</template>
<template #body>
<UForm :state="state" :schema="schema" class="space-y-4" @submit="onSubmit">
<UFormField label="Name" name="name">
<UInput v-model="state.name" class="w-full" />
</UFormField>
<UFormField label="Slug" name="slug">
<UInput v-model="state.slug" class="w-full" @input="isSlugEdited = true" />
</UFormField>
<UFormField label="Logo (optional)" name="logo">
<input type="file" accept="image/*" @change="handleLogoChange" />
<img v-if="state.logo" :src="state.logo" alt="Logo preview" class="w-16 h-16 object-cover mt-2" />
</UFormField>
<div class="flex justify-end gap-2">
<UButton type="submit" :loading="loading">
{{ loading ? 'Creating...' : 'Create' }}
</UButton>
</div>
</UForm>
</template>
</UModal>
</template>
<script setup lang="ts">
import type { FormSubmitEvent } from '@nuxt/ui'
import * as z from 'zod'
const emit = defineEmits(['formSubmitted'])
const open = ref(false)
const loading = ref(false)
const isSlugEdited = ref(false)
type Schema = z.output<typeof schema>
const schema = z.object({
name: z.string().min(2, 'Too short'),
slug: z.string().min(2, 'Too short'),
logo: z.string().optional()
})
const state = reactive<Partial<Schema>>({
name: undefined,
slug: undefined,
logo: undefined
})
watch(
() => state.name,
(newName) => {
if (!isSlugEdited.value) {
state.slug = (newName ?? '').trim().toLowerCase().replace(/\s+/g, '-')
}
}
)
watch(open, (val) => {
if (val) {
state.name = ''
state.slug = ''
state.logo = undefined
isSlugEdited.value = false
}
})
function handleLogoChange(event: Event) {
const input = event.target as HTMLInputElement
const file = input?.files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = () => {
state.logo = reader.result as string
}
reader.readAsDataURL(file)
}
async function onSubmit(event: FormSubmitEvent<Schema>) {
console.log('onSubmit', state)
loading.value = true
await organization.create(
{
name: event.data.name,
slug: event.data.slug,
logo: event.data.logo
},
{
onSuccess: () => {
useToast().add({ title: 'Organisation erfolgreich erstellt', color: 'success' })
emit('formSubmitted', { name: state.name, slug: state.slug })
open.value = false
},
onError: (ctx) => {
useToast().add({
title: 'Fehler bei der Erstellung der Organisation',
description: ctx.error.message,
color: 'error'
})
},
onResponse: () => {
loading.value = false
}
}
)
}
</script>

View File

@@ -0,0 +1,82 @@
<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>
<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>
<UFormField label="Role" name="role">
<USelect
v-model="form.role"
:items="roleOptions"
placeholder="Select a role"
value-key="value"
class="w-full"
/>
</UFormField>
<div class="flex justify-end">
<UButton type="submit" :loading="loading">
{{ loading ? 'Inviting...' : 'Invite' }}
</UButton>
</div>
</UForm>
</template>
</UModal>
</template>
<script setup lang="ts">
const props = defineProps<{
organization: ActiveOrganization
}>()
const emit = defineEmits(['update'])
const open = ref(false)
const loading = ref(false)
const form = ref({
email: '',
role: 'member'
})
const roleOptions = [
{ label: 'Admin', value: 'admin' },
{ label: 'Member', value: 'member' }
]
watch(open, (val) => {
if (val) {
form.value = { email: '', role: 'member' }
}
})
async function handleSubmit() {
loading.value = true
await organization.inviteMember({
email: form.value.email,
role: form.value.role as 'member' | 'admin',
fetchOptions: {
throw: true,
onSuccess: (ctx) => {
const updatedOrg = {
...props.organization,
invitations: [...(props.organization.invitations || []), ctx.data]
}
emit('update', updatedOrg)
open.value = false
},
onError: (error) => {
useToast().add({ title: 'Fehler beim Einladen', description: error.error.message, color: 'error' })
},
onResponse: () => {
loading.value = false
}
}
})
useToast().add({ title: 'Invitation sent', color: 'success' })
}
</script>

View File

@@ -83,6 +83,11 @@ const items = computed<DropdownMenuItem[][]>(() => [
label: 'Profile',
icon: 'i-lucide-user'
},
{
label: 'Administration',
icon: 'i-lucide-user',
to: '/administration'
},
{
label: 'Settings',
icon: 'i-lucide-settings',

View File

@@ -0,0 +1,218 @@
<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="selectedOrgId"
:items="selectItems"
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="activeOrganization?.members" class="space-y-2">
<div
v-for="member in activeOrganization.members"
: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="session && canRemove({ role: 'owner' }, member)">
<UButton size="xs" color="error" @click="organization.removeMember({ memberIdOrEmail: member.id })">
{{ member.user.id === session.id ? 'Leave' : 'Remove' }}
</UButton>
</div>
</div>
<div v-if="!activeOrganization?.id" class="flex items-center gap-2">
<UAvatar :src="session?.image ?? undefined" />
<div>
<p class="text-sm">{{ session?.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">
<div
v-for="inv in activeOrganization?.invitations.filter((i: Invitation) => i.status === 'pending')"
:key="inv.id"
class="flex justify-between items-center"
>
<div>
<p class="text-sm">{{ inv.email }}</p>
<p class="text-xs text-gray-500">{{ inv.role }}</p>
</div>
<div class="flex items-center gap-2">
<UButton
size="xs"
color="error"
:loading="isRevoking.includes(inv.id)"
@click="() => handleRevokeInvitation(inv.id)"
>
Revoke
</UButton>
<UButton icon="i-lucide-copy" size="xs" @click="copy(getInviteLink(inv.id))">
{{ copied ? 'Copied!' : 'Copy' }}
</UButton>
</div>
</div>
<p v-if="!activeOrganization?.invitations.length" 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'
const { copy, copied } = useClipboard()
const toast = useToast()
const selectedOrgId = ref<string | undefined>(undefined)
const activeOrganization = ref<ActiveOrganization | null>(null)
const organizations = computed(() => useListOrganizations().value.data || [])
const selectItems = computed(() => organizations.value.map((org) => ({ label: org.name, value: org.id })))
watch(selectedOrgId, async (newId) => {
const { data } = await organization.setActive({ organizationId: newId || null })
activeOrganization.value = data
})
const { data: sessionData } = useSession().value
const session = computed(() => sessionData?.user)
const isRevoking = ref<string[]>([])
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 handleRevokeInvitation(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(
(inv) => inv.id !== invitationId
)
}
},
onError: (ctx) => {
toast.add({ title: 'Error', description: ctx.error.message, color: 'error' })
isRevoking.value = isRevoking.value.filter((id) => id !== 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 authClient.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' })
}
}
)
}
</script>

View File

@@ -1,11 +1,9 @@
import { betterAuth } from 'better-auth'
import Database from 'better-sqlite3'
import { organization } from 'better-auth/plugins'
export const auth = betterAuth({
database: new Database('./sqlite.db'),
emailAndPassword: { enabled: true, autoSignIn: false },
user: { modelName: 'better_auth_user' },
session: { modelName: 'better_auth_session' },
account: { modelName: 'better_auth_account' },
verification: { modelName: 'better_auth_verification' }
plugins: [organization()]
})

View File

@@ -0,0 +1,39 @@
import type { FormSubmitEvent } from '@nuxt/ui'
export const useUserStore = defineStore('User', () => {
const name = ref('')
async function signUp(payload: FormSubmitEvent<Schema>) {
await authClient.signUp.email(
{
email: payload.data.email,
password: payload.data.password,
name: payload.data.name
},
{
onRequest: (ctx) => {
// TODO: Show loading spinner
console.log('Sending register request')
},
onResponse: () => {
// TODO: Hide loading spinner
console.log('Receiving register response')
},
onSuccess: async (ctx) => {
console.log('Successfully registered!')
await navigateTo('/')
},
onError: (ctx) => {
console.log(ctx.error.message)
useToast().add({
title: 'Fehler bei der Registrierung',
description: ctx.error.message,
color: 'success'
})
}
}
)
}
return { name, signUp }
})

View File

@@ -1,7 +1,18 @@
import { createAuthClient } from 'better-auth/vue'
import { organizationClient } from 'better-auth/client/plugins'
export const authClient = createAuthClient({
baseURL: 'http://localhost:3000'
baseURL: 'http://localhost:3001',
plugins: [organizationClient()]
})
export const { signIn, signOut, signUp, useSession, forgetPassword, resetPassword } = authClient
export const {
signIn,
signOut,
signUp,
useSession,
forgetPassword,
resetPassword,
organization,
useListOrganizations
} = authClient

View File

@@ -0,0 +1,6 @@
import type { auth } from '../server/utils/auth'
import type { authClient } from './auth-client'
export type Session = typeof auth.$Infer.Session
export type ActiveOrganization = typeof authClient.$Infer.ActiveOrganization
export type Invitation = typeof authClient.$Infer.Invitation