Skip to content

feat: add oauth planningcenter #426

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,7 @@ It can also be set using environment variables:
- LiveChat
- Microsoft
- PayPal
- Planning Center
- Polar
- Salesforce
- Seznam
Expand Down
6 changes: 5 additions & 1 deletion playground/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
NUXT_OAUTH_HEROKU_REDIRECT_URL=
#Planning Center
NUXT_OAUTH_PLANNINGCENTER_CLIENT_ID=
NUXT_OAUTH_PLANNINGCENTER_CLIENT_SECRET=
NUXT_OAUTH_PLANNINGCENTER_REDIRECT_URL=
6 changes: 6 additions & 0 deletions playground/app.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions playground/auth.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ declare module '#auth-utils' {
salesforce?: string
slack?: string
heroku?: string
planningcenter?: string
}

interface UserSession {
Expand Down
15 changes: 15 additions & 0 deletions playground/server/routes/auth/planningcenter.get.ts
Original file line number Diff line number Diff line change
@@ -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, '/')
},
})
6 changes: 6 additions & 0 deletions src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,12 @@ export default defineNuxtModule<ModuleOptions>({
clientSecret: '',
redirectURL: '',
})
// Planning Center OAuth
runtimeConfig.oauth.planningcenter = defu(runtimeConfig.oauth.planningcenter, {
clientId: '',
clientSecret: '',
redirectURL: '',
})

// Atproto OAuth
for (const provider of atprotoProviders) {
Expand Down
133 changes: 133 additions & 0 deletions src/runtime/server/lib/oauth/planningcenter.ts
Original file line number Diff line number Diff line change
@@ -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<OAuthPlanningCenterConfig>) {
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<string, unknown>
}
}>('https://api.planningcenteronline.com/people/v2/me', {
headers: {
Authorization: 'Bearer ' + tokens.access_token,
},
})

return onSuccess(event, {
user: userData.data.attributes,
tokens,
})
})
}
2 changes: 1 addition & 1 deletion src/runtime/types/oauth-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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> | void

Expand Down