@nuxt-customer-portal/planning 0.4.1 → 0.4.3
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 +20 -0
- package/README.md +4 -2
- package/app/pages/availability.vue +14 -10
- package/package.json +4 -4
- package/portal.manifest.mjs +1 -1
- package/server/api/planning/calendar-busy.get.ts +1 -1
- package/server/utils/crypto.ts +15 -15
- package/shared/validation.ts +32 -27
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.3
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- eb2a85c: Add a single base64-encoded `PORTAL_ENCRYPTION_KEY` that derives isolated keys for credentials saved by Portal Core, Products, and Planning. Existing module-specific keys remain supported as overrides and can decrypt their legacy ciphertext formats.
|
|
8
|
+
- Keep planning date and identifier validation compatible with the Zod runtime resolved by Nuxt production bundles.
|
|
9
|
+
- Updated dependencies [eb2a85c]
|
|
10
|
+
- @nuxt-customer-portal/core@0.4.3
|
|
11
|
+
- @nuxt-customer-portal/products@0.4.3
|
|
12
|
+
- @nuxt-customer-portal/ui@0.4.3
|
|
13
|
+
|
|
14
|
+
## 0.4.2
|
|
15
|
+
|
|
16
|
+
### Patch Changes
|
|
17
|
+
|
|
18
|
+
- Updated dependencies
|
|
19
|
+
- @nuxt-customer-portal/core@0.4.2
|
|
20
|
+
- @nuxt-customer-portal/products@0.4.2
|
|
21
|
+
- @nuxt-customer-portal/ui@0.4.2
|
|
22
|
+
|
|
3
23
|
## 0.4.1
|
|
4
24
|
|
|
5
25
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -24,14 +24,16 @@ Run Nitro's scheduled tasks every minute in production. HTTP deployments need a
|
|
|
24
24
|
Use dedicated Google and Zoom OAuth applications, separate from portal sign-in applications:
|
|
25
25
|
|
|
26
26
|
```dotenv
|
|
27
|
-
|
|
27
|
+
PORTAL_ENCRYPTION_KEY=<base64-encoded 32-byte random key>
|
|
28
28
|
PLANNING_GOOGLE_CLIENT_ID=
|
|
29
29
|
PLANNING_GOOGLE_CLIENT_SECRET=
|
|
30
30
|
PLANNING_ZOOM_CLIENT_ID=
|
|
31
31
|
PLANNING_ZOOM_CLIENT_SECRET=
|
|
32
32
|
```
|
|
33
33
|
|
|
34
|
-
Generate the encryption key with `openssl rand -base64 32`. Keep this key stable and backed up with the encrypted database.
|
|
34
|
+
Generate the encryption key with `openssl rand -base64 32`. Keep this key stable, identical across application instances, and backed up with the encrypted database. Planning derives its own purpose-specific AES-256-GCM key from it. OAuth tokens are never returned through setup APIs.
|
|
35
|
+
|
|
36
|
+
Existing deployments may retain `PLANNING_ENCRYPTION_KEY` as a module-specific override. Keep it configured while legacy OAuth credentials encrypted with it remain in the database. Reconnecting providers writes the versioned format; removing the override still requires reconnecting under the portal-wide key first.
|
|
35
37
|
|
|
36
38
|
Register redirect URLs using the portal's configured public origin:
|
|
37
39
|
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { z } from 'zod'
|
|
3
3
|
import { localParts, wallInstant } from '../../shared/availability'
|
|
4
|
-
import { availabilitySchema } from '../../shared/validation'
|
|
4
|
+
import type { availabilitySchema } from '../../shared/validation'
|
|
5
|
+
import { availabilityShape, validateAvailability } from '../../shared/validation'
|
|
5
6
|
import type { AvailabilityWindow } from '../../shared/types'
|
|
6
7
|
import type { ProviderConfiguration, AppointmentListItem, CalendarBusyPeriod } from '../composables/usePlanning'
|
|
7
8
|
|
|
@@ -65,15 +66,18 @@ const windowState = reactive<z.infer<typeof availabilitySchema>>({
|
|
|
65
66
|
exceptions: []
|
|
66
67
|
})
|
|
67
68
|
const windowFormSchema = computed(() =>
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
69
|
+
z
|
|
70
|
+
.object({
|
|
71
|
+
...availabilityShape,
|
|
72
|
+
productIds: z
|
|
73
|
+
.array(z.string().min(1).max(100))
|
|
74
|
+
.min(1, t('planning.selectAtLeastOneProduct'))
|
|
75
|
+
.max(100)
|
|
76
|
+
.nullable()
|
|
77
|
+
.default(null),
|
|
78
|
+
endDate: z.union([availabilityShape.endDate, z.literal('')]).transform((v) => v || null)
|
|
79
|
+
})
|
|
80
|
+
.superRefine(validateAvailability)
|
|
77
81
|
)
|
|
78
82
|
const allProducts = computed({
|
|
79
83
|
get: () => windowState.productIds === null,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nuxt-customer-portal/planning",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.3",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"homepage": "https://nuxt-customer-portal.com",
|
|
6
6
|
"repository": {
|
|
@@ -40,9 +40,9 @@
|
|
|
40
40
|
"drizzle-orm": "^0.45.2",
|
|
41
41
|
"pg": "^8.22.0",
|
|
42
42
|
"zod": "^4.4.3",
|
|
43
|
-
"@nuxt-customer-portal/
|
|
44
|
-
"@nuxt-customer-portal/
|
|
45
|
-
"@nuxt-customer-portal/
|
|
43
|
+
"@nuxt-customer-portal/core": "^0.4.3",
|
|
44
|
+
"@nuxt-customer-portal/ui": "^0.4.3",
|
|
45
|
+
"@nuxt-customer-portal/products": "^0.4.3"
|
|
46
46
|
},
|
|
47
47
|
"peerDependencies": {
|
|
48
48
|
"nuxt": "^4.5.1",
|
package/portal.manifest.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import { providerAccess } from '@nuxt-customer-portal/planning/server/utils/acce
|
|
|
6
6
|
import { externalBusyDetails } from '@nuxt-customer-portal/planning/server/utils/adapters'
|
|
7
7
|
|
|
8
8
|
const querySchema = z
|
|
9
|
-
.object({ from: z.
|
|
9
|
+
.object({ from: z.string().datetime({ offset: true }), to: z.string().datetime({ offset: true }) })
|
|
10
10
|
.refine(
|
|
11
11
|
({ from, to }) => Date.parse(to) > Date.parse(from) && Date.parse(to) - Date.parse(from) <= 14 * 86400000,
|
|
12
12
|
'Choose a range of at most 14 days'
|
package/server/utils/crypto.ts
CHANGED
|
@@ -1,23 +1,23 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { randomBytes, createHash } from 'node:crypto'
|
|
2
|
+
import {
|
|
3
|
+
decryptLegacyBinarySecret,
|
|
4
|
+
decryptPortalSecret,
|
|
5
|
+
encryptPortalSecret,
|
|
6
|
+
isPortalSecretCiphertext
|
|
7
|
+
} from '@nuxt-customer-portal/core/server/utils/portal-encryption'
|
|
2
8
|
|
|
3
9
|
export const digest = (value: string) => createHash('sha256').update(value).digest('hex')
|
|
4
10
|
export const secret = () => randomBytes(32).toString('base64url')
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
throw new Error('Configure PLANNING_ENCRYPTION_KEY with a base64-encoded 32-byte key')
|
|
9
|
-
}
|
|
10
|
-
return value
|
|
11
|
+
const planningEncryption = {
|
|
12
|
+
purpose: 'planning/oauth',
|
|
13
|
+
overrides: [{ env: 'PLANNING_ENCRYPTION_KEY', format: 'base64-32' as const }]
|
|
11
14
|
}
|
|
12
15
|
export function encrypt(value: unknown) {
|
|
13
|
-
|
|
14
|
-
cipher = createCipheriv('aes-256-gcm', key(), iv)
|
|
15
|
-
const ciphertext = Buffer.concat([cipher.update(JSON.stringify(value)), cipher.final()])
|
|
16
|
-
return Buffer.concat([iv, cipher.getAuthTag(), ciphertext]).toString('base64')
|
|
16
|
+
return encryptPortalSecret(JSON.stringify(value), planningEncryption)
|
|
17
17
|
}
|
|
18
18
|
export function decrypt<T>(value: string): T {
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
return JSON.parse(
|
|
19
|
+
const decrypted = isPortalSecretCiphertext(value)
|
|
20
|
+
? decryptPortalSecret(value, planningEncryption)
|
|
21
|
+
: decryptLegacyBinarySecret(value, planningEncryption.overrides)
|
|
22
|
+
return JSON.parse(decrypted) as T
|
|
23
23
|
}
|
package/shared/validation.ts
CHANGED
|
@@ -8,27 +8,30 @@ const date = z
|
|
|
8
8
|
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
|
9
9
|
.refine((v) => Number.isFinite(Date.parse(v)) && new Date(v).toISOString().slice(0, 10) === v, 'Invalid date')
|
|
10
10
|
const time = z.string().regex(/^(?:[01]\d|2[0-3]):[0-5]\d$/)
|
|
11
|
-
export const
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
11
|
+
export const availabilityShape = {
|
|
12
|
+
date,
|
|
13
|
+
endDate: date.nullable().default(null),
|
|
14
|
+
startTime: time,
|
|
15
|
+
endTime: z
|
|
16
|
+
.string()
|
|
17
|
+
.transform((value) => (value === '00:00' ? '24:00' : value))
|
|
18
|
+
.pipe(z.union([time, z.literal('24:00')])),
|
|
19
|
+
recurring: z.boolean().default(false),
|
|
20
|
+
productIds: z.array(z.string().min(1).max(100)).min(1).max(100).nullable().default(null),
|
|
21
|
+
exceptions: z.array(date).max(365).default([])
|
|
22
|
+
}
|
|
23
|
+
export const validateAvailability = (
|
|
24
|
+
v: z.infer<ReturnType<typeof z.object<typeof availabilityShape>>>,
|
|
25
|
+
c: z.RefinementCtx
|
|
26
|
+
) => {
|
|
27
|
+
if (v.endTime <= v.startTime) {
|
|
28
|
+
c.addIssue({ code: 'custom', path: ['endTime'], message: 'End must follow start on the same day' })
|
|
29
|
+
}
|
|
30
|
+
if (v.endDate && v.endDate < v.date) {
|
|
31
|
+
c.addIssue({ code: 'custom', path: ['endDate'], message: 'End date must follow start date' })
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
export const availabilitySchema = z.object(availabilityShape).superRefine(validateAvailability)
|
|
32
35
|
export const providerSettingsSchema = z.object({
|
|
33
36
|
timezone: timezoneSchema,
|
|
34
37
|
graceMinutes: z.number().int().min(0).max(1440),
|
|
@@ -38,8 +41,8 @@ export const providerSettingsSchema = z.object({
|
|
|
38
41
|
})
|
|
39
42
|
export const availabilityQuerySchema = z
|
|
40
43
|
.object({
|
|
41
|
-
from: z.
|
|
42
|
-
to: z.
|
|
44
|
+
from: z.string().datetime({ offset: true }),
|
|
45
|
+
to: z.string().datetime({ offset: true }),
|
|
43
46
|
providerUserId: z.string().max(100).optional()
|
|
44
47
|
})
|
|
45
48
|
.refine(
|
|
@@ -49,16 +52,18 @@ export const availabilityQuerySchema = z
|
|
|
49
52
|
export const holdSchema = z.object({
|
|
50
53
|
productId: z.string().min(1).max(100),
|
|
51
54
|
providerUserId: z.string().min(1).max(100),
|
|
52
|
-
start: z.
|
|
55
|
+
start: z.string().datetime({ offset: true }),
|
|
53
56
|
customerTimezone: timezoneSchema,
|
|
54
57
|
currency: z.string().regex(/^[A-Z]{3}$/),
|
|
55
58
|
locale: z.enum(['en', 'nl']).default('en'),
|
|
56
|
-
replacesId: z.uuid().optional(),
|
|
59
|
+
replacesId: z.string().uuid().optional(),
|
|
57
60
|
previousHoldToken: z.string().min(32).max(200).optional()
|
|
58
61
|
})
|
|
59
62
|
export const holdCredentialSchema = z.object({ holdToken: z.string().min(32).max(200) })
|
|
60
63
|
|
|
61
|
-
export const availabilityEditSchema =
|
|
64
|
+
export const availabilityEditSchema = z
|
|
65
|
+
.object({ ...availabilityShape, occurrenceDate: date.optional() })
|
|
66
|
+
.superRefine(validateAvailability)
|
|
62
67
|
|
|
63
68
|
export const appointmentListSchema = z.object({
|
|
64
69
|
search: z.string().trim().max(200).default(''),
|
|
@@ -71,7 +76,7 @@ export const appointmentListSchema = z.object({
|
|
|
71
76
|
})
|
|
72
77
|
export const staffRescheduleSchema = z.object({
|
|
73
78
|
providerUserId: z.string().min(1).max(100),
|
|
74
|
-
start: z.
|
|
79
|
+
start: z.string().datetime({ offset: true }),
|
|
75
80
|
customerTimezone: timezoneSchema,
|
|
76
81
|
revision: z.number().int().min(0)
|
|
77
82
|
})
|