feat(integrations): clarify REST Shopify and mapping workflows
This commit is contained in:
Generated
+42
-21
@@ -86,7 +86,6 @@
|
||||
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.7",
|
||||
"@babel/generator": "^7.29.7",
|
||||
@@ -302,6 +301,27 @@
|
||||
"integrity": "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.11.3",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz",
|
||||
"integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.2.3",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.11.3",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
|
||||
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz",
|
||||
@@ -625,6 +645,7 @@
|
||||
"integrity": "sha512-h5LUOzGArYemnW1NMz/DuuQhBi96J6JL2Bk8zE4kvqxB5Sg3jxmCiH4uyOWHDkiKSt5vWlG4FIwCR/DbstcNRQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/Boshen"
|
||||
}
|
||||
@@ -1361,6 +1382,7 @@
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
@@ -1378,6 +1400,7 @@
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
@@ -1395,6 +1418,7 @@
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
@@ -1412,6 +1436,7 @@
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
@@ -1429,6 +1454,7 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
@@ -1446,6 +1472,7 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
@@ -1463,6 +1490,7 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
@@ -1480,6 +1508,7 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
@@ -1497,6 +1526,7 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
@@ -1514,6 +1544,7 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
@@ -1531,6 +1562,7 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
@@ -1548,6 +1580,7 @@
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
@@ -1562,6 +1595,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@emnapi/core": "1.11.2",
|
||||
"@emnapi/runtime": "1.11.2",
|
||||
@@ -1578,6 +1612,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.2.2",
|
||||
"tslib": "^2.4.0"
|
||||
@@ -1590,6 +1625,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
@@ -1601,6 +1637,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
@@ -1618,6 +1655,7 @@
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
@@ -1635,6 +1673,7 @@
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
@@ -1645,7 +1684,6 @@
|
||||
"integrity": "sha512-+zEk16yGlz1F9STiRr6uG9hmIXb6nprjLczV/htGptYuLoCuxb+itZ03RKCEeOhBpDDd1NU7qF6x1VLMUp62bw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"picomatch": "^4.0.4"
|
||||
},
|
||||
@@ -2121,7 +2159,6 @@
|
||||
"integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~7.18.0"
|
||||
}
|
||||
@@ -2131,7 +2168,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
|
||||
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
@@ -2142,7 +2178,6 @@
|
||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
@@ -2198,7 +2233,6 @@
|
||||
"integrity": "sha512-PJ5vePq5/ognBbrIcoC5+SHO5dfpeLPzP9FpLkzWrguoYQEeeSjlJpVwOpo1JRSTEi7dRcwNy4h4dzV70PqHcg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.61.1",
|
||||
"@typescript-eslint/types": "8.61.1",
|
||||
@@ -2429,7 +2463,6 @@
|
||||
"integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -2512,7 +2545,6 @@
|
||||
"integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.26.0"
|
||||
}
|
||||
@@ -2573,7 +2605,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.10.12",
|
||||
"caniuse-lite": "^1.0.30001782",
|
||||
@@ -3025,7 +3056,6 @@
|
||||
"integrity": "sha512-1y+7C+vi12bUK1IpZeaV3gsH9fHLBmPvYmPx42pvT/E9yG0IC8g3PUZZgp0+JLJl7ZDK0flc2gc+Aw9dpCvIsQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
@@ -4289,7 +4319,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
|
||||
"integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -4325,7 +4354,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz",
|
||||
"integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
@@ -4344,7 +4372,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.81.0.tgz",
|
||||
"integrity": "sha512-ocbmr2p5KBMoAfj4WCUvped33lVi1Kd5DuDUvQDnB6VEAacOjPI/jMbtDdbhco4y9ct4xUuCmMY0b/C9L0QHjw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
@@ -4360,15 +4387,13 @@
|
||||
"version": "19.2.7",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz",
|
||||
"integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-redux": {
|
||||
"version": "9.3.0",
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz",
|
||||
"integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
@@ -4557,8 +4582,7 @@
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/redux-thunk": {
|
||||
"version": "3.1.0",
|
||||
@@ -4822,7 +4846,6 @@
|
||||
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -4982,7 +5005,6 @@
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz",
|
||||
"integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.32.0",
|
||||
"picomatch": "^4.0.4",
|
||||
@@ -5463,7 +5485,6 @@
|
||||
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Bell, ChevronDown, Building2, Globe, LogOut, Shield, User, Settings, CheckCircle2, ChevronRight, Menu, ShieldAlert } from "lucide-react";
|
||||
import { Bell, ChevronDown, Building2, Globe, LogOut, Shield, User, Settings, CheckCircle2, ChevronRight, Menu, ShieldAlert, Home } from "lucide-react";
|
||||
import { useLanguage, type Language } from "../../contexts/LanguageContext";
|
||||
import { useHeader } from "../../contexts/HeaderContext";
|
||||
import { useSidebar } from "../../contexts/SidebarContext";
|
||||
@@ -173,6 +173,14 @@ export function Header() {
|
||||
|
||||
{/* Right Side Controls */}
|
||||
<div className="flex items-center gap-2 sm:gap-4">
|
||||
<button
|
||||
onClick={() => window.location.assign(`${import.meta.env.VITE_SAAS_BASE_URL || 'http://localhost:3000'}/dashboard`)}
|
||||
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm font-semibold text-primary bg-primary/10 hover:bg-primary/15 transition-colors"
|
||||
title="Return to SaaS home"
|
||||
>
|
||||
<Home className="w-4 h-4" />
|
||||
<span className="hidden lg:inline">SaaS Home</span>
|
||||
</button>
|
||||
{/* Standard Non-Platform Tenant Badge */}
|
||||
{!isPlatformUser && (
|
||||
<div className="hidden sm:flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm font-medium border border-border bg-surface-muted text-foreground">
|
||||
|
||||
@@ -1,95 +1,283 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Copy, KeyRound, Plus, ShieldCheck, Trash2 } from "lucide-react";
|
||||
import apiClient from "../../../api/axiosInstance";
|
||||
import { useAppSelector } from "../../../store";
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Copy, Check, KeyRound, Plus, ShieldCheck, Trash2, AlertTriangle, Key } from 'lucide-react';
|
||||
import apiClient from '../../../api/axiosInstance';
|
||||
import { useAppSelector } from '../../../store';
|
||||
|
||||
type ApiKeyRecord = {
|
||||
id: string; name: string; prefix: string; scopes: string[]; status: string;
|
||||
expiresAt: string; lastUsedAt?: string | null; createdAt: string; apiKey?: string;
|
||||
id: string;
|
||||
name: string;
|
||||
prefix: string;
|
||||
scopes: string[];
|
||||
status: string;
|
||||
expiresAt: string;
|
||||
lastUsedAt?: string | null;
|
||||
createdAt: string;
|
||||
apiKey?: string;
|
||||
};
|
||||
|
||||
type ApiResponse<T> = { success: boolean; data: T };
|
||||
|
||||
export default function ApiAccessTab() {
|
||||
const user = useAppSelector((state) => state.auth.user);
|
||||
const isPlatformUser = user?.type === "platform" || user?.user_type === "platform";
|
||||
const isSupportMode = isPlatformUser && Boolean(localStorage.getItem("impersonatedTenantId"));
|
||||
const isPlatformUser = user?.type === 'platform' || user?.user_type === 'platform';
|
||||
const isSupportMode = isPlatformUser && Boolean(localStorage.getItem('impersonatedTenantId'));
|
||||
const [keys, setKeys] = useState<ApiKeyRecord[]>([]);
|
||||
const [name, setName] = useState("");
|
||||
const [name, setName] = useState('');
|
||||
const [expiresInDays, setExpiresInDays] = useState(90);
|
||||
const [createdKey, setCreatedKey] = useState<ApiKeyRecord | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [error, setError] = useState<string>('');
|
||||
|
||||
const load = async () => {
|
||||
const response = await apiClient.get<ApiResponse<ApiKeyRecord[]>>("/api/v1/api-keys");
|
||||
setKeys(response.data || []);
|
||||
try {
|
||||
const response: any = await apiClient.get('/api/v1/api-keys');
|
||||
const rawList = Array.isArray(response) ? response : (Array.isArray(response?.data) ? response.data : []);
|
||||
setKeys(rawList);
|
||||
} catch (cause: any) {
|
||||
const msg = typeof cause?.response?.data?.message === 'string'
|
||||
? cause.response.data.message
|
||||
: (typeof cause?.message === 'string' ? cause.message : 'Could not load API keys.');
|
||||
setError(msg);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSupportMode) load().catch((cause: any) => setError(cause?.response?.data?.message || "Could not load API keys."));
|
||||
if (!isSupportMode) {
|
||||
load();
|
||||
}
|
||||
}, [isSupportMode]);
|
||||
|
||||
if (isSupportMode) {
|
||||
return (
|
||||
<div className="rounded-lg border border-amber-300 bg-amber-50 p-5 text-sm text-amber-950">
|
||||
<div className="flex items-center gap-2 font-semibold"><ShieldCheck className="h-4 w-4" />API-key management is protected in Support Mode</div>
|
||||
<p className="mt-2">Platform support can inspect tenant data for troubleshooting, but cannot create, view, or revoke credentials on the tenant's behalf.</p>
|
||||
<p className="mt-2">A real tenant administrator must sign in to this workspace and open <strong>Integration Hub → API Access</strong> to manage product API keys.</p>
|
||||
<div className="rounded-xl border border-amber-500/30 bg-amber-500/10 p-5 text-sm text-amber-200">
|
||||
<div className="flex items-center gap-2 font-semibold"><ShieldCheck className="h-5 w-5 text-amber-400" />API-key management is protected in Support Mode</div>
|
||||
<p className="mt-2 text-muted-foreground">Platform support can inspect tenant data for troubleshooting, but cannot create, view, or revoke credentials on the tenant's behalf.</p>
|
||||
<p className="mt-2 text-muted-foreground">A real tenant administrator must sign in to this workspace and open <strong>Integration Hub → API Access</strong> to manage product API keys.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const createKey = async () => {
|
||||
setBusy(true); setError("");
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
const response = await apiClient.post<ApiResponse<ApiKeyRecord>>("/api/v1/api-keys", { name, expiresInDays });
|
||||
setCreatedKey(response.data); setName(""); await load();
|
||||
const response: any = await apiClient.post('/api/v1/api-keys', { name, expiresInDays });
|
||||
const record = response?.data || response;
|
||||
if (record && typeof record === 'object' && !Array.isArray(record)) {
|
||||
setCreatedKey(record);
|
||||
}
|
||||
setName('');
|
||||
setCopied(false);
|
||||
await load();
|
||||
} catch (cause: any) {
|
||||
setError(cause?.response?.data?.message || "Could not create API key.");
|
||||
} finally { setBusy(false); }
|
||||
const msg = typeof cause?.response?.data?.message === 'string'
|
||||
? cause.response.data.message
|
||||
: (typeof cause?.message === 'string' ? cause.message : 'Could not create API key.');
|
||||
setError(msg);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = (text: string) => {
|
||||
if (!text) return;
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 3000);
|
||||
};
|
||||
|
||||
const revoke = async (id: string) => {
|
||||
if (!window.confirm("Revoke this API key? Applications using it will immediately lose access.")) return;
|
||||
await apiClient.delete(`/api/v1/api-keys/${id}`); await load();
|
||||
if (!window.confirm('Revoke this API key? Applications using it will immediately lose access.')) return;
|
||||
try {
|
||||
await apiClient.delete('/api/v1/api-keys/' + id);
|
||||
await load();
|
||||
} catch (cause: any) {
|
||||
const msg = typeof cause?.response?.data?.message === 'string'
|
||||
? cause.response.data.message
|
||||
: (typeof cause?.message === 'string' ? cause.message : 'Could not revoke API key.');
|
||||
setError(msg);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4 text-sm text-blue-900">
|
||||
<div className="flex items-center gap-2 font-semibold"><ShieldCheck className="h-4 w-4" />What this key can do</div>
|
||||
<p className="mt-2">It can only read products belonging to your tenant through <code>GET /api/v1/external/products</code>. It cannot create, edit, delete, publish, or access another tenant.</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border p-4">
|
||||
<h3 className="font-semibold text-foreground">Create a read-only product key</h3>
|
||||
<div className="mt-3 flex flex-wrap gap-3">
|
||||
<input aria-label="API key name" value={name} onChange={(event) => setName(event.target.value)} placeholder="Example: Website product reader" maxLength={120} className="min-w-64 rounded-lg border border-border bg-surface px-3 py-2 text-sm" />
|
||||
<select aria-label="API key expiry" value={expiresInDays} onChange={(event) => setExpiresInDays(Number(event.target.value))} className="rounded-lg border border-border bg-surface px-3 py-2 text-sm">
|
||||
<option value={30}>30 days</option><option value={90}>90 days</option><option value={180}>180 days</option><option value={365}>365 days</option>
|
||||
</select>
|
||||
<button disabled={busy || !name.trim()} onClick={createKey} className="inline-flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white disabled:opacity-50"><Plus className="h-4 w-4" />Create key</button>
|
||||
{/* Information Banner */}
|
||||
<div className="rounded-xl border border-blue-500/20 bg-blue-500/10 p-4 text-sm text-blue-300">
|
||||
<div className="flex items-center gap-2 font-semibold text-blue-200">
|
||||
<ShieldCheck className="h-4 w-4 text-blue-400" />
|
||||
What this key can do
|
||||
</div>
|
||||
{error && <p className="mt-2 text-sm text-red-600">{error}</p>}
|
||||
<p className="mt-1 text-xs text-blue-300/80 leading-relaxed">
|
||||
Keys generated here grant read-only access to your tenant catalog via <code>GET /api/v1/external/products</code>. They are strictly isolated to your tenant data.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{createdKey?.apiKey && (
|
||||
<div className="rounded-lg border-2 border-amber-300 bg-amber-50 p-4">
|
||||
<p className="font-semibold text-amber-950">Copy this key now — it will never be shown again.</p>
|
||||
<div className="mt-3 flex gap-2"><code className="min-w-0 flex-1 overflow-x-auto rounded bg-white p-3 text-sm">{createdKey.apiKey}</code><button aria-label="Copy API key" onClick={() => navigator.clipboard.writeText(createdKey.apiKey!)} className="rounded-lg border border-amber-300 bg-white px-3"><Copy className="h-4 w-4" /></button></div>
|
||||
<button onClick={() => setCreatedKey(null)} className="mt-3 text-sm font-medium text-amber-900 underline">I have saved it securely</button>
|
||||
{/* Creation Form */}
|
||||
<div className="rounded-xl border border-border/60 bg-card p-5 shadow-sm">
|
||||
<h3 className="font-semibold text-foreground text-sm flex items-center gap-2">
|
||||
<Key className="h-4 w-4 text-primary" />
|
||||
Generate New API Key
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Create secure tokens for external integrations like Inventory, Mobile Apps, or Storefronts.</p>
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center gap-3">
|
||||
<input
|
||||
aria-label="API key name"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
placeholder="e.g., Inventory Sync Integration"
|
||||
maxLength={120}
|
||||
className="min-w-[280px] flex-1 rounded-lg border border-border/80 bg-background px-3.5 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/40"
|
||||
/>
|
||||
<select
|
||||
aria-label="API key expiry"
|
||||
value={expiresInDays}
|
||||
onChange={(event) => setExpiresInDays(Number(event.target.value))}
|
||||
className="rounded-lg border border-border/80 bg-background px-3.5 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/40"
|
||||
>
|
||||
<option value={30}>Expires in 30 days</option>
|
||||
<option value={90}>Expires in 90 days</option>
|
||||
<option value={180}>Expires in 180 days</option>
|
||||
<option value={365}>Expires in 1 year</option>
|
||||
</select>
|
||||
<button
|
||||
disabled={busy || !name.trim()}
|
||||
onClick={createKey}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-primary hover:bg-primary/90 px-4 py-2 text-sm font-medium text-white transition-all disabled:opacity-50 shadow-sm"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Generate Key
|
||||
</button>
|
||||
</div>
|
||||
{error ? <p className="mt-2 text-xs font-medium text-red-400">{String(error)}</p> : null}
|
||||
</div>
|
||||
|
||||
{/* Pop-up / Reveal Card when key is created */}
|
||||
{createdKey && typeof createdKey.apiKey === 'string' && (
|
||||
<div className="rounded-xl border border-amber-500/40 bg-amber-950/30 p-5 backdrop-blur-sm shadow-md animate-in fade-in duration-200">
|
||||
<div className="flex items-start gap-2.5">
|
||||
<AlertTriangle className="h-5 w-5 text-amber-400 shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="text-sm font-semibold text-amber-200">Save this API key immediately</h4>
|
||||
<p className="text-xs text-amber-300/80 mt-0.5">For security reasons, this secret key will <strong>never be shown again</strong>. Copy and store it securely.</p>
|
||||
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<div className="flex-1 min-w-0 overflow-x-auto rounded-lg border border-amber-500/30 bg-black/40 px-3.5 py-2.5 text-xs font-mono text-amber-100 selection:bg-amber-500 selection:text-black">
|
||||
{createdKey.apiKey}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleCopy(createdKey.apiKey!)}
|
||||
className={copied ? 'inline-flex items-center gap-1.5 rounded-lg px-3.5 py-2.5 text-xs font-medium transition-all shadow-sm shrink-0 bg-emerald-600 text-white' : 'inline-flex items-center gap-1.5 rounded-lg px-3.5 py-2.5 text-xs font-medium transition-all shadow-sm shrink-0 bg-amber-500 hover:bg-amber-400 text-black'}
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="h-4 w-4" />
|
||||
Copied!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-4 w-4" />
|
||||
Copy Key
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex justify-end">
|
||||
<button
|
||||
onClick={() => setCreatedKey(null)}
|
||||
className="text-xs font-medium text-amber-400 hover:text-amber-300 underline underline-offset-2 transition-colors"
|
||||
>
|
||||
I have saved it securely (Dismiss)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-hidden rounded-lg border border-border">
|
||||
<table className="w-full text-left text-sm"><thead className="bg-surface-muted"><tr><th className="p-3">Name</th><th className="p-3">Prefix</th><th className="p-3">Scope</th><th className="p-3">Expires</th><th className="p-3">Last used</th><th className="p-3">Status</th><th className="p-3"></th></tr></thead>
|
||||
<tbody>{keys.map((key) => <tr key={key.id} className="border-t border-border"><td className="p-3 font-medium"><span className="inline-flex items-center gap-2"><KeyRound className="h-4 w-4" />{key.name}</span></td><td className="p-3 font-mono text-xs">{key.prefix}…</td><td className="p-3">products:read</td><td className="p-3">{new Date(key.expiresAt).toLocaleDateString()}</td><td className="p-3">{key.lastUsedAt ? new Date(key.lastUsedAt).toLocaleString() : "Never"}</td><td className="p-3 capitalize">{key.status}</td><td className="p-3 text-right">{key.status === "active" && <button aria-label={`Revoke ${key.name}`} onClick={() => revoke(key.id)} className="text-red-600"><Trash2 className="h-4 w-4" /></button>}</td></tr>)}</tbody>
|
||||
</table>
|
||||
{keys.length === 0 && <p className="p-6 text-center text-muted-foreground">No API keys yet.</p>}
|
||||
{/* Active Keys Table */}
|
||||
<div className="overflow-hidden rounded-xl border border-border/60 bg-card shadow-sm">
|
||||
<div className="px-5 py-3.5 border-b border-border/60 bg-muted/30 flex items-center justify-between">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Active API Keys</h4>
|
||||
<span className="text-xs text-muted-foreground">{keys.length} key{keys.length === 1 ? '' : 's'}</span>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="bg-muted/40 text-xs font-medium text-muted-foreground border-b border-border/60">
|
||||
<tr>
|
||||
<th className="py-3 px-5">Key Name</th>
|
||||
<th className="py-3 px-5">Key Prefix</th>
|
||||
<th className="py-3 px-5">Scope</th>
|
||||
<th className="py-3 px-5">Expires</th>
|
||||
<th className="py-3 px-5">Last Used</th>
|
||||
<th className="py-3 px-5">Status</th>
|
||||
<th className="py-3 px-5 text-right">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/40">
|
||||
{keys.map((key) => (
|
||||
<tr key={key.id} className="hover:bg-muted/20 transition-colors">
|
||||
<td className="py-3 px-5 font-medium text-foreground">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<KeyRound className="h-3.5 w-3.5 text-primary" />
|
||||
{String(key.name || '')}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-5 font-mono text-xs text-muted-foreground">
|
||||
<span className="bg-muted/50 px-2 py-0.5 rounded border border-border/40">
|
||||
{String(key.prefix || '')}…
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-5 text-xs text-muted-foreground">
|
||||
<span className="bg-primary/10 text-primary px-2 py-0.5 rounded-full font-medium">
|
||||
products:read
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-5 text-xs text-muted-foreground">
|
||||
{key.expiresAt ? new Date(key.expiresAt).toLocaleDateString() : '—'}
|
||||
</td>
|
||||
<td className="py-3 px-5 text-xs text-muted-foreground">
|
||||
{key.lastUsedAt ? new Date(key.lastUsedAt).toLocaleString() : 'Never'}
|
||||
</td>
|
||||
<td className="py-3 px-5 text-xs">
|
||||
<span className="inline-flex items-center gap-1.5 font-medium text-emerald-400 bg-emerald-500/10 px-2 py-0.5 rounded-full capitalize">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-emerald-400" />
|
||||
{String(key.status || 'active')}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-5 text-right">
|
||||
{key.status === 'active' && (
|
||||
<button
|
||||
title="Revoke key"
|
||||
aria-label={'Revoke ' + String(key.name || '')}
|
||||
onClick={() => revoke(key.id)}
|
||||
className="inline-flex items-center justify-center h-8 w-8 rounded-lg text-muted-foreground hover:text-red-400 hover:bg-red-500/10 transition-colors"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{keys.length === 0 && (
|
||||
<div className="p-8 text-center">
|
||||
<KeyRound className="h-8 w-8 text-muted-foreground/40 mx-auto mb-2" />
|
||||
<p className="text-sm text-muted-foreground">No API keys generated yet.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border p-4 text-sm"><h3 className="font-semibold">Example request</h3><pre className="mt-2 overflow-x-auto rounded bg-slate-950 p-3 text-slate-100">{`curl https://YOUR-PIM/api/v1/external/products \\\n -H "X-API-Key: pim_live_your_key"`}</pre></div>
|
||||
{/* Code Snippet */}
|
||||
<div className="rounded-xl border border-border/60 bg-card p-5 text-sm shadow-sm">
|
||||
<h4 className="font-semibold text-foreground text-xs uppercase tracking-wider text-muted-foreground mb-2">Example Usage</h4>
|
||||
<pre className="overflow-x-auto rounded-lg bg-black/60 border border-border/40 p-3.5 text-xs font-mono text-emerald-400">
|
||||
curl http://localhost:5002/api/v1/external/products -H "X-API-Key: pim_live_your_key_here"
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Layers, RefreshCw, Save, Check, Plus, ArrowRight } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { Layers, RefreshCw, Save, Plus, ArrowRight } from 'lucide-react';
|
||||
import { notify } from '../../../services/toast/index';
|
||||
|
||||
interface MappingRow {
|
||||
@@ -77,7 +77,7 @@ export default function FieldMappingsTab() {
|
||||
<tr className="bg-background border-b border-border text-muted-foreground font-semibold">
|
||||
<th className="py-3 px-4">Canonical Source Path (PIM)</th>
|
||||
<th className="py-3 px-2 text-center">Transform</th>
|
||||
<th className="py-3 px-4">Channel Target Path (Shopify)</th>
|
||||
<th className="py-3 px-4">Destination Field / JSON Path</th>
|
||||
<th className="py-3 px-4">Transformation Type</th>
|
||||
<th className="py-3 px-4">Default Value</th>
|
||||
<th className="py-3 px-4 text-center">Required</th>
|
||||
|
||||
@@ -100,7 +100,7 @@ export const IntegrationTemplateGallery: React.FC<IntegrationTemplateGalleryProp
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Custom API Card */}
|
||||
{/* Outbound Custom API Card */}
|
||||
<div className="bg-surface border border-border hover:border-primary/50 rounded-xl p-4 transition-all shadow-2xs group flex flex-col justify-between">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
@@ -111,9 +111,9 @@ export const IntegrationTemplateGallery: React.FC<IntegrationTemplateGalleryProp
|
||||
Custom Wizard
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="font-bold text-foreground text-sm group-hover:text-primary transition-colors">Custom API / Webhook</h3>
|
||||
<h3 className="font-bold text-foreground text-sm group-hover:text-primary transition-colors">External API / Webhook</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
|
||||
Configure multi-step generic REST/GraphQL endpoints with custom headers and transformations.
|
||||
Send PIM products outward to a website or application endpoint using its credentials.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -121,7 +121,7 @@ export const IntegrationTemplateGallery: React.FC<IntegrationTemplateGalleryProp
|
||||
onClick={onSelectCustomApi}
|
||||
className="mt-4 w-full py-2 px-3 bg-surface border border-border hover:bg-background text-foreground rounded-lg text-xs font-semibold shadow-2xs flex items-center justify-center gap-1.5 cursor-pointer transition-colors"
|
||||
>
|
||||
Custom Wizard <ArrowRight className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
Configure Outbound API <ArrowRight className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { X, ShoppingCart, Zap, Key, Globe, Loader2, CheckCircle2, AlertCircle, Eye, EyeOff, ExternalLink, ArrowLeft } from 'lucide-react';
|
||||
import { X, ShoppingCart, Zap, Globe, Loader2, CheckCircle2, AlertCircle, Eye, EyeOff, ExternalLink, ArrowLeft } from 'lucide-react';
|
||||
import { useIntegration } from '../hook/useIntegration';
|
||||
import { integrationsService } from '../services/integrations.service';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
@@ -77,7 +77,7 @@ export const ShopifyTemplateModal: React.FC<ShopifyTemplateModalProps> = ({
|
||||
const created = await createItem({
|
||||
name,
|
||||
channel: 'shopify',
|
||||
integration_type: 'ecommerce',
|
||||
integrationType: 'shopify',
|
||||
sync_mode: syncMode,
|
||||
sync_frequency: syncMode === 'auto' ? 'realtime' : 'manual',
|
||||
status: 'pending'
|
||||
|
||||
@@ -1,27 +1,10 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { RefreshCw, Download, Layers } from "lucide-react";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { DataTable } from "../../../components/customs/DataTable";
|
||||
import { StatusBadge, type BadgeVariant } from "../../../components/customs/StatusBadge";
|
||||
import { Button } from "../../../components/customs/Button";
|
||||
import { useIntegration } from "../hook/useIntegration";
|
||||
import { SyncJobStatusModal } from "./SyncJobStatusModal";
|
||||
import type { SyncJob } from "../types/integrations.types";
|
||||
|
||||
const MOCK_JOBS = [
|
||||
{
|
||||
id: "JOB-2892",
|
||||
integration: "Shopify Main Store",
|
||||
type: "Realtime Sync",
|
||||
records: "128",
|
||||
success: "128",
|
||||
failed: "0",
|
||||
status: "Completed",
|
||||
started: "2026-08-30 04:30",
|
||||
completed: "2026-08-30 04:31",
|
||||
duration: "1m 02s",
|
||||
triggered: "Outbox Trigger"
|
||||
}
|
||||
];
|
||||
|
||||
export default function SyncJobsList() {
|
||||
const [jobs, setJobs] = useState<any[]>([]);
|
||||
|
||||
@@ -27,7 +27,6 @@ import { useChannel } from "../../channels/hook/useChannel";
|
||||
import type { Integration } from "../types/integrations.types";
|
||||
import { ConfirmationModal } from "../../../components/modals/ConfirmationModal";
|
||||
import { StatsCard } from "../../../components/customs/StatsCard";
|
||||
import { channelsApi } from "../../channels/api/channels.api";
|
||||
|
||||
// Import Custom Feature Components & Pre-Built Templates
|
||||
import { IntegrationHealthBadge } from "../components/IntegrationHealthBadge";
|
||||
@@ -57,7 +56,7 @@ const INTEGRATION_META: Record<string, { label: string; icon: any; color: string
|
||||
export default function IntegrationList() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [activeTab, setActiveTab] = useState<"Connections" | "Field Mappings" | "Publishing Rules" | "Sync Jobs" | "Error Center" | "Audit & Logs">("Connections");
|
||||
const [activeTab, setActiveTab] = useState<"Connections" | "Field Mappings" | "Publishing Rules" | "Sync Jobs" | "Error Center" | "Audit & Logs" | "API Access">("Connections");
|
||||
const [statusFilter, setStatusFilter] = useState("All Status");
|
||||
const [typeFilter, setTypeFilter] = useState("All Types");
|
||||
|
||||
@@ -78,9 +77,6 @@ export default function IntegrationList() {
|
||||
useEffect(() => {
|
||||
fetchItems();
|
||||
fetchChannels();
|
||||
Promise.all([channelsApi.getAllJobs(), channelsApi.getErrors()])
|
||||
.then(([jobs, errors]) => setOperations({ jobs, errors }))
|
||||
.catch(() => setOperations({ jobs: [], errors: [] }));
|
||||
}, [fetchItems, fetchChannels]);
|
||||
|
||||
// Automatically open Shopify modal into Step 3 when returning from OAuth redirect
|
||||
@@ -132,8 +128,8 @@ export default function IntegrationList() {
|
||||
label: "Integration Name",
|
||||
sortable: true,
|
||||
render: (_: any, row: Integration) => {
|
||||
const metaType = row.integrationType || 'ecommerce';
|
||||
const meta = INTEGRATION_META[metaType] || INTEGRATION_META.ecommerce;
|
||||
const metaType = row.integrationType || 'custom_api';
|
||||
const meta = INTEGRATION_META[metaType] || INTEGRATION_META.custom_api;
|
||||
const Icon = meta.icon;
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -153,7 +149,7 @@ export default function IntegrationList() {
|
||||
label: "Channel",
|
||||
render: (val: string) => {
|
||||
const chan = channels.find(c => c.code === val || c.id === val);
|
||||
return <span className="font-semibold text-xs uppercase px-2 py-0.5 rounded bg-surface border border-border">{chan ? chan.name : val || 'Shopify'}</span>;
|
||||
return <span className="font-semibold text-xs uppercase px-2 py-0.5 rounded bg-surface border border-border">{chan ? chan.name : val || 'Not assigned'}</span>;
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -207,8 +203,10 @@ export default function IntegrationList() {
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCredentialModal({ isOpen: true, integrationId: row.id, name: row.name })}
|
||||
title="Configure Credentials"
|
||||
onClick={() => row.integrationType === 'shopify'
|
||||
? setCredentialModal({ isOpen: true, integrationId: row.id, name: row.name })
|
||||
: navigate(`${row.id}/edit`)}
|
||||
title={row.integrationType === 'shopify' ? "Configure Shopify Credentials" : "Configure API Endpoint & Credentials"}
|
||||
className="p-1.5 rounded-lg border border-border bg-surface hover:bg-background text-foreground cursor-pointer"
|
||||
>
|
||||
<Key className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
@@ -249,6 +247,9 @@ export default function IntegrationList() {
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setActiveTab("API Access")}>
|
||||
<Key className="w-4 h-4 mr-2" />Generate PIM API Key
|
||||
</Button>
|
||||
<Button onClick={() => setShopifyModalOpen(true)} className="bg-emerald-600 hover:bg-emerald-700 text-white">
|
||||
<Plus className="w-4 h-4 mr-2" />Setup Shopify
|
||||
</Button>
|
||||
@@ -257,9 +258,9 @@ export default function IntegrationList() {
|
||||
/>
|
||||
|
||||
{/* Quick Pre-Built Template Gallery */}
|
||||
<IntegrationTemplateGallery
|
||||
onSelectShopify={() => setShopifyModalOpen(true)}
|
||||
onSelectCustomApi={() => navigate("new")}
|
||||
<IntegrationTemplateGallery
|
||||
onSelectShopify={() => setShopifyModalOpen(true)}
|
||||
onSelectCustomApi={() => navigate("new?type=custom_api")}
|
||||
/>
|
||||
|
||||
{/* Stats Cards */}
|
||||
@@ -303,7 +304,8 @@ export default function IntegrationList() {
|
||||
{ id: "Publishing Rules", count: 1 },
|
||||
{ id: "Sync Jobs", count: null },
|
||||
{ id: "Error Center", count: 0 },
|
||||
{ id: "Audit & Logs", count: null }
|
||||
{ id: "Audit & Logs", count: null },
|
||||
{ id: "API Access", count: null }
|
||||
].map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
@@ -351,6 +353,15 @@ export default function IntegrationList() {
|
||||
<option>Pending</option>
|
||||
<option>Disconnected</option>
|
||||
</select>
|
||||
<select
|
||||
className="h-9 px-3 py-1.5 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary text-foreground bg-surface"
|
||||
value={typeFilter}
|
||||
onChange={(e) => setTypeFilter(e.target.value)}
|
||||
>
|
||||
<option>All Types</option>
|
||||
<option value="custom_api">Custom API</option>
|
||||
<option value="shopify">Shopify</option>
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
@@ -361,6 +372,7 @@ export default function IntegrationList() {
|
||||
{activeTab === "Sync Jobs" && <SyncJobsList />}
|
||||
{activeTab === "Error Center" && <ErrorCenterList />}
|
||||
{activeTab === "Audit & Logs" && <AuditLogsList />}
|
||||
{activeTab === "API Access" && <ApiAccessTab />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate, useParams, useLocation } from "react-router-dom";
|
||||
import { useNavigate, useParams, useLocation, useSearchParams } from "react-router-dom";
|
||||
import { useFormik } from "formik";
|
||||
import * as Yup from "yup";
|
||||
import {
|
||||
@@ -90,8 +90,8 @@ const integrationSchema = Yup.object({
|
||||
storeUrl: Yup.string().when('integrationType', { is: 'shopify', then: schema => schema.required('Permanent .myshopify.com domain is required').matches(/^https:\/\/[a-z0-9][a-z0-9-]*\.myshopify\.com\/?$/i, 'Use https://your-store.myshopify.com') }),
|
||||
clientId: Yup.string().when('integrationType', { is: 'shopify', then: schema => schema.required('Shopify Client ID is required') }),
|
||||
clientSecret: Yup.string(),
|
||||
endpoint: Yup.string().when('integrationType', { is: 'custom_api', then: schema => schema.required('Delivery endpoint is required').url('Enter a complete HTTPS URL').matches(/^https:\/\//i, 'External endpoints must use HTTPS') }),
|
||||
testEndpoint: Yup.string().url('Enter a complete URL').matches(/^(https:\/\/|$)/i, 'External endpoints must use HTTPS'),
|
||||
endpoint: Yup.string().when('integrationType', { is: 'custom_api', then: schema => schema.required('Delivery endpoint is required').url('Enter a valid URL').matches(/^(https:\/\/|http:\/\/(localhost|127\.0\.0\.1))/i, 'Endpoints must use HTTPS (or http://localhost for local testing)') }),
|
||||
testEndpoint: Yup.string().url('Enter a valid URL').matches(/^(https:\/\/|http:\/\/(localhost|127\.0\.0\.1)|$)/i, 'Endpoints must use HTTPS (or http://localhost for local testing)'),
|
||||
customApiHeaderName: Yup.string().matches(/^[A-Za-z0-9-]*$/, 'Header names may contain letters, numbers and hyphens only'),
|
||||
});
|
||||
|
||||
@@ -99,8 +99,10 @@ export default function NewIntegration() {
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams<{ id?: string }>();
|
||||
const { pathname } = useLocation();
|
||||
const [searchParams] = useSearchParams();
|
||||
const isView = pathname.includes('/view');
|
||||
const isEdit = Boolean(id) && !isView;
|
||||
const requestedType = searchParams.get('type') === 'shopify' ? 'shopify' : 'custom_api';
|
||||
const [activeStep, setActiveStep] = useState('general');
|
||||
const [showToken, setShowToken] = useState(false);
|
||||
const [loadingItem, setLoadingItem] = useState(false);
|
||||
@@ -118,9 +120,9 @@ export default function NewIntegration() {
|
||||
initialValues: {
|
||||
name: "",
|
||||
channel: "",
|
||||
integrationType: "custom_api",
|
||||
integrationType: requestedType,
|
||||
environment: "production",
|
||||
status: "Connected",
|
||||
status: "Pending",
|
||||
description: "",
|
||||
storeUrl: "",
|
||||
accessToken: "",
|
||||
@@ -205,8 +207,8 @@ export default function NewIntegration() {
|
||||
formik.setValues({
|
||||
name: item.name,
|
||||
channel: item.channel,
|
||||
integrationType: item.integrationType,
|
||||
environment: item.environment,
|
||||
integrationType: item.integrationType || "custom_api",
|
||||
environment: item.environment || "production",
|
||||
status: item.status,
|
||||
description: item.description || "",
|
||||
storeUrl: item.storeUrl || "",
|
||||
@@ -214,10 +216,10 @@ export default function NewIntegration() {
|
||||
apiVersion: item.apiVersion || "2026-07",
|
||||
webhookSecret: item.webhookSecret || "",
|
||||
shopIdentifier: item.shopIdentifier || "",
|
||||
syncDirection: item.syncDirection,
|
||||
syncFrequency: item.syncFrequency,
|
||||
autoRetry: item.autoRetry,
|
||||
retryAttempts: item.retryAttempts,
|
||||
syncDirection: item.syncDirection || "pim_to_channel",
|
||||
syncFrequency: item.syncFrequency || "manual",
|
||||
autoRetry: item.autoRetry ?? true,
|
||||
retryAttempts: item.retryAttempts ?? 3,
|
||||
|
||||
// Other credentials
|
||||
sellerId: item.sellerId || "",
|
||||
@@ -260,7 +262,7 @@ export default function NewIntegration() {
|
||||
setConnectionMessage("");
|
||||
try {
|
||||
const result = await integrationsService.testConnection(id);
|
||||
setConnectionMessage(result.message);
|
||||
setConnectionMessage(result.message || (result.connected ? "Connection successful" : "Connection failed"));
|
||||
} catch (error: any) {
|
||||
setConnectionMessage(error?.response?.data?.message || "Connection test failed");
|
||||
} finally {
|
||||
|
||||
@@ -9,83 +9,87 @@ type ApiResponse<T> = {
|
||||
|
||||
export const integrationService = {
|
||||
getAll: async (params?: Record<string, any>): Promise<Integration[]> => {
|
||||
const res = await apiClient.get<ApiResponse<Integration[]>>('/api/v1/integrations', { params });
|
||||
const raw: any = res.data;
|
||||
const res = await apiClient.get<ApiResponse<Integration[]>>('/api/v1/integrations', params);
|
||||
const raw: any = res;
|
||||
return (Array.isArray(raw) ? raw : raw?.data || []) as Integration[];
|
||||
},
|
||||
|
||||
getById: async (id: string): Promise<Integration> => {
|
||||
const res = await apiClient.get<ApiResponse<Integration>>();
|
||||
const raw: any = res.data;
|
||||
const res = await apiClient.get<ApiResponse<Integration>>('/api/v1/integrations/' + id);
|
||||
const raw: any = res;
|
||||
return (raw?.data || raw) as Integration;
|
||||
},
|
||||
|
||||
create: async (data: IntegrationCreateRequest): Promise<Integration> => {
|
||||
const res = await apiClient.post<ApiResponse<Integration>>('/api/v1/integrations', data);
|
||||
const raw: any = res.data;
|
||||
const raw: any = res;
|
||||
return (raw?.data || raw) as Integration;
|
||||
},
|
||||
|
||||
update: async (id: string, data: IntegrationUpdateRequest): Promise<Integration> => {
|
||||
const res = await apiClient.put<ApiResponse<Integration>>(, data);
|
||||
const raw: any = res.data;
|
||||
const res = await apiClient.put<ApiResponse<Integration>>('/api/v1/integrations/' + id, data);
|
||||
const raw: any = res;
|
||||
return (raw?.data || raw) as Integration;
|
||||
},
|
||||
|
||||
delete: async (id: string): Promise<boolean> => {
|
||||
const res = await apiClient.delete<ApiResponse<any>>();
|
||||
return res.success || true;
|
||||
const res = await apiClient.delete<ApiResponse<any>>('/api/v1/integrations/' + id);
|
||||
const raw: any = res;
|
||||
return raw?.success !== undefined ? raw.success : true;
|
||||
},
|
||||
|
||||
getCredentials: async (id: string): Promise<Record<string, string>> => {
|
||||
const res = await apiClient.get<ApiResponse<Record<string, string>>>();
|
||||
const raw: any = res.data;
|
||||
const res = await apiClient.get<ApiResponse<Record<string, string>>>('/api/v1/integrations/' + id + '/credentials');
|
||||
const raw: any = res;
|
||||
return (raw?.data || raw) as Record<string, string>;
|
||||
},
|
||||
|
||||
setCredentials: async (id: string, credentialType: string, secretValue: string, expiresAt?: string): Promise<any> => {
|
||||
const res = await apiClient.post<ApiResponse<any>>(, {
|
||||
const res = await apiClient.post<ApiResponse<any>>('/api/v1/integrations/' + id + '/credentials', {
|
||||
credential_type: credentialType,
|
||||
secret_value: secretValue,
|
||||
expires_at: expiresAt
|
||||
});
|
||||
const raw: any = res.data;
|
||||
const raw: any = res;
|
||||
return raw?.data || raw;
|
||||
},
|
||||
|
||||
testConnection: async (id: string): Promise<TestConnectionResult> => {
|
||||
const res = await apiClient.post<ApiResponse<TestConnectionResult>>();
|
||||
const raw: any = res.data;
|
||||
const res = await apiClient.post<ApiResponse<TestConnectionResult>>('/api/v1/integrations/' + id + '/test-connection');
|
||||
const raw: any = res;
|
||||
return (raw?.data || raw) as TestConnectionResult;
|
||||
},
|
||||
|
||||
triggerSync: async (id: string, options?: { productId?: string; productIds?: string[] }): Promise<any> => {
|
||||
const res = await apiClient.post<ApiResponse<any>>(, options || {});
|
||||
const raw: any = res.data;
|
||||
const res = await apiClient.post<ApiResponse<any>>('/api/v1/integrations/' + id + '/sync', options || {});
|
||||
const raw: any = res;
|
||||
return raw?.data || raw;
|
||||
},
|
||||
|
||||
getSyncJobs: async (id: string): Promise<SyncJob[]> => {
|
||||
const res = await apiClient.get<ApiResponse<SyncJob[]>>();
|
||||
const raw: any = res.data;
|
||||
const res = await apiClient.get<ApiResponse<SyncJob[]>>('/api/v1/integrations/' + id + '/jobs');
|
||||
const raw: any = res;
|
||||
return (Array.isArray(raw) ? raw : raw?.data || []) as SyncJob[];
|
||||
},
|
||||
|
||||
getAllSyncJobs: async (): Promise<SyncJob[]> => {
|
||||
const res = await apiClient.get<ApiResponse<SyncJob[]>>();
|
||||
const raw: any = res.data;
|
||||
const res = await apiClient.get<ApiResponse<SyncJob[]>>('/api/v1/integrations/jobs/all');
|
||||
const raw: any = res;
|
||||
return (Array.isArray(raw) ? raw : raw?.data || []) as SyncJob[];
|
||||
},
|
||||
|
||||
getSyncItems: async (jobId: string): Promise<SyncItem[]> => {
|
||||
const res = await apiClient.get<ApiResponse<SyncItem[]>>();
|
||||
const raw: any = res.data;
|
||||
const res = await apiClient.get<ApiResponse<SyncItem[]>>('/api/v1/integrations/jobs/' + jobId + '/items');
|
||||
const raw: any = res;
|
||||
return (Array.isArray(raw) ? raw : raw?.data || []) as SyncItem[];
|
||||
},
|
||||
|
||||
startShopifyOAuth: async (id: string): Promise<{ authorizationUrl: string; state: string }> => {
|
||||
const res = await apiClient.post<ApiResponse<any>>();
|
||||
const raw: any = res.data;
|
||||
const res = await apiClient.post<ApiResponse<any>>('/api/v1/integrations/' + id + '/shopify/oauth/start');
|
||||
const raw: any = res;
|
||||
return raw?.data || raw;
|
||||
}
|
||||
};
|
||||
|
||||
export const integrationsService = integrationService;
|
||||
export default integrationService;
|
||||
|
||||
Reference in New Issue
Block a user