mirror of
https://github.com/docmost/docmost.git
synced 2026-08-24 09:32:12 +10:00
token refresh, and filter inactive connections
This commit is contained in:
@@ -6,8 +6,7 @@ import { getIntegrationIcon } from "./integration-icons";
|
|||||||
type ConnectionRowProps = {
|
type ConnectionRowProps = {
|
||||||
definition: IntegrationDefinition;
|
definition: IntegrationDefinition;
|
||||||
connection?: UserConnection;
|
connection?: UserConnection;
|
||||||
installed: boolean;
|
onConnect: (type: string) => void;
|
||||||
onConnect: (integrationId: string) => void;
|
|
||||||
onDisconnect: (integrationId: string) => void;
|
onDisconnect: (integrationId: string) => void;
|
||||||
isDisconnecting?: boolean;
|
isDisconnecting?: boolean;
|
||||||
};
|
};
|
||||||
@@ -15,14 +14,11 @@ type ConnectionRowProps = {
|
|||||||
export default function ConnectionRow({
|
export default function ConnectionRow({
|
||||||
definition,
|
definition,
|
||||||
connection,
|
connection,
|
||||||
installed,
|
|
||||||
onConnect,
|
onConnect,
|
||||||
onDisconnect,
|
onDisconnect,
|
||||||
isDisconnecting,
|
isDisconnecting,
|
||||||
}: ConnectionRowProps) {
|
}: ConnectionRowProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const isConnected = !!connection;
|
|
||||||
const isAvailable = installed && (connection?.isEnabled ?? true);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
@@ -30,7 +26,6 @@ export default function ConnectionRow({
|
|||||||
px="xs"
|
px="xs"
|
||||||
style={{
|
style={{
|
||||||
borderBottom: "1px solid var(--mantine-color-default-border)",
|
borderBottom: "1px solid var(--mantine-color-default-border)",
|
||||||
opacity: isAvailable || isConnected ? 1 : 0.5,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Group justify="space-between" wrap="nowrap">
|
<Group justify="space-between" wrap="nowrap">
|
||||||
@@ -47,7 +42,7 @@ export default function ConnectionRow({
|
|||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Group gap="sm" wrap="nowrap" style={{ flexShrink: 0 }}>
|
<Group gap="sm" wrap="nowrap" style={{ flexShrink: 0 }}>
|
||||||
{isConnected ? (
|
{connection ? (
|
||||||
<>
|
<>
|
||||||
<Text size="xs" c="green">
|
<Text size="xs" c="green">
|
||||||
{t("Connected")}
|
{t("Connected")}
|
||||||
@@ -63,7 +58,7 @@ export default function ConnectionRow({
|
|||||||
{t("Disconnect")}
|
{t("Disconnect")}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : isAvailable ? (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
size="xs"
|
size="xs"
|
||||||
variant="light"
|
variant="light"
|
||||||
@@ -71,10 +66,6 @@ export default function ConnectionRow({
|
|||||||
>
|
>
|
||||||
{t("Connect")}
|
{t("Connect")}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
{t("Not available")}
|
|
||||||
</Text>
|
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -80,9 +80,12 @@ export default function Connections() {
|
|||||||
) : (
|
) : (
|
||||||
<Stack gap={0}>
|
<Stack gap={0}>
|
||||||
{available
|
{available
|
||||||
.filter((def) => def.capabilities.includes("oauth"))
|
.filter((def) => {
|
||||||
.map((def) => {
|
if (!def.capabilities.includes("oauth")) return false;
|
||||||
const installation = installed?.find((i) => i.type === def.type);
|
const installation = installed?.find((i) => i.type === def.type);
|
||||||
|
return installation?.isEnabled;
|
||||||
|
})
|
||||||
|
.map((def) => {
|
||||||
const connection = myConnections?.find(
|
const connection = myConnections?.find(
|
||||||
(c) => c.type === def.type,
|
(c) => c.type === def.type,
|
||||||
);
|
);
|
||||||
@@ -92,7 +95,6 @@ export default function Connections() {
|
|||||||
key={def.type}
|
key={def.type}
|
||||||
definition={def}
|
definition={def}
|
||||||
connection={connection}
|
connection={connection}
|
||||||
installed={!!installation}
|
|
||||||
onConnect={handleConnect}
|
onConnect={handleConnect}
|
||||||
onDisconnect={handleDisconnect}
|
onDisconnect={handleDisconnect}
|
||||||
isDisconnecting={disconnectMutation.isPending}
|
isDisconnecting={disconnectMutation.isPending}
|
||||||
|
|||||||
@@ -1,17 +1,34 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
|
||||||
import { OnEvent } from '@nestjs/event-emitter';
|
import { OnEvent } from '@nestjs/event-emitter';
|
||||||
import { InjectQueue } from '@nestjs/bullmq';
|
import { InjectQueue } from '@nestjs/bullmq';
|
||||||
import { Queue } from 'bullmq';
|
import { Queue } from 'bullmq';
|
||||||
import { QueueJob, QueueName } from '../../integrations/queue/constants/queue.constants';
|
import { QueueJob, QueueName } from '../../integrations/queue/constants/queue.constants';
|
||||||
import { EventName } from '../../common/events/event.contants';
|
import { EventName } from '../../common/events/event.contants';
|
||||||
|
|
||||||
|
const TOKEN_REFRESH_SCHEDULER_ID = 'integration-token-refresh-scheduler';
|
||||||
|
const TOKEN_REFRESH_INTERVAL_MS = 10 * 60 * 1000; // 10 minutes
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class IntegrationListener {
|
export class IntegrationListener implements OnApplicationBootstrap {
|
||||||
|
private readonly logger = new Logger(IntegrationListener.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@InjectQueue(QueueName.INTEGRATION_QUEUE)
|
@InjectQueue(QueueName.INTEGRATION_QUEUE)
|
||||||
private readonly integrationQueue: Queue,
|
private readonly integrationQueue: Queue,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
async onApplicationBootstrap() {
|
||||||
|
await this.integrationQueue.upsertJobScheduler(
|
||||||
|
TOKEN_REFRESH_SCHEDULER_ID,
|
||||||
|
{ every: TOKEN_REFRESH_INTERVAL_MS },
|
||||||
|
{
|
||||||
|
name: QueueJob.INTEGRATION_TOKEN_REFRESH,
|
||||||
|
data: {},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
this.logger.debug('Integration token refresh scheduler created');
|
||||||
|
}
|
||||||
|
|
||||||
@OnEvent(EventName.PAGE_CREATED)
|
@OnEvent(EventName.PAGE_CREATED)
|
||||||
async onPageCreated(payload: any) {
|
async onPageCreated(payload: any) {
|
||||||
await this.integrationQueue.add(QueueJob.INTEGRATION_EVENT, {
|
await this.integrationQueue.add(QueueJob.INTEGRATION_EVENT, {
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { IntegrationRepo } from './repos/integration.repo';
|
|||||||
import { IntegrationConnectionRepo } from './repos/integration-connection.repo';
|
import { IntegrationConnectionRepo } from './repos/integration-connection.repo';
|
||||||
import { OAuthService } from './oauth/oauth.service';
|
import { OAuthService } from './oauth/oauth.service';
|
||||||
|
|
||||||
|
const TOKEN_REFRESH_WINDOW_MS = 15 * 60 * 1000; // 15 minutes
|
||||||
|
|
||||||
@Processor(QueueName.INTEGRATION_QUEUE)
|
@Processor(QueueName.INTEGRATION_QUEUE)
|
||||||
export class IntegrationProcessor extends WorkerHost {
|
export class IntegrationProcessor extends WorkerHost {
|
||||||
private readonly logger = new Logger(IntegrationProcessor.name);
|
private readonly logger = new Logger(IntegrationProcessor.name);
|
||||||
@@ -25,11 +27,38 @@ export class IntegrationProcessor extends WorkerHost {
|
|||||||
case QueueJob.INTEGRATION_EVENT:
|
case QueueJob.INTEGRATION_EVENT:
|
||||||
await this.handleIntegrationEvent(job);
|
await this.handleIntegrationEvent(job);
|
||||||
break;
|
break;
|
||||||
|
case QueueJob.INTEGRATION_TOKEN_REFRESH:
|
||||||
|
await this.handleTokenRefresh();
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
this.logger.warn(`Unknown job: ${job.name}`);
|
this.logger.warn(`Unknown job: ${job.name}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async handleTokenRefresh(): Promise<void> {
|
||||||
|
const connections = await this.connectionRepo.findExpiringTokens(
|
||||||
|
TOKEN_REFRESH_WINDOW_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (connections.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Refreshing tokens for ${connections.length} connection(s)`,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const connection of connections) {
|
||||||
|
try {
|
||||||
|
await this.oauthService.getValidAccessToken(connection);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(
|
||||||
|
`Token refresh failed for connection ${connection.id}: ${(err as Error).message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async handleIntegrationEvent(job: Job): Promise<void> {
|
private async handleIntegrationEvent(job: Job): Promise<void> {
|
||||||
const { eventName, workspaceId, ...payload } = job.data;
|
const { eventName, workspaceId, ...payload } = job.data;
|
||||||
|
|
||||||
|
|||||||
@@ -148,6 +148,19 @@ export class IntegrationConnectionRepo {
|
|||||||
.execute();
|
.execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findExpiringTokens(
|
||||||
|
expiresBeforeMs: number,
|
||||||
|
): Promise<IntegrationConnection[]> {
|
||||||
|
const threshold = new Date(Date.now() + expiresBeforeMs);
|
||||||
|
return this.db
|
||||||
|
.selectFrom('integrationConnections')
|
||||||
|
.selectAll()
|
||||||
|
.where('refreshToken', 'is not', null)
|
||||||
|
.where('tokenExpiresAt', 'is not', null)
|
||||||
|
.where('tokenExpiresAt', '<', threshold)
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
async deleteByIntegration(
|
async deleteByIntegration(
|
||||||
integrationId: string,
|
integrationId: string,
|
||||||
trx?: KyselyTransaction,
|
trx?: KyselyTransaction,
|
||||||
|
|||||||
@@ -70,4 +70,5 @@ export enum QueueJob {
|
|||||||
PAGE_MENTION_NOTIFICATION = 'page-mention-notification',
|
PAGE_MENTION_NOTIFICATION = 'page-mention-notification',
|
||||||
|
|
||||||
INTEGRATION_EVENT = 'integration-event',
|
INTEGRATION_EVENT = 'integration-event',
|
||||||
|
INTEGRATION_TOKEN_REFRESH = 'integration-token-refresh',
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user