@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 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--2NeIKGR.js");
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
- const getSession = async () => {
184
+ let refreshTimeoutId = null;
185
+ const getSession = async (isRefresh = false) => {
185
186
  try {
186
- setPresenceState((prev) => ({ ...prev, status: "requesting" }));
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}] Presence session obtained for:`, data.user?.email);
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
- setPresenceState((prev) => ({ ...prev, status: "connecting" }));
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-CzvX8YTe.mjs";
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
- const getSession = async () => {
180
+ let refreshTimeoutId = null;
181
+ const getSession = async (isRefresh = false) => {
181
182
  try {
182
- setPresenceState((prev) => ({ ...prev, status: "requesting" }));
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}] Presence session obtained for:`, data.user?.email);
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
- setPresenceState((prev) => ({ ...prev, status: "connecting" }));
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--2NeIKGR.js");
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-CzvX8YTe.mjs";
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;