feat(frontend): Add organization creation, deletion, add better-auth organization plugin
This commit is contained in:
@@ -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)
|
||||
@@ -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"));
|
||||
117
legalconsenthub/components/CreateOrganizationModal.vue
Normal file
117
legalconsenthub/components/CreateOrganizationModal.vue
Normal 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>
|
||||
82
legalconsenthub/components/InviteMemberModal.vue
Normal file
82
legalconsenthub/components/InviteMemberModal.vue
Normal 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>
|
||||
@@ -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',
|
||||
|
||||
218
legalconsenthub/pages/administration.vue
Normal file
218
legalconsenthub/pages/administration.vue
Normal 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>
|
||||
@@ -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()]
|
||||
})
|
||||
|
||||
39
legalconsenthub/stores/useUserStore.ts
Normal file
39
legalconsenthub/stores/useUserStore.ts
Normal 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 }
|
||||
})
|
||||
@@ -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
|
||||
|
||||
6
legalconsenthub/utils/auth-types.ts
Normal file
6
legalconsenthub/utils/auth-types.ts
Normal 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
|
||||
Reference in New Issue
Block a user