mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 63a8282ee2 | |||
| 948ac02c5e | |||
| 07dc974337 | |||
| cd67566e3e | |||
| 7e9ce2fc64 | |||
| c0ff5aa55b | |||
| 68bc6d20af | |||
| 27f003d9c9 | |||
| b689ebd8ca | |||
| 8e74bb7d01 | |||
| c02bc22cce | |||
| 7ea308a52c | |||
| e4d1a38367 |
@@ -70,7 +70,7 @@ const LinkEditor: React.FC<Props> = ({
|
||||
React.useCallback(async () => {
|
||||
const res = await client.post("/suggestions.mention", { query });
|
||||
res.data.documents.map(documents.add);
|
||||
}, [query])
|
||||
}, [query, documents.add])
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -79,6 +79,22 @@ const LinkEditor: React.FC<Props> = ({
|
||||
}
|
||||
}, [trimmedQuery, request]);
|
||||
|
||||
const save = React.useCallback(
|
||||
(href: string, title?: string) => {
|
||||
href = href.trim();
|
||||
|
||||
if (href.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
discardRef.current = true;
|
||||
href = sanitizeUrl(href) ?? "";
|
||||
|
||||
onSelectLink({ href, title, from, to });
|
||||
},
|
||||
[onSelectLink, from, to]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleGlobalKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "k" && event.metaKey) {
|
||||
@@ -107,20 +123,7 @@ const LinkEditor: React.FC<Props> = ({
|
||||
|
||||
save(trimmedQuery, trimmedQuery);
|
||||
};
|
||||
}, [trimmedQuery, initialValue]);
|
||||
|
||||
const save = (href: string, title?: string) => {
|
||||
href = href.trim();
|
||||
|
||||
if (href.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
discardRef.current = true;
|
||||
href = sanitizeUrl(href) ?? "";
|
||||
|
||||
onSelectLink({ href, title, from, to });
|
||||
};
|
||||
}, [trimmedQuery, initialValue, handleRemoveLink, save]);
|
||||
|
||||
const moveSelectionToEnd = () => {
|
||||
const { state, dispatch } = view;
|
||||
@@ -195,7 +198,7 @@ const LinkEditor: React.FC<Props> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveLink = () => {
|
||||
const handleRemoveLink = React.useCallback(() => {
|
||||
discardRef.current = true;
|
||||
|
||||
const { state, dispatch } = view;
|
||||
@@ -203,9 +206,12 @@ const LinkEditor: React.FC<Props> = ({
|
||||
dispatch(state.tr.removeMark(from, to, mark));
|
||||
}
|
||||
|
||||
onRemoveLink?.();
|
||||
if (onRemoveLink) {
|
||||
onRemoveLink();
|
||||
}
|
||||
|
||||
view.focus();
|
||||
};
|
||||
}, [view, mark, from, to, onRemoveLink]);
|
||||
|
||||
const isInternal = isInternalUrl(query);
|
||||
const hasResults = !!results.length;
|
||||
|
||||
@@ -184,7 +184,16 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
setItems(items);
|
||||
setLoaded(true);
|
||||
}
|
||||
}, [t, actorId, loading, search, users, documents, maxResultsInSection]);
|
||||
}, [
|
||||
t,
|
||||
actorId,
|
||||
loading,
|
||||
search,
|
||||
users,
|
||||
documents,
|
||||
maxResultsInSection,
|
||||
collections,
|
||||
]);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
async (item: MentionItem) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import Flex from "@shared/components/Flex";
|
||||
@@ -132,10 +132,10 @@ function Authorize() {
|
||||
{t("Required OAuth parameters are missing")}
|
||||
<Pre>
|
||||
{missingParams.map((param) => (
|
||||
<>
|
||||
<React.Fragment key={param}>
|
||||
{param}
|
||||
<br />
|
||||
</>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Pre>
|
||||
</Text>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { CopyIcon } from "outline-icons";
|
||||
import { useState, useRef, useCallback } from "react";
|
||||
import React, { useState, useRef, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import ApiKey from "~/models/ApiKey";
|
||||
@@ -50,10 +50,10 @@ const ApiKeyListItem = ({ apiKey }: Props) => {
|
||||
{apiKey.scope && (
|
||||
<Tooltip
|
||||
content={apiKey.scope.map((s) => (
|
||||
<>
|
||||
<React.Fragment key={s}>
|
||||
{s}
|
||||
<br />
|
||||
</>
|
||||
</React.Fragment>
|
||||
))}
|
||||
>
|
||||
<Text type="tertiary">{t("Restricted scope")}</Text>
|
||||
|
||||
@@ -43,7 +43,8 @@ export const isURLMentionable = ({
|
||||
|
||||
return (
|
||||
hostname === "gitlab.com" &&
|
||||
settings.gitlab?.project.path_with_namespace === pathParts.slice(1, -2).join("/") // ensure installed project path matches with the provided url.
|
||||
settings.gitlab?.project.path_with_namespace ===
|
||||
pathParts.slice(1, -2).join("/") // ensure installed project path matches with the provided url.
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -48,4 +48,3 @@ export default function Icon({ size = 24, fill = "currentColor" }: Props) {
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -59,8 +59,8 @@ function GitLab() {
|
||||
<>
|
||||
<Text as="p">
|
||||
<Trans>
|
||||
Enable previews of GitLab issues and merge requests in documents by connecting a
|
||||
GitLab project to {appName}.
|
||||
Enable previews of GitLab issues and merge requests in documents
|
||||
by connecting a GitLab project to {appName}.
|
||||
</Trans>
|
||||
</Text>
|
||||
{integrations.gitlab.length ? (
|
||||
@@ -73,8 +73,7 @@ function GitLab() {
|
||||
</Heading>
|
||||
<List>
|
||||
{integrations.gitlab.map((integration) => {
|
||||
const gitlabProject =
|
||||
integration.settings?.gitlab?.project;
|
||||
const gitlabProject = integration.settings?.gitlab?.project;
|
||||
const integrationCreatedBy = integration.user
|
||||
? integration.user.name
|
||||
: undefined;
|
||||
@@ -138,4 +137,3 @@ function GitLab() {
|
||||
}
|
||||
|
||||
export default observer(GitLab);
|
||||
|
||||
|
||||
@@ -21,4 +21,3 @@ export function GitLabConnectButton(props: Props<HTMLButtonElement>) {
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as React from "react";
|
||||
import { createLazyComponent } from "~/components/LazyLoad";
|
||||
import { Hook, PluginManager } from "~/utils/PluginManager";
|
||||
import config from "../plugin.json";
|
||||
import Icon from "./Icon";
|
||||
@@ -10,8 +10,7 @@ PluginManager.add([
|
||||
value: {
|
||||
group: "Integrations",
|
||||
icon: Icon,
|
||||
component: React.lazy(() => import("./Settings")),
|
||||
component: createLazyComponent(() => import("./Settings")),
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
|
||||
@@ -5,4 +5,3 @@
|
||||
"description": "Adds a GitLab integration for link unfurling and converting links to mentions.",
|
||||
"after": "linear"
|
||||
}
|
||||
|
||||
|
||||
@@ -97,4 +97,3 @@ router.get(
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
|
||||
@@ -15,4 +15,3 @@ export const GitLabCallbackSchema = BaseSchema.extend({
|
||||
});
|
||||
|
||||
export type GitLabCallbackReq = z.infer<typeof GitLabCallbackSchema>;
|
||||
|
||||
|
||||
@@ -4,4 +4,3 @@ export default {
|
||||
GITLAB_CLIENT_ID: env.GITLAB_CLIENT_ID,
|
||||
GITLAB_CLIENT_SECRET: env.GITLAB_CLIENT_SECRET,
|
||||
};
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
import Logger from "@server/logging/Logger";
|
||||
import { Integration } from "@server/models";
|
||||
import User from "@server/models/User";
|
||||
import { UnfurlIssueOrPR, UnfurlSignature } from "@server/types";
|
||||
import { UnfurlSignature } from "@server/types";
|
||||
import { GitLabUtils } from "../shared/GitLabUtils";
|
||||
import env from "./env";
|
||||
|
||||
@@ -197,31 +197,51 @@ export class GitLab {
|
||||
// Fetch labels if they exist
|
||||
let labels = [];
|
||||
if (data.labels && data.labels.length > 0) {
|
||||
labels = data.labels.map((label) => ({
|
||||
labels = data.labels.map((label: string) => ({
|
||||
name: label,
|
||||
color: "#428BCA", // Default GitLab blue
|
||||
}));
|
||||
}
|
||||
|
||||
return {
|
||||
type: resourceType,
|
||||
url,
|
||||
id: `#${data.iid}`,
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
author: {
|
||||
name: data.author.name,
|
||||
avatarUrl: data.author.avatar_url || "",
|
||||
},
|
||||
labels,
|
||||
state: {
|
||||
name: data.state,
|
||||
color: data.state === "opened" ? "#1aaa55" : "#db3b21", // Green for open, red for closed
|
||||
draft:
|
||||
resourceType === UnfurlResourceType.PR ? data.draft : undefined,
|
||||
},
|
||||
createdAt: data.created_at,
|
||||
} satisfies UnfurlIssueOrPR;
|
||||
// Create the appropriate response based on the resource type
|
||||
if (resourceType === UnfurlResourceType.Issue) {
|
||||
return {
|
||||
type: UnfurlResourceType.Issue,
|
||||
url,
|
||||
id: `#${data.iid}`,
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
author: {
|
||||
name: data.author.name,
|
||||
avatarUrl: data.author.avatar_url || "",
|
||||
},
|
||||
labels,
|
||||
state: {
|
||||
name: data.state,
|
||||
color: data.state === "opened" ? "#1aaa55" : "#db3b21", // Green for open, red for closed
|
||||
},
|
||||
createdAt: data.created_at,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
type: UnfurlResourceType.PR,
|
||||
url,
|
||||
id: `#${data.iid}`,
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
author: {
|
||||
name: data.author.name,
|
||||
avatarUrl: data.author.avatar_url || "",
|
||||
},
|
||||
labels,
|
||||
state: {
|
||||
name: data.state,
|
||||
color: data.state === "opened" ? "#1aaa55" : "#db3b21", // Green for open, red for closed
|
||||
draft: !!data.draft,
|
||||
},
|
||||
createdAt: data.created_at,
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
Logger.warn("Failed to fetch resource from GitLab", err);
|
||||
return { error: err.message || "Unknown error" };
|
||||
|
||||
@@ -1,14 +1,31 @@
|
||||
import { IntegrationType } from "@shared/types";
|
||||
import BaseTask from "@server/queues/tasks/BaseTask";
|
||||
import { Integration } from "@server/models";
|
||||
import { FileOperation } from "@server/models";
|
||||
import fetch from "node-fetch";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import {
|
||||
FileOperationState,
|
||||
FileOperationType,
|
||||
FileOperationFormat,
|
||||
} from "@shared/types";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
type Props = {
|
||||
integrationId: string;
|
||||
avatarUrl: string;
|
||||
};
|
||||
|
||||
// Define a type for GitLab settings
|
||||
interface GitLabSettings {
|
||||
gitlab: {
|
||||
project?: {
|
||||
avatar_url?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export default class UploadGitLabProjectAvatarTask extends BaseTask<Props> {
|
||||
public async perform({ integrationId, avatarUrl }: Props) {
|
||||
const integration = await Integration.findByPk(integrationId, {
|
||||
@@ -19,38 +36,40 @@ export default class UploadGitLabProjectAvatarTask extends BaseTask<Props> {
|
||||
const res = await fetch(avatarUrl);
|
||||
const buffer = await res.buffer();
|
||||
const name = avatarUrl.split("/").pop() || "avatar";
|
||||
const contentType = res.headers.get("content-type") || "image/png";
|
||||
|
||||
const operation = await FileOperation.createFromBuffer({
|
||||
buffer,
|
||||
contentType,
|
||||
name,
|
||||
// Create a file operation with the correct parameters
|
||||
const operation = await FileOperation.create({
|
||||
type: FileOperationType.Import,
|
||||
state: FileOperationState.Creating,
|
||||
format: FileOperationFormat.JSON, // Use a valid FileOperationFormat
|
||||
key: `uploads/${integration.teamId}/${uuidv4()}/${name}`,
|
||||
userId: integration.userId,
|
||||
teamId: integration.teamId,
|
||||
source: "gitlab",
|
||||
size: buffer.length,
|
||||
});
|
||||
|
||||
// Cast the settings to our GitLabSettings interface
|
||||
const currentSettings = integration.settings as unknown as GitLabSettings;
|
||||
|
||||
// Update the integration settings with the avatar URL
|
||||
await integration.update({
|
||||
settings: {
|
||||
...integration.settings,
|
||||
gitlab: {
|
||||
...(integration.settings as Integration<IntegrationType.Embed>)
|
||||
.gitlab,
|
||||
...currentSettings.gitlab,
|
||||
project: {
|
||||
...(integration.settings as Integration<IntegrationType.Embed>)
|
||||
.gitlab?.project,
|
||||
...currentSettings.gitlab?.project,
|
||||
avatar_url: operation.url,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as Record<string, unknown>,
|
||||
});
|
||||
} catch (err) {
|
||||
} catch (err: unknown) {
|
||||
// If the avatar upload fails, we don't need to fail the entire task
|
||||
// as it's not critical to the integration's functionality.
|
||||
// Just log the error and continue.
|
||||
this.logger.error(
|
||||
`Failed to upload GitLab project avatar: ${err.message}`
|
||||
);
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
Logger.error("Failed to upload GitLab project avatar", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import queryString from "query-string";
|
||||
import * as queryString from "query-string";
|
||||
import env from "@shared/env";
|
||||
import { integrationSettingsPath } from "@shared/utils/routeHelpers";
|
||||
|
||||
@@ -49,4 +49,3 @@ export class GitLabUtils {
|
||||
return `${this.authBaseUrl}?${queryString.stringify(params)}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -55,7 +55,15 @@ export const Notion = observer(() => {
|
||||
onClose: clearQueryParams,
|
||||
});
|
||||
}
|
||||
}, [t, dialogs, oauthSuccess, service, clearQueryParams]);
|
||||
}, [
|
||||
t,
|
||||
dialogs,
|
||||
oauthSuccess,
|
||||
service,
|
||||
clearQueryParams,
|
||||
handleSubmit,
|
||||
integrationId,
|
||||
]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!oauthError) {
|
||||
|
||||
@@ -52,7 +52,15 @@ export function ImportDialog({ integrationId, onSubmit }: Props) {
|
||||
toast.error(err.message);
|
||||
resetSubmitting();
|
||||
}
|
||||
}, [permission, onSubmit]);
|
||||
}, [
|
||||
permission,
|
||||
onSubmit,
|
||||
integrationId,
|
||||
t,
|
||||
imports,
|
||||
resetSubmitting,
|
||||
setSubmitting,
|
||||
]);
|
||||
|
||||
return (
|
||||
<Flex column gap={12}>
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { HttpsProxyAgent } from "https-proxy-agent";
|
||||
import OAuth2Strategy, { Strategy } from "passport-oauth2";
|
||||
import {
|
||||
Strategy,
|
||||
StrategyOptionsWithRequest,
|
||||
VerifyFunctionWithRequest,
|
||||
} from "passport-oauth2";
|
||||
import { Request } from "express";
|
||||
|
||||
interface OIDCOptions extends StrategyOptionsWithRequest {
|
||||
originalQuery?: Record<string, string | string[]>;
|
||||
}
|
||||
|
||||
export class OIDCStrategy extends Strategy {
|
||||
constructor(
|
||||
options: OAuth2Strategy.StrategyOptionsWithRequest,
|
||||
verify: OAuth2Strategy.VerifyFunctionWithRequest
|
||||
options: StrategyOptionsWithRequest,
|
||||
verify: VerifyFunctionWithRequest
|
||||
) {
|
||||
super(options, verify);
|
||||
|
||||
@@ -15,12 +23,13 @@ export class OIDCStrategy extends Strategy {
|
||||
}
|
||||
}
|
||||
|
||||
authenticate(req: Request, options: Record<string, unknown>) {
|
||||
options.originalQuery = req.query;
|
||||
super.authenticate(req, options);
|
||||
authenticate(req: Request, options?: Record<string, unknown>) {
|
||||
const opts = options ? { ...options } : ({} as OIDCOptions);
|
||||
opts.originalQuery = req.query as Record<string, string | string[]>;
|
||||
super.authenticate(req, opts);
|
||||
}
|
||||
|
||||
authorizationParams(options: Record<string, unknown>) {
|
||||
authorizationParams(options: OIDCOptions) {
|
||||
return {
|
||||
...options.originalQuery,
|
||||
...super.authorizationParams?.(options),
|
||||
|
||||
@@ -1,22 +1,18 @@
|
||||
import * as React from "react";
|
||||
import { isSafari } from "../../utils/browser";
|
||||
import { BaseIconProps } from ".";
|
||||
|
||||
/** Renders an icon for a specific GitLab issue state */
|
||||
export function GitLabIssueStatusIcon(props: BaseIconProps) {
|
||||
// No theme needed for this component
|
||||
const { state } = props;
|
||||
const { state, className, size = 16 } = props;
|
||||
const isOpen = state.name === "opened";
|
||||
const color = state.color || (isOpen ? "#1aaa55" : "#db3b21"); // Green for open, red for closed
|
||||
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
width={size}
|
||||
height={size}
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
style={{ marginTop: isSafari() ? 0 : -2 }}
|
||||
className={className}
|
||||
>
|
||||
<circle cx="8" cy="8" r="7" stroke={color} strokeWidth="2" fill="none" />
|
||||
{!isOpen && (
|
||||
@@ -27,7 +23,7 @@ export function GitLabIssueStatusIcon(props: BaseIconProps) {
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
)}
|
||||
{state.draft && (
|
||||
{"draft" in state && state.draft && (
|
||||
<rect x="4" y="7" width="8" height="2" rx="1" fill={color} />
|
||||
)}
|
||||
</svg>
|
||||
|
||||
+9
-2
@@ -141,7 +141,9 @@ export const ImportableIntegrationService = {
|
||||
|
||||
export type IssueTrackerIntegrationService = Extract<
|
||||
IntegrationService,
|
||||
IntegrationService.GitHub | IntegrationService.Linear | IntegrationService.GitLab
|
||||
| IntegrationService.GitHub
|
||||
| IntegrationService.Linear
|
||||
| IntegrationService.GitLab
|
||||
>;
|
||||
|
||||
export const IssueTrackerIntegrationService = {
|
||||
@@ -192,7 +194,12 @@ export type IntegrationSettings<T> = T extends IntegrationType.Embed
|
||||
workspace: { id: string; name: string; key: string; logoUrl?: string };
|
||||
};
|
||||
gitlab?: {
|
||||
project: { id: string; name: string; path_with_namespace: string; avatar_url?: string };
|
||||
project: {
|
||||
id: string;
|
||||
name: string;
|
||||
path_with_namespace: string;
|
||||
avatar_url?: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
: T extends IntegrationType.Analytics
|
||||
|
||||
Reference in New Issue
Block a user