feat(integrations): defer workspace-scoped install until OAuth succeeds

This commit is contained in:
Philipinho
2026-05-23 12:38:43 +01:00
parent 91a2abd8d3
commit b4c917ac07
5 changed files with 166 additions and 15 deletions
@@ -14,7 +14,11 @@ import {
useUpdateIntegrationSettings,
} from "../queries/integration-query";
import { Integration } from "../types/integration.types";
import { getOAuthAuthorizeUrl } from "../services/integration-service";
import {
getOAuthAuthorizeUrl,
getOAuthInstallUrl,
} from "../services/integration-service";
import { notifications } from "@mantine/notifications";
export default function Integrations() {
const { t } = useTranslation();
@@ -31,19 +35,29 @@ export default function Integrations() {
const handleInstall = useCallback(
async (type: string) => {
const definition = available?.find((d) => d.type === type);
try {
const integration = await installMutation.mutateAsync({ type });
if (definition?.oauth?.connectionScope === 'workspace') {
const { authorizationUrl } = await getOAuthAuthorizeUrl({
integrationId: integration.id,
});
// Workspace-scoped (Slack): the install row is only persisted when the
// OAuth callback succeeds. Skip the upfront install API call entirely.
if (definition?.oauth?.connectionScope === "workspace") {
try {
const { authorizationUrl } = await getOAuthInstallUrl({ type });
window.location.href = authorizationUrl;
} catch (err: any) {
notifications.show({
message:
err?.response?.data?.message ?? t("Failed to start installation"),
color: "red",
});
}
} catch (err) {
// installMutation's onError already shows a notification
return;
}
// Per-user OAuth providers (Linear, Jira, GitHub, ...): keep existing
// two-step flow — create the integration row, then individual users
// OAuth-connect from /settings/account/connections.
installMutation.mutate({ type });
},
[installMutation, available],
[installMutation, available, t],
);
const handleUninstall = useCallback(
@@ -68,6 +68,21 @@ export async function getOAuthAuthorizeUrl(data: {
return req.data;
}
/**
* For workspace-scoped providers: returns the authorize URL WITHOUT creating
* the integration row. The row is created atomically when the OAuth callback
* succeeds; a cancelled OAuth leaves no half-installed state.
*/
export async function getOAuthInstallUrl(data: {
type: string;
}): Promise<{ authorizationUrl: string }> {
const req = await api.post<{ authorizationUrl: string }>(
"/integrations/oauth/install",
data,
);
return req.data;
}
export async function disconnectIntegration(data: {
integrationId: string;
}): Promise<void> {
@@ -49,3 +49,9 @@ export class OAuthDisconnectDto {
@IsString()
integrationId: string;
}
export class OAuthInstallDto {
@IsNotEmpty()
@IsString()
type: string;
}
@@ -18,7 +18,11 @@ import { AuthUser } from '../../../common/decorators/auth-user.decorator';
import { AuthWorkspace } from '../../../common/decorators/auth-workspace.decorator';
import { User, Workspace } from '@docmost/db/types/entity.types';
import { OAuthService } from './oauth.service';
import { OAuthAuthorizeDto, OAuthDisconnectDto } from '../dto/integration.dto';
import {
OAuthAuthorizeDto,
OAuthDisconnectDto,
OAuthInstallDto,
} from '../dto/integration.dto';
import { IntegrationConnectionService } from '../integration-connection.service';
import { EnvironmentService } from '../../../integrations/environment/environment.service';
@@ -49,6 +53,29 @@ export class OAuthController {
return { authorizationUrl };
}
/**
* Install-and-authorize for workspace-scoped providers (Slack model).
* Returns the authorize URL without first creating the integration row;
* the row is created atomically on successful OAuth callback so a cancelled
* OAuth flow leaves no half-installed state.
*/
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('install')
async installAndAuthorize(
@Body() dto: OAuthInstallDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const { authorizationUrl } = await this.oauthService.getInstallAuthorizationUrl(
dto.type,
workspace.id,
user.id,
);
return { authorizationUrl };
}
@Get(':type/callback')
async callback(
@Param('type') type: string,
@@ -24,7 +24,12 @@ type OAuthTokenResponse = {
};
export type OAuthStatePayload = {
integrationId: string;
// For "authorize-only" flows (per-user OAuth on an already-installed
// integration) integrationId is set; for "install-and-authorize" flows
// (workspace-scoped providers like Slack) it's null until the callback
// resolves-or-creates the row atomically with token exchange success.
integrationId: string | null;
type: string;
userId: string;
workspaceId: string;
// Workspace's canonical URL at authorize time. Cloud workspaces are routed
@@ -76,6 +81,7 @@ export class OAuthService {
const state = this.createSignedState({
integrationId,
type: integration.type,
userId,
workspaceId,
returnUrl,
@@ -98,6 +104,75 @@ export class OAuthService {
};
}
/**
* Install-and-authorize for workspace-scoped providers (Slack model).
*
* Skips creating the integration row up front. The callback (atomic with
* token exchange success) is what actually persists the integration; if the
* user cancels at Slack's consent screen, nothing is written. Refusing the
* already-installed case here keeps the install button idempotent.
*/
async getInstallAuthorizationUrl(
type: string,
workspaceId: string,
userId: string,
): Promise<{ authorizationUrl: string }> {
const provider = this.registry.getProvider(type);
if (!provider || !provider.definition.oauth) {
throw new BadRequestException('Integration does not support OAuth');
}
if (provider.definition.oauth.connectionScope !== 'workspace') {
throw new BadRequestException(
'This integration uses per-user OAuth; use the standard install + authorize flow',
);
}
const existing = await this.integrationRepo.findByWorkspaceAndType(
workspaceId,
type,
);
if (existing) {
throw new BadRequestException(
`Integration "${type}" is already installed`,
);
}
const oauthConfig = provider.getOAuthConfig
? provider.getOAuthConfig({})
: provider.definition.oauth;
const callbackUrl = this.buildCallbackUrl(type);
const workspace = await this.workspaceRepo.findById(workspaceId);
const returnUrl = this.domainService.getWorkspaceUrl(
workspace ?? { hostname: null, customDomain: null },
);
const state = this.createSignedState({
integrationId: null,
type,
userId,
workspaceId,
returnUrl,
exp: Date.now() + 10 * 60 * 1000,
});
const params = new URLSearchParams({
client_id: this.getClientId(type),
redirect_uri: callbackUrl,
response_type: 'code',
state,
});
const scope = oauthConfig.scopes
.map((s) => encodeURIComponent(s))
.join('%20');
return {
authorizationUrl: `${oauthConfig.authUrl}?${params.toString()}&scope=${scope}`,
};
}
verifySignedState(state: string): OAuthStatePayload | null {
const dotIndex = state.lastIndexOf('.');
if (dotIndex === -1) return null;
@@ -129,7 +204,7 @@ export class OAuthService {
async exchangeCodeForTokens(
type: string,
code: string,
integrationId: string,
integrationId: string | null,
userId: string,
workspaceId: string,
): Promise<IntegrationConnection> {
@@ -138,8 +213,22 @@ export class OAuthService {
throw new BadRequestException('Integration does not support OAuth');
}
const integration = await this.integrationRepo.findById(integrationId);
const settings = (integration?.settings as Record<string, any>) ?? {};
// Install-and-authorize flow (workspace-scoped providers): no integration
// row exists yet. Create or restore it now that OAuth has succeeded.
let integration = integrationId
? await this.integrationRepo.findById(integrationId)
: null;
if (!integration) {
integration = await this.integrationRepo.insertOrRestore({
type,
workspaceId,
installedById: userId,
});
integrationId = integration.id;
}
const settings = (integration.settings as Record<string, any>) ?? {};
const oauthConfig = provider.getOAuthConfig
? provider.getOAuthConfig(settings)