mirror of
https://github.com/docmost/docmost.git
synced 2026-08-22 00:01:38 +10:00
wip
This commit is contained in:
@@ -45,7 +45,9 @@
|
||||
"Date": "Date",
|
||||
"Delete": "Delete",
|
||||
"Initiative": "Initiative",
|
||||
"Last modified by {{name}}": "Last modified by {{name}}",
|
||||
"Open in Slack": "Open in Slack",
|
||||
"Paid": "Paid",
|
||||
"Paste as": "Paste as",
|
||||
"Project": "Project",
|
||||
"Remove from page": "Remove from page",
|
||||
@@ -217,6 +219,7 @@
|
||||
"Theme": "Theme",
|
||||
"To change your email, you have to enter your password and new email.": "To change your email, you have to enter your password and new email.",
|
||||
"Toggle full page width": "Toggle full page width",
|
||||
"Toggle {{name}} integration": "Toggle {{name}} integration",
|
||||
"Unable to import pages. Please try again.": "Unable to import pages. Please try again.",
|
||||
"Unassigned": "Unassigned",
|
||||
"untitled": "untitled",
|
||||
|
||||
@@ -22,4 +22,5 @@ export const Feature = {
|
||||
PERSONAL_SPACES: 'spaces:personal',
|
||||
DOCX_EXPORT: 'export:docx',
|
||||
BASES: 'bases',
|
||||
INTEGRATIONS: 'integrations',
|
||||
} as const;
|
||||
|
||||
@@ -26,15 +26,14 @@ const ATTACHMENT_NODE_TYPES = [
|
||||
|
||||
const ATTACHMENT_URL_RE = /\/api\/files\/([0-9a-f-]+)\//;
|
||||
|
||||
// Only installed + enabled providers get card treatment; anything else pastes
|
||||
// as an ordinary link. The cache is prefetched when the page editor mounts;
|
||||
// Only installed providers get card treatment; anything else pastes as an
|
||||
// ordinary link. The cache is prefetched when the page editor mounts;
|
||||
// a cold cache also means ordinary link.
|
||||
function isIntegrationInstalled(provider: string): boolean {
|
||||
const installed = queryClient.getQueryData<Integration[]>([
|
||||
"installed-integrations",
|
||||
]);
|
||||
const integration = installed?.find((i) => i.type === provider);
|
||||
return Boolean(integration?.isEnabled);
|
||||
return Boolean(installed?.some((i) => i.type === provider));
|
||||
}
|
||||
|
||||
export const handlePaste = (
|
||||
|
||||
+12
@@ -13,6 +13,18 @@
|
||||
background-color: var(--mantine-color-dark-5);
|
||||
}
|
||||
|
||||
.thumbnail {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-height: 320px;
|
||||
object-fit: cover;
|
||||
background-color: var(--mantine-color-gray-0);
|
||||
}
|
||||
|
||||
:global([data-mantine-color-scheme="dark"]) .thumbnail {
|
||||
background-color: var(--mantine-color-dark-6);
|
||||
}
|
||||
|
||||
.mention {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -215,6 +215,85 @@ function JiraIssueCard({
|
||||
);
|
||||
}
|
||||
|
||||
function FigmaFileCard({
|
||||
url,
|
||||
unfurlData,
|
||||
}: {
|
||||
url: string;
|
||||
unfurlData: Record<string, any>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
// Figma thumbnail links are pre-signed and expire; drop the preview rather
|
||||
// than render a broken image.
|
||||
const [thumbnailFailed, setThumbnailFailed] = useState(false);
|
||||
const meta = unfurlData.metadata ?? {};
|
||||
const thumbnailUrl: string | undefined = meta.thumbnailUrl;
|
||||
const showThumbnail = Boolean(thumbnailUrl) && !thumbnailFailed;
|
||||
|
||||
const subtitle = [
|
||||
unfurlData.author
|
||||
? t("Last modified by {{name}}", { name: unfurlData.author })
|
||||
: unfurlData.description,
|
||||
meta.lastModified ? timeAgo(new Date(meta.lastModified)) : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" • ");
|
||||
|
||||
return (
|
||||
<NodeViewWrapper data-drag-handle="">
|
||||
<Card
|
||||
className={classes.card}
|
||||
withBorder
|
||||
padding="sm"
|
||||
radius="sm"
|
||||
component="a"
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
style={{ textDecoration: "none", color: "inherit" }}
|
||||
>
|
||||
{showThumbnail && (
|
||||
<Card.Section withBorder>
|
||||
<img
|
||||
src={thumbnailUrl}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className={classes.thumbnail}
|
||||
onError={() => setThumbnailFailed(true)}
|
||||
/>
|
||||
</Card.Section>
|
||||
)}
|
||||
|
||||
<Group gap="sm" wrap="nowrap" mt={showThumbnail ? "sm" : undefined}>
|
||||
<Avatar
|
||||
src={unfurlData.authorAvatarUrl}
|
||||
size={28}
|
||||
radius="xl"
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
{(unfurlData.author ?? unfurlData.title ?? "F").charAt(0)}
|
||||
</Avatar>
|
||||
|
||||
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text size="sm" fw={600} truncate>
|
||||
{unfurlData.title}
|
||||
</Text>
|
||||
{subtitle && (
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{subtitle}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<div style={{ flexShrink: 0, alignSelf: "center" }}>
|
||||
{getIntegrationIcon("figma", 18)}
|
||||
</div>
|
||||
</Group>
|
||||
</Card>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
function IntegrationLinkView(props: any) {
|
||||
const { node } = props;
|
||||
const { url, provider } = node.attrs;
|
||||
@@ -331,6 +410,10 @@ function IntegrationLinkView(props: any) {
|
||||
return <JiraIssueCard url={url} unfurlData={unfurlData} />;
|
||||
}
|
||||
|
||||
if (provider === "figma") {
|
||||
return <FigmaFileCard url={url} unfurlData={unfurlData} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<NodeViewWrapper data-drag-handle="">
|
||||
<Card
|
||||
|
||||
@@ -60,10 +60,26 @@ export default function ConnectionRow({
|
||||
<>
|
||||
{connection ? (
|
||||
<>
|
||||
<Text size="xs" c="green">
|
||||
{t("Connected")}
|
||||
{connection.providerUserId && ` (${connection.providerUserId})`}
|
||||
</Text>
|
||||
{connection.invalidatedAt ? (
|
||||
<>
|
||||
<Text size="xs" c="orange">
|
||||
{t("Connection expired")}
|
||||
</Text>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="orange"
|
||||
onClick={() => onConnect(definition.type)}
|
||||
>
|
||||
{t("Reconnect")}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Text size="xs" c="green">
|
||||
{t("Connected")}
|
||||
{connection.providerUserId && ` (${connection.providerUserId})`}
|
||||
</Text>
|
||||
)}
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
|
||||
@@ -1,17 +1,27 @@
|
||||
import { Group, Text, Badge, Button, Switch, Box, Stack } from "@mantine/core";
|
||||
import {
|
||||
Group,
|
||||
Text,
|
||||
Badge,
|
||||
Button,
|
||||
Box,
|
||||
Stack,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
IntegrationDefinition,
|
||||
Integration,
|
||||
} from "../types/integration.types";
|
||||
import { getIntegrationIcon } from "./integration-icons";
|
||||
import { useHasFeature } from "@/ee/hooks/use-feature";
|
||||
import { Feature } from "@/ee/features";
|
||||
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
|
||||
|
||||
type IntegrationRowProps = {
|
||||
definition: IntegrationDefinition;
|
||||
installation?: Integration;
|
||||
onInstall: (type: string) => void;
|
||||
onUninstall: (integrationId: string) => void;
|
||||
onToggle: (integration: Integration, enabled: boolean) => void;
|
||||
};
|
||||
|
||||
export default function IntegrationRow({
|
||||
@@ -19,10 +29,12 @@ export default function IntegrationRow({
|
||||
installation,
|
||||
onInstall,
|
||||
onUninstall,
|
||||
onToggle,
|
||||
}: IntegrationRowProps) {
|
||||
const { t } = useTranslation();
|
||||
const isInstalled = !!installation;
|
||||
const hasAccess = useHasFeature(Feature.INTEGRATIONS);
|
||||
const locked = !!definition.requiresLicense && !hasAccess;
|
||||
const upgradeLabel = useUpgradeLabel();
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -40,6 +52,11 @@ export default function IntegrationRow({
|
||||
<Text size="sm" fw={500}>
|
||||
{definition.name}
|
||||
</Text>
|
||||
{locked && (
|
||||
<Badge size="xs" variant="light" color="violet">
|
||||
{t("Paid")}
|
||||
</Badge>
|
||||
)}
|
||||
{definition.capabilities.map((cap) => (
|
||||
<Badge key={cap} size="xs" variant="light">
|
||||
{cap}
|
||||
@@ -54,31 +71,25 @@ export default function IntegrationRow({
|
||||
|
||||
<Group gap="sm" wrap="nowrap" style={{ flexShrink: 0 }}>
|
||||
{isInstalled ? (
|
||||
<>
|
||||
<Switch
|
||||
checked={installation.isEnabled}
|
||||
onChange={(e) =>
|
||||
onToggle(installation, e.currentTarget.checked)
|
||||
}
|
||||
size="sm"
|
||||
/>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={() => onUninstall(installation.id)}
|
||||
>
|
||||
{t("Uninstall")}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
onClick={() => onInstall(definition.type)}
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={() => onUninstall(installation.id)}
|
||||
>
|
||||
{t("Install")}
|
||||
{t("Uninstall")}
|
||||
</Button>
|
||||
) : (
|
||||
<Tooltip label={upgradeLabel} disabled={!locked}>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
disabled={locked}
|
||||
onClick={() => onInstall(definition.type)}
|
||||
>
|
||||
{t("Install")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
@@ -81,8 +81,7 @@ export default function Connections() {
|
||||
{available
|
||||
.filter((def) => {
|
||||
if (!def.capabilities.includes("oauth")) return false;
|
||||
const installation = installed?.find((i) => i.type === def.type);
|
||||
return installation?.isEnabled;
|
||||
return installed?.some((i) => i.type === def.type);
|
||||
})
|
||||
.map((def) => {
|
||||
const connection = myConnections?.find(
|
||||
|
||||
@@ -11,13 +11,12 @@ import {
|
||||
useInstalledIntegrations,
|
||||
useInstallIntegration,
|
||||
useUninstallIntegration,
|
||||
useUpdateIntegrationSettings,
|
||||
} from "../queries/integration-query";
|
||||
import { Integration } from "../types/integration.types";
|
||||
import {
|
||||
getOAuthAuthorizeUrl,
|
||||
getOAuthInstallUrl,
|
||||
} from "../services/integration-service";
|
||||
import { Integration } from "../types/integration.types";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
|
||||
export default function Integrations() {
|
||||
@@ -28,7 +27,6 @@ export default function Integrations() {
|
||||
useInstalledIntegrations();
|
||||
const installMutation = useInstallIntegration();
|
||||
const uninstallMutation = useUninstallIntegration();
|
||||
const updateMutation = useUpdateIntegrationSettings();
|
||||
|
||||
const handleInstall = useCallback(
|
||||
async (type: string) => {
|
||||
@@ -50,10 +48,34 @@ export default function Integrations() {
|
||||
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 });
|
||||
// Per-user OAuth providers (GitLab, Jira, GitHub, ...): create the
|
||||
// integration row, then send the installing admin straight into their
|
||||
// own OAuth so they leave with a working connection. Other members
|
||||
// connect for themselves from /settings/account/connections.
|
||||
let integration: Integration;
|
||||
try {
|
||||
integration = await installMutation.mutateAsync({ type });
|
||||
} catch {
|
||||
return; // the mutation reports its own failure
|
||||
}
|
||||
|
||||
if (!definition?.capabilities?.includes("oauth")) return;
|
||||
|
||||
try {
|
||||
const { authorizationUrl } = await getOAuthAuthorizeUrl({
|
||||
integrationId: integration.id,
|
||||
returnPath: "/settings/integrations",
|
||||
});
|
||||
window.location.href = authorizationUrl;
|
||||
} catch (err: any) {
|
||||
// The integration stays installed; the admin can connect later.
|
||||
notifications.show({
|
||||
message:
|
||||
err?.response?.data?.message ??
|
||||
t("Failed to start OAuth connection"),
|
||||
color: "red",
|
||||
});
|
||||
}
|
||||
},
|
||||
[installMutation, available, t],
|
||||
);
|
||||
@@ -65,16 +87,6 @@ export default function Integrations() {
|
||||
[uninstallMutation],
|
||||
);
|
||||
|
||||
const handleToggle = useCallback(
|
||||
(integration: Integration, enabled: boolean) => {
|
||||
updateMutation.mutate({
|
||||
integrationId: integration.id,
|
||||
isEnabled: enabled,
|
||||
});
|
||||
},
|
||||
[updateMutation],
|
||||
);
|
||||
|
||||
const isLoading = loadingAvailable || loadingInstalled;
|
||||
const error = new URLSearchParams(window.location.search).get("error");
|
||||
|
||||
@@ -115,7 +127,6 @@ export default function Integrations() {
|
||||
installation={installation}
|
||||
onInstall={handleInstall}
|
||||
onUninstall={handleUninstall}
|
||||
onToggle={handleToggle}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -57,25 +57,6 @@ export function useUninstallIntegration() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateIntegrationSettings() {
|
||||
const qc = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
return useMutation({
|
||||
mutationFn: integrationService.updateIntegrationSettings,
|
||||
onSuccess: () => {
|
||||
notifications.show({ message: t("Integration updated successfully") });
|
||||
qc.invalidateQueries({ queryKey: ["installed-integrations"] });
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = error["response"]?.data?.message;
|
||||
notifications.show({
|
||||
message: errorMessage || t("Failed to update integration"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useMyConnections() {
|
||||
return useQuery({
|
||||
queryKey: ["my-connections"],
|
||||
|
||||
@@ -35,15 +35,6 @@ export async function uninstallIntegration(data: {
|
||||
await api.post("/integrations/uninstall", data);
|
||||
}
|
||||
|
||||
export async function updateIntegrationSettings(data: {
|
||||
integrationId: string;
|
||||
settings?: Record<string, any>;
|
||||
isEnabled?: boolean;
|
||||
}): Promise<Integration> {
|
||||
const req = await api.post<Integration>("/integrations/update", data);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function getMyConnections(): Promise<UserConnection[]> {
|
||||
const req = await api.post<UserConnection[]>("/integrations/connections/mine");
|
||||
return req.data;
|
||||
|
||||
@@ -14,13 +14,13 @@ export type IntegrationDefinition = {
|
||||
icon: string;
|
||||
capabilities: IntegrationCapability[];
|
||||
oauth?: OAuthConfig;
|
||||
requiresLicense?: boolean;
|
||||
};
|
||||
|
||||
export type Integration = {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
type: string;
|
||||
isEnabled: boolean;
|
||||
settings: Record<string, any> | null;
|
||||
installedById: string | null;
|
||||
createdAt: string;
|
||||
@@ -35,9 +35,9 @@ export type ConnectionStatus = {
|
||||
export type UserConnection = {
|
||||
integrationId: string;
|
||||
type: string;
|
||||
isEnabled: boolean;
|
||||
providerUserId: string | null;
|
||||
connectedAt: string;
|
||||
invalidatedAt: string | null;
|
||||
};
|
||||
|
||||
export type UnfurlResult = {
|
||||
|
||||
Reference in New Issue
Block a user