refactor design

This commit is contained in:
Philipinho
2026-02-23 02:33:03 +00:00
parent 04f218833f
commit 5c3e715a10
17 changed files with 448 additions and 92 deletions
+2
View File
@@ -38,6 +38,7 @@ import UserApiKeys from "@/ee/api-key/pages/user-api-keys";
import WorkspaceApiKeys from "@/ee/api-key/pages/workspace-api-keys";
import AiSettings from "@/ee/ai/pages/ai-settings.tsx";
import Integrations from "@/features/integration/pages/integrations.tsx";
import Connections from "@/features/integration/pages/connections.tsx";
export default function App() {
const { t } = useTranslation();
@@ -94,6 +95,7 @@ export default function App() {
element={<AccountPreferences />}
/>
<Route path={"account/api-keys"} element={<UserApiKeys />} />
<Route path={"account/connections"} element={<Connections />} />
<Route path={"workspace"} element={<WorkspaceSettings />} />
<Route path={"members"} element={<WorkspaceMembers />} />
<Route path={"api-keys"} element={<WorkspaceApiKeys />} />
@@ -10,6 +10,7 @@ export { LinearIcon } from "./linear-icon.tsx";
export { TypeformIcon } from "./typeform-icon.tsx";
export { VimeoIcon } from "./vimeo-icon.tsx";
export { MiroIcon } from "./miro-icon.tsx";
export { SlackIcon } from "./slack-icon.tsx";
export { FramerIcon } from "./framer-icon.tsx";
export { LoomIcon } from "./loom-icon.tsx";
export { YoutubeIcon } from "./youtube-icon.tsx";
@@ -0,0 +1,32 @@
import { rem } from '@mantine/core';
interface Props {
size?: number | string;
}
export function SlackIcon({ size }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 512 512"
style={{ width: rem(size), height: rem(size) }}
>
<path
d="M107.9 323.6c0 29.7-24 53.8-53.8 53.8S.3 353.4.3 323.6c0-29.7 24-53.8 53.8-53.8h53.8zm26.9 0c0-29.7 24-53.8 53.8-53.8s53.8 24 53.8 53.8V458c0 29.7-24 53.8-53.8 53.8s-53.8-24-53.8-53.8z"
fill="#e01e5a"
/>
<path
d="M188.6 107.7c-29.7 0-53.8-24-53.8-53.8S158.8.1 188.6.1s53.8 24 53.8 53.8v53.8zm0 27.3c29.7 0 53.8 24 53.8 53.8s-24 53.8-53.8 53.8H53.8C24 242.6 0 218.5 0 188.8S24 135 53.8 135z"
fill="#36c5f0"
/>
<path
d="M404.1 188.8c0-29.7 24-53.8 53.8-53.8s53.8 24 53.8 53.8-24 53.8-53.8 53.8h-53.8zm-26.9 0c0 29.7-24 53.8-53.8 53.8-29.7 0-53.8-24-53.8-53.8V54c0-29.7 24-53.8 53.8-53.8s53.8 24 53.8 53.8z"
fill="#2eb67d"
/>
<path
d="M323.4 404.3c29.7 0 53.8 24 53.8 53.8 0 29.7-24 53.8-53.8 53.8-29.7 0-53.8-24-53.8-53.8v-53.8zm0-26.9c-29.7 0-53.8-24-53.8-53.8s24-53.8 53.8-53.8h134.8c29.7 0 53.8 24 53.8 53.8 0 29.7-24 53.8-53.8 53.8z"
fill="#ecb22e"
/>
</svg>
);
}
@@ -72,6 +72,11 @@ const groupedData: DataGroup[] = [
isEnterprise: true,
showDisabledInNonEE: true,
},
{
label: "Connections",
icon: IconPlug,
path: "/settings/account/connections",
},
],
},
{
@@ -9,15 +9,8 @@ import {
Anchor,
Stack,
} from "@mantine/core";
import { useEffect, useCallback, memo, type ReactNode } from "react";
import {
FigmaIcon,
GithubIcon,
GitlabIcon,
GoogleDocsIcon,
JiraIcon,
LinearIcon,
} from "@/components/icons";
import { useEffect, useCallback, memo } from "react";
import { getIntegrationIcon } from "@/features/integration/components/integration-icons";
import { unfurlUrl } from "@/features/integration/services/integration-service";
import classes from "./integration-link-view.module.css";
@@ -43,15 +36,6 @@ function toBadgeColor(raw?: string): string {
return raw;
}
const providerIcons: Record<string, (size: number) => ReactNode> = {
github: (size) => <GithubIcon size={size} />,
gitlab: (size) => <GitlabIcon size={size} />,
figma: (size) => <FigmaIcon size={size} />,
linear: (size) => <LinearIcon size={size} />,
jira: (size) => <JiraIcon size={size} />,
google_docs: (size) => <GoogleDocsIcon size={size} />,
};
function IntegrationLinkView(props: any) {
const { node, updateAttributes, editor } = props;
const { url, provider, unfurlData, status } = node.attrs;
@@ -108,8 +92,6 @@ function IntegrationLinkView(props: any) {
);
}
const renderIcon = providerIcons[provider];
return (
<NodeViewWrapper data-drag-handle="">
<Card
@@ -126,9 +108,9 @@ function IntegrationLinkView(props: any) {
<Group gap="sm" wrap="nowrap">
{unfurlData.authorAvatarUrl ? (
<Avatar src={unfurlData.authorAvatarUrl} size={28} radius="xl" style={{ flexShrink: 0 }} />
) : renderIcon ? (
<div style={{ flexShrink: 0 }}>{renderIcon(28)}</div>
) : null}
) : (
<div style={{ flexShrink: 0 }}>{getIntegrationIcon(provider, 28)}</div>
)}
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
<Group gap="xs" wrap="nowrap">
@@ -154,9 +136,9 @@ function IntegrationLinkView(props: any) {
)}
</Stack>
{renderIcon && (
{provider && (
<div style={{ flexShrink: 0, alignSelf: "center" }}>
{renderIcon(18)}
{getIntegrationIcon(provider, 18)}
</div>
)}
</Group>
@@ -0,0 +1,83 @@
import { Group, Text, Button, Box } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { IntegrationDefinition, UserConnection } from "../types/integration.types";
import { getIntegrationIcon } from "./integration-icons";
type ConnectionRowProps = {
definition: IntegrationDefinition;
connection?: UserConnection;
installed: boolean;
onConnect: (integrationId: string) => void;
onDisconnect: (integrationId: string) => void;
isDisconnecting?: boolean;
};
export default function ConnectionRow({
definition,
connection,
installed,
onConnect,
onDisconnect,
isDisconnecting,
}: ConnectionRowProps) {
const { t } = useTranslation();
const isConnected = !!connection;
const isAvailable = installed && (connection?.isEnabled ?? true);
return (
<Box
py="sm"
px="xs"
style={{
borderBottom: "1px solid var(--mantine-color-default-border)",
opacity: isAvailable || isConnected ? 1 : 0.5,
}}
>
<Group justify="space-between" wrap="nowrap">
<Group gap="sm" wrap="nowrap">
{getIntegrationIcon(definition.type, 28)}
<div>
<Text size="sm" fw={500}>
{definition.name}
</Text>
<Text size="xs" c="dimmed">
{definition.description}
</Text>
</div>
</Group>
<Group gap="sm" wrap="nowrap" style={{ flexShrink: 0 }}>
{isConnected ? (
<>
<Text size="xs" c="green">
{t("Connected")}
{connection.providerUserId && ` (${connection.providerUserId})`}
</Text>
<Button
size="xs"
variant="subtle"
color="red"
onClick={() => onDisconnect(connection.integrationId)}
loading={isDisconnecting}
>
{t("Disconnect")}
</Button>
</>
) : isAvailable ? (
<Button
size="xs"
variant="light"
onClick={() => onConnect(definition.type)}
>
{t("Connect")}
</Button>
) : (
<Text size="xs" c="dimmed">
{t("Not available")}
</Text>
)}
</Group>
</Group>
</Box>
);
}
@@ -0,0 +1,30 @@
import { ReactNode } from "react";
import {
FigmaIcon,
GithubIcon,
GitlabIcon,
GoogleDocsIcon,
JiraIcon,
LinearIcon,
SlackIcon,
} from "@/components/icons";
import { IconPuzzle } from "@tabler/icons-react";
const integrationIconMap: Record<string, (size: number) => ReactNode> = {
github: (size) => <GithubIcon size={size} />,
gitlab: (size) => <GitlabIcon size={size} />,
slack: (size) => <SlackIcon size={size} />,
linear: (size) => <LinearIcon size={size} />,
jira: (size) => <JiraIcon size={size} />,
figma: (size) => <FigmaIcon size={size} />,
google_docs: (size) => <GoogleDocsIcon size={size} />,
};
export function getIntegrationIcon(
type: string,
size: number,
): ReactNode {
const renderIcon = integrationIconMap[type];
if (renderIcon) return renderIcon(size);
return <IconPuzzle size={size} stroke={1.5} />;
}
@@ -0,0 +1,96 @@
import { Group, Text, Badge, Button, Switch, Box, Stack } from "@mantine/core";
import { useTranslation } from "react-i18next";
import {
IntegrationDefinition,
Integration,
} from "../types/integration.types";
import { getIntegrationIcon } from "./integration-icons";
type IntegrationRowProps = {
definition: IntegrationDefinition;
installation?: Integration;
onInstall: (type: string) => void;
onUninstall: (integrationId: string) => void;
onConfigure: (integration: Integration) => void;
onToggle: (integration: Integration, enabled: boolean) => void;
};
export default function IntegrationRow({
definition,
installation,
onInstall,
onUninstall,
onConfigure,
onToggle,
}: IntegrationRowProps) {
const { t } = useTranslation();
const isInstalled = !!installation;
return (
<Box
py="sm"
px="xs"
style={{
borderBottom: "1px solid var(--mantine-color-default-border)",
}}
>
<Group justify="space-between" wrap="nowrap">
<Group gap="sm" wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
{getIntegrationIcon(definition.type, 28)}
<Stack gap={2} style={{ minWidth: 0 }}>
<Group gap="xs" wrap="nowrap">
<Text size="sm" fw={500}>
{definition.name}
</Text>
{definition.capabilities.map((cap) => (
<Badge key={cap} size="xs" variant="light">
{cap}
</Badge>
))}
</Group>
<Text size="xs" c="dimmed" truncate>
{definition.description}
</Text>
</Stack>
</Group>
<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="light"
onClick={() => onConfigure(installation)}
>
{t("Configure")}
</Button>
<Button
size="xs"
variant="subtle"
color="red"
onClick={() => onUninstall(installation.id)}
>
{t("Uninstall")}
</Button>
</>
) : (
<Button
size="xs"
variant="light"
onClick={() => onInstall(definition.type)}
>
{t("Install")}
</Button>
)}
</Group>
</Group>
</Box>
);
}
@@ -1,12 +1,6 @@
import { Modal, Button, Group, Stack, TextInput, Text } from "@mantine/core";
import { notifications } from "@mantine/notifications";
import { Modal, Stack, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { Integration, ConnectionStatus } from "../types/integration.types";
import {
useConnectionStatus,
useDisconnectIntegration,
} from "../queries/integration-query";
import * as integrationService from "../services/integration-service";
import { Integration } from "../types/integration.types";
type IntegrationSettingsModalProps = {
integration: Integration | null;
@@ -20,34 +14,9 @@ export default function IntegrationSettingsModal({
onClose,
}: IntegrationSettingsModalProps) {
const { t } = useTranslation();
const { data: connectionStatus } = useConnectionStatus(integration?.id);
const disconnectMutation = useDisconnectIntegration();
if (!integration) return null;
const handleConnect = async () => {
try {
const result = await integrationService.getOAuthAuthorizeUrl({
integrationId: integration.id,
});
window.location.href = result.authorizationUrl;
} catch (error) {
const errorMessage = error["response"]?.data?.message;
notifications.show({
message: errorMessage || t("Failed to start OAuth connection"),
color: "red",
});
}
};
const handleDisconnect = async () => {
await disconnectMutation.mutateAsync({
integrationId: integration.id,
});
};
const hasOAuth = true;
return (
<Modal
opened={opened}
@@ -56,35 +25,9 @@ export default function IntegrationSettingsModal({
size="md"
>
<Stack gap="md">
{hasOAuth && (
<div>
<Text size="sm" fw={600} mb="xs">
{t("Connection")}
</Text>
{connectionStatus?.connected ? (
<Group gap="sm">
<Text size="sm" c="green">
{t("Connected")}
{connectionStatus.providerUserId &&
` (${connectionStatus.providerUserId})`}
</Text>
<Button
size="xs"
variant="subtle"
color="red"
onClick={handleDisconnect}
loading={disconnectMutation.isPending}
>
{t("Disconnect")}
</Button>
</Group>
) : (
<Button size="xs" variant="light" onClick={handleConnect}>
{t("Connect")} {integration.type}
</Button>
)}
</div>
)}
<Text size="sm" c="dimmed">
{t("Integration settings will appear here.")}
</Text>
</Stack>
</Modal>
);
@@ -0,0 +1,106 @@
import { Text, Loader, Center, Alert, Stack } from "@mantine/core";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import { notifications } from "@mantine/notifications";
import { getAppName } from "@/lib/config";
import SettingsTitle from "@/components/settings/settings-title";
import ConnectionRow from "../components/connection-row";
import {
useAvailableIntegrations,
useInstalledIntegrations,
useMyConnections,
useDisconnectIntegration,
} from "../queries/integration-query";
import * as integrationService from "../services/integration-service";
export default function Connections() {
const { t } = useTranslation();
const { data: available, isLoading: loadingAvailable } =
useAvailableIntegrations();
const { data: installed, isLoading: loadingInstalled } =
useInstalledIntegrations();
const { data: myConnections, isLoading: loadingConnections } =
useMyConnections();
const disconnectMutation = useDisconnectIntegration();
const isLoading = loadingAvailable || loadingInstalled || loadingConnections;
const handleConnect = async (type: string) => {
const integration = installed?.find((i) => i.type === type);
if (!integration) return;
try {
const result = await integrationService.getOAuthAuthorizeUrl({
integrationId: integration.id,
});
window.location.href = result.authorizationUrl;
} catch (error) {
const errorMessage = error["response"]?.data?.message;
notifications.show({
message: errorMessage || t("Failed to start OAuth connection"),
color: "red",
});
}
};
const handleDisconnect = (integrationId: string) => {
disconnectMutation.mutate({ integrationId });
};
const error = new URLSearchParams(window.location.search).get("error");
return (
<>
<Helmet>
<title>
{t("Connections")} - {getAppName()}
</title>
</Helmet>
<SettingsTitle title={t("Connections")} />
<Text size="sm" c="dimmed" mb="md">
{t("Manage the apps you have connected to your account.")}
</Text>
{error === "oauth_failed" && (
<Alert color="red" mb="md">
{t("OAuth connection failed. Please try again.")}
</Alert>
)}
{isLoading ? (
<Center py="xl">
<Loader />
</Center>
) : !available?.length ? (
<Text c="dimmed" size="sm">
{t("No integrations available.")}
</Text>
) : (
<Stack gap={0}>
{available
.filter((def) => def.capabilities.includes("oauth"))
.map((def) => {
const installation = installed?.find((i) => i.type === def.type);
const connection = myConnections?.find(
(c) => c.type === def.type,
);
return (
<ConnectionRow
key={def.type}
definition={def}
connection={connection}
installed={!!installation}
onConnect={handleConnect}
onDisconnect={handleDisconnect}
isDisconnecting={disconnectMutation.isPending}
/>
);
})}
</Stack>
)}
</>
);
}
@@ -1,10 +1,10 @@
import { SimpleGrid, Text, Loader, Center, Alert } from "@mantine/core";
import { Text, Loader, Center, Alert, Stack } from "@mantine/core";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import { useState, useCallback } from "react";
import { getAppName } from "@/lib/config";
import SettingsTitle from "@/components/settings/settings-title";
import IntegrationCard from "../components/integration-card";
import IntegrationRow from "../components/integration-row";
import IntegrationSettingsModal from "../components/integration-settings-modal";
import {
useAvailableIntegrations,
@@ -68,6 +68,10 @@ export default function Integrations() {
<SettingsTitle title={t("Integrations")} />
<Text size="sm" c="dimmed" mb="md">
{t("Manage workspace integrations.")}
</Text>
{error === "oauth_failed" && (
<Alert color="red" mb="md">
{t("OAuth connection failed. Please try again.")}
@@ -83,11 +87,11 @@ export default function Integrations() {
{t("No integrations available.")}
</Text>
) : (
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<Stack gap={0}>
{available.map((def) => {
const installation = installed?.find((i) => i.type === def.type);
return (
<IntegrationCard
<IntegrationRow
key={def.type}
definition={def}
installation={installation}
@@ -98,7 +102,7 @@ export default function Integrations() {
/>
);
})}
</SimpleGrid>
</Stack>
)}
<IntegrationSettingsModal
@@ -76,6 +76,13 @@ export function useUpdateIntegrationSettings() {
});
}
export function useMyConnections() {
return useQuery({
queryKey: ["my-connections"],
queryFn: integrationService.getMyConnections,
});
}
export function useConnectionStatus(integrationId: string | undefined) {
return useQuery({
queryKey: ["integration-connection", integrationId],
@@ -3,6 +3,7 @@ import {
IntegrationDefinition,
Integration,
ConnectionStatus,
UserConnection,
UnfurlResult,
} from "../types/integration.types";
@@ -42,6 +43,11 @@ export async function updateIntegrationSettings(data: {
return req.data;
}
export async function getMyConnections(): Promise<UserConnection[]> {
const req = await api.post<UserConnection[]>("/integrations/connections/mine");
return req.data;
}
export async function getConnectionStatus(data: {
integrationId: string;
}): Promise<ConnectionStatus> {
@@ -24,6 +24,14 @@ export type ConnectionStatus = {
providerUserId?: string;
};
export type UserConnection = {
integrationId: string;
type: string;
isEnabled: boolean;
providerUserId: string | null;
connectedAt: string;
};
export type UnfurlResult = {
title: string;
description?: string;
@@ -50,6 +50,21 @@ export class IntegrationConnectionService {
);
}
async getUserConnections(userId: string, workspaceId: string) {
const rows = await this.connectionRepo.findByUserAndWorkspace(
userId,
workspaceId,
);
return rows.map((row) => ({
integrationId: row.integrationId,
type: row.type,
isEnabled: row.isEnabled,
providerUserId: row.providerUserId ?? null,
connectedAt: row.createdAt,
}));
}
async disconnect(
integrationId: string,
userId: string,
@@ -116,6 +116,16 @@ export class IntegrationController {
});
}
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('connections/mine')
async getMyConnections(
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
return this.connectionService.getUserConnections(user.id, workspace.id);
}
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('connection/status')
@@ -122,6 +122,32 @@ export class IntegrationConnectionRepo {
.execute();
}
async findByUserAndWorkspace(
userId: string,
workspaceId: string,
trx?: KyselyTransaction,
) {
const db = dbOrTx(this.db, trx);
return db
.selectFrom('integrationConnections')
.innerJoin(
'integrations',
'integrations.id',
'integrationConnections.integrationId',
)
.select([
'integrationConnections.integrationId',
'integrations.type',
'integrations.isEnabled',
'integrationConnections.providerUserId',
'integrationConnections.createdAt',
])
.where('integrationConnections.userId', '=', userId)
.where('integrations.workspaceId', '=', workspaceId)
.where('integrations.deletedAt', 'is', null)
.execute();
}
async deleteByIntegration(
integrationId: string,
trx?: KyselyTransaction,