mirror of
https://github.com/docmost/docmost.git
synced 2026-08-24 18:52:13 +10:00
feat(integrations): defer workspace-scoped install until OAuth succeeds
This commit is contained in:
@@ -14,7 +14,11 @@ import {
|
|||||||
useUpdateIntegrationSettings,
|
useUpdateIntegrationSettings,
|
||||||
} from "../queries/integration-query";
|
} from "../queries/integration-query";
|
||||||
import { Integration } from "../types/integration.types";
|
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() {
|
export default function Integrations() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -31,19 +35,29 @@ export default function Integrations() {
|
|||||||
const handleInstall = useCallback(
|
const handleInstall = useCallback(
|
||||||
async (type: string) => {
|
async (type: string) => {
|
||||||
const definition = available?.find((d) => d.type === type);
|
const definition = available?.find((d) => d.type === type);
|
||||||
try {
|
|
||||||
const integration = await installMutation.mutateAsync({ type });
|
// Workspace-scoped (Slack): the install row is only persisted when the
|
||||||
if (definition?.oauth?.connectionScope === 'workspace') {
|
// OAuth callback succeeds. Skip the upfront install API call entirely.
|
||||||
const { authorizationUrl } = await getOAuthAuthorizeUrl({
|
if (definition?.oauth?.connectionScope === "workspace") {
|
||||||
integrationId: integration.id,
|
try {
|
||||||
});
|
const { authorizationUrl } = await getOAuthInstallUrl({ type });
|
||||||
window.location.href = authorizationUrl;
|
window.location.href = authorizationUrl;
|
||||||
|
} catch (err: any) {
|
||||||
|
notifications.show({
|
||||||
|
message:
|
||||||
|
err?.response?.data?.message ?? t("Failed to start installation"),
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (err) {
|
return;
|
||||||
// installMutation's onError already shows a notification
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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(
|
const handleUninstall = useCallback(
|
||||||
|
|||||||
@@ -68,6 +68,21 @@ export async function getOAuthAuthorizeUrl(data: {
|
|||||||
return req.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: {
|
export async function disconnectIntegration(data: {
|
||||||
integrationId: string;
|
integrationId: string;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
|
|||||||
@@ -49,3 +49,9 @@ export class OAuthDisconnectDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
integrationId: string;
|
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 { AuthWorkspace } from '../../../common/decorators/auth-workspace.decorator';
|
||||||
import { User, Workspace } from '@docmost/db/types/entity.types';
|
import { User, Workspace } from '@docmost/db/types/entity.types';
|
||||||
import { OAuthService } from './oauth.service';
|
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 { IntegrationConnectionService } from '../integration-connection.service';
|
||||||
import { EnvironmentService } from '../../../integrations/environment/environment.service';
|
import { EnvironmentService } from '../../../integrations/environment/environment.service';
|
||||||
|
|
||||||
@@ -49,6 +53,29 @@ export class OAuthController {
|
|||||||
return { authorizationUrl };
|
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')
|
@Get(':type/callback')
|
||||||
async callback(
|
async callback(
|
||||||
@Param('type') type: string,
|
@Param('type') type: string,
|
||||||
|
|||||||
@@ -24,7 +24,12 @@ type OAuthTokenResponse = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type OAuthStatePayload = {
|
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;
|
userId: string;
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
// Workspace's canonical URL at authorize time. Cloud workspaces are routed
|
// Workspace's canonical URL at authorize time. Cloud workspaces are routed
|
||||||
@@ -76,6 +81,7 @@ export class OAuthService {
|
|||||||
|
|
||||||
const state = this.createSignedState({
|
const state = this.createSignedState({
|
||||||
integrationId,
|
integrationId,
|
||||||
|
type: integration.type,
|
||||||
userId,
|
userId,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
returnUrl,
|
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 {
|
verifySignedState(state: string): OAuthStatePayload | null {
|
||||||
const dotIndex = state.lastIndexOf('.');
|
const dotIndex = state.lastIndexOf('.');
|
||||||
if (dotIndex === -1) return null;
|
if (dotIndex === -1) return null;
|
||||||
@@ -129,7 +204,7 @@ export class OAuthService {
|
|||||||
async exchangeCodeForTokens(
|
async exchangeCodeForTokens(
|
||||||
type: string,
|
type: string,
|
||||||
code: string,
|
code: string,
|
||||||
integrationId: string,
|
integrationId: string | null,
|
||||||
userId: string,
|
userId: string,
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
): Promise<IntegrationConnection> {
|
): Promise<IntegrationConnection> {
|
||||||
@@ -138,8 +213,22 @@ export class OAuthService {
|
|||||||
throw new BadRequestException('Integration does not support OAuth');
|
throw new BadRequestException('Integration does not support OAuth');
|
||||||
}
|
}
|
||||||
|
|
||||||
const integration = await this.integrationRepo.findById(integrationId);
|
// Install-and-authorize flow (workspace-scoped providers): no integration
|
||||||
const settings = (integration?.settings as Record<string, any>) ?? {};
|
// 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
|
const oauthConfig = provider.getOAuthConfig
|
||||||
? provider.getOAuthConfig(settings)
|
? provider.getOAuthConfig(settings)
|
||||||
|
|||||||
Reference in New Issue
Block a user