@nuxt-customer-portal/core 0.3.1 → 0.3.2
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/nuxt.config.ts +1 -0
- package/package.json +1 -1
- package/portal.manifest.mjs +1 -1
- package/server/middleware/00-demo-guard.ts +9 -0
- package/server/utils/auth.ts +12 -1
- package/server/utils/demo.ts +13 -0
- package/server/utils/portal-email.ts +10 -0
- package/shared/demo-policy.ts +75 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# @nuxt-customer-portal/core
|
|
2
2
|
|
|
3
|
+
## 0.3.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- f23f664: Add opt-in server restrictions for shared demo deployments, protecting accounts and access settings and preventing all outgoing email. The private example apps now support anonymous sample identities, shared historical business data, user switching, and transactional daily resets at midnight Europe/Amsterdam.
|
|
8
|
+
|
|
3
9
|
## 0.3.1
|
|
4
10
|
|
|
5
11
|
## 0.3.0
|
package/nuxt.config.ts
CHANGED
package/package.json
CHANGED
package/portal.manifest.mjs
CHANGED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { defineEventHandler } from 'h3'
|
|
2
|
+
import { isPortalDemo, rejectDemoAction } from '../utils/demo'
|
|
3
|
+
import { isDemoRequestAllowed } from '../../shared/demo-policy'
|
|
4
|
+
|
|
5
|
+
export default defineEventHandler((event) => {
|
|
6
|
+
if (isPortalDemo() && !isDemoRequestAllowed(event.method, event.path)) {
|
|
7
|
+
rejectDemoAction(event)
|
|
8
|
+
}
|
|
9
|
+
})
|
package/server/utils/auth.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { isPortalDemo } from './demo'
|
|
2
|
+
import { demoMessage, isDemoRequestAllowed } from '../../shared/demo-policy'
|
|
1
3
|
import { betterAuth } from 'better-auth'
|
|
2
4
|
import { APIError, createAuthMiddleware, getSessionFromCtx } from 'better-auth/api'
|
|
3
5
|
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
|
|
@@ -35,7 +37,7 @@ const registrationMode = ['open', 'invitation-only', 'disabled'].includes(proces
|
|
|
35
37
|
: portalAuthConfig.registrationMode
|
|
36
38
|
const githubEnabled = envFlag(process.env.PORTAL_GITHUB_ENABLED, portalAuthConfig.githubEnabled)
|
|
37
39
|
const googleEnabled = envFlag(process.env.PORTAL_GOOGLE_ENABLED, portalAuthConfig.googleEnabled)
|
|
38
|
-
const systemAdminEmails = parseSystemAdminEmails(process.env.ADMIN_EMAILS)
|
|
40
|
+
const systemAdminEmails = isPortalDemo() ? new Set<string>() : parseSystemAdminEmails(process.env.ADMIN_EMAILS)
|
|
39
41
|
|
|
40
42
|
export const auth = betterAuth({
|
|
41
43
|
baseURL: process.env.BETTER_AUTH_URL || process.env.PUBLIC_URL || 'http://localhost:3051',
|
|
@@ -55,6 +57,15 @@ export const auth = betterAuth({
|
|
|
55
57
|
}),
|
|
56
58
|
hooks: {
|
|
57
59
|
before: createAuthMiddleware(async (ctx) => {
|
|
60
|
+
if (
|
|
61
|
+
isPortalDemo() &&
|
|
62
|
+
!isDemoRequestAllowed(ctx.request?.method || (ctx.body ? 'POST' : 'GET'), `/api/auth${ctx.path}`)
|
|
63
|
+
) {
|
|
64
|
+
throw new APIError('FORBIDDEN', {
|
|
65
|
+
code: 'DEMO_RESTRICTED',
|
|
66
|
+
message: demoMessage(ctx.headers?.get('accept-language') || '')
|
|
67
|
+
})
|
|
68
|
+
}
|
|
58
69
|
if (ctx.path !== '/organization/list-members' && ctx.path !== '/organization/list-invitations') {
|
|
59
70
|
return
|
|
60
71
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { createError, getCookie, getHeader, type H3Event } from 'h3'
|
|
2
|
+
import { demoMessage } from '../../shared/demo-policy'
|
|
3
|
+
|
|
4
|
+
export const isPortalDemo = () => useRuntimeConfig().portalDemo?.enabled === true
|
|
5
|
+
export function rejectDemoAction(event?: H3Event): never {
|
|
6
|
+
const message = demoMessage(event ? getCookie(event, 'i18n_redirected') || getHeader(event, 'accept-language') : '')
|
|
7
|
+
throw createError({
|
|
8
|
+
statusCode: 403,
|
|
9
|
+
statusMessage: 'Demo action unavailable',
|
|
10
|
+
message,
|
|
11
|
+
data: { code: 'DEMO_RESTRICTED', message }
|
|
12
|
+
})
|
|
13
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isPortalDemo, rejectDemoAction } from './demo'
|
|
1
2
|
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto'
|
|
2
3
|
import { eq } from 'drizzle-orm'
|
|
3
4
|
import { createError } from 'h3'
|
|
@@ -302,6 +303,9 @@ const providerConfiguration = async () => {
|
|
|
302
303
|
}
|
|
303
304
|
|
|
304
305
|
export const getPortalEmailProviderStatus = async () => {
|
|
306
|
+
if (isPortalDemo()) {
|
|
307
|
+
rejectDemoAction()
|
|
308
|
+
}
|
|
305
309
|
const settings = await getPortalEmailSettings()
|
|
306
310
|
if (!settings.configured) {
|
|
307
311
|
return { ...settings, verifiedDomains: [] as string[] }
|
|
@@ -389,6 +393,9 @@ export const sendPortalEmail = async (input: {
|
|
|
389
393
|
attachments?: PortalEmailAttachment[]
|
|
390
394
|
idempotencyKey?: string
|
|
391
395
|
}) => {
|
|
396
|
+
if (isPortalDemo()) {
|
|
397
|
+
rejectDemoAction()
|
|
398
|
+
}
|
|
392
399
|
const [rendered, provider] = await Promise.all([
|
|
393
400
|
renderPortalEmail({ ...input, inlineBrandAssets: true }),
|
|
394
401
|
providerConfiguration()
|
|
@@ -415,6 +422,9 @@ export const sendPortalEmail = async (input: {
|
|
|
415
422
|
}
|
|
416
423
|
|
|
417
424
|
export const retrievePortalEmail = async (providerMessageId: string) => {
|
|
425
|
+
if (isPortalDemo()) {
|
|
426
|
+
rejectDemoAction()
|
|
427
|
+
}
|
|
418
428
|
const provider = await providerConfiguration()
|
|
419
429
|
const { data, error } = await new Resend(provider.apiKey).emails.get(providerMessageId)
|
|
420
430
|
if (error) {
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
export const demoMessages = {
|
|
2
|
+
en: 'This action is unavailable in the demo. No changes have been applied.',
|
|
3
|
+
nl: 'Deze actie is niet beschikbaar in de demo. Er zijn geen wijzigingen toegepast.'
|
|
4
|
+
} as const
|
|
5
|
+
|
|
6
|
+
export function demoMessage(locale = '') {
|
|
7
|
+
return demoMessages[locale.toLowerCase().startsWith('nl') ? 'nl' : 'en']
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// Explicit allowlists keep newly introduced account endpoints closed in demos.
|
|
11
|
+
export function isDemoRequestAllowed(method: string, rawPath: string): boolean {
|
|
12
|
+
let path: string
|
|
13
|
+
try {
|
|
14
|
+
path = decodeURIComponent(rawPath.split('?')[0]!).replace(/\/+$/, '')
|
|
15
|
+
} catch {
|
|
16
|
+
return false
|
|
17
|
+
}
|
|
18
|
+
if (path.includes('\\') || path.split('/').some((part) => part === '..' || part === '.')) {
|
|
19
|
+
return false
|
|
20
|
+
}
|
|
21
|
+
const read = method === 'GET' || method === 'HEAD'
|
|
22
|
+
if (path.startsWith('/api/auth/')) {
|
|
23
|
+
const route = path.slice('/api/auth/'.length)
|
|
24
|
+
if (read) {
|
|
25
|
+
return [
|
|
26
|
+
'get-session',
|
|
27
|
+
'open-api/generate-schema',
|
|
28
|
+
'permissions',
|
|
29
|
+
'list-sessions',
|
|
30
|
+
'list-accounts',
|
|
31
|
+
'organization/list',
|
|
32
|
+
'organization/get-full-organization',
|
|
33
|
+
'organization/list-members',
|
|
34
|
+
'organization/list-invitations',
|
|
35
|
+
'organization/get-active-member',
|
|
36
|
+
'organization/get-active-member-role',
|
|
37
|
+
'organization/get-invitation',
|
|
38
|
+
'admin/list-users',
|
|
39
|
+
'admin/get-user',
|
|
40
|
+
'admin/list-user-sessions'
|
|
41
|
+
].includes(route)
|
|
42
|
+
}
|
|
43
|
+
return (
|
|
44
|
+
method === 'POST' &&
|
|
45
|
+
['organization/set-active', 'organization/has-permission', 'admin/has-permission'].includes(route)
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
if (!path.startsWith('/api/')) {
|
|
49
|
+
return true
|
|
50
|
+
}
|
|
51
|
+
if (read) {
|
|
52
|
+
return true
|
|
53
|
+
}
|
|
54
|
+
if (path === '/api/demo/switch') {
|
|
55
|
+
return method === 'POST'
|
|
56
|
+
}
|
|
57
|
+
if (
|
|
58
|
+
/\/(members|invitations|reviewers|viewers|modules|internal-approvals|team|organization-capabilities|client-settings)(\/|$)/.test(
|
|
59
|
+
path
|
|
60
|
+
)
|
|
61
|
+
) {
|
|
62
|
+
// Submitting an approval is business activity; configuring approvers changes access.
|
|
63
|
+
return method === 'POST' && /^\/api\/timesheets\/internal-approvals\/[^/]+$/.test(path)
|
|
64
|
+
}
|
|
65
|
+
if (/^\/api\/(profile|auth|admin|organizations)(\/|$)/.test(path)) {
|
|
66
|
+
return false
|
|
67
|
+
}
|
|
68
|
+
if (/^\/api\/timesheets\/admin\/(clients|settings)(\/|$)/.test(path) || path === '/api/invoices/admin/settings') {
|
|
69
|
+
return false
|
|
70
|
+
}
|
|
71
|
+
if (/\/(email|email-status|reminder)(\/|$)/.test(path)) {
|
|
72
|
+
return false
|
|
73
|
+
}
|
|
74
|
+
return /^\/api\/(clients|service-requests|timesheets|invoices|invoice-timesheets)(\/|$)/.test(path)
|
|
75
|
+
}
|