feat(frontend): Refactor auth and split into separate files

This commit is contained in:
2025-10-03 09:17:03 +02:00
parent 6c88b4fd96
commit a2b80d42ae
9 changed files with 229 additions and 189 deletions

View File

@@ -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<SignUpSchema>) {
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
}
}

View File

@@ -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
}
}

View File

@@ -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<InferSessionFromClient<ClientOptions> | null>(null)
const user = ref<InferUserFromClient<ClientOptions> | null>(null)
const sessionFetching = import.meta.server ? ref(false) : ref(false)
const jwt = ref<string | null>(null)
const organizations = ref<
{
id: string
name: string
createdAt: Date
slug: string
metadata?: Record<string, unknown>
logo?: string | null
}[]
>([])
const selectedOrganization = ref<{
id: string
name: string
createdAt: Date
slug: string
metadata?: Record<string, unknown>
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<RuntimeAuthConfig>, {
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)
}
}

View File

@@ -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<FormElementId, ComplianceStatus>())

View File

@@ -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<InferSessionFromClient<ClientOptions> | null>(null)
const user = ref<InferUserFromClient<ClientOptions> | null>(null)
const sessionFetching = import.meta.server ? ref(false) : ref(false)
const jwt = ref<string | null>(null)
const organizations = ref<
{
id: string
name: string
createdAt: Date
slug: string
metadata?: Record<string, unknown>
logo?: string | null
}[]
>([])
const selectedOrganization = ref<{
id: string
name: string
createdAt: Date
slug: string
metadata?: Record<string, unknown>
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<RuntimeAuthConfig>, {
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
}
}

View File

@@ -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)

View File

@@ -30,7 +30,7 @@ definePageMeta({ layout: 'auth' })
useSeoMeta({ title: 'Login' })
const toast = useToast()
const { signIn } = useUserStore()
const { signIn } = useAuth()
const fields = [
{

View File

@@ -26,7 +26,7 @@ definePageMeta({ layout: 'auth' })
useSeoMeta({ title: 'Sign up' })
const toast = useToast()
const { signUp } = useUserStore()
const { signUp } = useAuth()
const fields = [
{

View File

@@ -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<typeof useAuth>['client']
type Client = ReturnType<typeof useAuthClient>['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<Client['organization']['listMembers']>[0]
export type ListMembersResponse = Awaited<ReturnType<Client['organization']['listMembers']>>
export type ListMembersResponse = Awaited<ReturnType<Client['organization']['listMembers']>>['data']
export type ListMembersQuery = NonNullable<ListMembersOptions>['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