@nuxt-customer-portal/core 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/LICENSE +21 -0
- package/README.md +35 -0
- package/app/composables/useAutoPagination.ts +30 -0
- package/app/composables/useOrganization.ts +117 -0
- package/app/composables/usePaginatedResource.ts +167 -0
- package/app/composables/usePortalFeatures.ts +74 -0
- package/app/composables/usePortalSession.ts +20 -0
- package/app/composables/useUserStore.ts +460 -0
- package/app/middleware/auth.global.ts +32 -0
- package/app/plugins/auth.ts +28 -0
- package/app/plugins/portal-core-feature.ts +3 -0
- package/app/types/index.d.ts +75 -0
- package/app/utils/auth-client.ts +31 -0
- package/app/utils/permission-utils.ts +21 -0
- package/app/utils/random.ts +9 -0
- package/i18n/locales/en.json +81 -0
- package/i18n/locales/nl.json +81 -0
- package/migrations/0000_baseline.sql +119 -0
- package/migrations/0001_organization_types.sql +12 -0
- package/migrations/0002_invitation_created_at.sql +1 -0
- package/migrations/0003_system_admin_role.sql +5 -0
- package/migrations/0004_provider_organization_type.sql +16 -0
- package/migrations/0005_portal_email_settings.sql +15 -0
- package/migrations/meta/0000_snapshot.json +880 -0
- package/migrations/meta/_journal.json +13 -0
- package/nuxt.config.ts +55 -0
- package/package.json +66 -0
- package/portal.manifest.mjs +8 -0
- package/server/api/auth/[...].ts +18 -0
- package/server/api/auth/permissions.get.ts +64 -0
- package/server/api/notifications.ts +294 -0
- package/server/db/core-schema.ts +1 -0
- package/server/db/schema/auth-schema.ts +198 -0
- package/server/db/schema/index.ts +1 -0
- package/server/middleware/api-docs-auth.ts +26 -0
- package/server/middleware/auth.ts +61 -0
- package/server/portal.ts +212 -0
- package/server/routes/api-docs/openapi.json.get.ts +22 -0
- package/server/utils/admin-email-allowlist.ts +12 -0
- package/server/utils/auth.ts +326 -0
- package/server/utils/business-hooks.ts +28 -0
- package/server/utils/db.ts +18 -0
- package/server/utils/email-template.html +105 -0
- package/server/utils/email-texts.ts +110 -0
- package/server/utils/email.ts +37 -0
- package/server/utils/invitation-management.ts +29 -0
- package/server/utils/openapi-auth.ts +67 -0
- package/server/utils/openapi-contracts.ts +129 -0
- package/server/utils/openapi-order.ts +136 -0
- package/server/utils/permissions.ts +179 -0
- package/server/utils/portal-email.ts +424 -0
- package/server/utils/query-builder.ts +270 -0
- package/server/utils/route-meta.ts +28 -0
- package/shared/core-feature.ts +152 -0
- package/shared/feature-registry.ts +62 -0
- package/shared/invitation-validation.ts +8 -0
- package/shared/organization-avatar.ts +11 -0
- package/shared/permissions.ts +26 -0
- package/shared/portal-session.ts +17 -0
- package/shared/types/feature.ts +130 -0
- package/shared/types/index.ts +40 -0
- package/shared/types/sections/api.ts +134 -0
- package/shared/types/sections/auth.ts +71 -0
- package/shared/types/sections/database.ts +86 -0
- package/shared/types/sections/queryBuilder.ts +31 -0
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
import { defineStore } from 'pinia'
|
|
2
|
+
import type { AuthSession, AuthUser, AuthSessionResponse } from '@nuxt-customer-portal/core/app/utils/auth-client'
|
|
3
|
+
import { authClient } from '@nuxt-customer-portal/core/app/utils/auth-client'
|
|
4
|
+
import type { Organization } from '@nuxt-customer-portal/core/shared/types/index'
|
|
5
|
+
import type { DashboardUser } from '@nuxt-customer-portal/core/app/types'
|
|
6
|
+
|
|
7
|
+
interface UserPermissions {
|
|
8
|
+
[key: string]: boolean
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface PermissionsResponse {
|
|
12
|
+
permissions: UserPermissions
|
|
13
|
+
role: string
|
|
14
|
+
organizationRole: string | null
|
|
15
|
+
activeOrganization: Organization | null
|
|
16
|
+
organizationType: 'PROVIDER' | 'CLIENT' | null
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const useUserStore = defineStore('user', () => {
|
|
20
|
+
const currentSession = ref<AuthSession | null>(null)
|
|
21
|
+
const currentUser = ref<AuthUser | null>(null)
|
|
22
|
+
|
|
23
|
+
// Manual organizations state to prevent 401 errors for unauthenticated users
|
|
24
|
+
const organizationsData = ref<Organization[] | null>(null)
|
|
25
|
+
const organizationsError = ref<Error | null>(null)
|
|
26
|
+
const organizationsIsPending = ref(false)
|
|
27
|
+
const organizationsIsRefetching = ref(false)
|
|
28
|
+
|
|
29
|
+
// Fetch organizations manually - only when authenticated
|
|
30
|
+
const fetchOrganizations = async () => {
|
|
31
|
+
if (!currentUser.value) {
|
|
32
|
+
organizationsData.value = null
|
|
33
|
+
organizationsError.value = null
|
|
34
|
+
return
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
organizationsIsPending.value = true
|
|
39
|
+
organizationsError.value = null
|
|
40
|
+
const { data, error } = await authClient.organization.list()
|
|
41
|
+
if (error) {
|
|
42
|
+
organizationsError.value = new Error(error.message || 'Failed to fetch organizations')
|
|
43
|
+
organizationsData.value = null
|
|
44
|
+
} else if (data) {
|
|
45
|
+
// Convert null logos to undefined to match Organization type
|
|
46
|
+
organizationsData.value = data.map((org) => ({
|
|
47
|
+
...org,
|
|
48
|
+
logo: org.logo ?? undefined
|
|
49
|
+
})) as Organization[]
|
|
50
|
+
} else {
|
|
51
|
+
organizationsData.value = null
|
|
52
|
+
}
|
|
53
|
+
} catch (error) {
|
|
54
|
+
organizationsError.value = error instanceof Error ? error : new Error('Failed to fetch organizations')
|
|
55
|
+
organizationsData.value = null
|
|
56
|
+
} finally {
|
|
57
|
+
organizationsIsPending.value = false
|
|
58
|
+
organizationsIsRefetching.value = false
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Create a helper object that matches the composable interface
|
|
63
|
+
const organizationsHelper = computed(() => ({
|
|
64
|
+
data: organizationsData.value,
|
|
65
|
+
error: organizationsError.value,
|
|
66
|
+
isPending: organizationsIsPending.value,
|
|
67
|
+
isRefetching: organizationsIsRefetching.value,
|
|
68
|
+
refetch: async () => {
|
|
69
|
+
organizationsIsRefetching.value = true
|
|
70
|
+
await fetchOrganizations()
|
|
71
|
+
}
|
|
72
|
+
}))
|
|
73
|
+
|
|
74
|
+
const refreshOrganizations = async () => {
|
|
75
|
+
await fetchOrganizations()
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Watch authentication status and fetch organizations when user becomes authenticated
|
|
79
|
+
watch(
|
|
80
|
+
currentUser,
|
|
81
|
+
(user, oldUser) => {
|
|
82
|
+
if (user && !oldUser) {
|
|
83
|
+
// User just logged in, fetch organizations
|
|
84
|
+
fetchOrganizations()
|
|
85
|
+
} else if (!user) {
|
|
86
|
+
// User logged out, clear organizations
|
|
87
|
+
organizationsData.value = null
|
|
88
|
+
organizationsError.value = null
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
{ immediate: true }
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
const permissions = ref<UserPermissions>({})
|
|
95
|
+
const role = ref<string | null>(null)
|
|
96
|
+
const organizationRole = ref<string | null>(null)
|
|
97
|
+
const activeOrganizationFromPermissions = ref<Organization | null>(null)
|
|
98
|
+
const isLoading = ref(false)
|
|
99
|
+
const activeOrganization = computed(() => {
|
|
100
|
+
// Prefer the one from permissions API if available, otherwise use organizationsHelper
|
|
101
|
+
return (
|
|
102
|
+
activeOrganizationFromPermissions.value ||
|
|
103
|
+
organizationsHelper.value.data?.find((org: Organization) => org.id === activeOrganizationId.value)
|
|
104
|
+
)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
const colorMode = useColorMode() // Use the colorMode composable
|
|
108
|
+
const theme = ref(colorMode.preference)
|
|
109
|
+
|
|
110
|
+
// Watch for changes in colorMode.preference from @nuxtjs/color-mode
|
|
111
|
+
watch(
|
|
112
|
+
() => colorMode.preference,
|
|
113
|
+
(newValue) => {
|
|
114
|
+
theme.value = newValue // Update our store's theme ref
|
|
115
|
+
}
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
const hasPermission = (subject: string, action: string): boolean => {
|
|
119
|
+
return permissions.value[`${subject}.${action}`] ?? false
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// This is the global platform role. It is deliberately independent from
|
|
123
|
+
// activeOrganizationRole, so organization owners do not become system admins.
|
|
124
|
+
const isAdmin = computed(() => role.value === 'admin')
|
|
125
|
+
|
|
126
|
+
const userInitials = computed(() => {
|
|
127
|
+
const user = currentUser.value
|
|
128
|
+
if (!user) {
|
|
129
|
+
return undefined
|
|
130
|
+
}
|
|
131
|
+
const name = user.name || user.email || ''
|
|
132
|
+
if (!name) {
|
|
133
|
+
return ''
|
|
134
|
+
}
|
|
135
|
+
const parts = name.split(' ').filter((part) => part.length > 0)
|
|
136
|
+
if (parts.length === 0) {
|
|
137
|
+
return ''
|
|
138
|
+
}
|
|
139
|
+
if (parts.length === 1) {
|
|
140
|
+
return parts[0]?.charAt(0).toUpperCase() || ''
|
|
141
|
+
}
|
|
142
|
+
// Multiple parts: return first char of first part + first char of last part
|
|
143
|
+
const first = parts[0]?.charAt(0) || ''
|
|
144
|
+
const last = parts[parts.length - 1]?.charAt(0) || ''
|
|
145
|
+
return (first + last).toUpperCase()
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
const activeOrganizationId = computed(() => {
|
|
149
|
+
return currentSession.value?.activeOrganizationId
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
// Store the active organization role
|
|
153
|
+
const activeOrganizationRoleValue = ref<string | null>(null)
|
|
154
|
+
|
|
155
|
+
// Watch for changes in activeOrganizationId and fetch the role
|
|
156
|
+
watch(
|
|
157
|
+
[activeOrganizationId, currentUser],
|
|
158
|
+
async ([newOrgId, user]) => {
|
|
159
|
+
if (newOrgId && user) {
|
|
160
|
+
try {
|
|
161
|
+
const { data: roleData } = await authClient.organization.getActiveMemberRole()
|
|
162
|
+
activeOrganizationRoleValue.value = roleData?.role || null
|
|
163
|
+
} catch (error) {
|
|
164
|
+
console.error('Error fetching active organization role:', error)
|
|
165
|
+
activeOrganizationRoleValue.value = null
|
|
166
|
+
}
|
|
167
|
+
} else {
|
|
168
|
+
activeOrganizationRoleValue.value = null
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
{ immediate: true }
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
// Computed property that returns the active organization role
|
|
175
|
+
const activeOrganizationRole = computed(() => {
|
|
176
|
+
return activeOrganizationRoleValue.value
|
|
177
|
+
})
|
|
178
|
+
const activeOrganizationType = computed(() => activeOrganization.value?.organizationType ?? null)
|
|
179
|
+
|
|
180
|
+
const loggedInUsingEmail = computed(() => {
|
|
181
|
+
const user = currentUser.value
|
|
182
|
+
if (!user || typeof user.providerId === 'undefined') {
|
|
183
|
+
return false
|
|
184
|
+
}
|
|
185
|
+
return user.providerId === 'credential'
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
const fetchUserPermissions = async () => {
|
|
189
|
+
// If there is no current user, or permissions have already been fetched, do nothing.
|
|
190
|
+
if (!currentUser.value) {
|
|
191
|
+
permissions.value = {}
|
|
192
|
+
role.value = null
|
|
193
|
+
return
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// console.log('Fetching user permissions from API for user:', currentUser.value.id)
|
|
197
|
+
try {
|
|
198
|
+
const data = await $fetch<PermissionsResponse>('/api/auth/permissions')
|
|
199
|
+
if (data) {
|
|
200
|
+
permissions.value = data.permissions
|
|
201
|
+
role.value = data.role
|
|
202
|
+
organizationRole.value = data.organizationRole
|
|
203
|
+
activeOrganizationFromPermissions.value = data.activeOrganization
|
|
204
|
+
} else {
|
|
205
|
+
console.warn('Permissions API returned no data. Clearing user data as a precaution.')
|
|
206
|
+
clearUserData() // Clear data if API returns nothing, as state is uncertain
|
|
207
|
+
}
|
|
208
|
+
} catch (error) {
|
|
209
|
+
console.error('Error in fetchUserPermissions:', error)
|
|
210
|
+
clearUserData()
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const clearUserData = () => {
|
|
215
|
+
console.log('Clearing user data and permissions fetch status.')
|
|
216
|
+
permissions.value = {}
|
|
217
|
+
role.value = null
|
|
218
|
+
organizationRole.value = null
|
|
219
|
+
activeOrganizationFromPermissions.value = null
|
|
220
|
+
currentSession.value = null
|
|
221
|
+
currentUser.value = null
|
|
222
|
+
activeOrganizationRoleValue.value = null
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const setActiveOrganizationId = async (organizationId: string) => {
|
|
226
|
+
try {
|
|
227
|
+
isLoading.value = true
|
|
228
|
+
const { data, error } = await authClient.organization.setActive({ organizationId })
|
|
229
|
+
if (error) {
|
|
230
|
+
console.error('Error setting active organization:', error)
|
|
231
|
+
throw error
|
|
232
|
+
}
|
|
233
|
+
if (data) {
|
|
234
|
+
console.log('Active organization set:', data)
|
|
235
|
+
// Fetch the updated session - retry a few times if needed
|
|
236
|
+
let retries = 3
|
|
237
|
+
let sessionResponse = null
|
|
238
|
+
while (retries > 0) {
|
|
239
|
+
sessionResponse = await authClient.getSession()
|
|
240
|
+
// Check if the session has been updated with the new organizationId
|
|
241
|
+
const sessionData = sessionResponse?.data as unknown as AuthSessionResponse & {
|
|
242
|
+
activeOrganizationId?: string
|
|
243
|
+
}
|
|
244
|
+
if (sessionData && sessionData.activeOrganizationId === organizationId) {
|
|
245
|
+
break
|
|
246
|
+
}
|
|
247
|
+
console.log('ActiverOganizationId not yet set in Session data, retrying (retries left: ${retries -1})...')
|
|
248
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
249
|
+
retries--
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (sessionResponse?.data) {
|
|
253
|
+
const sessionData = sessionResponse.data as unknown as AuthSessionResponse
|
|
254
|
+
await setSession(sessionData)
|
|
255
|
+
} else {
|
|
256
|
+
console.warn('Session data not available after setting active organization')
|
|
257
|
+
// Still try to fetch once more
|
|
258
|
+
const finalSession = await authClient.getSession()
|
|
259
|
+
if (finalSession?.data) {
|
|
260
|
+
await setSession(finalSession.data as unknown as AuthSessionResponse)
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
} catch (error) {
|
|
265
|
+
console.error('Error setting active organization:', error)
|
|
266
|
+
throw error
|
|
267
|
+
} finally {
|
|
268
|
+
isLoading.value = false
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const setSession = async (data: AuthSessionResponse | null) => {
|
|
273
|
+
if (data) {
|
|
274
|
+
try {
|
|
275
|
+
const { user, ...session } = data
|
|
276
|
+
currentSession.value = session
|
|
277
|
+
currentUser.value = data.user as AuthUser
|
|
278
|
+
|
|
279
|
+
if (currentUser.value) {
|
|
280
|
+
await fetchUserPermissions()
|
|
281
|
+
}
|
|
282
|
+
} catch (error) {
|
|
283
|
+
console.error('Error setting session:', error)
|
|
284
|
+
clearUserData()
|
|
285
|
+
throw error
|
|
286
|
+
} finally {
|
|
287
|
+
isLoading.value = false
|
|
288
|
+
}
|
|
289
|
+
} else {
|
|
290
|
+
// Session is null/undefined - clear user data (e.g., on logout)
|
|
291
|
+
clearUserData()
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const isAuthenticated = computed(() => {
|
|
296
|
+
return currentUser.value !== null
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
// Store roles for each organization
|
|
300
|
+
const organizationRoles = ref<Record<string, string>>({})
|
|
301
|
+
|
|
302
|
+
// Track if we've attempted to auto-set the first organization
|
|
303
|
+
const hasAttemptedAutoSet = ref(false)
|
|
304
|
+
|
|
305
|
+
// Auto-set first organization when activeOrganizationId is null but user has organizations
|
|
306
|
+
watch(
|
|
307
|
+
[activeOrganizationId, () => organizationsHelper.value.data, currentUser],
|
|
308
|
+
async ([orgId, organizations, user]) => {
|
|
309
|
+
// Only run on client side
|
|
310
|
+
if (import.meta.server) {
|
|
311
|
+
return
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Only proceed if:
|
|
315
|
+
// 1. User is authenticated
|
|
316
|
+
// 2. activeOrganizationId is null
|
|
317
|
+
// 3. User has at least one organization
|
|
318
|
+
// 4. We haven't already attempted to set it
|
|
319
|
+
// 5. Organizations are not still loading
|
|
320
|
+
if (
|
|
321
|
+
user &&
|
|
322
|
+
!orgId &&
|
|
323
|
+
organizations &&
|
|
324
|
+
organizations.length > 0 &&
|
|
325
|
+
!hasAttemptedAutoSet.value &&
|
|
326
|
+
!organizationsHelper.value.isPending
|
|
327
|
+
) {
|
|
328
|
+
hasAttemptedAutoSet.value = true
|
|
329
|
+
try {
|
|
330
|
+
// A provider user may also belong to one or more client organizations.
|
|
331
|
+
// Prefer the provider workspace as the initial context so provider-only
|
|
332
|
+
// modules (Clients, administration, and sales invoices) are available
|
|
333
|
+
// after a session without an active organization is restored.
|
|
334
|
+
const firstOrg = organizations.find((org) => org.organizationType === 'PROVIDER') ?? organizations[0]
|
|
335
|
+
if (firstOrg?.id) {
|
|
336
|
+
console.log('Auto-setting first organization as active:', firstOrg.id)
|
|
337
|
+
await setActiveOrganizationId(firstOrg.id)
|
|
338
|
+
}
|
|
339
|
+
} catch (error) {
|
|
340
|
+
console.error('Error auto-setting first organization:', error)
|
|
341
|
+
// Reset flag on error so we can retry later
|
|
342
|
+
hasAttemptedAutoSet.value = false
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
},
|
|
346
|
+
{ immediate: true }
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
// Reset the auto-set flag when activeOrganizationId changes to a non-null value or user logs out
|
|
350
|
+
watch([activeOrganizationId, currentUser], ([orgId, user]) => {
|
|
351
|
+
if (orgId) {
|
|
352
|
+
hasAttemptedAutoSet.value = false
|
|
353
|
+
}
|
|
354
|
+
if (!user) {
|
|
355
|
+
hasAttemptedAutoSet.value = false
|
|
356
|
+
}
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
// Fetch roles for all organizations when organizations are loaded
|
|
360
|
+
watch(
|
|
361
|
+
() => organizationsHelper.value.data,
|
|
362
|
+
async (organizations) => {
|
|
363
|
+
if (!organizations || !currentUser.value) {
|
|
364
|
+
organizationRoles.value = {}
|
|
365
|
+
return
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Fetch only the current user's role. Listing the full member directory is
|
|
369
|
+
// restricted to organization owners and admins.
|
|
370
|
+
const roles: Record<string, string> = {}
|
|
371
|
+
for (const org of organizations) {
|
|
372
|
+
try {
|
|
373
|
+
const { data } = await authClient.organization.getActiveMemberRole({
|
|
374
|
+
query: { organizationId: org.id }
|
|
375
|
+
})
|
|
376
|
+
if (data?.role) {
|
|
377
|
+
roles[org.id] = data.role
|
|
378
|
+
}
|
|
379
|
+
} catch (error) {
|
|
380
|
+
console.error(`Error fetching role for organization ${org.id}:`, error)
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
organizationRoles.value = roles
|
|
384
|
+
},
|
|
385
|
+
{ immediate: true }
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
// Enhanced organizations with roles
|
|
389
|
+
const myOrganizations = computed(() => {
|
|
390
|
+
const orgs = organizationsHelper.value.data as Organization[] | null | undefined
|
|
391
|
+
if (!orgs) {
|
|
392
|
+
return orgs
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
return orgs.map((org) => ({
|
|
396
|
+
...org,
|
|
397
|
+
role: organizationRoles.value[org.id] || null
|
|
398
|
+
}))
|
|
399
|
+
})
|
|
400
|
+
|
|
401
|
+
const loadingOrganization = computed(() => {
|
|
402
|
+
return organizationsHelper.value.isPending ?? false
|
|
403
|
+
})
|
|
404
|
+
|
|
405
|
+
const setCurrentUser = (data: AuthUser | null) => {
|
|
406
|
+
currentUser.value = data
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const dashboardUser = computed(() => {
|
|
410
|
+
if (!currentUser.value) {
|
|
411
|
+
return {
|
|
412
|
+
name: 'User',
|
|
413
|
+
avatar: {
|
|
414
|
+
src: undefined,
|
|
415
|
+
alt: 'User'
|
|
416
|
+
}
|
|
417
|
+
} as DashboardUser
|
|
418
|
+
}
|
|
419
|
+
return {
|
|
420
|
+
name: currentUser.value.name || currentUser.value?.email || 'User',
|
|
421
|
+
avatar: {
|
|
422
|
+
src: currentUser.value.image,
|
|
423
|
+
alt: currentUser.value.name || currentUser.value?.email || 'User'
|
|
424
|
+
}
|
|
425
|
+
} as DashboardUser
|
|
426
|
+
})
|
|
427
|
+
|
|
428
|
+
const changePasswordAllowed = computed(() => {
|
|
429
|
+
return currentUser.value?.providerId === 'credential'
|
|
430
|
+
})
|
|
431
|
+
// Fetch current session data using getSession()
|
|
432
|
+
return {
|
|
433
|
+
permissions,
|
|
434
|
+
role,
|
|
435
|
+
organizationRole,
|
|
436
|
+
isAuthenticated,
|
|
437
|
+
isLoading,
|
|
438
|
+
currentSession,
|
|
439
|
+
currentUser,
|
|
440
|
+
isAdmin,
|
|
441
|
+
userInitials,
|
|
442
|
+
loggedInUsingEmail,
|
|
443
|
+
theme,
|
|
444
|
+
activeOrganizationId,
|
|
445
|
+
activeOrganization,
|
|
446
|
+
activeOrganizationRole,
|
|
447
|
+
activeOrganizationType,
|
|
448
|
+
myOrganizations,
|
|
449
|
+
loadingOrganization,
|
|
450
|
+
refreshOrganizations,
|
|
451
|
+
fetchUserPermissions,
|
|
452
|
+
hasPermission,
|
|
453
|
+
clearUserData,
|
|
454
|
+
setSession,
|
|
455
|
+
setActiveOrganizationId,
|
|
456
|
+
setCurrentUser,
|
|
457
|
+
dashboardUser,
|
|
458
|
+
changePasswordAllowed
|
|
459
|
+
}
|
|
460
|
+
})
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { authClient } from '@nuxt-customer-portal/core/app/utils/auth-client'
|
|
2
|
+
|
|
3
|
+
export default defineNuxtRouteMiddleware(async (to) => {
|
|
4
|
+
if (to.meta.public === true) {
|
|
5
|
+
return
|
|
6
|
+
}
|
|
7
|
+
try {
|
|
8
|
+
const { data: session } = await authClient.useSession(useFetch)
|
|
9
|
+
|
|
10
|
+
if (!session.value) {
|
|
11
|
+
console.log('No active session found, redirecting to login')
|
|
12
|
+
// No active session, redirect the user to the login page
|
|
13
|
+
// Preserve the original intended path via a redirect query parameter
|
|
14
|
+
const redirectPath = `/login?redirect=${encodeURIComponent(to.fullPath)}`
|
|
15
|
+
return navigateTo(redirectPath, {
|
|
16
|
+
external: false,
|
|
17
|
+
replace: true // Use replace to avoid adding the original protected path to history
|
|
18
|
+
})
|
|
19
|
+
} else {
|
|
20
|
+
console.log('Active session found, allowing access to:', to.path)
|
|
21
|
+
}
|
|
22
|
+
} catch (error) {
|
|
23
|
+
console.error('Auth middleware error:', error)
|
|
24
|
+
// If there's an error checking auth, redirect to login
|
|
25
|
+
return navigateTo('/login', {
|
|
26
|
+
external: false,
|
|
27
|
+
replace: true
|
|
28
|
+
})
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// User is authenticated, allow access to the protected route
|
|
32
|
+
})
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { authClient } from '@nuxt-customer-portal/core/app/utils/auth-client'
|
|
2
|
+
import type { AuthSessionResponse } from '@nuxt-customer-portal/core/app/utils/auth-client'
|
|
3
|
+
|
|
4
|
+
export default defineNuxtPlugin({
|
|
5
|
+
name: 'auth',
|
|
6
|
+
async setup() {
|
|
7
|
+
const userStore = useUserStore()
|
|
8
|
+
// const organizationStore = useOrganization()
|
|
9
|
+
try {
|
|
10
|
+
const sessionData = await authClient.getSession()
|
|
11
|
+
await userStore.setSession(sessionData?.data as unknown as AuthSessionResponse)
|
|
12
|
+
} catch (error) {
|
|
13
|
+
console.log('getSession error:', error)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const { data: session } = await authClient.useSession(useFetch)
|
|
17
|
+
|
|
18
|
+
// Also watch for session changes to handle OTP verification and logout
|
|
19
|
+
watch(
|
|
20
|
+
() => session.value,
|
|
21
|
+
async (newSession) => {
|
|
22
|
+
const sessionData = newSession as unknown as AuthSessionResponse | null
|
|
23
|
+
await userStore.setSession(sessionData)
|
|
24
|
+
},
|
|
25
|
+
{ deep: true }
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
})
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { ParsedContent } from '@nuxt/content'
|
|
2
|
+
import type { Avatar, Badge, Link } from '#ui/types'
|
|
3
|
+
import type { AvatarProps } from '@nuxt/ui'
|
|
4
|
+
|
|
5
|
+
export interface BlogPost extends ParsedContent {
|
|
6
|
+
title: string
|
|
7
|
+
description: string
|
|
8
|
+
date: string
|
|
9
|
+
image?: HTMLImageElement
|
|
10
|
+
badge?: Badge
|
|
11
|
+
authors?: ({
|
|
12
|
+
name: string
|
|
13
|
+
description?: string
|
|
14
|
+
avatar: Avatar
|
|
15
|
+
} & Link)[]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type UserStatus = 'subscribed' | 'unsubscribed' | 'bounced'
|
|
19
|
+
export type SaleStatus = 'paid' | 'failed' | 'refunded'
|
|
20
|
+
|
|
21
|
+
export interface DashboardUser {
|
|
22
|
+
id: number
|
|
23
|
+
name: string
|
|
24
|
+
email: string
|
|
25
|
+
avatar?: AvatarProps
|
|
26
|
+
status: UserStatus
|
|
27
|
+
location: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface Mail {
|
|
31
|
+
id: number
|
|
32
|
+
unread?: boolean
|
|
33
|
+
from: DashboardUser
|
|
34
|
+
subject: string
|
|
35
|
+
body: string
|
|
36
|
+
date: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface Member {
|
|
40
|
+
name: string
|
|
41
|
+
username: string
|
|
42
|
+
role: 'member' | 'owner'
|
|
43
|
+
avatar: AvatarProps
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface Stat {
|
|
47
|
+
title: string
|
|
48
|
+
icon: string
|
|
49
|
+
value: number | string
|
|
50
|
+
variation: number
|
|
51
|
+
formatter?: (value: number) => string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface Sale {
|
|
55
|
+
id: string
|
|
56
|
+
date: string
|
|
57
|
+
status: SaleStatus
|
|
58
|
+
email: string
|
|
59
|
+
amount: number
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface Notification {
|
|
63
|
+
id: number
|
|
64
|
+
unread?: boolean
|
|
65
|
+
sender: DashboardUser
|
|
66
|
+
body: string
|
|
67
|
+
date: string
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export type Period = 'daily' | 'weekly' | 'monthly'
|
|
71
|
+
|
|
72
|
+
export interface Range {
|
|
73
|
+
start: Date
|
|
74
|
+
end: Date
|
|
75
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { createAuthClient } from 'better-auth/vue' // Use the Vue-specific import
|
|
2
|
+
import { adminClient, emailOTPClient, organizationClient } from 'better-auth/client/plugins'
|
|
3
|
+
import { ac, user, admin as adminRole } from '@nuxt-customer-portal/core/shared/permissions'
|
|
4
|
+
|
|
5
|
+
const baseURL = process.env.BETTER_AUTH_URL
|
|
6
|
+
|
|
7
|
+
export const authClient = createAuthClient({
|
|
8
|
+
baseURL: baseURL,
|
|
9
|
+
plugins: [
|
|
10
|
+
adminClient({
|
|
11
|
+
ac,
|
|
12
|
+
roles: {
|
|
13
|
+
user,
|
|
14
|
+
admin: adminRole
|
|
15
|
+
}
|
|
16
|
+
}),
|
|
17
|
+
emailOTPClient(),
|
|
18
|
+
organizationClient() // Add organization client
|
|
19
|
+
]
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
export type AuthSession = typeof authClient.$Infer.Session.session
|
|
23
|
+
export type AuthUser = typeof authClient.$Infer.Session.user & { providerId?: string }
|
|
24
|
+
export type AuthSessionResponse = typeof authClient.$Infer.Session.session & { user: User }
|
|
25
|
+
export type AuthActiveOganization = typeof authClient.$Infer.ActiveOrganization
|
|
26
|
+
export type AuthMember = typeof authClient.$Infer.Member
|
|
27
|
+
export type AuthOrganization = typeof authClient.$Infer.Organization
|
|
28
|
+
export type AuthInvitation = typeof authClient.$Infer.Invitation
|
|
29
|
+
export type AuthTeam = typeof authClient.$Infer.Team
|
|
30
|
+
|
|
31
|
+
export const { signIn, signOut, signUp, useSession, getSession, emailOtp, sendVerificationEmail } = authClient
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { authClient } from './auth-client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Checks if a role has a specific permission for a subject
|
|
5
|
+
* @param subject The subject to check permissions against
|
|
6
|
+
* @param permission The specific permission to check
|
|
7
|
+
* @returns boolean indicating if the role has the permission
|
|
8
|
+
*/
|
|
9
|
+
export const hasPermission = async (subject: string, permission: string): Promise<boolean> => {
|
|
10
|
+
try {
|
|
11
|
+
const response = await authClient.admin.hasPermission({
|
|
12
|
+
permissions: {
|
|
13
|
+
[subject]: [permission]
|
|
14
|
+
}
|
|
15
|
+
})
|
|
16
|
+
return response?.data?.success || false
|
|
17
|
+
} catch (error) {
|
|
18
|
+
console.error('Error checking permission:', error)
|
|
19
|
+
return false
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export const randomInt = (min: number, max: number): number => Math.floor(Math.random() * (max - min + 1)) + min
|
|
2
|
+
|
|
3
|
+
export const randomFrom = <T>(items: readonly T[]): T => {
|
|
4
|
+
const item = items[randomInt(0, items.length - 1)]
|
|
5
|
+
if (item === undefined) {
|
|
6
|
+
throw new Error('Cannot select a random item from an empty array')
|
|
7
|
+
}
|
|
8
|
+
return item
|
|
9
|
+
}
|