diff --git a/legalconsenthub/stores/useUserStore.ts b/legalconsenthub/composables/auth/useAuthActions.ts similarity index 69% rename from legalconsenthub/stores/useUserStore.ts rename to legalconsenthub/composables/auth/useAuthActions.ts index 6ea512d..00f0063 100644 --- a/legalconsenthub/stores/useUserStore.ts +++ b/legalconsenthub/composables/auth/useAuthActions.ts @@ -1,12 +1,29 @@ import type { FormSubmitEvent } from '@nuxt/ui' import { UserRole } from '~/.api-client' import type { SignInSchema, SignUpSchema } from '~/types/schemas' +import type { RouteLocationRaw } from 'vue-router' +import { useAuthClient } from '~/composables/auth/useAuthClient' +import { useAuthState } from '~/composables/auth/useAuthState' -export const useUserStore = defineStore('User', () => { +export function useAuthActions() { + const { client } = useAuthClient() + const { session, user } = useAuthState() const { createUser } = useUser() - const name = ref('') const toast = useToast() - const { client } = useAuth() + + async function signOut({ redirectTo }: { redirectTo?: RouteLocationRaw } = {}) { + const res = await client.signOut() + if (res.error) { + console.error('Error signing out:', res.error) + return res + } + session.value = null + user.value = null + if (redirectTo) { + await navigateTo(redirectTo, { external: true }) + } + return res + } async function signUp(payload: FormSubmitEvent) { await client.signUp.email( @@ -48,7 +65,7 @@ export const useUserStore = defineStore('User', () => { }, onError: async (ctx) => { console.log(ctx.error.message) - useToast().add({ + toast.add({ title: 'Fehler bei der Registrierung', description: ctx.error.message, color: 'error' @@ -68,15 +85,25 @@ export const useUserStore = defineStore('User', () => { onRequest: () => { console.log('Sending login request') }, - onSuccess: () => { + onSuccess: async () => { console.log('Successfully logged in!') + await navigateTo('/') }, onError: (ctx) => { console.log(ctx.error.message) + toast.add({ + title: 'Fehler bei der Anmeldung', + description: ctx.error.message, + color: 'error' + }) } } ) } - return { name, signUp, signIn } -}) + return { + signOut, + signUp, + signIn + } +} diff --git a/legalconsenthub/composables/auth/useAuthClient.ts b/legalconsenthub/composables/auth/useAuthClient.ts new file mode 100644 index 0000000..55bb940 --- /dev/null +++ b/legalconsenthub/composables/auth/useAuthClient.ts @@ -0,0 +1,46 @@ +import { createAuthClient } from 'better-auth/vue' +import { jwtClient, organizationClient } from 'better-auth/client/plugins' +import { + accessControl, + adminRole, + employeeRole, + employerRole, + ownerRole, + ROLES, + worksCouncilMemberRole +} from '~/server/utils/permissions' + +export function useAuthClient() { + const url = useRequestURL() + const headers = import.meta.server ? useRequestHeaders() : undefined + + const client = createAuthClient({ + baseURL: url.origin, + fetchOptions: { + headers + }, + user: { + deleteUser: { + enabled: true + } + }, + 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, + [ROLES.OWNER]: ownerRole + } + }), + jwtClient() + ] + }) + + return { + client + } +} diff --git a/legalconsenthub/composables/auth/useAuthState.ts b/legalconsenthub/composables/auth/useAuthState.ts new file mode 100644 index 0000000..000f4a1 --- /dev/null +++ b/legalconsenthub/composables/auth/useAuthState.ts @@ -0,0 +1,130 @@ +import type { ClientOptions, InferSessionFromClient, InferUserFromClient } from 'better-auth/client' +import type { RuntimeAuthConfig } from '~/types/auth' +import { defu } from 'defu' +import { useAuthClient } from '~/composables/auth/useAuthClient' + +// Global state for auth +const session = ref | null>(null) +const user = ref | null>(null) +const sessionFetching = import.meta.server ? ref(false) : ref(false) +const jwt = ref(null) +const organizations = ref< + { + id: string + name: string + createdAt: Date + slug: string + metadata?: Record + logo?: string | null + }[] +>([]) +const selectedOrganization = ref<{ + id: string + name: string + createdAt: Date + slug: string + metadata?: Record + logo?: string | null +} | null>(null) +const activeMember = ref<{ role: string } | null>(null) + +export function useAuthState() { + const { client } = useAuthClient() + const route = useRoute() + + const options = defu(useRuntimeConfig().public.auth as Partial, { + redirectUserTo: '/', + redirectGuestTo: '/login' + }) + + const headers = import.meta.server ? useRequestHeaders() : undefined + + async function fetchSession(targetPath?: string) { + if (sessionFetching.value) { + console.log('already fetching session') + return + } + sessionFetching.value = true + const { data } = await client.getSession({ + fetchOptions: { + headers + } + }) + session.value = data?.session || null + user.value = data?.user || null + sessionFetching.value = false + + // Only fetch JWT and organizations if we have a session and not on public routes + if (session.value && !isPublicPath(targetPath)) { + await fetchJwtAndOrganizations() + } + + return data + } + + async function fetchJwtAndOrganizations() { + // Fetch JWT + const tokenResult = await client.token() + jwt.value = tokenResult.data?.token ?? null + + // Fetch organization + const orgResult = await client.organization.list({ + fetchOptions: { + headers + } + }) + organizations.value = orgResult.data ?? [] + + 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 + } + + function isPublicPath(path?: string) { + const finalPath = path ?? route.path + const publicRoutes = ['/login', '/signup', '/accept-invitation'] + return publicRoutes.some((path) => finalPath.startsWith(path)) + } + + // Watch organization changes + watch( + () => selectedOrganization.value, + async (newValue) => { + if (newValue) { + await client.organization.setActive({ + organizationId: newValue?.id + }) + } + } + ) + + // Client-side session listening + if (import.meta.client) { + client.$store.listen('$sessionSignal', async (signal) => { + if (!signal) return + await fetchSession() + }) + } + + return { + session, + user, + sessionFetching, + jwt, + organizations, + selectedOrganization, + activeMember, + options, + fetchSession, + isPublicPath, + loggedIn: computed(() => !!session.value) + } +} diff --git a/legalconsenthub/composables/useApplicationFormValidator.ts b/legalconsenthub/composables/useApplicationFormValidator.ts index 7543733..65aedf0 100644 --- a/legalconsenthub/composables/useApplicationFormValidator.ts +++ b/legalconsenthub/composables/useApplicationFormValidator.ts @@ -1,6 +1,6 @@ import { ComplianceStatus, type FormElementDto } from '~/.api-client' import { complianceCheckableElementTypes, complianceMap } from './complianceMap' -import type { FormElementId } from '~/types/FormElement' +import type { FormElementId } from '~/types/formElement' const formElementComplianceMap = ref(new Map()) diff --git a/legalconsenthub/composables/useAuth.ts b/legalconsenthub/composables/useAuth.ts index fed8899..285029d 100644 --- a/legalconsenthub/composables/useAuth.ts +++ b/legalconsenthub/composables/useAuth.ts @@ -1,183 +1,19 @@ // Copied from https://github.com/atinux/nuxthub-better-auth -import { defu } from 'defu' -import { createAuthClient } from 'better-auth/vue' -import type { ClientOptions, InferSessionFromClient, InferUserFromClient } from 'better-auth/client' -import { jwtClient, organizationClient } from 'better-auth/client/plugins' -import type { RouteLocationRaw } from 'vue-router' -import { - accessControl, - adminRole, - employeeRole, - employerRole, - ownerRole, - ROLES, - worksCouncilMemberRole -} from '~/server/utils/permissions' -import type { RuntimeAuthConfig } from '~/types/auth' - -const session = ref | null>(null) -const user = ref | null>(null) -const sessionFetching = import.meta.server ? ref(false) : ref(false) -const jwt = ref(null) -const organizations = ref< - { - id: string - name: string - createdAt: Date - slug: string - metadata?: Record - logo?: string | null - }[] ->([]) -const selectedOrganization = ref<{ - id: string - name: string - createdAt: Date - slug: string - metadata?: Record - logo?: string | null -} | null>(null) -const activeMember = ref<{ role: string } | null>(null) +import { useAuthState } from '~/composables/auth/useAuthState' +import { useAuthActions } from '~/composables/auth/useAuthActions' +import { useAuthClient } from '~/composables/auth/useAuthClient' export function useAuth() { - const url = useRequestURL() - const route = useRoute() - const headers = import.meta.server ? useRequestHeaders() : undefined - - const client = createAuthClient({ - baseURL: url.origin, - fetchOptions: { - headers - }, - user: { - deleteUser: { - enabled: true - } - }, - 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, - [ROLES.OWNER]: ownerRole - } - }), - jwtClient() - ] - }) - - const options = defu(useRuntimeConfig().public.auth as Partial, { - redirectUserTo: '/', - redirectGuestTo: '/login' - }) - - async function fetchSession(targetPath?: string) { - if (sessionFetching.value) { - console.log('already fetching session') - return - } - sessionFetching.value = true - const { data } = await client.getSession({ - fetchOptions: { - headers - } - }) - session.value = data?.session || null - user.value = data?.user || null - sessionFetching.value = false - - // Only fetch JWT and organizations if we have a session and not on public routes - if (session.value && !isPublicPath(targetPath)) { - await fetchJwtAndOrganizations() - } - - return data - } - - async function fetchJwtAndOrganizations() { - // Fetch JWT - const tokenResult = await client.token() - jwt.value = tokenResult.data?.token ?? null - - // Fetch organization - const orgResult = await client.organization.list({ - fetchOptions: { - headers - } - }) - organizations.value = orgResult.data ?? [] - - 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( - () => selectedOrganization.value, - async (newValue) => { - if (newValue) { - await client.organization.setActive({ - organizationId: newValue?.id - }) - } - } - ) - - if (import.meta.client) { - client.$store.listen('$sessionSignal', async (signal) => { - if (!signal) return - await fetchSession() - }) - } - - function isPublicPath(path?: string) { - const finalPath = path ?? route.path - const publicRoutes = ['/login', '/signup', '/accept-invitation'] - return publicRoutes.some((path) => finalPath.startsWith(path)) - } - - async function signOut({ redirectTo }: { redirectTo?: RouteLocationRaw } = {}) { - const res = await client.signOut() - if (res.error) { - console.error('Error signing out:', res.error) - return res - } - session.value = null - user.value = null - if (redirectTo) { - await navigateTo(redirectTo, { external: true }) - } - return res - } + const authState = useAuthState() + const authActions = useAuthActions() + const { client } = useAuthClient() return { - session, - user, - loggedIn: computed(() => !!session.value), - signIn: client.signIn, - deleteUser: client.deleteUser, - signOut, - organization: client.organization, - organizations, - selectedOrganization, - options, - fetchSession, + ...authState, + ...authActions, client, - jwt, - isPublicPath, - activeMember + deleteUser: client.deleteUser, + organization: client.organization } } diff --git a/legalconsenthub/composables/user/useUserApi.ts b/legalconsenthub/composables/user/useUserApi.ts index 48d84f9..627d63d 100644 --- a/legalconsenthub/composables/user/useUserApi.ts +++ b/legalconsenthub/composables/user/useUserApi.ts @@ -1,10 +1,11 @@ import { UserApi, Configuration, type CreateUserDto, type UserDto } from '~/.api-client' import { cleanDoubleSlashes, withoutTrailingSlash } from 'ufo' +import { useAuthState } from '~/composables/auth/useAuthState' export function useUserApi() { const appBaseUrl = useRuntimeConfig().app.baseURL const { serverApiBaseUrl, serverApiBasePath, clientProxyBasePath } = useRuntimeConfig().public - const { jwt } = useAuth() + const { jwt } = useAuthState() const basePath = withoutTrailingSlash( cleanDoubleSlashes(import.meta.client ? appBaseUrl + clientProxyBasePath : serverApiBaseUrl + serverApiBasePath) diff --git a/legalconsenthub/pages/login.vue b/legalconsenthub/pages/login.vue index 92bbfba..2e0eb18 100644 --- a/legalconsenthub/pages/login.vue +++ b/legalconsenthub/pages/login.vue @@ -30,7 +30,7 @@ definePageMeta({ layout: 'auth' }) useSeoMeta({ title: 'Login' }) const toast = useToast() -const { signIn } = useUserStore() +const { signIn } = useAuth() const fields = [ { diff --git a/legalconsenthub/pages/signup.vue b/legalconsenthub/pages/signup.vue index 4b663e0..95fa3a2 100644 --- a/legalconsenthub/pages/signup.vue +++ b/legalconsenthub/pages/signup.vue @@ -26,7 +26,7 @@ definePageMeta({ layout: 'auth' }) useSeoMeta({ title: 'Sign up' }) const toast = useToast() -const { signUp } = useUserStore() +const { signUp } = useAuth() const fields = [ { diff --git a/legalconsenthub/types/auth.ts b/legalconsenthub/types/auth.ts index ab9f39f..40d9dcf 100644 --- a/legalconsenthub/types/auth.ts +++ b/legalconsenthub/types/auth.ts @@ -1,5 +1,5 @@ import type { RouteLocationRaw } from '#vue-router' -import type { useAuth } from '~/composables/useAuth' +import type { useAuthClient } from '~/composables/auth/useAuthClient' export interface RuntimeAuthConfig { redirectUserTo: RouteLocationRaw | string @@ -7,7 +7,7 @@ export interface RuntimeAuthConfig { } // 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['client'] +type Client = ReturnType['client'] export type Session = Client['$Infer']['Session'] export type User = Session['user'] export type ActiveOrganization = Client['$Infer']['ActiveOrganization'] @@ -15,7 +15,7 @@ export type Organization = Client['$Infer']['Organization'] export type Invitation = Client['$Infer']['Invitation'] export type Member = Client['$Infer']['Member'] export type ListMembersOptions = Parameters[0] -export type ListMembersResponse = Awaited> +export type ListMembersResponse = Awaited>['data'] export type ListMembersQuery = NonNullable['query'] // Extended invitation type with additional organization and inviter details export type CustomInvitation = @@ -24,4 +24,4 @@ export type CustomInvitation = organizationSlug: string inviterEmail: string }) - | null + | null \ No newline at end of file