diff --git a/apps/backend/src/api/api.module.ts b/apps/backend/src/api/api.module.ts index 291479c7..80b9a1ec 100644 --- a/apps/backend/src/api/api.module.ts +++ b/apps/backend/src/api/api.module.ts @@ -31,6 +31,7 @@ import { AutopostController } from '@gitroom/backend/api/routes/autopost.control import { SetsController } from '@gitroom/backend/api/routes/sets.controller'; import { ThirdPartyController } from '@gitroom/backend/api/routes/third-party.controller'; import { MonitorController } from '@gitroom/backend/api/routes/monitor.controller'; +import { NoAuthIntegrationsController } from '@gitroom/backend/api/routes/no.auth.integrations.controller'; const authenticatedController = [ UsersController, @@ -56,6 +57,7 @@ const authenticatedController = [ AuthController, PublicController, MonitorController, + NoAuthIntegrationsController, ...authenticatedController, ], providers: [ diff --git a/apps/backend/src/api/routes/integrations.controller.ts b/apps/backend/src/api/routes/integrations.controller.ts index 40cf9c58..0a3ab089 100644 --- a/apps/backend/src/api/routes/integrations.controller.ts +++ b/apps/backend/src/api/routes/integrations.controller.ts @@ -3,15 +3,12 @@ import { Controller, Delete, Get, - HttpException, Param, Post, Put, Query, - UseFilters, } from '@nestjs/common'; import { ioRedis } from '@gitroom/nestjs-libraries/redis/redis.service'; -import { ConnectIntegrationDto } from '@gitroom/nestjs-libraries/dtos/integrations/connect.integration.dto'; import { IntegrationManager } from '@gitroom/nestjs-libraries/integrations/integration.manager'; import { IntegrationService } from '@gitroom/nestjs-libraries/database/prisma/integrations/integration.service'; import { GetOrgFromRequest } from '@gitroom/nestjs-libraries/user/org.from.request'; @@ -21,16 +18,11 @@ import { CheckPolicies } from '@gitroom/backend/services/auth/permissions/permis import { pricing } from '@gitroom/nestjs-libraries/database/prisma/subscriptions/pricing'; import { ApiTags } from '@nestjs/swagger'; import { GetUserFromRequest } from '@gitroom/nestjs-libraries/user/user.from.request'; -import { NotEnoughScopesFilter } from '@gitroom/nestjs-libraries/integrations/integration.missing.scopes'; import { PostsService } from '@gitroom/nestjs-libraries/database/prisma/posts/posts.service'; import { IntegrationTimeDto } from '@gitroom/nestjs-libraries/dtos/integrations/integration.time.dto'; -import { AuthService } from '@gitroom/helpers/auth/auth.service'; -import { AuthTokenDetails } from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; import { PlugDto } from '@gitroom/nestjs-libraries/dtos/plugs/plug.dto'; -import { - NotEnoughScopes, - RefreshToken, -} from '@gitroom/nestjs-libraries/integrations/social.abstract'; +import { RefreshToken } from '@gitroom/nestjs-libraries/integrations/social.abstract'; + import { timer } from '@gitroom/helpers/utils/timer'; import { TelegramProvider } from '@gitroom/nestjs-libraries/integrations/social/telegram.provider'; import { @@ -196,7 +188,8 @@ export class IntegrationsController { @Param('integration') integration: string, @Query('refresh') refresh: string, @Query('externalUrl') externalUrl: string, - @Query('onboarding') onboarding: string + @Query('onboarding') onboarding: string, + @GetOrgFromRequest() org: Organization ) { if ( !this._integrationManager @@ -225,19 +218,20 @@ export class IntegrationsController { await integrationProvider.generateAuthUrl(getExternalUrl); if (refresh) { - await ioRedis.set(`refresh:${state}`, refresh, 'EX', 300); + await ioRedis.set(`refresh:${state}`, refresh, 'EX', 3600); } if (onboarding === 'true') { - await ioRedis.set(`onboarding:${state}`, 'true', 'EX', 300); + await ioRedis.set(`onboarding:${state}`, 'true', 'EX', 3600); } - await ioRedis.set(`login:${state}`, codeVerifier, 'EX', 300); + await ioRedis.set(`organization:${state}`, org.id, 'EX', 3600); + await ioRedis.set(`login:${state}`, codeVerifier, 'EX', 3600); await ioRedis.set( `external:${state}`, JSON.stringify(getExternalUrl), 'EX', - 300 + 3600 ); return { url }; @@ -371,171 +365,6 @@ export class IntegrationsController { throw new Error('Function not found'); } - @Post('/social/:integration/connect') - @CheckPolicies([AuthorizationActions.Create, Sections.CHANNEL]) - @UseFilters(new NotEnoughScopesFilter()) - async connectSocialMedia( - @GetOrgFromRequest() org: Organization, - @Param('integration') integration: string, - @Body() body: ConnectIntegrationDto - ) { - if ( - !this._integrationManager - .getAllowedSocialsIntegrations() - .includes(integration) - ) { - throw new Error('Integration not allowed'); - } - - const integrationProvider = - this._integrationManager.getSocialIntegration(integration); - - const getCodeVerifier = integrationProvider.customFields - ? 'none' - : await ioRedis.get(`login:${body.state}`); - if (!getCodeVerifier) { - throw new Error('Invalid state'); - } - - if (!integrationProvider.customFields) { - await ioRedis.del(`login:${body.state}`); - } - - const details = integrationProvider.externalUrl - ? await ioRedis.get(`external:${body.state}`) - : undefined; - - if (details) { - await ioRedis.del(`external:${body.state}`); - } - - const refresh = await ioRedis.get(`refresh:${body.state}`); - if (refresh) { - await ioRedis.del(`refresh:${body.state}`); - } - - const onboarding = await ioRedis.get(`onboarding:${body.state}`); - if (onboarding) { - await ioRedis.del(`onboarding:${body.state}`); - } - - const { - error, - accessToken, - expiresIn, - refreshToken, - id, - name, - picture, - username, - additionalSettings, - // eslint-disable-next-line no-async-promise-executor - } = await new Promise(async (res) => { - const auth = await integrationProvider.authenticate( - { - code: body.code, - codeVerifier: getCodeVerifier, - refresh: body.refresh, - }, - details ? JSON.parse(details) : undefined - ); - - if (typeof auth === 'string') { - return res({ - error: auth, - accessToken: '', - id: '', - name: '', - picture: '', - username: '', - additionalSettings: [], - }); - } - - if (refresh && integrationProvider.reConnect) { - const newAuth = await integrationProvider.reConnect( - auth.id, - refresh, - auth.accessToken - ); - return res({ ...newAuth, refreshToken: body.refresh }); - } - - return res(auth); - }); - - if (error) { - throw new NotEnoughScopes(error); - } - - if (!id) { - throw new NotEnoughScopes('Invalid API key'); - } - - if (refresh && String(id) !== String(refresh)) { - throw new NotEnoughScopes( - 'Please refresh the channel that needs to be refreshed' - ); - } - - let validName = name; - if (!validName) { - if (username) { - validName = username.split('.')[0] ?? username; - } else { - validName = `Channel_${String(id).slice(0, 8)}`; - } - } - - if ( - process.env.STRIPE_PUBLISHABLE_KEY && - org.isTrailing && - (await this._integrationService.checkPreviousConnections( - org.id, - String(id) - )) - ) { - throw new HttpException('', 412); - } - - const createUpdate = - await this._integrationService.createOrUpdateIntegration( - additionalSettings, - !!integrationProvider.oneTimeToken, - org.id, - validName.trim(), - picture, - 'social', - String(id), - integration, - accessToken, - refreshToken, - expiresIn, - username, - refresh ? false : integrationProvider.isBetweenSteps, - body.refresh, - +body.timezone, - details - ? AuthService.fixedEncryption(details) - : integrationProvider.customFields - ? AuthService.fixedEncryption( - Buffer.from(body.code, 'base64').toString() - ) - : undefined - ); - - this._refreshIntegrationService - .startRefreshWorkflow(org.id, createUpdate.id, integrationProvider) - .catch((err) => { - console.log(err); - }); - - return { - ...createUpdate, - onboarding: onboarding === 'true', - }; - } - @Post('/disable') disableChannel( @GetOrgFromRequest() org: Organization, @@ -544,15 +373,6 @@ export class IntegrationsController { return this._integrationService.disableChannel(org.id, id); } - @Post('/provider/:id/connect') - async saveProviderPage( - @Param('id') id: string, - @Body() body: any, - @GetOrgFromRequest() org: Organization - ) { - return this._integrationService.saveProviderPage(org.id, id, body); - } - @Post('/enable') enableChannel( @GetOrgFromRequest() org: Organization, @@ -577,7 +397,7 @@ export class IntegrationsController { ); if (isTherePosts.length) { for (const post of isTherePosts) { - await this._postService.deletePost(org.id, post.group); + this._postService.deletePost(org.id, post.group).catch((err) => {}); } } diff --git a/apps/backend/src/api/routes/no.auth.integrations.controller.ts b/apps/backend/src/api/routes/no.auth.integrations.controller.ts new file mode 100644 index 00000000..673e77e7 --- /dev/null +++ b/apps/backend/src/api/routes/no.auth.integrations.controller.ts @@ -0,0 +1,281 @@ +import { + Body, + Controller, + HttpException, + Param, + Post, + UseFilters, +} from '@nestjs/common'; +import { ioRedis } from '@gitroom/nestjs-libraries/redis/redis.service'; +import { ConnectIntegrationDto } from '@gitroom/nestjs-libraries/dtos/integrations/connect.integration.dto'; +import { IntegrationManager } from '@gitroom/nestjs-libraries/integrations/integration.manager'; +import { IntegrationService } from '@gitroom/nestjs-libraries/database/prisma/integrations/integration.service'; +import { CheckPolicies } from '@gitroom/backend/services/auth/permissions/permissions.ability'; +import { ApiTags } from '@nestjs/swagger'; +import { NotEnoughScopesFilter } from '@gitroom/nestjs-libraries/integrations/integration.missing.scopes'; +import { AuthService } from '@gitroom/helpers/auth/auth.service'; +import { AuthTokenDetails } from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; +import { NotEnoughScopes } from '@gitroom/nestjs-libraries/integrations/social.abstract'; +import { + AuthorizationActions, + Sections, +} from '@gitroom/backend/services/auth/permissions/permission.exception.class'; +import { RefreshIntegrationService } from '@gitroom/nestjs-libraries/integrations/refresh.integration.service'; +import { OrganizationService } from '@gitroom/nestjs-libraries/database/prisma/organizations/organization.service'; + +@ApiTags('Integrations') +@Controller('/integrations') +export class NoAuthIntegrationsController { + constructor( + private _integrationManager: IntegrationManager, + private _integrationService: IntegrationService, + private _refreshIntegrationService: RefreshIntegrationService, + private _organizationService: OrganizationService + ) {} + + @Post('/social-connect/:integration') + @CheckPolicies([AuthorizationActions.Create, Sections.CHANNEL]) + @UseFilters(new NotEnoughScopesFilter()) + async connectSocialMedia( + @Param('integration') integration: string, + @Body() body: ConnectIntegrationDto + ) { + if ( + !this._integrationManager + .getAllowedSocialsIntegrations() + .includes(integration) + ) { + throw new Error('Integration not allowed'); + } + + const integrationProvider = + this._integrationManager.getSocialIntegration(integration); + + const getCodeVerifier = integrationProvider.customFields + ? 'none' + : await ioRedis.get(`login:${body.state}`); + if (!getCodeVerifier) { + throw new Error('Invalid state'); + } + + const organization = await ioRedis.get(`organization:${body.state}`); + if (!organization) { + throw new Error('Organization not found'); + } + + const org = await this._organizationService.getOrgById(organization); + + if (!integrationProvider.customFields) { + await ioRedis.del(`login:${body.state}`); + } + + const details = integrationProvider.externalUrl + ? await ioRedis.get(`external:${body.state}`) + : undefined; + + if (details) { + await ioRedis.del(`external:${body.state}`); + } + + const refresh = await ioRedis.get(`refresh:${body.state}`); + if (refresh) { + await ioRedis.del(`refresh:${body.state}`); + } + + const onboarding = await ioRedis.get(`onboarding:${body.state}`); + if (onboarding) { + await ioRedis.del(`onboarding:${body.state}`); + } + + const { + error, + accessToken, + expiresIn, + refreshToken, + id, + name, + picture, + username, + additionalSettings, + // eslint-disable-next-line no-async-promise-executor + } = await new Promise(async (res) => { + try { + const auth = await integrationProvider.authenticate( + { + code: body.code, + codeVerifier: getCodeVerifier, + refresh: body.refresh, + }, + details ? JSON.parse(details) : undefined + ); + + if (typeof auth === 'string') { + return res({ + error: auth, + accessToken: '', + id: '', + name: '', + picture: '', + username: '', + additionalSettings: [], + }); + } + + if (refresh && integrationProvider.reConnect) { + console.log('reconnect'); + try { + const newAuth = await integrationProvider.reConnect( + auth.id, + refresh, + auth.accessToken + ); + return res({ ...newAuth, refreshToken: body.refresh }); + } catch (err: any) { + return res({ + error: err.message, + accessToken: '', + id: '', + name: '', + picture: '', + username: '', + additionalSettings: [], + }); + } + } + + return res(auth); + } catch (err) { + if (err instanceof NotEnoughScopes) { + return res({ + error: err.message, + accessToken: '', + id: '', + name: '', + picture: '', + username: '', + additionalSettings: [], + }); + } + + return res({ + error: 'Authentication failed', + accessToken: '', + id: '', + name: '', + picture: '', + username: '', + additionalSettings: [], + }); + } + }); + + if (error) { + throw new NotEnoughScopes(error); + } + + if (!id) { + throw new NotEnoughScopes('Invalid API key'); + } + + if (refresh && String(id) !== String(refresh)) { + throw new NotEnoughScopes( + 'Please refresh the channel that needs to be refreshed' + ); + } + + let validName = name; + if (!validName) { + if (username) { + validName = username.split('.')[0] ?? username; + } else { + validName = `Channel_${String(id).slice(0, 8)}`; + } + } + + if ( + process.env.STRIPE_PUBLISHABLE_KEY && + org.isTrailing && + (await this._integrationService.checkPreviousConnections( + org.id, + String(id) + )) + ) { + throw new HttpException('', 412); + } + + const createUpdate = + await this._integrationService.createOrUpdateIntegration( + additionalSettings, + !!integrationProvider.oneTimeToken, + org.id, + validName.trim(), + picture, + 'social', + String(id), + integration, + accessToken, + refreshToken, + expiresIn, + username, + refresh ? false : integrationProvider.isBetweenSteps, + body.refresh, + +body.timezone, + details + ? AuthService.fixedEncryption(details) + : integrationProvider.customFields + ? AuthService.fixedEncryption( + Buffer.from(body.code, 'base64').toString() + ) + : undefined + ); + + this._refreshIntegrationService + .startRefreshWorkflow(org.id, createUpdate.id, integrationProvider) + .catch((err) => { + console.log(err); + }); + + // Fetch pages if this is a two-step provider and not a refresh + let pages: any[] = []; + if (integrationProvider.isBetweenSteps && !refresh) { + try { + // Check which method the provider uses (pages or companies) + const fetchMethod = + 'pages' in integrationProvider + ? 'pages' + : 'companies' in integrationProvider + ? 'companies' + : null; + + if (fetchMethod) { + // @ts-ignore - dynamic method call + pages = await integrationProvider[fetchMethod](accessToken); + } + } catch (err) { + console.log('Failed to fetch pages:', err); + } + } + + return { + ...createUpdate, + onboarding: onboarding === 'true', + pages, + }; + } + + @Post('/provider/:id/connect') + async saveProviderPage(@Param('id') id: string, @Body() body: any) { + if (!body.state) { + throw new Error('Invalid state'); + } + + const organization = await ioRedis.get(`organization:${body.state}`); + if (!organization) { + throw new Error('Organization not found'); + } + + const org = await this._organizationService.getOrgById(organization); + + return this._integrationService.saveProviderPage(org.id, id, body); + } +} diff --git a/apps/backend/src/services/auth/permissions/permissions.guard.ts b/apps/backend/src/services/auth/permissions/permissions.guard.ts index f1ab478c..98666258 100644 --- a/apps/backend/src/services/auth/permissions/permissions.guard.ts +++ b/apps/backend/src/services/auth/permissions/permissions.guard.ts @@ -23,7 +23,9 @@ export class PoliciesGuard implements CanActivate { const request: Request = context.switchToHttp().getRequest(); if ( request.path.indexOf('/auth') > -1 || - request.path.indexOf('/stripe') > -1 + request.path.indexOf('/auth') > -1 || + request.path.indexOf('/integrations/social-connect') > -1 || + request.path.indexOf('/integrations/provider') > -1 ) { return true; } diff --git a/apps/backend/src/services/auth/providers/google.provider.ts b/apps/backend/src/services/auth/providers/google.provider.ts index 993eda7e..67487d78 100644 --- a/apps/backend/src/services/auth/providers/google.provider.ts +++ b/apps/backend/src/services/auth/providers/google.provider.ts @@ -33,7 +33,7 @@ const clientAndYoutube = () => { export class GoogleProvider implements ProvidersInterface { generateLink() { - const state = makeId(7); + const state = 'login'; const { client } = clientAndYoutube(); return client.generateAuthUrl({ access_type: 'online', diff --git a/apps/frontend/src/app/(app)/(site)/integrations/social/layout.tsx b/apps/frontend/src/app/(app)/(site)/integrations/social/layout.tsx deleted file mode 100644 index 124e0fec..00000000 --- a/apps/frontend/src/app/(app)/(site)/integrations/social/layout.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { ReactNode } from 'react'; -import { getT } from '@gitroom/react/translation/get.translation.service.backend'; -export default async function IntegrationLayout({ - children, -}: { - children: ReactNode; -}) { - const t = await getT(); - - return ( -
-
- {t('adding_channel_redirecting_you', 'Adding channel, Redirecting You')} - {children} -
-
- ); -} diff --git a/apps/frontend/src/app/(app)/(site)/integrations/social/[provider]/page.tsx b/apps/frontend/src/app/(app)/integrations/social/[provider]/page.tsx similarity index 74% rename from apps/frontend/src/app/(app)/(site)/integrations/social/[provider]/page.tsx rename to apps/frontend/src/app/(app)/integrations/social/[provider]/page.tsx index 473a0cf1..57d207bf 100644 --- a/apps/frontend/src/app/(app)/(site)/integrations/social/[provider]/page.tsx +++ b/apps/frontend/src/app/(app)/integrations/social/[provider]/page.tsx @@ -1,5 +1,8 @@ import { ContinueIntegration } from '@gitroom/frontend/components/launches/continue.integration'; +import { cookies } from 'next/headers'; + export const dynamic = 'force-dynamic'; + export default async function Page({ params: { provider }, searchParams, @@ -9,5 +12,6 @@ export default async function Page({ }; searchParams: any; }) { - return ; + const get = cookies().get('auth'); + return ; } diff --git a/apps/frontend/src/app/(app)/integrations/social/layout.tsx b/apps/frontend/src/app/(app)/integrations/social/layout.tsx new file mode 100644 index 00000000..af980b6c --- /dev/null +++ b/apps/frontend/src/app/(app)/integrations/social/layout.tsx @@ -0,0 +1,13 @@ +import { ReactNode } from 'react'; + +export default async function IntegrationLayout({ + children, +}: { + children: ReactNode; +}) { + return ( +
+ {children} +
+ ); +} diff --git a/apps/frontend/src/components/launches/add.provider.component.tsx b/apps/frontend/src/components/launches/add.provider.component.tsx index 6207ecf0..77cc2abf 100644 --- a/apps/frontend/src/components/launches/add.provider.component.tsx +++ b/apps/frontend/src/components/launches/add.provider.component.tsx @@ -16,10 +16,11 @@ import { object, string } from 'yup'; import { yupResolver } from '@hookform/resolvers/yup'; import { web3List } from '@gitroom/frontend/components/launches/web3/web3.list'; import { useT } from '@gitroom/react/translation/get.transation.service.client'; -import { ModalWrapperComponent } from '@gitroom/frontend/components/new-launch/modal.wrapper.component'; import clsx from 'clsx'; +import copy from 'copy-to-clipboard'; const resolver = classValidatorResolver(ApiKeyDto); -export const useAddProvider = (update?: () => void) => { + +export const useAddProvider = (update?: () => void, invite?: boolean) => { const modal = useModals(); const fetch = useFetch(); return useCallback(async () => { @@ -27,7 +28,9 @@ export const useAddProvider = (update?: () => void) => { modal.openModal({ title: 'Add Channel', withCloseButton: true, - children: , + children: ( + + ), }); }, []); }; @@ -36,34 +39,69 @@ export const AddProviderButton: FC<{ }> = (props) => { const { update } = props; const add = useAddProvider(update); + const invite = useAddProvider(update, true); const t = useT(); return ( - + + + ); }; export const ApiModal: FC<{ @@ -307,6 +345,7 @@ export const AddProviderComponent: FC<{ identifier: string; name: string; }>; + invite: boolean; update?: () => void; onboarding?: boolean; }> = (props) => { @@ -318,6 +357,7 @@ export const AddProviderComponent: FC<{ const modal = useModals(); const getSocialLink = useCallback( ( + invite: boolean, identifier: string, isExternal: boolean, isWeb3: boolean, @@ -336,7 +376,11 @@ export const AddProviderComponent: FC<{ (item) => item.identifier === identifier )!; const { url } = await ( - await fetch(`/integrations/social/${identifier}${onboarding ? '?onboarding=true' : ''}`) + await fetch( + `/integrations/social/${identifier}${ + onboarding ? '?onboarding=true' : '' + }` + ) ).json(); modal.openModal({ title: t('web3_provider', 'Web3 provider'), @@ -347,7 +391,9 @@ export const AddProviderComponent: FC<{ children: ( { - window.location.href = `/integrations/social/${identifier}?code=${code}&state=${newState}${onboarding ? '&onboarding=true' : ''}`; + window.location.href = `/integrations/social/${identifier}?code=${code}&state=${newState}${ + onboarding ? '&onboarding=true' : '' + }`; }} nonce={url} /> @@ -359,16 +405,35 @@ export const AddProviderComponent: FC<{ const params = [ externalUrl ? `externalUrl=${externalUrl}` : '', onboardingParam, - ].filter(Boolean).join('&'); + ] + .filter(Boolean) + .join('&'); const { url, err } = await ( await fetch( `/integrations/social/${identifier}${params ? `?${params}` : ''}` ) ).json(); if (err) { - toaster.show(t('could_not_connect_to_platform', 'Could not connect to the platform'), 'warning'); + toaster.show( + t( + 'could_not_connect_to_platform', + 'Could not connect to the platform' + ), + 'warning' + ); return; } + + if (invite) { + toaster.show( + 'Invite link copied to clipboard, link will be available for 1 hour', + 'success' + ); + modal.closeAll(); + copy(url); + return; + } + window.location.href = url; }; if (isWeb3) { @@ -430,56 +495,74 @@ export const AddProviderComponent: FC<{ return (
-
- {social.map((item) => ( -
+ {social + .filter((item) => { + if (!props.invite) { + return true; } - > -
- {item.identifier === 'youtube' ? ( - - ) : ( - + + return !item.isExternal && !item.isWeb3 && !item.customFields; + }) + .map((item) => ( +
-
- {item.name} - {!!item.toolTip && ( - - +
+ {item.identifier === 'youtube' ? ( + + ) : ( + - - )} + )} +
+
+ {item.name} + {!!item.toolTip && ( + + + + )} +
-
- ))} + ))}
{!isGeneral && ( diff --git a/apps/frontend/src/components/launches/continue.integration.tsx b/apps/frontend/src/components/launches/continue.integration.tsx index d3456c07..7e05c027 100644 --- a/apps/frontend/src/components/launches/continue.integration.tsx +++ b/apps/frontend/src/components/launches/continue.integration.tsx @@ -1,56 +1,121 @@ 'use client'; -import { FC, useEffect, useState } from 'react'; +import { FC, useCallback, useEffect, useMemo, useState } from 'react'; import { HttpStatusCode } from 'axios'; import { useRouter } from 'next/navigation'; import { Redirect } from '@gitroom/frontend/components/layout/redirect'; import { useT } from '@gitroom/react/translation/get.transation.service.client'; import { useFetch } from '@gitroom/helpers/utils/custom.fetch'; import dayjs from 'dayjs'; +import { continueProviderList } from '@gitroom/frontend/components/new-launch/providers/continue-provider/list'; +import { IntegrationContext } from '@gitroom/frontend/components/launches/helpers/use.integration'; +import { newDayjs } from '@gitroom/frontend/components/layout/set.timezone'; + +interface TwoStepState { + integrationId: string; + onboarding: boolean; + pages: any[]; + returnURL?: string; +} + +interface SuccessState { + message: string; +} export const ContinueIntegration: FC<{ provider: string; searchParams: any; + logged: boolean; }> = (props) => { - const { provider, searchParams } = props; + const { provider, searchParams, logged } = props; const { push } = useRouter(); const t = useT(); const fetch = useFetch(); const [error, setError] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + const [twoStepState, setTwoStepState] = useState(null); + const [successState, setSuccessState] = useState(null); + const [isSaving, setIsSaving] = useState(false); + + // Helper to handle navigation - redirects if logged or returnURL exists, otherwise shows inline + const navigateOrShow = useCallback( + ( + path: string, + returnURL: string | undefined, + successMessage: string + ) => { + if (returnURL) { + // If returnURL exists, always redirect to it with the path params + const params = path.includes('?') ? path.split('?')[1] : ''; + push(params ? `${returnURL}?${params}` : returnURL); + } else if (logged) { + // If logged in without returnURL, use normal navigation + push(path); + } else { + // If not logged in without returnURL, show success inline + setSuccessState({ message: successMessage }); + } + }, + [logged, push] + ); + const modifiedParams = useMemo(() => { + if (provider === 'x') { + return { + state: searchParams.oauth_token || '', + code: searchParams.oauth_verifier || '', + refresh: searchParams.refresh || '', + }; + } + + if (provider === 'vk') { + return { + ...searchParams, + state: searchParams.state || '', + code: searchParams.code + '&&&&' + searchParams.device_id, + }; + } + + return searchParams; + }, []); useEffect(() => { (async () => { const timezone = String(dayjs.tz().utcOffset()); - const modifiedParams = { ...searchParams }; - if (provider === 'x') { - Object.assign(modifiedParams, { - state: searchParams.oauth_token || '', - code: searchParams.oauth_verifier || '', - refresh: searchParams.refresh || '', - }); - } - if (provider === 'vk') { - Object.assign(modifiedParams, { - ...searchParams, - state: searchParams.state || '', - code: searchParams.code + '&&&&' + searchParams.device_id, - }); - } - - const data = await fetch(`/integrations/social/${provider}/connect`, { + // Try public endpoint first (handles both public and fallback scenarios) + let data = await fetch(`/integrations/social-connect/${provider}`, { method: 'POST', body: JSON.stringify({ ...modifiedParams, timezone }), }); + // If public endpoint fails with specific errors, try authenticated endpoint + if (data.status === HttpStatusCode.BadRequest) { + const errorData = await data.json().catch(() => ({})); + // "Invalid connection type" means this wasn't started as a public flow + if ( + errorData.message?.includes('Invalid connection type') || + errorData.message?.includes('Invalid or expired state') + ) { + data = await fetch(`/integrations/social-connect/${provider}`, { + method: 'POST', + body: JSON.stringify({ ...modifiedParams, timezone }), + }); + } + } + if (data.status === HttpStatusCode.PreconditionFailed) { - push(`/launches?precondition=true`); - return ; + const { returnURL } = await data.json().catch(() => ({})); + navigateOrShow( + `/launches?precondition=true`, + returnURL, + 'Precondition failed' + ); + return; } if (data.status === HttpStatusCode.NotAcceptable) { - const { msg } = await data.json(); - push(`/launches?msg=${msg}`); + const { msg, returnURL } = await data.json(); + navigateOrShow(`/launches?msg=${msg}`, returnURL, msg); return; } @@ -58,28 +123,259 @@ export const ContinueIntegration: FC<{ data.status !== HttpStatusCode.Ok && data.status !== HttpStatusCode.Created ) { + const errorData = await data.json().catch(() => ({})); + setErrorMessage(errorData.message || errorData.msg || 'Could not add provider'); setError(true); return; } - const { inBetweenSteps, id, onboarding: resOnboarding } = await data.json(); + const { + inBetweenSteps, + id, + onboarding: resOnboarding, + pages, + returnURL, + } = await data.json(); const onboarding = resOnboarding || searchParams.onboarding === 'true'; + + // If it's a two-step provider, show the selection UI inline if (inBetweenSteps && !searchParams.refresh) { - push(`/launches?added=${provider}&continue=${id}${onboarding ? '&onboarding=true' : ''}`); + setTwoStepState({ + integrationId: id, + onboarding, + pages: pages || [], + returnURL, + }); return; } - push(`/launches?added=${provider}&msg=Channel Updated${onboarding ? '&onboarding=true' : ''}`); - })(); - }, [provider, searchParams]); - return error ? ( - <> -
- {t('could_not_add_provider', 'Could not add provider.')} -
- {t('you_are_being_redirected_back', 'You are being redirected back')} + navigateOrShow( + `/launches?added=${provider}&msg=Channel Updated${ + onboarding ? '&onboarding=true' : '' + }`, + returnURL, + 'Channel Updated' + ); + })(); + }, []); + + const onSave = useCallback( + async (data: any) => { + if (!twoStepState) return; + + setIsSaving(true); + + try { + // Use public or authenticated endpoint based on the flow + const endpoint = `/integrations/provider/${twoStepState.integrationId}/connect`; + + const response = await fetch(endpoint, { + method: 'POST', + body: JSON.stringify({ ...modifiedParams, ...data }), + }); + + if ( + response.status !== HttpStatusCode.Ok && + response.status !== HttpStatusCode.Created + ) { + const errorData = await response.json().catch(() => ({})); + setErrorMessage( + errorData.message || 'Failed to save channel configuration' + ); + setError(true); + return; + } + + navigateOrShow( + `/launches?added=${provider}&msg=Channel Added${ + twoStepState.onboarding ? '&onboarding=true' : '' + }`, + twoStepState.returnURL, + 'Channel Added' + ); + } finally { + setIsSaving(false); + } + }, + [twoStepState, fetch, modifiedParams, provider, navigateOrShow] + ); + + const Provider = useMemo(() => { + return ( + continueProviderList[provider as keyof typeof continueProviderList] || + null + ); + }, [provider]); + + const providerDisplayName = useMemo(() => { + const names: Record = { + facebook: 'Facebook', + instagram: 'Instagram', + 'linkedin-page': 'LinkedIn', + youtube: 'YouTube', + gmb: 'Google Business', + }; + return names[provider] || provider; + }, [provider]); + + // Success state for non-logged users without returnURL + if (successState) { + return ( +
+ {/* Background gradient decoration */} +
+
+
+
+ +
+
+ + + +
+
+ {t('channel_connected', 'Channel Connected!')} +
+
+ {successState.message || + t( + 'channel_connected_description', + `Your ${providerDisplayName} channel has been successfully connected. You can close this window now.` + )} +
+
- - - ) : null; + ); + } + + // Show the two-step selection UI + if (twoStepState && Provider) { + return ( +
+ {/* Background gradient decoration */} +
+
+
+
+ + {/* Content */} +
+
+
+

+ {t('configure_your_channel', 'Configure Your Channel')} +

+

+ {t( + 'select_the_page_or_account', + `Select the ${providerDisplayName} page or account you want to connect.` + )} +

+
+ + + + +
+
+
+ ); + } + + if (error) { + return ( +
+ {/* Background gradient decoration */} +
+
+
+
+ +
+
+ + + +
+
+ {t('could_not_add_provider', 'Could not add provider')} +
+
+ {errorMessage || + t( + 'you_are_being_redirected_back', + 'An error occurred. Please try again.' + )} +
+ {logged && } +
+
+ ); + } + + // Loading state + return ( +
+ {/* Background gradient decoration */} +
+
+
+
+ +
+
+ {t('adding_channel', 'Adding Channel')} +
+
+ {t('please_wait', 'Please wait while we connect your account...')} +
+ {/* Loading spinner */} +
+
+
+
+
+ ); }; diff --git a/apps/frontend/src/components/launches/new.post.tsx b/apps/frontend/src/components/launches/new.post.tsx index d011290c..359822ed 100644 --- a/apps/frontend/src/components/launches/new.post.tsx +++ b/apps/frontend/src/components/launches/new.post.tsx @@ -95,7 +95,7 @@ export const NewPost = () => { strokeLinejoin="round" /> -
+
{t('create_new_post', 'Create Post')}
diff --git a/apps/frontend/src/components/new-launch/providers/continue-provider/facebook/facebook.continue.tsx b/apps/frontend/src/components/new-launch/providers/continue-provider/facebook/facebook.continue.tsx index 59391cb4..bd988d2e 100644 --- a/apps/frontend/src/components/new-launch/providers/continue-provider/facebook/facebook.continue.tsx +++ b/apps/frontend/src/components/new-launch/providers/continue-provider/facebook/facebook.continue.tsx @@ -1,112 +1,47 @@ 'use client'; -import { FC, useCallback, useMemo, useState } from 'react'; -import useSWR from 'swr'; -import clsx from 'clsx'; -import { Button } from '@gitroom/react/form/button'; -import { useT } from '@gitroom/react/translation/get.transation.service.client'; -import { useCustomProviderFunction } from '@gitroom/frontend/components/launches/helpers/use.custom.provider.function'; +import { withContinueProvider } from '../with-continue-provider'; -export const FacebookContinue: FC<{ - onSave: (data: any) => Promise; - existingId: string[]; -}> = (props) => { - const { onSave, existingId } = props; - const call = useCustomProviderFunction(); - const [page, setSelectedPage] = useState(null); - const loadPages = useCallback(async () => { - try { - const pages = await call.get('pages'); - return pages; - } catch (e) { - // Handle error silently - } - }, []); - const setPage = useCallback( - (id: string) => () => { - setSelectedPage(id); +interface FacebookItem { + id: string; + username: string; + name: string; + picture: { + data: { + url: string; + }; + }; +} + +export const FacebookContinue = withContinueProvider({ + endpoint: 'pages', + swrKey: 'load-facebook-pages', + titleKey: 'select_page', + titleDefault: 'Select Page:', + emptyStateMessages: [ + { + key: 'we_couldn_t_find_any_business_connected_to_the_selected_pages', + text: "We couldn't find any business connected to the selected pages.", }, - [] - ); - const { data, isLoading } = useSWR('load-pages', loadPages, { - refreshWhenHidden: false, - refreshWhenOffline: false, - revalidateOnFocus: false, - revalidateIfStale: false, - revalidateOnMount: true, - revalidateOnReconnect: false, - refreshInterval: 0, - }); - const t = useT(); - - const saveFacebook = useCallback(async () => { - await onSave({ page }); - }, [onSave, page]); - const filteredData = useMemo(() => { - return ( - data?.filter((p: { id: string }) => !existingId.includes(p.id)) || [] - ); - }, [data]); - if (!isLoading && !data?.length) { - return ( -
- {t( - 'we_couldn_t_find_any_business_connected_to_the_selected_pages', - "We couldn't find any business connected to the selected pages." - )} -
- {t( - 'we_recommend_you_to_connect_all_the_pages_and_all_the_businesses', - 'We recommend you to connect all the pages and all the businesses.' - )} -
- {t( - 'please_close_this_dialog_delete_your_integration_and_add_a_new_channel_again', - 'Please close this dialog, delete your integration and add a new channel\n again.' - )} -
- ); - } - return ( -
-
{t('select_page', 'Select Page:')}
-
- {filteredData?.map( - (p: { - id: string; - username: string; - name: string; - picture: { - data: { - url: string; - }; - }; - }) => ( -
-
- profile -
-
{p.name}
-
- ) - )} -
+ { + key: 'we_recommend_you_to_connect_all_the_pages_and_all_the_businesses', + text: 'We recommend you to connect all the pages and all the businesses.', + }, + { + key: 'please_close_this_dialog_delete_your_integration_and_add_a_new_channel_again', + text: 'Please close this dialog, delete your integration and add a new channel again.', + }, + ], + getItemId: (item) => item.id, + getSelectionValue: (item) => item.id, + transformSaveData: (selection) => ({ page: selection }), + isSelected: (item, selection) => selection === item.id, + renderItem: (item) => ( + <>
- + profile
-
- ); -}; +
{item.name}
+ + ), +}); diff --git a/apps/frontend/src/components/new-launch/providers/continue-provider/gmb/gmb.continue.tsx b/apps/frontend/src/components/new-launch/providers/continue-provider/gmb/gmb.continue.tsx index 87e2cafb..d4adf6f3 100644 --- a/apps/frontend/src/components/new-launch/providers/continue-provider/gmb/gmb.continue.tsx +++ b/apps/frontend/src/components/new-launch/providers/continue-provider/gmb/gmb.continue.tsx @@ -1,149 +1,81 @@ 'use client'; -import { FC, useCallback, useMemo, useState } from 'react'; -import useSWR from 'swr'; -import clsx from 'clsx'; -import { Button } from '@gitroom/react/form/button'; -import { useT } from '@gitroom/react/translation/get.transation.service.client'; -import { useCustomProviderFunction } from '@gitroom/frontend/components/launches/helpers/use.custom.provider.function'; +import { withContinueProvider } from '../with-continue-provider'; -export const GmbContinue: FC<{ - onSave: (data: any) => Promise; - existingId: string[]; -}> = (props) => { - const { onSave, existingId } = props; - const call = useCustomProviderFunction(); - const [location, setSelectedLocation] = useState(null); - const t = useT(); +interface GmbItem { + id: string; + name: string; + accountName: string; + locationName: string; + picture?: { + data: { + url: string; + }; + }; +} - const loadPages = useCallback(async () => { - try { - const pages = await call.get('pages'); - return pages; - } catch (e) { - // Handle error silently - } - }, []); +interface GmbSelection { + id: string; + accountName: string; + locationName: string; +} - const setLocation = useCallback( - (param: { id: string; accountName: string; locationName: string }) => () => { - setSelectedLocation(param); +export const GmbContinue = withContinueProvider({ + endpoint: 'pages', + swrKey: 'load-gmb-locations', + titleKey: 'select_location', + titleDefault: 'Select Business Location:', + emptyStateMessages: [ + { + key: 'gmb_no_locations_found', + text: "We couldn't find any business locations connected to your account.", }, - [] - ); - - const { data, isLoading } = useSWR('load-gmb-locations', loadPages, { - refreshWhenHidden: false, - refreshWhenOffline: false, - revalidateOnFocus: false, - revalidateIfStale: false, - revalidateOnMount: true, - revalidateOnReconnect: false, - refreshInterval: 0, - }); - - const saveGmb = useCallback(async () => { - await onSave(location); - }, [onSave, location]); - - const filteredData = useMemo(() => { - return ( - data?.filter((p: { id: string }) => !existingId.includes(p.id)) || [] - ); - }, [data, existingId]); - - if (!isLoading && !data?.length) { - return ( -
- {t( - 'gmb_no_locations_found', - "We couldn't find any business locations connected to your account." - )} -
-
- {t( - 'gmb_ensure_business_verified', - 'Please ensure your business is verified on Google My Business.' - )} -
-
- {t( - 'gmb_try_again', - 'Please close this dialog, delete the integration and try again.' - )} -
- ); - } - - return ( -
-
{t('select_location', 'Select Business Location:')}
-
- {filteredData?.map( - (p: { - id: string; - name: string; - accountName: string; - locationName: string; - picture: { - data: { - url: string; - }; - }; - }) => ( -
item.id, + getSelectionValue: (item) => ({ + id: item.id, + accountName: item.accountName, + locationName: item.locationName, + }), + transformSaveData: (selection) => selection, + isSelected: (item, selection) => selection?.id === item.id, + renderItem: (item) => ( + <> +
+ {item.picture?.data?.url ? ( + {item.name} + ) : ( +
+ -
- {p.picture?.data?.url ? ( - {p.name} - ) : ( -
- - - - -
- )} -
-
{p.name}
-
- ) + + + +
)}
-
- -
-
- ); -}; - +
{item.name}
+ + ), +}); diff --git a/apps/frontend/src/components/new-launch/providers/continue-provider/instagram/instagram.continue.tsx b/apps/frontend/src/components/new-launch/providers/continue-provider/instagram/instagram.continue.tsx index 9b7abd7f..4dc7a8c1 100644 --- a/apps/frontend/src/components/new-launch/providers/continue-provider/instagram/instagram.continue.tsx +++ b/apps/frontend/src/components/new-launch/providers/continue-provider/instagram/instagram.continue.tsx @@ -1,116 +1,60 @@ 'use client'; -import { FC, useCallback, useMemo, useState } from 'react'; -import useSWR from 'swr'; -import clsx from 'clsx'; -import { Button } from '@gitroom/react/form/button'; -import { useT } from '@gitroom/react/translation/get.transation.service.client'; -import { useCustomProviderFunction } from '@gitroom/frontend/components/launches/helpers/use.custom.provider.function'; +import { withContinueProvider } from '../with-continue-provider'; -export const InstagramContinue: FC<{ - onSave: (data: any) => Promise; - existingId: string[]; -}> = (props) => { - const { onSave, existingId } = props; - const call = useCustomProviderFunction(); - const [page, setSelectedPage] = useState(null); - const loadPages = useCallback(async () => { - try { - const pages = await call.get('pages'); - return pages; - } catch (e) { - // Handle error silently - } - }, []); - const t = useT(); +interface InstagramItem { + id: string; + pageId: string; + username: string; + name: string; + picture: { + data: { + url: string; + }; + }; +} - const setPage = useCallback( - (param: { id: string; pageId: string }) => () => { - setSelectedPage(param); +interface InstagramSelection { + id: string; + pageId: string; +} + +export const InstagramContinue = withContinueProvider< + InstagramItem, + InstagramSelection +>({ + endpoint: 'pages', + swrKey: 'load-instagram-pages', + titleKey: 'select_instagram_account', + titleDefault: 'Select Instagram Account:', + emptyStateMessages: [ + { + key: 'we_couldn_t_find_any_business_connected_to_the_selected_pages', + text: "We couldn't find any business connected to the selected pages.", }, - [] - ); - const { data, isLoading } = useSWR('load-pages', loadPages, { - refreshWhenHidden: false, - refreshWhenOffline: false, - revalidateOnFocus: false, - revalidateIfStale: false, - revalidateOnMount: true, - revalidateOnReconnect: false, - refreshInterval: 0, - }); - const saveInstagram = useCallback(async () => { - await onSave(page); - }, [onSave, page]); - const filteredData = useMemo(() => { - return ( - data?.filter((p: { id: string }) => !existingId.includes(p.id)) || [] - ); - }, [data]); - if (!isLoading && !data?.length) { - return ( -
- {t( - 'we_couldn_t_find_any_business_connected_to_the_selected_pages', - "We couldn't find any business connected to the selected pages." - )} -
- {t( - 'we_recommend_you_to_connect_all_the_pages_and_all_the_businesses', - 'We recommend you to connect all the pages and all the businesses.' - )} -
- {t( - 'please_close_this_dialog_delete_your_integration_and_add_a_new_channel_again', - 'Please close this dialog, delete your integration and add a new channel\n again.' - )} -
- ); - } - return ( -
-
{t('select_instagram_account', 'Select Instagram Account:')}
-
- {filteredData?.map( - (p: { - id: string; - pageId: string; - username: string; - name: string; - picture: { - data: { - url: string; - }; - }; - }) => ( -
-
- profile -
-
{p.name}
-
- ) - )} -
+ { + key: 'we_recommend_you_to_connect_all_the_pages_and_all_the_businesses', + text: 'We recommend you to connect all the pages and all the businesses.', + }, + { + key: 'please_close_this_dialog_delete_your_integration_and_add_a_new_channel_again', + text: 'Please close this dialog, delete your integration and add a new channel again.', + }, + ], + getItemId: (item) => item.id, + getSelectionValue: (item) => ({ id: item.id, pageId: item.pageId }), + transformSaveData: (selection) => selection, + isSelected: (item, selection) => selection?.id === item.id, + renderItem: (item) => ( + <>
- + profile
-
- ); -}; +
{item.name}
+ + ), +}); diff --git a/apps/frontend/src/components/new-launch/providers/continue-provider/linkedin/linkedin.continue.tsx b/apps/frontend/src/components/new-launch/providers/continue-provider/linkedin/linkedin.continue.tsx index c9da01a2..3bc55822 100644 --- a/apps/frontend/src/components/new-launch/providers/continue-provider/linkedin/linkedin.continue.tsx +++ b/apps/frontend/src/components/new-launch/providers/continue-provider/linkedin/linkedin.continue.tsx @@ -1,103 +1,48 @@ 'use client'; -import { FC, useCallback, useMemo, useState } from 'react'; -import useSWR from 'swr'; -import clsx from 'clsx'; -import { Button } from '@gitroom/react/form/button'; -import { useT } from '@gitroom/react/translation/get.transation.service.client'; -import { useCustomProviderFunction } from '@gitroom/frontend/components/launches/helpers/use.custom.provider.function'; +import { withContinueProvider } from '../with-continue-provider'; -export const LinkedinContinue: FC<{ - onSave: (data: any) => Promise; - existingId: string[]; -}> = (props) => { - const { onSave, existingId } = props; - const t = useT(); +interface LinkedinItem { + id: string; + pageId: string; + username: string; + name: string; + picture: string; +} - const call = useCustomProviderFunction(); - const [page, setSelectedPage] = useState(null); - const loadPages = useCallback(async () => { - try { - const pages = await call.get('companies'); - return pages; - } catch (e) { - // Handle error silently - } - }, []); - const setPage = useCallback( - (param: { id: string; pageId: string }) => () => { - setSelectedPage(param); +interface LinkedinSelection { + id: string; + pageId: string; +} + +export const LinkedinContinue = withContinueProvider< + LinkedinItem, + LinkedinSelection +>({ + endpoint: 'companies', + swrKey: 'load-linkedin-pages', + titleKey: 'select_linkedin_page', + titleDefault: 'Select Linkedin Page:', + emptyStateMessages: [ + { + key: 'we_couldn_t_find_any_business_connected_to_your_linkedin_page', + text: "We couldn't find any business connected to your LinkedIn Page.", }, - [] - ); - const { data, isLoading } = useSWR('load-pages', loadPages, { - refreshWhenHidden: false, - refreshWhenOffline: false, - revalidateOnFocus: false, - revalidateIfStale: false, - revalidateOnMount: true, - revalidateOnReconnect: false, - refreshInterval: 0, - }); - const saveLinkedin = useCallback(async () => { - await onSave({ page: page?.id }); - }, [onSave, page]); - const filteredData = useMemo(() => { - return ( - data?.filter((p: { id: string }) => !existingId.includes(p.id)) || [] - ); - }, [data]); - if (!isLoading && !data?.length) { - return ( -
- {t( - 'we_couldn_t_find_any_business_connected_to_your_linkedin_page', - "We couldn't find any business connected to your LinkedIn Page." - )} -
- {t( - 'please_close_this_dialog_create_a_new_page_and_add_a_new_channel_again', - 'Please close this dialog, create a new page, and add a new channel again.' - )} -
- ); - } - return ( -
-
{t('select_linkedin_page', 'Select Linkedin Page:')}
-
- {filteredData?.map( - (p: { - id: string; - pageId: string; - username: string; - name: string; - picture: string; - }) => ( -
-
- profile -
-
{p.name}
-
- ) - )} -
+ { + key: 'please_close_this_dialog_create_a_new_page_and_add_a_new_channel_again', + text: 'Please close this dialog, create a new page, and add a new channel again.', + }, + ], + getItemId: (item) => item.id, + getSelectionValue: (item) => ({ id: item.id, pageId: item.pageId }), + transformSaveData: (selection) => ({ page: selection.id }), + isSelected: (item, selection) => selection?.id === item.id, + renderItem: (item) => ( + <>
- + profile
-
- ); -}; +
{item.name}
+ + ), +}); diff --git a/apps/frontend/src/components/new-launch/providers/continue-provider/with-continue-provider.tsx b/apps/frontend/src/components/new-launch/providers/continue-provider/with-continue-provider.tsx new file mode 100644 index 00000000..50491d64 --- /dev/null +++ b/apps/frontend/src/components/new-launch/providers/continue-provider/with-continue-provider.tsx @@ -0,0 +1,151 @@ +'use client'; + +import { FC, ReactNode, useCallback, useMemo, useState } from 'react'; +import useSWR from 'swr'; +import clsx from 'clsx'; +import { Button } from '@gitroom/react/form/button'; +import { useT } from '@gitroom/react/translation/get.transation.service.client'; +import { useCustomProviderFunction } from '@gitroom/frontend/components/launches/helpers/use.custom.provider.function'; + +const SWR_OPTIONS = { + refreshWhenHidden: false, + refreshWhenOffline: false, + revalidateOnFocus: false, + revalidateIfStale: false, + revalidateOnMount: true, + revalidateOnReconnect: false, + refreshInterval: 0, +}; + +export interface ContinueProviderProps { + onSave: (data: any) => Promise; + existingId: string[]; + initialData?: any[]; + isSaving?: boolean; +} + +export interface EmptyStateMessage { + key: string; + text: string; +} + +export interface ContinueProviderConfig { + endpoint: string; + swrKey: string; + titleKey: string; + titleDefault: string; + emptyStateMessages: EmptyStateMessage[]; + getSelectionValue: (item: TItem) => TSelection; + transformSaveData: (selection: TSelection) => any; + renderItem: (item: TItem, isSelected: boolean) => ReactNode; + isSelected: (item: TItem, selection: TSelection | null) => boolean; + getItemId: (item: TItem) => string; +} + +export function withContinueProvider( + config: ContinueProviderConfig +): FC { + const { + endpoint, + swrKey, + titleKey, + titleDefault, + emptyStateMessages, + getSelectionValue, + transformSaveData, + renderItem, + isSelected, + getItemId, + } = config; + + return function ContinueProviderComponent(props: ContinueProviderProps) { + const { onSave, existingId, initialData, isSaving } = props; + const call = useCustomProviderFunction(); + const t = useT(); + const [selection, setSelection] = useState(null); + + const loadData = useCallback(async () => { + // Skip fetch if initial data was provided + if (initialData) { + return initialData; + } + try { + return await call.get(endpoint); + } catch (e) { + // Handle error silently + } + }, [initialData]); + + const { data, isLoading } = useSWR( + initialData ? null : swrKey, + loadData, + SWR_OPTIONS + ); + + const resolvedData = initialData || data; + + const handleSelect = useCallback( + (item: TItem) => () => { + setSelection(getSelectionValue(item)); + }, + [] + ); + + const handleSave = useCallback(async () => { + if (selection) { + await onSave(transformSaveData(selection)); + } + }, [onSave, selection]); + + const filteredData = useMemo(() => { + return ( + (resolvedData as TItem[])?.filter( + (item) => !existingId.includes(getItemId(item)) + ) || [] + ); + }, [resolvedData, existingId]); + + if (!isLoading && !resolvedData?.length) { + return ( +
+ {emptyStateMessages.map((msg, index) => ( + + {t(msg.key, msg.text)} + {index < emptyStateMessages.length - 1 && ( + <> +
+
+ + )} +
+ ))} +
+ ); + } + + return ( +
+
{t(titleKey, titleDefault)}
+
+ {filteredData.map((item) => ( +
+ {renderItem(item, isSelected(item, selection))} +
+ ))} +
+
+ +
+
+ ); + }; +} diff --git a/apps/frontend/src/components/new-launch/providers/continue-provider/youtube/youtube.continue.tsx b/apps/frontend/src/components/new-launch/providers/continue-provider/youtube/youtube.continue.tsx index ace60189..a631d409 100644 --- a/apps/frontend/src/components/new-launch/providers/continue-provider/youtube/youtube.continue.tsx +++ b/apps/frontend/src/components/new-launch/providers/continue-provider/youtube/youtube.continue.tsx @@ -1,149 +1,86 @@ 'use client'; -import { FC, useCallback, useMemo, useState } from 'react'; -import useSWR from 'swr'; -import clsx from 'clsx'; -import { Button } from '@gitroom/react/form/button'; -import { useT } from '@gitroom/react/translation/get.transation.service.client'; -import { useCustomProviderFunction } from '@gitroom/frontend/components/launches/helpers/use.custom.provider.function'; +import { withContinueProvider } from '../with-continue-provider'; -export const YoutubeContinue: FC<{ - onSave: (data: any) => Promise; - existingId: string[]; -}> = (props) => { - const { onSave, existingId } = props; - const call = useCustomProviderFunction(); - const [channel, setSelectedChannel] = useState(null); - const t = useT(); +interface YoutubeItem { + id: string; + name: string; + username?: string; + subscriberCount?: string; + picture?: { + data: { + url: string; + }; + }; +} - const loadChannels = useCallback(async () => { - try { - const channels = await call.get('pages'); - return channels; - } catch (e) { - // Handle error silently - } - }, []); +interface YoutubeSelection { + id: string; +} - const setChannel = useCallback( - (param: { id: string }) => () => { - setSelectedChannel(param); +export const YoutubeContinue = withContinueProvider< + YoutubeItem, + YoutubeSelection +>({ + endpoint: 'pages', + swrKey: 'load-youtube-channels', + titleKey: 'select_channel', + titleDefault: 'Select YouTube Channel:', + emptyStateMessages: [ + { + key: 'youtube_no_channels_found', + text: "We couldn't find any YouTube channels connected to your account.", }, - [] - ); - - const { data, isLoading } = useSWR('load-youtube-channels', loadChannels, { - refreshWhenHidden: false, - refreshWhenOffline: false, - revalidateOnFocus: false, - revalidateIfStale: false, - revalidateOnMount: true, - revalidateOnReconnect: false, - refreshInterval: 0, - }); - - const saveYoutube = useCallback(async () => { - await onSave(channel); - }, [onSave, channel]); - - const filteredData = useMemo(() => { - return ( - data?.filter((p: { id: string }) => !existingId.includes(p.id)) || [] - ); - }, [data, existingId]); - - if (!isLoading && !data?.length) { - return ( -
- {t( - 'youtube_no_channels_found', - "We couldn't find any YouTube channels connected to your account." - )} -
-
- {t( - 'youtube_ensure_channel_exists', - 'Please ensure you have a YouTube channel created.' - )} -
-
- {t( - 'youtube_try_again', - 'Please close this dialog, delete the integration and try again.' - )} -
- ); - } - - return ( -
-
{t('select_channel', 'Select YouTube Channel:')}
-
- {filteredData?.map( - (p: { - id: string; - name: string; - username: string; - subscriberCount: string; - picture: { - data: { - url: string; - }; - }; - }) => ( -
item.id, + getSelectionValue: (item) => ({ id: item.id }), + transformSaveData: (selection) => selection, + isSelected: (item, selection) => selection?.id === item.id, + renderItem: (item) => ( + <> +
+ {item.picture?.data?.url ? ( + {item.name} + ) : ( +
+ -
- {p.picture?.data?.url ? ( - {p.name} - ) : ( -
- - - - -
- )} -
-
{p.name}
- {p.username && ( -
{p.username}
- )} - {p.subscriberCount && ( -
- {parseInt(p.subscriberCount).toLocaleString()} subscribers -
- )} -
- ) + + + +
)}
-
- -
-
- ); -}; - +
{item.name}
+ {item.username && ( +
{item.username}
+ )} + {item.subscriberCount && ( +
+ {parseInt(item.subscriberCount).toLocaleString()} subscribers +
+ )} + + ), +}); diff --git a/apps/frontend/src/components/onboarding/onboarding.modal.tsx b/apps/frontend/src/components/onboarding/onboarding.modal.tsx index 3a45c65e..69c0d05e 100644 --- a/apps/frontend/src/components/onboarding/onboarding.modal.tsx +++ b/apps/frontend/src/components/onboarding/onboarding.modal.tsx @@ -202,6 +202,7 @@ const OnboardingStep1: FC<{ onNext: () => void; onSkip: () => void }> = ({
{data && ( -1) { const response = NextResponse.redirect( diff --git a/libraries/nestjs-libraries/src/database/prisma/integrations/integration.repository.ts b/libraries/nestjs-libraries/src/database/prisma/integrations/integration.repository.ts index bb81ae8c..549b06b5 100644 --- a/libraries/nestjs-libraries/src/database/prisma/integrations/integration.repository.ts +++ b/libraries/nestjs-libraries/src/database/prisma/integrations/integration.repository.ts @@ -151,12 +151,31 @@ export class IntegrationRepository { params.picture = await this.storage.uploadSimple(params.picture); } + const existing = await this._integration.model.integration.findUnique({ + where: { + organizationId_internalId: { + organizationId: params.organizationId!, + internalId: params.internalId, + }, + }, + }); + + if (existing) { + await this._integration.model.integration.delete({ + where: { + id, + }, + }); + } + return this._integration.model.integration.update({ where: { - id, + ...(existing ? { id: existing.id } : { id }), }, data: { ...params, + disabled: false, + deletedAt: null, }, }); } diff --git a/libraries/nestjs-libraries/src/database/prisma/integrations/integration.service.ts b/libraries/nestjs-libraries/src/database/prisma/integrations/integration.service.ts index 0a145eb0..de6dd1af 100644 --- a/libraries/nestjs-libraries/src/database/prisma/integrations/integration.service.ts +++ b/libraries/nestjs-libraries/src/database/prisma/integrations/integration.service.ts @@ -316,6 +316,7 @@ export class IntegrationService { await this._integrationRepository.updateIntegration(id, { picture: getIntegrationInformation.picture, internalId: String(getIntegrationInformation.id), + organizationId: org, name: getIntegrationInformation.name, inBetweenSteps: false, token: getIntegrationInformation.access_token, diff --git a/libraries/nestjs-libraries/src/integrations/social.abstract.ts b/libraries/nestjs-libraries/src/integrations/social.abstract.ts index 4d96e605..10795896 100644 --- a/libraries/nestjs-libraries/src/integrations/social.abstract.ts +++ b/libraries/nestjs-libraries/src/integrations/social.abstract.ts @@ -27,7 +27,9 @@ export class BadBody extends ApplicationFailure { } export class NotEnoughScopes { - constructor(public message = 'Not enough scopes') {} + constructor( + public message = 'Not enough scopes, when choosing a provider, please add all the scopes' + ) {} } function safeStringify(obj: any) {