@strapi-community/plugin-io 5.1.0 → 5.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +127 -0
- package/dist/_chunks/{LivePresencePanel-CNaEK-Gk.js → LivePresencePanel-BkeWL4kq.js} +35 -5
- package/dist/_chunks/{LivePresencePanel-BeNq_EnQ.mjs → LivePresencePanel-D_vzQr4B.mjs} +35 -5
- package/dist/_chunks/{MonitoringPage-K5Y3hhKF.js → MonitoringPage-CYGqkzva.js} +1 -1
- package/dist/_chunks/{MonitoringPage-Bn9XJSlg.mjs → MonitoringPage-DKfhYUgU.mjs} +1 -1
- package/dist/_chunks/OnlineEditorsWidget-Bf8hfVha.js +341 -0
- package/dist/_chunks/OnlineEditorsWidget-RcYLxQke.mjs +339 -0
- package/dist/_chunks/{SettingsPage-4OkXJAjU.js → SettingsPage-0k9qPAJZ.js} +1 -1
- package/dist/_chunks/{SettingsPage-DMbMGU6J.mjs → SettingsPage-Qi0iMaWc.mjs} +1 -1
- package/dist/_chunks/{index-CzvX8YTe.mjs → index-Bw7WjN5H.mjs} +17 -4
- package/dist/_chunks/{index--2NeIKGR.js → index-DVNfszio.js} +17 -4
- package/dist/admin/index.js +1 -1
- package/dist/admin/index.mjs +1 -1
- package/dist/server/index.js +412 -49
- package/dist/server/index.mjs +412 -49
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -770,6 +770,130 @@ Configure permissions in the Strapi admin panel:
|
|
|
770
770
|
|
|
771
771
|
---
|
|
772
772
|
|
|
773
|
+
## Security
|
|
774
|
+
|
|
775
|
+
The plugin implements multiple security layers to protect your real-time connections.
|
|
776
|
+
|
|
777
|
+
### Admin Session Tokens
|
|
778
|
+
|
|
779
|
+
For admin panel connections (Live Presence), the plugin uses secure session tokens:
|
|
780
|
+
|
|
781
|
+
```
|
|
782
|
+
+------------------+ +------------------+ +------------------+
|
|
783
|
+
| Admin Browser | ---> | Session Endpoint| ---> | Socket.IO |
|
|
784
|
+
| (Strapi Admin) | | /io/presence/ | | Server |
|
|
785
|
+
+------------------+ +------------------+ +------------------+
|
|
786
|
+
| | |
|
|
787
|
+
| 1. Request session | |
|
|
788
|
+
| (Admin JWT in header) | |
|
|
789
|
+
+------------------------>| |
|
|
790
|
+
| | |
|
|
791
|
+
| 2. Return session token | |
|
|
792
|
+
| (UUID, 10 min TTL) | |
|
|
793
|
+
|<------------------------+ |
|
|
794
|
+
| | |
|
|
795
|
+
| 3. Connect Socket.IO | |
|
|
796
|
+
| (Session token in auth) | |
|
|
797
|
+
+-------------------------------------------------->|
|
|
798
|
+
| | |
|
|
799
|
+
| 4. Validate & connect | |
|
|
800
|
+
|<--------------------------------------------------+
|
|
801
|
+
```
|
|
802
|
+
|
|
803
|
+
**Security Features:**
|
|
804
|
+
- **Token Hashing**: Tokens stored as SHA-256 hashes (plaintext never persisted)
|
|
805
|
+
- **Short TTL**: 10-minute expiration with automatic refresh at 70%
|
|
806
|
+
- **Usage Limits**: Max 10 reconnects per token to prevent replay attacks
|
|
807
|
+
- **Rate Limiting**: 30-second cooldown between token requests
|
|
808
|
+
- **Minimal Data**: Only essential user info stored (ID, firstname, lastname)
|
|
809
|
+
|
|
810
|
+
### Rate Limiting
|
|
811
|
+
|
|
812
|
+
Prevent abuse with configurable rate limits:
|
|
813
|
+
|
|
814
|
+
```javascript
|
|
815
|
+
// In config/plugins.js
|
|
816
|
+
module.exports = {
|
|
817
|
+
io: {
|
|
818
|
+
enabled: true,
|
|
819
|
+
config: {
|
|
820
|
+
security: {
|
|
821
|
+
rateLimit: {
|
|
822
|
+
enabled: true,
|
|
823
|
+
maxEventsPerSecond: 10, // Max events per socket per second
|
|
824
|
+
maxConnectionsPerIp: 50 // Max connections from single IP
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
};
|
|
830
|
+
```
|
|
831
|
+
|
|
832
|
+
### IP Whitelisting/Blacklisting
|
|
833
|
+
|
|
834
|
+
Restrict access by IP address:
|
|
835
|
+
|
|
836
|
+
```javascript
|
|
837
|
+
// In config/plugins.js
|
|
838
|
+
module.exports = {
|
|
839
|
+
io: {
|
|
840
|
+
enabled: true,
|
|
841
|
+
config: {
|
|
842
|
+
security: {
|
|
843
|
+
ipWhitelist: ['192.168.1.0/24', '10.0.0.1'], // Only these IPs allowed
|
|
844
|
+
ipBlacklist: ['203.0.113.50'], // These IPs blocked
|
|
845
|
+
requireAuthentication: true // Require JWT/API token
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
};
|
|
850
|
+
```
|
|
851
|
+
|
|
852
|
+
### Security Monitoring API
|
|
853
|
+
|
|
854
|
+
Monitor active sessions via admin API:
|
|
855
|
+
|
|
856
|
+
```bash
|
|
857
|
+
# Get session statistics
|
|
858
|
+
GET /io/security/sessions
|
|
859
|
+
Authorization: Bearer <admin-jwt>
|
|
860
|
+
|
|
861
|
+
# Response:
|
|
862
|
+
{
|
|
863
|
+
"data": {
|
|
864
|
+
"activeSessions": 5,
|
|
865
|
+
"expiringSoon": 1,
|
|
866
|
+
"activeSocketConnections": 3,
|
|
867
|
+
"sessionTTL": 600000,
|
|
868
|
+
"refreshCooldown": 30000
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
# Force logout a user (invalidate all their sessions)
|
|
873
|
+
POST /io/security/invalidate/:userId
|
|
874
|
+
Authorization: Bearer <admin-jwt>
|
|
875
|
+
|
|
876
|
+
# Response:
|
|
877
|
+
{
|
|
878
|
+
"data": {
|
|
879
|
+
"userId": 1,
|
|
880
|
+
"invalidatedSessions": 2,
|
|
881
|
+
"message": "Successfully invalidated 2 session(s)"
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
```
|
|
885
|
+
|
|
886
|
+
### Best Practices
|
|
887
|
+
|
|
888
|
+
1. **Always use HTTPS** in production for encrypted WebSocket connections
|
|
889
|
+
2. **Enable authentication** for sensitive content types
|
|
890
|
+
3. **Configure CORS** to only allow your frontend domains
|
|
891
|
+
4. **Monitor connections** via the admin dashboard
|
|
892
|
+
5. **Set reasonable rate limits** based on your use case
|
|
893
|
+
6. **Review access logs** periodically for suspicious activity
|
|
894
|
+
|
|
895
|
+
---
|
|
896
|
+
|
|
773
897
|
## Admin Panel
|
|
774
898
|
|
|
775
899
|
The plugin provides a full admin interface for configuration and monitoring.
|
|
@@ -1327,6 +1451,9 @@ Copyright (c) 2024 Strapi Community
|
|
|
1327
1451
|
- **Admin Panel Sidebar** - Live presence panel integrated into edit view
|
|
1328
1452
|
- **Admin Session Authentication** - Secure session tokens for Socket.IO
|
|
1329
1453
|
- **Admin JWT Strategy** - New authentication strategy for admin users
|
|
1454
|
+
- **Enhanced Security** - Token hashing (SHA-256), usage limits, rate limiting
|
|
1455
|
+
- **Automatic Token Refresh** - Tokens auto-refresh at 70% of TTL
|
|
1456
|
+
- **Security Monitoring API** - Session stats and force-logout endpoints
|
|
1330
1457
|
|
|
1331
1458
|
### v5.0.0
|
|
1332
1459
|
- Strapi v5 support
|
|
@@ -7,7 +7,7 @@ const designSystem = require("@strapi/design-system");
|
|
|
7
7
|
const admin = require("@strapi/strapi/admin");
|
|
8
8
|
const styled = require("styled-components");
|
|
9
9
|
const socket_ioClient = require("socket.io-client");
|
|
10
|
-
const index$1 = require("./index
|
|
10
|
+
const index$1 = require("./index-DVNfszio.js");
|
|
11
11
|
const _interopDefault = (e) => e && e.__esModule ? e : { default: e };
|
|
12
12
|
const styled__default = /* @__PURE__ */ _interopDefault(styled);
|
|
13
13
|
const pulse = styled.keyframes`
|
|
@@ -181,19 +181,46 @@ const LivePresencePanel = ({ documentId, model, document }) => {
|
|
|
181
181
|
return;
|
|
182
182
|
}
|
|
183
183
|
let cancelled = false;
|
|
184
|
-
|
|
184
|
+
let refreshTimeoutId = null;
|
|
185
|
+
const getSession = async (isRefresh = false) => {
|
|
185
186
|
try {
|
|
186
|
-
|
|
187
|
+
if (!isRefresh) {
|
|
188
|
+
setPresenceState((prev) => ({ ...prev, status: "requesting" }));
|
|
189
|
+
}
|
|
187
190
|
const { data } = await post(`/${index$1.PLUGIN_ID}/presence/session`, {});
|
|
188
191
|
if (cancelled) return;
|
|
189
192
|
if (!data || !data.token) {
|
|
190
193
|
throw new Error("Invalid session response");
|
|
191
194
|
}
|
|
192
|
-
console.log(`[${index$1.PLUGIN_ID}]
|
|
195
|
+
console.log(`[${index$1.PLUGIN_ID}] Session ${isRefresh ? "refreshed" : "obtained"}:`, {
|
|
196
|
+
expiresIn: Math.round((data.expiresAt - Date.now()) / 1e3) + "s",
|
|
197
|
+
refreshAfter: Math.round((data.refreshAfter - Date.now()) / 1e3) + "s"
|
|
198
|
+
});
|
|
193
199
|
setSessionData(data);
|
|
194
|
-
|
|
200
|
+
if (!isRefresh) {
|
|
201
|
+
setPresenceState((prev) => ({ ...prev, status: "connecting" }));
|
|
202
|
+
}
|
|
203
|
+
if (data.refreshAfter) {
|
|
204
|
+
const refreshIn = data.refreshAfter - Date.now();
|
|
205
|
+
if (refreshIn > 0) {
|
|
206
|
+
console.log(`[${index$1.PLUGIN_ID}] Token refresh scheduled in ${Math.round(refreshIn / 1e3)}s`);
|
|
207
|
+
refreshTimeoutId = setTimeout(() => {
|
|
208
|
+
if (!cancelled) {
|
|
209
|
+
console.log(`[${index$1.PLUGIN_ID}] Refreshing session token...`);
|
|
210
|
+
getSession(true);
|
|
211
|
+
}
|
|
212
|
+
}, refreshIn);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
195
215
|
} catch (error2) {
|
|
196
216
|
if (cancelled) return;
|
|
217
|
+
if (error2.response?.status === 429) {
|
|
218
|
+
console.warn(`[${index$1.PLUGIN_ID}] Rate limited, retrying in 30s...`);
|
|
219
|
+
refreshTimeoutId = setTimeout(() => {
|
|
220
|
+
if (!cancelled) getSession(isRefresh);
|
|
221
|
+
}, 3e4);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
197
224
|
console.error(`[${index$1.PLUGIN_ID}] Failed to get presence session:`, error2);
|
|
198
225
|
setPresenceState((prev) => ({
|
|
199
226
|
...prev,
|
|
@@ -205,6 +232,9 @@ const LivePresencePanel = ({ documentId, model, document }) => {
|
|
|
205
232
|
getSession();
|
|
206
233
|
return () => {
|
|
207
234
|
cancelled = true;
|
|
235
|
+
if (refreshTimeoutId) {
|
|
236
|
+
clearTimeout(refreshTimeoutId);
|
|
237
|
+
}
|
|
208
238
|
};
|
|
209
239
|
}, [uid, documentId, post]);
|
|
210
240
|
React.useEffect(() => {
|
|
@@ -5,7 +5,7 @@ import { Flex } from "@strapi/design-system";
|
|
|
5
5
|
import { useFetchClient } from "@strapi/strapi/admin";
|
|
6
6
|
import styled, { css, keyframes } from "styled-components";
|
|
7
7
|
import { io } from "socket.io-client";
|
|
8
|
-
import { P as PLUGIN_ID } from "./index-
|
|
8
|
+
import { P as PLUGIN_ID } from "./index-Bw7WjN5H.mjs";
|
|
9
9
|
const pulse = keyframes`
|
|
10
10
|
0%, 100% {
|
|
11
11
|
box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.2);
|
|
@@ -177,19 +177,46 @@ const LivePresencePanel = ({ documentId, model, document }) => {
|
|
|
177
177
|
return;
|
|
178
178
|
}
|
|
179
179
|
let cancelled = false;
|
|
180
|
-
|
|
180
|
+
let refreshTimeoutId = null;
|
|
181
|
+
const getSession = async (isRefresh = false) => {
|
|
181
182
|
try {
|
|
182
|
-
|
|
183
|
+
if (!isRefresh) {
|
|
184
|
+
setPresenceState((prev) => ({ ...prev, status: "requesting" }));
|
|
185
|
+
}
|
|
183
186
|
const { data } = await post(`/${PLUGIN_ID}/presence/session`, {});
|
|
184
187
|
if (cancelled) return;
|
|
185
188
|
if (!data || !data.token) {
|
|
186
189
|
throw new Error("Invalid session response");
|
|
187
190
|
}
|
|
188
|
-
console.log(`[${PLUGIN_ID}]
|
|
191
|
+
console.log(`[${PLUGIN_ID}] Session ${isRefresh ? "refreshed" : "obtained"}:`, {
|
|
192
|
+
expiresIn: Math.round((data.expiresAt - Date.now()) / 1e3) + "s",
|
|
193
|
+
refreshAfter: Math.round((data.refreshAfter - Date.now()) / 1e3) + "s"
|
|
194
|
+
});
|
|
189
195
|
setSessionData(data);
|
|
190
|
-
|
|
196
|
+
if (!isRefresh) {
|
|
197
|
+
setPresenceState((prev) => ({ ...prev, status: "connecting" }));
|
|
198
|
+
}
|
|
199
|
+
if (data.refreshAfter) {
|
|
200
|
+
const refreshIn = data.refreshAfter - Date.now();
|
|
201
|
+
if (refreshIn > 0) {
|
|
202
|
+
console.log(`[${PLUGIN_ID}] Token refresh scheduled in ${Math.round(refreshIn / 1e3)}s`);
|
|
203
|
+
refreshTimeoutId = setTimeout(() => {
|
|
204
|
+
if (!cancelled) {
|
|
205
|
+
console.log(`[${PLUGIN_ID}] Refreshing session token...`);
|
|
206
|
+
getSession(true);
|
|
207
|
+
}
|
|
208
|
+
}, refreshIn);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
191
211
|
} catch (error2) {
|
|
192
212
|
if (cancelled) return;
|
|
213
|
+
if (error2.response?.status === 429) {
|
|
214
|
+
console.warn(`[${PLUGIN_ID}] Rate limited, retrying in 30s...`);
|
|
215
|
+
refreshTimeoutId = setTimeout(() => {
|
|
216
|
+
if (!cancelled) getSession(isRefresh);
|
|
217
|
+
}, 3e4);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
193
220
|
console.error(`[${PLUGIN_ID}] Failed to get presence session:`, error2);
|
|
194
221
|
setPresenceState((prev) => ({
|
|
195
222
|
...prev,
|
|
@@ -201,6 +228,9 @@ const LivePresencePanel = ({ documentId, model, document }) => {
|
|
|
201
228
|
getSession();
|
|
202
229
|
return () => {
|
|
203
230
|
cancelled = true;
|
|
231
|
+
if (refreshTimeoutId) {
|
|
232
|
+
clearTimeout(refreshTimeoutId);
|
|
233
|
+
}
|
|
204
234
|
};
|
|
205
235
|
}, [uid, documentId, post]);
|
|
206
236
|
useEffect(() => {
|
|
@@ -6,7 +6,7 @@ const admin = require("@strapi/strapi/admin");
|
|
|
6
6
|
const styled = require("styled-components");
|
|
7
7
|
const designSystem = require("@strapi/design-system");
|
|
8
8
|
const index = require("./index-DkTxsEqL.js");
|
|
9
|
-
const index$1 = require("./index
|
|
9
|
+
const index$1 = require("./index-DVNfszio.js");
|
|
10
10
|
const _interopDefault = (e) => e && e.__esModule ? e : { default: e };
|
|
11
11
|
const styled__default = /* @__PURE__ */ _interopDefault(styled);
|
|
12
12
|
const UsersIcon = () => /* @__PURE__ */ jsxRuntime.jsx("svg", { xmlns: "http://www.w3.org/2000/svg", fill: "none", viewBox: "0 0 24 24", strokeWidth: 1.5, stroke: "currentColor", children: /* @__PURE__ */ jsxRuntime.jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", d: "M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z" }) });
|
|
@@ -4,7 +4,7 @@ import { useFetchClient, useNotification } from "@strapi/strapi/admin";
|
|
|
4
4
|
import styled, { css, keyframes } from "styled-components";
|
|
5
5
|
import { Box, Loader, Flex, Field, TextInput, Badge, Typography } from "@strapi/design-system";
|
|
6
6
|
import { u as useIntl } from "./index-CEh8vkxY.mjs";
|
|
7
|
-
import { P as PLUGIN_ID } from "./index-
|
|
7
|
+
import { P as PLUGIN_ID } from "./index-Bw7WjN5H.mjs";
|
|
8
8
|
const UsersIcon = () => /* @__PURE__ */ jsx("svg", { xmlns: "http://www.w3.org/2000/svg", fill: "none", viewBox: "0 0 24 24", strokeWidth: 1.5, stroke: "currentColor", children: /* @__PURE__ */ jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", d: "M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z" }) });
|
|
9
9
|
const BoltIcon = () => /* @__PURE__ */ jsx("svg", { xmlns: "http://www.w3.org/2000/svg", fill: "none", viewBox: "0 0 24 24", strokeWidth: 1.5, stroke: "currentColor", children: /* @__PURE__ */ jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", d: "m3.75 13.5 10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75Z" }) });
|
|
10
10
|
const ChartBarIcon = () => /* @__PURE__ */ jsx("svg", { xmlns: "http://www.w3.org/2000/svg", fill: "none", viewBox: "0 0 24 24", strokeWidth: 1.5, stroke: "currentColor", children: /* @__PURE__ */ jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", d: "M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 0 1 3 19.875v-6.75ZM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V8.625ZM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V4.125Z" }) });
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
3
|
+
const jsxRuntime = require("react/jsx-runtime");
|
|
4
|
+
const React = require("react");
|
|
5
|
+
const designSystem = require("@strapi/design-system");
|
|
6
|
+
const icons = require("@strapi/icons");
|
|
7
|
+
const admin = require("@strapi/strapi/admin");
|
|
8
|
+
const styled = require("styled-components");
|
|
9
|
+
const socket_ioClient = require("socket.io-client");
|
|
10
|
+
const index = require("./index-DVNfszio.js");
|
|
11
|
+
const _interopDefault = (e) => e && e.__esModule ? e : { default: e };
|
|
12
|
+
const styled__default = /* @__PURE__ */ _interopDefault(styled);
|
|
13
|
+
const pulse = styled.keyframes`
|
|
14
|
+
0%, 100% { opacity: 1; }
|
|
15
|
+
50% { opacity: 0.5; }
|
|
16
|
+
`;
|
|
17
|
+
const WidgetContainer = styled__default.default(designSystem.Box)`
|
|
18
|
+
padding: 0;
|
|
19
|
+
position: relative;
|
|
20
|
+
`;
|
|
21
|
+
const HeaderContainer = styled__default.default(designSystem.Flex)`
|
|
22
|
+
justify-content: space-between;
|
|
23
|
+
align-items: center;
|
|
24
|
+
margin-bottom: ${({ theme }) => theme.spaces[3]};
|
|
25
|
+
padding-bottom: ${({ theme }) => theme.spaces[2]};
|
|
26
|
+
border-bottom: 1px solid ${({ theme }) => theme.colors.neutral150};
|
|
27
|
+
`;
|
|
28
|
+
const LiveDot = styled__default.default.span`
|
|
29
|
+
display: inline-block;
|
|
30
|
+
width: 8px;
|
|
31
|
+
height: 8px;
|
|
32
|
+
border-radius: 50%;
|
|
33
|
+
background: ${({ theme, $connected }) => $connected ? theme.colors.success500 : theme.colors.neutral400};
|
|
34
|
+
margin-right: ${({ theme }) => theme.spaces[2]};
|
|
35
|
+
animation: ${({ $connected }) => $connected ? pulse : "none"} 2s ease-in-out infinite;
|
|
36
|
+
`;
|
|
37
|
+
const CountBadge = styled__default.default.span`
|
|
38
|
+
display: inline-flex;
|
|
39
|
+
align-items: center;
|
|
40
|
+
justify-content: center;
|
|
41
|
+
min-width: 24px;
|
|
42
|
+
height: 24px;
|
|
43
|
+
padding: 0 8px;
|
|
44
|
+
background: ${({ theme, $active }) => $active ? theme.colors.primary100 : theme.colors.neutral100};
|
|
45
|
+
color: ${({ theme, $active }) => $active ? theme.colors.primary700 : theme.colors.neutral600};
|
|
46
|
+
border-radius: 12px;
|
|
47
|
+
font-size: 12px;
|
|
48
|
+
font-weight: 600;
|
|
49
|
+
`;
|
|
50
|
+
const UserList = styled__default.default.div`
|
|
51
|
+
display: flex;
|
|
52
|
+
flex-direction: column;
|
|
53
|
+
gap: ${({ theme }) => theme.spaces[2]};
|
|
54
|
+
max-height: 280px;
|
|
55
|
+
overflow-y: auto;
|
|
56
|
+
`;
|
|
57
|
+
const UserCard = styled__default.default.div`
|
|
58
|
+
display: flex;
|
|
59
|
+
align-items: flex-start;
|
|
60
|
+
gap: ${({ theme }) => theme.spaces[3]};
|
|
61
|
+
padding: ${({ theme }) => theme.spaces[3]};
|
|
62
|
+
background: ${({ theme }) => theme.colors.neutral0};
|
|
63
|
+
border: 1px solid ${({ theme }) => theme.colors.neutral150};
|
|
64
|
+
border-radius: ${({ theme }) => theme.borderRadius};
|
|
65
|
+
transition: all 0.2s ease;
|
|
66
|
+
|
|
67
|
+
&:hover {
|
|
68
|
+
border-color: ${({ theme }) => theme.colors.primary200};
|
|
69
|
+
box-shadow: 0 2px 8px rgba(73, 69, 255, 0.08);
|
|
70
|
+
}
|
|
71
|
+
`;
|
|
72
|
+
const AVATAR_COLORS = [
|
|
73
|
+
"linear-gradient(135deg, #4945ff 0%, #7b79ff 100%)",
|
|
74
|
+
"linear-gradient(135deg, #3b82f6 0%, #60a5fa 100%)",
|
|
75
|
+
"linear-gradient(135deg, #10b981 0%, #34d399 100%)",
|
|
76
|
+
"linear-gradient(135deg, #f59e0b 0%, #fbbf24 100%)",
|
|
77
|
+
"linear-gradient(135deg, #ef4444 0%, #f87171 100%)",
|
|
78
|
+
"linear-gradient(135deg, #8b5cf6 0%, #a78bfa 100%)"
|
|
79
|
+
];
|
|
80
|
+
const UserAvatar = styled__default.default.div`
|
|
81
|
+
width: 40px;
|
|
82
|
+
height: 40px;
|
|
83
|
+
border-radius: 50%;
|
|
84
|
+
background: ${({ $colorIndex }) => AVATAR_COLORS[$colorIndex % AVATAR_COLORS.length]};
|
|
85
|
+
color: white;
|
|
86
|
+
font-size: 14px;
|
|
87
|
+
font-weight: 700;
|
|
88
|
+
display: flex;
|
|
89
|
+
align-items: center;
|
|
90
|
+
justify-content: center;
|
|
91
|
+
flex-shrink: 0;
|
|
92
|
+
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
|
93
|
+
`;
|
|
94
|
+
const UserInfo = styled__default.default.div`
|
|
95
|
+
flex: 1;
|
|
96
|
+
min-width: 0;
|
|
97
|
+
`;
|
|
98
|
+
const UserName = styled__default.default.div`
|
|
99
|
+
font-size: 14px;
|
|
100
|
+
font-weight: 600;
|
|
101
|
+
color: ${({ theme }) => theme.colors.neutral800};
|
|
102
|
+
white-space: nowrap;
|
|
103
|
+
overflow: hidden;
|
|
104
|
+
text-overflow: ellipsis;
|
|
105
|
+
`;
|
|
106
|
+
const UserMeta = styled__default.default.div`
|
|
107
|
+
font-size: 12px;
|
|
108
|
+
color: ${({ theme }) => theme.colors.neutral500};
|
|
109
|
+
display: flex;
|
|
110
|
+
align-items: center;
|
|
111
|
+
gap: ${({ theme }) => theme.spaces[2]};
|
|
112
|
+
margin-top: 2px;
|
|
113
|
+
`;
|
|
114
|
+
const EditingBadge = styled__default.default.a`
|
|
115
|
+
display: inline-flex;
|
|
116
|
+
align-items: center;
|
|
117
|
+
gap: 4px;
|
|
118
|
+
font-size: 11px;
|
|
119
|
+
font-weight: 500;
|
|
120
|
+
color: ${({ theme }) => theme.colors.success700};
|
|
121
|
+
background: ${({ theme }) => theme.colors.success100};
|
|
122
|
+
padding: 4px 10px;
|
|
123
|
+
border-radius: 10px;
|
|
124
|
+
margin-top: ${({ theme }) => theme.spaces[1]};
|
|
125
|
+
word-break: break-all;
|
|
126
|
+
max-width: 100%;
|
|
127
|
+
text-decoration: none;
|
|
128
|
+
cursor: pointer;
|
|
129
|
+
transition: all 0.15s ease;
|
|
130
|
+
|
|
131
|
+
&:hover {
|
|
132
|
+
background: ${({ theme }) => theme.colors.success200};
|
|
133
|
+
color: ${({ theme }) => theme.colors.success800};
|
|
134
|
+
transform: translateY(-1px);
|
|
135
|
+
}
|
|
136
|
+
`;
|
|
137
|
+
const IdleBadge = styled__default.default.span`
|
|
138
|
+
display: inline-flex;
|
|
139
|
+
align-items: center;
|
|
140
|
+
gap: 4px;
|
|
141
|
+
font-size: 11px;
|
|
142
|
+
font-weight: 500;
|
|
143
|
+
color: ${({ theme }) => theme.colors.neutral600};
|
|
144
|
+
background: ${({ theme }) => theme.colors.neutral100};
|
|
145
|
+
padding: 2px 8px;
|
|
146
|
+
border-radius: 10px;
|
|
147
|
+
margin-top: ${({ theme }) => theme.spaces[1]};
|
|
148
|
+
`;
|
|
149
|
+
const EmptyState = styled__default.default.div`
|
|
150
|
+
display: flex;
|
|
151
|
+
flex-direction: column;
|
|
152
|
+
align-items: center;
|
|
153
|
+
justify-content: center;
|
|
154
|
+
text-align: center;
|
|
155
|
+
padding: ${({ theme }) => theme.spaces[8]} ${({ theme }) => theme.spaces[4]};
|
|
156
|
+
color: ${({ theme }) => theme.colors.neutral500};
|
|
157
|
+
min-height: 180px;
|
|
158
|
+
`;
|
|
159
|
+
const EmptyIcon = styled__default.default.div`
|
|
160
|
+
width: 64px;
|
|
161
|
+
height: 64px;
|
|
162
|
+
border-radius: 50%;
|
|
163
|
+
background: ${({ theme }) => theme.colors.neutral100};
|
|
164
|
+
display: flex;
|
|
165
|
+
align-items: center;
|
|
166
|
+
justify-content: center;
|
|
167
|
+
margin-bottom: ${({ theme }) => theme.spaces[3]};
|
|
168
|
+
`;
|
|
169
|
+
const LoadingContainer = styled__default.default.div`
|
|
170
|
+
display: flex;
|
|
171
|
+
align-items: center;
|
|
172
|
+
justify-content: center;
|
|
173
|
+
padding: ${({ theme }) => theme.spaces[6]};
|
|
174
|
+
`;
|
|
175
|
+
const FooterLink = styled__default.default.a`
|
|
176
|
+
font-size: 12px;
|
|
177
|
+
color: ${({ theme }) => theme.colors.primary600};
|
|
178
|
+
text-decoration: none;
|
|
179
|
+
|
|
180
|
+
&:hover {
|
|
181
|
+
text-decoration: underline;
|
|
182
|
+
}
|
|
183
|
+
`;
|
|
184
|
+
const getInitials = (user) => {
|
|
185
|
+
const first = (user.firstname?.[0] || user.username?.[0] || user.email?.[0] || "?").toUpperCase();
|
|
186
|
+
const last = (user.lastname?.[0] || "").toUpperCase();
|
|
187
|
+
return `${first}${last}`.trim() || "?";
|
|
188
|
+
};
|
|
189
|
+
const getDisplayName = (user) => {
|
|
190
|
+
if (user.firstname) {
|
|
191
|
+
return `${user.firstname} ${user.lastname || ""}`.trim();
|
|
192
|
+
}
|
|
193
|
+
return user.username || user.email || "Unknown";
|
|
194
|
+
};
|
|
195
|
+
const formatDuration = (seconds) => {
|
|
196
|
+
if (seconds < 60) return "just now";
|
|
197
|
+
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
|
|
198
|
+
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`;
|
|
199
|
+
return `${Math.floor(seconds / 86400)}d`;
|
|
200
|
+
};
|
|
201
|
+
const OnlineEditorsWidget = () => {
|
|
202
|
+
const { get, post } = admin.useFetchClient();
|
|
203
|
+
const [data, setData] = React.useState(null);
|
|
204
|
+
const [loading, setLoading] = React.useState(true);
|
|
205
|
+
const [error, setError] = React.useState(null);
|
|
206
|
+
const [connected, setConnected] = React.useState(false);
|
|
207
|
+
const socketRef = React.useRef(null);
|
|
208
|
+
const fetchOnlineUsers = React.useCallback(async () => {
|
|
209
|
+
try {
|
|
210
|
+
const response = await get(`/${index.PLUGIN_ID}/online-users`);
|
|
211
|
+
setData(response.data?.data || response.data);
|
|
212
|
+
setError(null);
|
|
213
|
+
setLoading(false);
|
|
214
|
+
} catch (err) {
|
|
215
|
+
console.error("[plugin-io] Failed to fetch online users:", err);
|
|
216
|
+
setError(err.message);
|
|
217
|
+
setLoading(false);
|
|
218
|
+
}
|
|
219
|
+
}, [get]);
|
|
220
|
+
React.useEffect(() => {
|
|
221
|
+
let cancelled = false;
|
|
222
|
+
let socket = null;
|
|
223
|
+
const connectSocket = async () => {
|
|
224
|
+
try {
|
|
225
|
+
const { data: sessionData } = await post(`/${index.PLUGIN_ID}/presence/session`, {});
|
|
226
|
+
if (cancelled || !sessionData?.token) return;
|
|
227
|
+
const socketUrl = sessionData.wsUrl || `${window.location.protocol}//${window.location.host}`;
|
|
228
|
+
socket = socket_ioClient.io(socketUrl, {
|
|
229
|
+
path: sessionData.wsPath || "/socket.io",
|
|
230
|
+
transports: ["websocket", "polling"],
|
|
231
|
+
auth: {
|
|
232
|
+
token: sessionData.token,
|
|
233
|
+
strategy: "admin-jwt",
|
|
234
|
+
isAdmin: true
|
|
235
|
+
},
|
|
236
|
+
reconnection: true,
|
|
237
|
+
reconnectionAttempts: 3
|
|
238
|
+
});
|
|
239
|
+
socketRef.current = socket;
|
|
240
|
+
socket.on("connect", () => {
|
|
241
|
+
if (!cancelled) {
|
|
242
|
+
setConnected(true);
|
|
243
|
+
console.log(`[${index.PLUGIN_ID}] Dashboard presence connected`);
|
|
244
|
+
fetchOnlineUsers();
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
socket.on("disconnect", () => {
|
|
248
|
+
if (!cancelled) {
|
|
249
|
+
setConnected(false);
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
socket.on("connect_error", (err) => {
|
|
253
|
+
console.warn(`[${index.PLUGIN_ID}] Dashboard socket error:`, err.message);
|
|
254
|
+
});
|
|
255
|
+
socket.on("presence:update", () => {
|
|
256
|
+
fetchOnlineUsers();
|
|
257
|
+
});
|
|
258
|
+
} catch (err) {
|
|
259
|
+
console.error("[plugin-io] Failed to connect dashboard socket:", err);
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
connectSocket();
|
|
263
|
+
return () => {
|
|
264
|
+
cancelled = true;
|
|
265
|
+
if (socket) {
|
|
266
|
+
socket.disconnect();
|
|
267
|
+
socketRef.current = null;
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
}, [post, fetchOnlineUsers]);
|
|
271
|
+
React.useEffect(() => {
|
|
272
|
+
const interval = setInterval(fetchOnlineUsers, 15e3);
|
|
273
|
+
return () => clearInterval(interval);
|
|
274
|
+
}, [fetchOnlineUsers]);
|
|
275
|
+
if (loading) {
|
|
276
|
+
return /* @__PURE__ */ jsxRuntime.jsx(LoadingContainer, { children: /* @__PURE__ */ jsxRuntime.jsx(designSystem.Typography, { variant: "pi", textColor: "neutral600", children: "Loading..." }) });
|
|
277
|
+
}
|
|
278
|
+
if (error) {
|
|
279
|
+
return /* @__PURE__ */ jsxRuntime.jsx(EmptyState, { children: /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Typography, { variant: "pi", textColor: "danger600", children: [
|
|
280
|
+
"Failed to load: ",
|
|
281
|
+
error
|
|
282
|
+
] }) });
|
|
283
|
+
}
|
|
284
|
+
const users = data?.users || [];
|
|
285
|
+
const counts = data?.counts || { total: 0, editing: 0 };
|
|
286
|
+
return /* @__PURE__ */ jsxRuntime.jsxs(WidgetContainer, { children: [
|
|
287
|
+
/* @__PURE__ */ jsxRuntime.jsxs(HeaderContainer, { children: [
|
|
288
|
+
/* @__PURE__ */ jsxRuntime.jsxs(designSystem.Flex, { alignItems: "center", gap: 2, children: [
|
|
289
|
+
/* @__PURE__ */ jsxRuntime.jsx(LiveDot, { $connected: connected }),
|
|
290
|
+
/* @__PURE__ */ jsxRuntime.jsx(designSystem.Typography, { variant: "omega", fontWeight: "bold", textColor: "neutral800", children: "Who's Online" })
|
|
291
|
+
] }),
|
|
292
|
+
/* @__PURE__ */ jsxRuntime.jsxs(designSystem.Flex, { gap: 2, children: [
|
|
293
|
+
/* @__PURE__ */ jsxRuntime.jsxs(CountBadge, { $active: counts.editing > 0, title: "Users editing", children: [
|
|
294
|
+
/* @__PURE__ */ jsxRuntime.jsx(icons.Pencil, { width: "12", height: "12", style: { marginRight: 4 } }),
|
|
295
|
+
counts.editing
|
|
296
|
+
] }),
|
|
297
|
+
/* @__PURE__ */ jsxRuntime.jsxs(CountBadge, { title: "Total online", children: [
|
|
298
|
+
/* @__PURE__ */ jsxRuntime.jsx(icons.User, { width: "12", height: "12", style: { marginRight: 4 } }),
|
|
299
|
+
counts.total
|
|
300
|
+
] })
|
|
301
|
+
] })
|
|
302
|
+
] }),
|
|
303
|
+
users.length === 0 ? /* @__PURE__ */ jsxRuntime.jsxs(EmptyState, { children: [
|
|
304
|
+
/* @__PURE__ */ jsxRuntime.jsx(EmptyIcon, { children: /* @__PURE__ */ jsxRuntime.jsx(icons.User, { width: "28", height: "28", fill: "#a5a5ba" }) }),
|
|
305
|
+
/* @__PURE__ */ jsxRuntime.jsx(designSystem.Typography, { variant: "omega", fontWeight: "semiBold", textColor: "neutral600", children: "No one else is online" }),
|
|
306
|
+
/* @__PURE__ */ jsxRuntime.jsx(designSystem.Typography, { variant: "pi", textColor: "neutral500", style: { marginTop: 4 }, children: "You're the only one here right now" })
|
|
307
|
+
] }) : /* @__PURE__ */ jsxRuntime.jsx(UserList, { children: users.map((userData, index2) => /* @__PURE__ */ jsxRuntime.jsxs(UserCard, { children: [
|
|
308
|
+
/* @__PURE__ */ jsxRuntime.jsx(UserAvatar, { $colorIndex: index2, children: getInitials(userData.user) }),
|
|
309
|
+
/* @__PURE__ */ jsxRuntime.jsxs(UserInfo, { children: [
|
|
310
|
+
/* @__PURE__ */ jsxRuntime.jsxs(UserName, { children: [
|
|
311
|
+
getDisplayName(userData.user),
|
|
312
|
+
userData.user.isAdmin && /* @__PURE__ */ jsxRuntime.jsx(designSystem.Badge, { size: "S", style: { marginLeft: 8 }, children: "Admin" })
|
|
313
|
+
] }),
|
|
314
|
+
/* @__PURE__ */ jsxRuntime.jsxs(UserMeta, { children: [
|
|
315
|
+
/* @__PURE__ */ jsxRuntime.jsx(icons.Clock, { width: "12", height: "12" }),
|
|
316
|
+
"Online ",
|
|
317
|
+
formatDuration(userData.onlineFor)
|
|
318
|
+
] }),
|
|
319
|
+
userData.isEditing ? userData.editingEntities.map((entity, idx) => /* @__PURE__ */ jsxRuntime.jsxs(
|
|
320
|
+
EditingBadge,
|
|
321
|
+
{
|
|
322
|
+
href: `/admin/content-manager/collection-types/${entity.uid}/${entity.documentId}`,
|
|
323
|
+
target: "_blank",
|
|
324
|
+
rel: "noopener noreferrer",
|
|
325
|
+
title: "Open in new tab",
|
|
326
|
+
children: [
|
|
327
|
+
/* @__PURE__ */ jsxRuntime.jsx(icons.Pencil, { width: "10", height: "10" }),
|
|
328
|
+
entity.contentTypeName,
|
|
329
|
+
" - ",
|
|
330
|
+
entity.documentId
|
|
331
|
+
]
|
|
332
|
+
},
|
|
333
|
+
idx
|
|
334
|
+
)) : /* @__PURE__ */ jsxRuntime.jsx(IdleBadge, { children: "Idle" })
|
|
335
|
+
] })
|
|
336
|
+
] }, userData.socketId)) }),
|
|
337
|
+
/* @__PURE__ */ jsxRuntime.jsx(designSystem.Flex, { justifyContent: "flex-end", marginTop: 3, children: /* @__PURE__ */ jsxRuntime.jsx(FooterLink, { href: "/admin/settings/io/monitoring", children: "View All Activity" }) })
|
|
338
|
+
] });
|
|
339
|
+
};
|
|
340
|
+
exports.OnlineEditorsWidget = OnlineEditorsWidget;
|
|
341
|
+
exports.default = OnlineEditorsWidget;
|