diff --git a/README.md b/README.md index 862f0133..6c06003a 100644 --- a/README.md +++ b/README.md @@ -237,6 +237,7 @@ It can also be set using environment variables: - LiveChat - Microsoft - PayPal +- Planning Center - Polar - Salesforce - Seznam diff --git a/playground/.env.example b/playground/.env.example index d2fbea9a..aeea67a5 100644 --- a/playground/.env.example +++ b/playground/.env.example @@ -136,4 +136,8 @@ NUXT_OAUTH_SLACK_REDIRECT_URL= #Heroku NUXT_OAUTH_HEROKU_CLIENT_ID= NUXT_OAUTH_HEROKU_CLIENT_SECRET= -NUXT_OAUTH_HEROKU_REDIRECT_URL= \ No newline at end of file +NUXT_OAUTH_HEROKU_REDIRECT_URL= +#Planning Center +NUXT_OAUTH_PLANNINGCENTER_CLIENT_ID= +NUXT_OAUTH_PLANNINGCENTER_CLIENT_SECRET= +NUXT_OAUTH_PLANNINGCENTER_REDIRECT_URL= diff --git a/playground/app.vue b/playground/app.vue index 369b90f7..95b1d1ea 100644 --- a/playground/app.vue +++ b/playground/app.vue @@ -248,6 +248,12 @@ const providers = computed(() => disabled: Boolean(user.value?.heroku), icon: 'i-simple-icons-heroku', }, + { + label: user.value?.planningcenter || 'Planning Center', + to: '/auth/planningcenter', + disabled: Boolean(user.value?.planningcenter), + icon: 'i-gravity-ui-lock', + }, ].map(p => ({ ...p, prefetch: false, diff --git a/playground/auth.d.ts b/playground/auth.d.ts index 61d718fb..9d4f962a 100644 --- a/playground/auth.d.ts +++ b/playground/auth.d.ts @@ -43,6 +43,7 @@ declare module '#auth-utils' { salesforce?: string slack?: string heroku?: string + planningcenter?: string } interface UserSession { diff --git a/playground/server/routes/auth/planningcenter.get.ts b/playground/server/routes/auth/planningcenter.get.ts new file mode 100644 index 00000000..b4f44310 --- /dev/null +++ b/playground/server/routes/auth/planningcenter.get.ts @@ -0,0 +1,15 @@ +export default defineOAuthPlanningCenterEventHandler({ + config: { + scope: ['services'], + }, + async onSuccess(event, { user }) { + await setUserSession(event, { + user: { + planningcenter: user.name, + }, + loggedInAt: Date.now(), + }) + + return sendRedirect(event, '/') + }, +}) diff --git a/src/module.ts b/src/module.ts index 99dbd418..cbf30c74 100644 --- a/src/module.ts +++ b/src/module.ts @@ -258,6 +258,12 @@ export default defineNuxtModule({ clientSecret: '', redirectURL: '', }) + // Planning Center OAuth + runtimeConfig.oauth.planningcenter = defu(runtimeConfig.oauth.planningcenter, { + clientId: '', + clientSecret: '', + redirectURL: '', + }) // Atproto OAuth for (const provider of atprotoProviders) { diff --git a/src/runtime/server/lib/oauth/planningcenter.ts b/src/runtime/server/lib/oauth/planningcenter.ts new file mode 100644 index 00000000..78ec9e30 --- /dev/null +++ b/src/runtime/server/lib/oauth/planningcenter.ts @@ -0,0 +1,133 @@ +import type { H3Event } from 'h3' +import { eventHandler, getQuery, sendRedirect } from 'h3' +import { withQuery } from 'ufo' +import defu from 'defu' +import { + getOAuthRedirectURL, + handleAccessTokenErrorResponse, + handleMissingConfiguration, + requestAccessToken, +} from '../utils' +import { useRuntimeConfig } from '#imports' +import type { OAuthConfig } from '#auth-utils' + +export interface OAuthPlanningCenterConfig { + /** + * PlanningCenter OAuth Client ID + * @default process.env.NUXT_OAUTH_PLANNING_CENTER_CLIENT_ID + */ + clientId?: string + + /** + * PlanningCenter OAuth Client Secret + * @default process.env.NUXT_OAUTH_PLANNING_CENTER_CLIENT_SECRET + */ + clientSecret?: string + + /** + * PlanningCenter OAuth Redirect URL + * @default process.env.NUXT_OAUTH_PLANNING_CENTER_REDIRECT_URL + */ + redirectURL?: string + + /** + * PlanningCenter OAuth Scope + * @default ['people'] + * @see https://developer.planning.center/docs/#/overview/authentication#scopes + * @example ['people', 'services', 'groups'] + */ + scope?: string[] +} + +export function defineOAuthPlanningCenterEventHandler({ + config, + onSuccess, + onError, +}: OAuthConfig) { + return eventHandler(async (event: H3Event) => { + config = defu( + config, + useRuntimeConfig(event).oauth?.planningcenter, + ) as OAuthPlanningCenterConfig + + if (!config.clientId || !config.clientSecret || !config.redirectURL) { + return handleMissingConfiguration( + event, + 'planningcenter', + ['clientId', 'clientSecret', 'redirectURL'], + onError, + ) + } + + const query = getQuery<{ + code?: string + state?: string + error?: string + error_description?: string + }>(event) + const redirectURL = config.redirectURL || getOAuthRedirectURL(event) + + if (query.error) { + return handleAccessTokenErrorResponse( + event, + 'planningcenter', + query, + onError, + ) + } + + if (!query.code) { + const scope = new Set(config.scope) + + // the people scope is required to access the authenticated user + scope.add('people') + + return sendRedirect( + event, + withQuery('https://api.planningcenteronline.com/oauth/authorize', { + client_id: config.clientId, + redirect_uri: redirectURL, + scope: [...scope].join(' '), + response_type: 'code', + }), + ) + } + + const tokens = await requestAccessToken( + 'https://api.planningcenteronline.com/oauth/token', + { + body: { + client_id: config.clientId, + client_secret: config.clientSecret, + code: query.code as string, + redirect_uri: redirectURL, + grant_type: 'authorization_code', + }, + }, + ) + + if (tokens.error) { + return handleAccessTokenErrorResponse( + event, + 'planningcenter', + tokens, + onError, + ) + } + + const userData = await $fetch<{ + data: { + attributes: Record + } + }>('https://api.planningcenteronline.com/people/v2/me', { + headers: { + Authorization: 'Bearer ' + tokens.access_token, + }, + }) + + return onSuccess(event, { + user: userData.data.attributes, + tokens, + }) + }) +} diff --git a/src/runtime/types/oauth-config.ts b/src/runtime/types/oauth-config.ts index ca7d962d..672c4c77 100644 --- a/src/runtime/types/oauth-config.ts +++ b/src/runtime/types/oauth-config.ts @@ -2,7 +2,7 @@ import type { H3Event, H3Error } from 'h3' export type ATProtoProvider = 'bluesky' -export type OAuthProvider = ATProtoProvider | 'atlassian' | 'auth0' | 'authentik' | 'azureb2c' | 'battledotnet' | 'cognito' | 'discord' | 'dropbox' | 'facebook' | 'gitea' | 'github' | 'gitlab' | 'google' | 'hubspot' | 'instagram' | 'kick' | 'keycloak' | 'line' | 'linear' | 'linkedin' | 'microsoft' | 'paypal' | 'polar' | 'spotify' | 'seznam' | 'steam' | 'strava' | 'tiktok' | 'twitch' | 'vk' | 'workos' | 'x' | 'xsuaa' | 'yandex' | 'zitadel' | 'apple' | 'livechat' | 'salesforce' | 'slack' | 'heroku' | (string & {}) +export type OAuthProvider = ATProtoProvider | 'atlassian' | 'auth0' | 'authentik' | 'azureb2c' | 'battledotnet' | 'cognito' | 'discord' | 'dropbox' | 'facebook' | 'gitea' | 'github' | 'gitlab' | 'google' | 'hubspot' | 'instagram' | 'kick' | 'keycloak' | 'line' | 'linear' | 'linkedin' | 'microsoft' | 'paypal' | 'polar' | 'spotify' | 'seznam' | 'steam' | 'strava' | 'tiktok' | 'twitch' | 'vk' | 'workos' | 'x' | 'xsuaa' | 'yandex' | 'zitadel' | 'apple' | 'livechat' | 'salesforce' | 'slack' | 'heroku' | 'planningcenter' | (string & {}) export type OnError = (event: H3Event, error: H3Error) => Promise | void