@nuxt-customer-portal/planning 0.6.1 → 0.7.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 +11 -0
- package/README.md +6 -0
- package/app/components/PlanningPersonalCalendarSettings.vue +47 -8
- package/app/pages/availability.vue +16 -2
- package/app/pages/booking/[slug].vue +14 -7
- package/i18n/locales/en.json +6 -2
- package/i18n/locales/nl.json +6 -2
- package/package.json +4 -4
- package/portal.manifest.mjs +1 -1
- package/server/api/planning/oauth/[provider]/callback.get.ts +6 -8
- package/server/utils/adapters.ts +23 -2
- package/server/utils/booking.ts +2 -2
- package/server/utils/oauth.ts +5 -2
- package/shared/google-permissions.ts +16 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.7.0
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 9ccc48d: Keep Google Calendar connection recovery available after expired or incomplete authorization, validate Calendar permissions before saving a connection, and retain saved availability with a warning when external busy times cannot load.
|
|
8
|
+
- 1d0419c: Add scheduled product discounts and free offers, original catalog prices, and checkout price revalidation.
|
|
9
|
+
- Updated dependencies [1d0419c]
|
|
10
|
+
- @nuxt-customer-portal/products@0.7.0
|
|
11
|
+
- @nuxt-customer-portal/core@0.7.0
|
|
12
|
+
- @nuxt-customer-portal/ui@0.7.0
|
|
13
|
+
|
|
3
14
|
## 0.6.1
|
|
4
15
|
|
|
5
16
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -115,3 +115,9 @@ Planning settings use separate components for the Team members, Booking policy,
|
|
|
115
115
|
Personal connections and preferences are managed under My appointment settings (`/planning/settings`): Calendar settings contains Google connection, timezone, grace time, and calendar selection; Meeting settings contains the Zoom connection. Availability (`/availability`) contains only the availability editor and appointment blocks.
|
|
116
116
|
|
|
117
117
|
Sandbox bookings use internal availability, holds, and appointments without requiring Google or Zoom connections or external calendar conflict checks. Live bookings still require healthy connections and configured calendars.
|
|
118
|
+
|
|
119
|
+
### Google Calendar authorization recovery
|
|
120
|
+
|
|
121
|
+
Google OAuth must use the **In production** publishing status for long-lived Calendar authorizations. External OAuth apps in **Testing** receive refresh tokens that expire after seven days when Calendar scopes are requested. After changing publishing status, reconnect and grant all requested Calendar permissions. Revocation or other Google account policies can still require reauthorization. Keep the portal encryption key stable across deployments.
|
|
122
|
+
|
|
123
|
+
The portal validates Calendar permissions before replacing a connection. Calendar settings retain reconnect, disconnect and retry actions when Google is unavailable or authorization is incomplete. Availability continues showing saved windows and appointments with a warning when external busy times cannot be checked. This warning does not make external conflicts safe to ignore; booking checks still require working Calendar access.
|
|
@@ -2,12 +2,15 @@
|
|
|
2
2
|
import { providerSettingsSchema } from '../../shared/validation'
|
|
3
3
|
|
|
4
4
|
const api = usePlanning(),
|
|
5
|
-
{ t } = useI18n()
|
|
5
|
+
{ t } = useI18n(),
|
|
6
|
+
route = useRoute()
|
|
6
7
|
const calendars = ref<Array<{ id: string; summary: string; accessRole: string }>>([]),
|
|
7
8
|
error = ref(''),
|
|
8
9
|
busy = ref(false),
|
|
9
10
|
ready = ref(false),
|
|
10
|
-
googleConnected = ref(false)
|
|
11
|
+
googleConnected = ref(false),
|
|
12
|
+
hasConnection = ref(false),
|
|
13
|
+
calendarsLoaded = ref(false)
|
|
11
14
|
const providerState = reactive({
|
|
12
15
|
timezone: 'Europe/Amsterdam',
|
|
13
16
|
graceMinutes: 0,
|
|
@@ -35,8 +38,11 @@ const additionalCalendarIds = computed({
|
|
|
35
38
|
|
|
36
39
|
async function load() {
|
|
37
40
|
error.value = ''
|
|
41
|
+
ready.value = false
|
|
42
|
+
calendarsLoaded.value = false
|
|
38
43
|
try {
|
|
39
44
|
const settings = await api.provider()
|
|
45
|
+
hasConnection.value = settings.connections.some((connection) => connection.provider === 'google')
|
|
40
46
|
googleConnected.value = settings.connections.some(
|
|
41
47
|
(connection) => connection.provider === 'google' && connection.healthy
|
|
42
48
|
)
|
|
@@ -51,20 +57,30 @@ async function load() {
|
|
|
51
57
|
if (googleConnected.value) {
|
|
52
58
|
try {
|
|
53
59
|
calendars.value = await api.calendars()
|
|
54
|
-
|
|
60
|
+
calendarsLoaded.value = true
|
|
61
|
+
} catch {
|
|
55
62
|
const refreshedSettings = await api.provider().catch(() => null)
|
|
56
63
|
const connectionStillHealthy = refreshedSettings?.connections.some(
|
|
57
64
|
(connection) => connection.provider === 'google' && connection.healthy
|
|
58
65
|
)
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
googleConnected.value = false
|
|
66
|
+
error.value = t(
|
|
67
|
+
connectionStillHealthy === false ? 'planning.googleReconnectRequired' : 'planning.googleCalendarUnavailable'
|
|
68
|
+
)
|
|
69
|
+
googleConnected.value = connectionStillHealthy !== false
|
|
63
70
|
calendars.value = []
|
|
64
71
|
}
|
|
65
72
|
} else {
|
|
66
73
|
calendars.value = []
|
|
67
74
|
}
|
|
75
|
+
if (!googleConnected.value && hasConnection.value) {
|
|
76
|
+
error.value = t('planning.googleReconnectRequired')
|
|
77
|
+
}
|
|
78
|
+
const connectionResult = route.query.connection
|
|
79
|
+
if (connectionResult === 'permissions') {
|
|
80
|
+
error.value = t('planning.googlePermissionsRequired')
|
|
81
|
+
} else if (connectionResult === 'error') {
|
|
82
|
+
error.value = t('planning.googleConnectionError')
|
|
83
|
+
}
|
|
68
84
|
ready.value = true
|
|
69
85
|
} catch {
|
|
70
86
|
error.value = t('planning.loadError')
|
|
@@ -72,6 +88,19 @@ async function load() {
|
|
|
72
88
|
}
|
|
73
89
|
await load()
|
|
74
90
|
|
|
91
|
+
async function disconnectGoogle() {
|
|
92
|
+
busy.value = true
|
|
93
|
+
error.value = ''
|
|
94
|
+
try {
|
|
95
|
+
await api.disconnect('google')
|
|
96
|
+
await load()
|
|
97
|
+
} catch {
|
|
98
|
+
error.value = t('planning.actionError')
|
|
99
|
+
} finally {
|
|
100
|
+
busy.value = false
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
75
104
|
async function connectMainCalendar() {
|
|
76
105
|
busy.value = true
|
|
77
106
|
error.value = ''
|
|
@@ -103,6 +132,16 @@ async function saveSettings() {
|
|
|
103
132
|
<div class="space-y-6">
|
|
104
133
|
<UAlert v-if="error" :title="error" color="error" variant="outline" />
|
|
105
134
|
|
|
135
|
+
<div class="flex flex-wrap gap-2">
|
|
136
|
+
<UButton :loading="busy" variant="outline" @click="connectMainCalendar">{{ t('planning.connect') }}</UButton>
|
|
137
|
+
<UButton v-if="hasConnection" :disabled="busy" color="neutral" variant="outline" @click="disconnectGoogle">{{
|
|
138
|
+
t('planning.disconnect')
|
|
139
|
+
}}</UButton>
|
|
140
|
+
<UButton :disabled="busy" color="neutral" variant="ghost" @click="load">{{
|
|
141
|
+
t('planning.retryConnection')
|
|
142
|
+
}}</UButton>
|
|
143
|
+
</div>
|
|
144
|
+
|
|
106
145
|
<UCard v-if="ready && !googleConnected">
|
|
107
146
|
<template #header>
|
|
108
147
|
<div class="flex items-center gap-3">
|
|
@@ -124,7 +163,7 @@ async function saveSettings() {
|
|
|
124
163
|
</UCard>
|
|
125
164
|
|
|
126
165
|
<UForm
|
|
127
|
-
v-else-if="ready"
|
|
166
|
+
v-else-if="ready && calendarsLoaded"
|
|
128
167
|
:state="providerState"
|
|
129
168
|
:schema="providerSettingsSchema"
|
|
130
169
|
novalidate
|
|
@@ -15,6 +15,7 @@ const api = usePlanning(),
|
|
|
15
15
|
windows = ref<AvailabilityWindow[]>([]),
|
|
16
16
|
appointments = ref<AppointmentListItem[]>([]),
|
|
17
17
|
externalBusy = ref<CalendarBusyPeriod[]>([]),
|
|
18
|
+
calendarWarning = ref(''),
|
|
18
19
|
error = ref(''),
|
|
19
20
|
busy = ref(false),
|
|
20
21
|
calendarNavigationDirection = ref<'previous' | 'next'>(),
|
|
@@ -227,8 +228,14 @@ async function fetchExternalBusy(targetDays: string[], timezone = calendarTimezo
|
|
|
227
228
|
'00:00',
|
|
228
229
|
timezone
|
|
229
230
|
)
|
|
230
|
-
|
|
231
|
-
|
|
231
|
+
calendarWarning.value = ''
|
|
232
|
+
try {
|
|
233
|
+
const periods = await api.calendarBusy({ from: from.toISOString(), to: to.toISOString() })
|
|
234
|
+
return Array.isArray(periods) ? periods : []
|
|
235
|
+
} catch {
|
|
236
|
+
calendarWarning.value = t('planning.googleBusyUnavailable')
|
|
237
|
+
return []
|
|
238
|
+
}
|
|
232
239
|
}
|
|
233
240
|
// Suspend the page setup until the calendar and its external events are ready.
|
|
234
241
|
// This ensures the all-day row has its final layout on the first rendered frame.
|
|
@@ -379,6 +386,13 @@ async function removeWindow() {
|
|
|
379
386
|
<template>
|
|
380
387
|
<div class="h-full min-h-0 overflow-hidden">
|
|
381
388
|
<UContainer class="flex h-full min-h-0 flex-col gap-6 py-8">
|
|
389
|
+
<UAlert
|
|
390
|
+
v-if="calendarWarning"
|
|
391
|
+
:title="calendarWarning"
|
|
392
|
+
color="warning"
|
|
393
|
+
variant="outline"
|
|
394
|
+
:actions="[{ label: t('planning.myAppointmentSettings'), to: '/planning/settings' }]"
|
|
395
|
+
/>
|
|
382
396
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
|
383
397
|
<div class="flex items-start gap-3">
|
|
384
398
|
<UIcon name="i-lucide-calendar-days" class="mt-1 size-6 shrink-0 text-primary" />
|
|
@@ -88,13 +88,20 @@ useHead({ title: `${t('planning.bookNow')} — ${product.title}`, meta: [{ name:
|
|
|
88
88
|
</p>
|
|
89
89
|
<div class="order-price">
|
|
90
90
|
<span>{{ t('products.total') }}</span
|
|
91
|
-
><
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
91
|
+
><span
|
|
92
|
+
><del
|
|
93
|
+
v-if="selectedPrice?.offer && selectedPrice.originalAmount > selectedPrice.amount"
|
|
94
|
+
class="mr-2 text-sm text-muted"
|
|
95
|
+
:aria-label="t('products.offers.originalPrice')"
|
|
96
|
+
>{{ formatMoney(selectedPrice.originalAmount, selectedPrice.currency, product.locale) }}</del
|
|
97
|
+
><strong>{{
|
|
98
|
+
product.isFree
|
|
99
|
+
? t('products.freeProduct')
|
|
100
|
+
: selectedPrice
|
|
101
|
+
? formatMoney(selectedPrice.amount, selectedPrice.currency, product.locale)
|
|
102
|
+
: ''
|
|
103
|
+
}}</strong></span
|
|
104
|
+
>
|
|
98
105
|
</div>
|
|
99
106
|
<UFormField v-if="product.prices.length > 1" :label="t('products.price')" class="mt-5">
|
|
100
107
|
<USelect
|
package/i18n/locales/en.json
CHANGED
|
@@ -165,7 +165,6 @@
|
|
|
165
165
|
"status": "Status",
|
|
166
166
|
"overridePolicySection": "Override this section",
|
|
167
167
|
"time": "Time",
|
|
168
|
-
"timezone": "Timezone",
|
|
169
168
|
"meetingUrl": "Meeting link",
|
|
170
169
|
"meetingDetails": "Meeting details",
|
|
171
170
|
"instructions": "Instructions after purchase",
|
|
@@ -258,6 +257,11 @@
|
|
|
258
257
|
"past": "Past date",
|
|
259
258
|
"loading": "Loading slots"
|
|
260
259
|
},
|
|
261
|
-
"slotTimesTimezone": "Times shown in {timezone}"
|
|
260
|
+
"slotTimesTimezone": "Times shown in {timezone}",
|
|
261
|
+
"googleReconnectRequired": "Google Calendar access has expired or was revoked. Reconnect and allow all requested Calendar permissions.",
|
|
262
|
+
"googleCalendarUnavailable": "Google calendars could not be loaded. Retry, reconnect with all Calendar permissions, or disconnect the account.",
|
|
263
|
+
"googlePermissionsRequired": "Calendar permissions were not granted. Reconnect and allow all requested Calendar permissions. Your previous connection has not been replaced.",
|
|
264
|
+
"retryConnection": "Retry",
|
|
265
|
+
"googleBusyUnavailable": "Google Calendar busy times are unavailable. Saved availability is shown, but external conflicts cannot be checked. Open appointment settings to reconnect."
|
|
262
266
|
}
|
|
263
267
|
}
|
package/i18n/locales/nl.json
CHANGED
|
@@ -165,7 +165,6 @@
|
|
|
165
165
|
"status": "Status",
|
|
166
166
|
"overridePolicySection": "Deze sectie overschrijven",
|
|
167
167
|
"time": "Tijd",
|
|
168
|
-
"timezone": "Tijdzone",
|
|
169
168
|
"meetingUrl": "Vergaderlink",
|
|
170
169
|
"meetingDetails": "Vergadergegevens",
|
|
171
170
|
"instructions": "Instructies na aankoop",
|
|
@@ -258,6 +257,11 @@
|
|
|
258
257
|
"past": "Datum verstreken",
|
|
259
258
|
"loading": "Tijden laden"
|
|
260
259
|
},
|
|
261
|
-
"slotTimesTimezone": "Tijden weergegeven in {timezone}"
|
|
260
|
+
"slotTimesTimezone": "Tijden weergegeven in {timezone}",
|
|
261
|
+
"googleReconnectRequired": "De toegang tot Google Agenda is verlopen of ingetrokken. Verbind opnieuw en geef alle gevraagde agendarechten.",
|
|
262
|
+
"googleCalendarUnavailable": "Google-agenda’s konden niet worden geladen. Probeer opnieuw, verbind opnieuw met alle agendarechten of verbreek de verbinding.",
|
|
263
|
+
"googlePermissionsRequired": "De agendarechten zijn niet verleend. Verbind opnieuw en geef alle gevraagde agendarechten. Je vorige verbinding is niet vervangen.",
|
|
264
|
+
"retryConnection": "Opnieuw proberen",
|
|
265
|
+
"googleBusyUnavailable": "Bezette tijden uit Google Agenda zijn niet beschikbaar. Je opgeslagen beschikbaarheid wordt getoond, maar externe conflicten kunnen niet worden gecontroleerd. Open je afspraakinstellingen om opnieuw te verbinden."
|
|
262
266
|
}
|
|
263
267
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nuxt-customer-portal/planning",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
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/core": "^0.
|
|
44
|
-
"@nuxt-customer-portal/
|
|
45
|
-
"@nuxt-customer-portal/
|
|
43
|
+
"@nuxt-customer-portal/core": "^0.7.0",
|
|
44
|
+
"@nuxt-customer-portal/ui": "^0.7.0",
|
|
45
|
+
"@nuxt-customer-portal/products": "^0.7.0"
|
|
46
46
|
},
|
|
47
47
|
"peerDependencies": {
|
|
48
48
|
"nuxt": "^4.5.1",
|
package/portal.manifest.mjs
CHANGED
|
@@ -3,16 +3,14 @@ import { finishOAuth, oauthProvider } from '../../../../utils/oauth'
|
|
|
3
3
|
|
|
4
4
|
export default defineEventHandler(async (event) => {
|
|
5
5
|
const query = getQuery(event)
|
|
6
|
+
const provider = oauthProvider(getRouterParam(event, 'provider'))
|
|
7
|
+
const tab = provider === 'google' ? 'calendar' : 'meeting'
|
|
6
8
|
try {
|
|
7
|
-
await finishOAuth(
|
|
8
|
-
|
|
9
|
-
oauthProvider(getRouterParam(event, 'provider')),
|
|
10
|
-
String(query.state || ''),
|
|
11
|
-
String(query.code || '')
|
|
12
|
-
)
|
|
13
|
-
return sendRedirect(event, '/planning/settings?tab=meeting&connection=connected', 303)
|
|
9
|
+
await finishOAuth(event, provider, String(query.state || ''), String(query.code || ''))
|
|
10
|
+
return sendRedirect(event, `/planning/settings?tab=${tab}&connection=connected`, 303)
|
|
14
11
|
} catch (error) {
|
|
15
12
|
console.error('Planning OAuth callback failed', error)
|
|
16
|
-
|
|
13
|
+
const reason = (error as { data?: { reason?: string } }).data?.reason === 'permissions' ? 'permissions' : 'error'
|
|
14
|
+
return sendRedirect(event, `/planning/settings?tab=${tab}&connection=${reason}`, 303)
|
|
17
15
|
}
|
|
18
16
|
})
|
package/server/utils/adapters.ts
CHANGED
|
@@ -9,6 +9,7 @@ export interface Credentials {
|
|
|
9
9
|
access_token: string
|
|
10
10
|
refresh_token: string
|
|
11
11
|
expiresAt: number
|
|
12
|
+
scope?: string
|
|
12
13
|
}
|
|
13
14
|
export interface CalendarEvent {
|
|
14
15
|
id: string
|
|
@@ -94,11 +95,17 @@ export async function exchange(provider: 'google' | 'zoom', params: Record<strin
|
|
|
94
95
|
if (!response.ok) {
|
|
95
96
|
throw new AuthorizationExchangeError(response.status)
|
|
96
97
|
}
|
|
97
|
-
const value = (await response.json()) as {
|
|
98
|
+
const value = (await response.json()) as {
|
|
99
|
+
access_token: string
|
|
100
|
+
refresh_token?: string
|
|
101
|
+
expires_in: number
|
|
102
|
+
scope?: string
|
|
103
|
+
}
|
|
98
104
|
if (!value.access_token || !value.expires_in) {
|
|
99
105
|
throw new Error('Invalid authorization response')
|
|
100
106
|
}
|
|
101
107
|
return {
|
|
108
|
+
scope: value.scope,
|
|
102
109
|
access_token: value.access_token,
|
|
103
110
|
refresh_token: value.refresh_token || params.refresh_token || '',
|
|
104
111
|
expiresAt: Date.now() + value.expires_in * 1000
|
|
@@ -174,7 +181,21 @@ async function api<T>(
|
|
|
174
181
|
}
|
|
175
182
|
let detail = ''
|
|
176
183
|
try {
|
|
177
|
-
const payload = (await response.json()) as {
|
|
184
|
+
const payload = (await response.json()) as {
|
|
185
|
+
error?: { message?: string; status?: string; errors?: Array<{ reason?: string }> }
|
|
186
|
+
message?: string
|
|
187
|
+
}
|
|
188
|
+
if (
|
|
189
|
+
provider === 'google' &&
|
|
190
|
+
response.status === 403 &&
|
|
191
|
+
((payload.error?.status === 'PERMISSION_DENIED' && /insufficient.*scope/i.test(payload.error.message || '')) ||
|
|
192
|
+
payload.error?.errors?.some((item) => item.reason === 'insufficientPermissions'))
|
|
193
|
+
) {
|
|
194
|
+
await rows(
|
|
195
|
+
'UPDATE planning.connection SET healthy=false,error=$4 WHERE store_id=$1 AND user_id=$2 AND provider=$3',
|
|
196
|
+
[storeId, userId, provider, 'Calendar permissions missing; reconnect']
|
|
197
|
+
)
|
|
198
|
+
}
|
|
178
199
|
detail = String(payload.error?.message || payload.message || '')
|
|
179
200
|
.replace(/\s+/g, ' ')
|
|
180
201
|
.trim()
|
package/server/utils/booking.ts
CHANGED
|
@@ -6,7 +6,7 @@ import type { z } from 'zod'
|
|
|
6
6
|
import { rows, transaction } from '@nuxt-customer-portal/products/server/utils/database'
|
|
7
7
|
import { getStore } from '@nuxt-customer-portal/products/server/utils/access'
|
|
8
8
|
import { developmentSandboxEffectsEnabled } from '@nuxt-customer-portal/products/server/utils/development'
|
|
9
|
-
import {
|
|
9
|
+
import { getEffectiveProduct, selectCopy } from '@nuxt-customer-portal/products/server/utils/catalog'
|
|
10
10
|
import { defaultPlanningPolicy, effectivePolicy } from '@nuxt-customer-portal/products/shared/planning'
|
|
11
11
|
import type { Order } from '@nuxt-customer-portal/products/shared/types'
|
|
12
12
|
import type { checkoutSchema } from '@nuxt-customer-portal/products/shared/validation'
|
|
@@ -59,7 +59,7 @@ export async function bookingBusy(
|
|
|
59
59
|
return calendarAdapter().busy(storeId, userId, calendarIds, from, to)
|
|
60
60
|
}
|
|
61
61
|
export async function providers(storeId: string, productId: string) {
|
|
62
|
-
const product = await
|
|
62
|
+
const product = await getEffectiveProduct(storeId, productId)
|
|
63
63
|
if (!product.planning?.enabled || product.status !== 'published') {
|
|
64
64
|
throw createError({ statusCode: 404, message: 'This product is not available for booking' })
|
|
65
65
|
}
|
package/server/utils/oauth.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { rows } from '@nuxt-customer-portal/products/server/utils/database'
|
|
|
3
3
|
import { providerAccess } from './access'
|
|
4
4
|
import { oauthConfig, exchange } from './adapters'
|
|
5
5
|
import { digest, secret, encrypt } from './crypto'
|
|
6
|
+
import { googleCalendarScopes, hasGoogleCalendarPermissions } from '../../shared/google-permissions'
|
|
6
7
|
|
|
7
8
|
export function oauthProvider(value: string | undefined): 'google' | 'zoom' {
|
|
8
9
|
if (value !== 'google' && value !== 'zoom') {
|
|
@@ -35,8 +36,7 @@ export async function startOAuth(event: H3Event, provider: 'google' | 'zoom') {
|
|
|
35
36
|
? {
|
|
36
37
|
access_type: 'offline',
|
|
37
38
|
prompt: 'consent',
|
|
38
|
-
scope:
|
|
39
|
-
'openid email https://www.googleapis.com/auth/calendar.events https://www.googleapis.com/auth/calendar.calendarlist.readonly https://www.googleapis.com/auth/calendar.freebusy'
|
|
39
|
+
scope: ['openid', 'email', ...googleCalendarScopes].join(' ')
|
|
40
40
|
}
|
|
41
41
|
: {})
|
|
42
42
|
})
|
|
@@ -59,6 +59,9 @@ export async function finishOAuth(event: H3Event, provider: 'google' | 'zoom', s
|
|
|
59
59
|
throw createError({ statusCode: 403, message: 'Authorization expired or belongs to another user' })
|
|
60
60
|
}
|
|
61
61
|
const credentials = await exchange(provider, { grant_type: 'authorization_code', code })
|
|
62
|
+
if (provider === 'google' && !hasGoogleCalendarPermissions(credentials.scope)) {
|
|
63
|
+
throw createError({ statusCode: 400, message: 'Calendar permissions required', data: { reason: 'permissions' } })
|
|
64
|
+
}
|
|
62
65
|
if (!credentials.refresh_token) {
|
|
63
66
|
throw createError({ statusCode: 400, message: 'Offline authorization required; reconnect with consent' })
|
|
64
67
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
const calendar = 'https://www.googleapis.com/auth/calendar'
|
|
2
|
+
export const googleCalendarScopes = [`${calendar}.events`, `${calendar}.calendarlist.readonly`, `${calendar}.freebusy`]
|
|
3
|
+
|
|
4
|
+
export function hasGoogleCalendarPermissions(scope: string | undefined) {
|
|
5
|
+
const granted = new Set((scope || '').split(/\s+/))
|
|
6
|
+
return (
|
|
7
|
+
granted.has(calendar) ||
|
|
8
|
+
(granted.has(`${calendar}.events`) &&
|
|
9
|
+
(granted.has(`${calendar}.calendarlist.readonly`) ||
|
|
10
|
+
granted.has(`${calendar}.calendarlist`) ||
|
|
11
|
+
granted.has(`${calendar}.readonly`)) &&
|
|
12
|
+
(granted.has(`${calendar}.freebusy`) ||
|
|
13
|
+
granted.has(`${calendar}.events.freebusy`) ||
|
|
14
|
+
granted.has(`${calendar}.readonly`)))
|
|
15
|
+
)
|
|
16
|
+
}
|