@strapi-community/plugin-io 5.2.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.
@@ -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-BEZDDgvZ.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`
@@ -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-Dof_eA3e.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);
@@ -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-BEZDDgvZ.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-Dof_eA3e.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;
@@ -0,0 +1,339 @@
1
+ import { jsx, jsxs } from "react/jsx-runtime";
2
+ import { useState, useRef, useCallback, useEffect } from "react";
3
+ import { Typography, Flex, Badge, Box } from "@strapi/design-system";
4
+ import { Pencil, User, Clock } from "@strapi/icons";
5
+ import { useFetchClient } from "@strapi/strapi/admin";
6
+ import styled, { keyframes } from "styled-components";
7
+ import { io } from "socket.io-client";
8
+ import { P as PLUGIN_ID } from "./index-Bw7WjN5H.mjs";
9
+ const pulse = keyframes`
10
+ 0%, 100% { opacity: 1; }
11
+ 50% { opacity: 0.5; }
12
+ `;
13
+ const WidgetContainer = styled(Box)`
14
+ padding: 0;
15
+ position: relative;
16
+ `;
17
+ const HeaderContainer = styled(Flex)`
18
+ justify-content: space-between;
19
+ align-items: center;
20
+ margin-bottom: ${({ theme }) => theme.spaces[3]};
21
+ padding-bottom: ${({ theme }) => theme.spaces[2]};
22
+ border-bottom: 1px solid ${({ theme }) => theme.colors.neutral150};
23
+ `;
24
+ const LiveDot = styled.span`
25
+ display: inline-block;
26
+ width: 8px;
27
+ height: 8px;
28
+ border-radius: 50%;
29
+ background: ${({ theme, $connected }) => $connected ? theme.colors.success500 : theme.colors.neutral400};
30
+ margin-right: ${({ theme }) => theme.spaces[2]};
31
+ animation: ${({ $connected }) => $connected ? pulse : "none"} 2s ease-in-out infinite;
32
+ `;
33
+ const CountBadge = styled.span`
34
+ display: inline-flex;
35
+ align-items: center;
36
+ justify-content: center;
37
+ min-width: 24px;
38
+ height: 24px;
39
+ padding: 0 8px;
40
+ background: ${({ theme, $active }) => $active ? theme.colors.primary100 : theme.colors.neutral100};
41
+ color: ${({ theme, $active }) => $active ? theme.colors.primary700 : theme.colors.neutral600};
42
+ border-radius: 12px;
43
+ font-size: 12px;
44
+ font-weight: 600;
45
+ `;
46
+ const UserList = styled.div`
47
+ display: flex;
48
+ flex-direction: column;
49
+ gap: ${({ theme }) => theme.spaces[2]};
50
+ max-height: 280px;
51
+ overflow-y: auto;
52
+ `;
53
+ const UserCard = styled.div`
54
+ display: flex;
55
+ align-items: flex-start;
56
+ gap: ${({ theme }) => theme.spaces[3]};
57
+ padding: ${({ theme }) => theme.spaces[3]};
58
+ background: ${({ theme }) => theme.colors.neutral0};
59
+ border: 1px solid ${({ theme }) => theme.colors.neutral150};
60
+ border-radius: ${({ theme }) => theme.borderRadius};
61
+ transition: all 0.2s ease;
62
+
63
+ &:hover {
64
+ border-color: ${({ theme }) => theme.colors.primary200};
65
+ box-shadow: 0 2px 8px rgba(73, 69, 255, 0.08);
66
+ }
67
+ `;
68
+ const AVATAR_COLORS = [
69
+ "linear-gradient(135deg, #4945ff 0%, #7b79ff 100%)",
70
+ "linear-gradient(135deg, #3b82f6 0%, #60a5fa 100%)",
71
+ "linear-gradient(135deg, #10b981 0%, #34d399 100%)",
72
+ "linear-gradient(135deg, #f59e0b 0%, #fbbf24 100%)",
73
+ "linear-gradient(135deg, #ef4444 0%, #f87171 100%)",
74
+ "linear-gradient(135deg, #8b5cf6 0%, #a78bfa 100%)"
75
+ ];
76
+ const UserAvatar = styled.div`
77
+ width: 40px;
78
+ height: 40px;
79
+ border-radius: 50%;
80
+ background: ${({ $colorIndex }) => AVATAR_COLORS[$colorIndex % AVATAR_COLORS.length]};
81
+ color: white;
82
+ font-size: 14px;
83
+ font-weight: 700;
84
+ display: flex;
85
+ align-items: center;
86
+ justify-content: center;
87
+ flex-shrink: 0;
88
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
89
+ `;
90
+ const UserInfo = styled.div`
91
+ flex: 1;
92
+ min-width: 0;
93
+ `;
94
+ const UserName = styled.div`
95
+ font-size: 14px;
96
+ font-weight: 600;
97
+ color: ${({ theme }) => theme.colors.neutral800};
98
+ white-space: nowrap;
99
+ overflow: hidden;
100
+ text-overflow: ellipsis;
101
+ `;
102
+ const UserMeta = styled.div`
103
+ font-size: 12px;
104
+ color: ${({ theme }) => theme.colors.neutral500};
105
+ display: flex;
106
+ align-items: center;
107
+ gap: ${({ theme }) => theme.spaces[2]};
108
+ margin-top: 2px;
109
+ `;
110
+ const EditingBadge = styled.a`
111
+ display: inline-flex;
112
+ align-items: center;
113
+ gap: 4px;
114
+ font-size: 11px;
115
+ font-weight: 500;
116
+ color: ${({ theme }) => theme.colors.success700};
117
+ background: ${({ theme }) => theme.colors.success100};
118
+ padding: 4px 10px;
119
+ border-radius: 10px;
120
+ margin-top: ${({ theme }) => theme.spaces[1]};
121
+ word-break: break-all;
122
+ max-width: 100%;
123
+ text-decoration: none;
124
+ cursor: pointer;
125
+ transition: all 0.15s ease;
126
+
127
+ &:hover {
128
+ background: ${({ theme }) => theme.colors.success200};
129
+ color: ${({ theme }) => theme.colors.success800};
130
+ transform: translateY(-1px);
131
+ }
132
+ `;
133
+ const IdleBadge = styled.span`
134
+ display: inline-flex;
135
+ align-items: center;
136
+ gap: 4px;
137
+ font-size: 11px;
138
+ font-weight: 500;
139
+ color: ${({ theme }) => theme.colors.neutral600};
140
+ background: ${({ theme }) => theme.colors.neutral100};
141
+ padding: 2px 8px;
142
+ border-radius: 10px;
143
+ margin-top: ${({ theme }) => theme.spaces[1]};
144
+ `;
145
+ const EmptyState = styled.div`
146
+ display: flex;
147
+ flex-direction: column;
148
+ align-items: center;
149
+ justify-content: center;
150
+ text-align: center;
151
+ padding: ${({ theme }) => theme.spaces[8]} ${({ theme }) => theme.spaces[4]};
152
+ color: ${({ theme }) => theme.colors.neutral500};
153
+ min-height: 180px;
154
+ `;
155
+ const EmptyIcon = styled.div`
156
+ width: 64px;
157
+ height: 64px;
158
+ border-radius: 50%;
159
+ background: ${({ theme }) => theme.colors.neutral100};
160
+ display: flex;
161
+ align-items: center;
162
+ justify-content: center;
163
+ margin-bottom: ${({ theme }) => theme.spaces[3]};
164
+ `;
165
+ const LoadingContainer = styled.div`
166
+ display: flex;
167
+ align-items: center;
168
+ justify-content: center;
169
+ padding: ${({ theme }) => theme.spaces[6]};
170
+ `;
171
+ const FooterLink = styled.a`
172
+ font-size: 12px;
173
+ color: ${({ theme }) => theme.colors.primary600};
174
+ text-decoration: none;
175
+
176
+ &:hover {
177
+ text-decoration: underline;
178
+ }
179
+ `;
180
+ const getInitials = (user) => {
181
+ const first = (user.firstname?.[0] || user.username?.[0] || user.email?.[0] || "?").toUpperCase();
182
+ const last = (user.lastname?.[0] || "").toUpperCase();
183
+ return `${first}${last}`.trim() || "?";
184
+ };
185
+ const getDisplayName = (user) => {
186
+ if (user.firstname) {
187
+ return `${user.firstname} ${user.lastname || ""}`.trim();
188
+ }
189
+ return user.username || user.email || "Unknown";
190
+ };
191
+ const formatDuration = (seconds) => {
192
+ if (seconds < 60) return "just now";
193
+ if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
194
+ if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`;
195
+ return `${Math.floor(seconds / 86400)}d`;
196
+ };
197
+ const OnlineEditorsWidget = () => {
198
+ const { get, post } = useFetchClient();
199
+ const [data, setData] = useState(null);
200
+ const [loading, setLoading] = useState(true);
201
+ const [error, setError] = useState(null);
202
+ const [connected, setConnected] = useState(false);
203
+ const socketRef = useRef(null);
204
+ const fetchOnlineUsers = useCallback(async () => {
205
+ try {
206
+ const response = await get(`/${PLUGIN_ID}/online-users`);
207
+ setData(response.data?.data || response.data);
208
+ setError(null);
209
+ setLoading(false);
210
+ } catch (err) {
211
+ console.error("[plugin-io] Failed to fetch online users:", err);
212
+ setError(err.message);
213
+ setLoading(false);
214
+ }
215
+ }, [get]);
216
+ useEffect(() => {
217
+ let cancelled = false;
218
+ let socket = null;
219
+ const connectSocket = async () => {
220
+ try {
221
+ const { data: sessionData } = await post(`/${PLUGIN_ID}/presence/session`, {});
222
+ if (cancelled || !sessionData?.token) return;
223
+ const socketUrl = sessionData.wsUrl || `${window.location.protocol}//${window.location.host}`;
224
+ socket = io(socketUrl, {
225
+ path: sessionData.wsPath || "/socket.io",
226
+ transports: ["websocket", "polling"],
227
+ auth: {
228
+ token: sessionData.token,
229
+ strategy: "admin-jwt",
230
+ isAdmin: true
231
+ },
232
+ reconnection: true,
233
+ reconnectionAttempts: 3
234
+ });
235
+ socketRef.current = socket;
236
+ socket.on("connect", () => {
237
+ if (!cancelled) {
238
+ setConnected(true);
239
+ console.log(`[${PLUGIN_ID}] Dashboard presence connected`);
240
+ fetchOnlineUsers();
241
+ }
242
+ });
243
+ socket.on("disconnect", () => {
244
+ if (!cancelled) {
245
+ setConnected(false);
246
+ }
247
+ });
248
+ socket.on("connect_error", (err) => {
249
+ console.warn(`[${PLUGIN_ID}] Dashboard socket error:`, err.message);
250
+ });
251
+ socket.on("presence:update", () => {
252
+ fetchOnlineUsers();
253
+ });
254
+ } catch (err) {
255
+ console.error("[plugin-io] Failed to connect dashboard socket:", err);
256
+ }
257
+ };
258
+ connectSocket();
259
+ return () => {
260
+ cancelled = true;
261
+ if (socket) {
262
+ socket.disconnect();
263
+ socketRef.current = null;
264
+ }
265
+ };
266
+ }, [post, fetchOnlineUsers]);
267
+ useEffect(() => {
268
+ const interval = setInterval(fetchOnlineUsers, 15e3);
269
+ return () => clearInterval(interval);
270
+ }, [fetchOnlineUsers]);
271
+ if (loading) {
272
+ return /* @__PURE__ */ jsx(LoadingContainer, { children: /* @__PURE__ */ jsx(Typography, { variant: "pi", textColor: "neutral600", children: "Loading..." }) });
273
+ }
274
+ if (error) {
275
+ return /* @__PURE__ */ jsx(EmptyState, { children: /* @__PURE__ */ jsxs(Typography, { variant: "pi", textColor: "danger600", children: [
276
+ "Failed to load: ",
277
+ error
278
+ ] }) });
279
+ }
280
+ const users = data?.users || [];
281
+ const counts = data?.counts || { total: 0, editing: 0 };
282
+ return /* @__PURE__ */ jsxs(WidgetContainer, { children: [
283
+ /* @__PURE__ */ jsxs(HeaderContainer, { children: [
284
+ /* @__PURE__ */ jsxs(Flex, { alignItems: "center", gap: 2, children: [
285
+ /* @__PURE__ */ jsx(LiveDot, { $connected: connected }),
286
+ /* @__PURE__ */ jsx(Typography, { variant: "omega", fontWeight: "bold", textColor: "neutral800", children: "Who's Online" })
287
+ ] }),
288
+ /* @__PURE__ */ jsxs(Flex, { gap: 2, children: [
289
+ /* @__PURE__ */ jsxs(CountBadge, { $active: counts.editing > 0, title: "Users editing", children: [
290
+ /* @__PURE__ */ jsx(Pencil, { width: "12", height: "12", style: { marginRight: 4 } }),
291
+ counts.editing
292
+ ] }),
293
+ /* @__PURE__ */ jsxs(CountBadge, { title: "Total online", children: [
294
+ /* @__PURE__ */ jsx(User, { width: "12", height: "12", style: { marginRight: 4 } }),
295
+ counts.total
296
+ ] })
297
+ ] })
298
+ ] }),
299
+ users.length === 0 ? /* @__PURE__ */ jsxs(EmptyState, { children: [
300
+ /* @__PURE__ */ jsx(EmptyIcon, { children: /* @__PURE__ */ jsx(User, { width: "28", height: "28", fill: "#a5a5ba" }) }),
301
+ /* @__PURE__ */ jsx(Typography, { variant: "omega", fontWeight: "semiBold", textColor: "neutral600", children: "No one else is online" }),
302
+ /* @__PURE__ */ jsx(Typography, { variant: "pi", textColor: "neutral500", style: { marginTop: 4 }, children: "You're the only one here right now" })
303
+ ] }) : /* @__PURE__ */ jsx(UserList, { children: users.map((userData, index) => /* @__PURE__ */ jsxs(UserCard, { children: [
304
+ /* @__PURE__ */ jsx(UserAvatar, { $colorIndex: index, children: getInitials(userData.user) }),
305
+ /* @__PURE__ */ jsxs(UserInfo, { children: [
306
+ /* @__PURE__ */ jsxs(UserName, { children: [
307
+ getDisplayName(userData.user),
308
+ userData.user.isAdmin && /* @__PURE__ */ jsx(Badge, { size: "S", style: { marginLeft: 8 }, children: "Admin" })
309
+ ] }),
310
+ /* @__PURE__ */ jsxs(UserMeta, { children: [
311
+ /* @__PURE__ */ jsx(Clock, { width: "12", height: "12" }),
312
+ "Online ",
313
+ formatDuration(userData.onlineFor)
314
+ ] }),
315
+ userData.isEditing ? userData.editingEntities.map((entity, idx) => /* @__PURE__ */ jsxs(
316
+ EditingBadge,
317
+ {
318
+ href: `/admin/content-manager/collection-types/${entity.uid}/${entity.documentId}`,
319
+ target: "_blank",
320
+ rel: "noopener noreferrer",
321
+ title: "Open in new tab",
322
+ children: [
323
+ /* @__PURE__ */ jsx(Pencil, { width: "10", height: "10" }),
324
+ entity.contentTypeName,
325
+ " - ",
326
+ entity.documentId
327
+ ]
328
+ },
329
+ idx
330
+ )) : /* @__PURE__ */ jsx(IdleBadge, { children: "Idle" })
331
+ ] })
332
+ ] }, userData.socketId)) }),
333
+ /* @__PURE__ */ jsx(Flex, { justifyContent: "flex-end", marginTop: 3, children: /* @__PURE__ */ jsx(FooterLink, { href: "/admin/settings/io/monitoring", children: "View All Activity" }) })
334
+ ] });
335
+ };
336
+ export {
337
+ OnlineEditorsWidget,
338
+ OnlineEditorsWidget as default
339
+ };
@@ -7,7 +7,7 @@ const icons = require("@strapi/icons");
7
7
  const admin = require("@strapi/strapi/admin");
8
8
  const index = require("./index-DkTxsEqL.js");
9
9
  const styled = require("styled-components");
10
- const index$1 = require("./index-BEZDDgvZ.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 ResponsiveMain = styled__default.default(designSystem.Main)`
@@ -5,7 +5,7 @@ import { Download, Upload, Check } from "@strapi/icons";
5
5
  import { useFetchClient, useNotification } from "@strapi/strapi/admin";
6
6
  import { u as useIntl } from "./index-CEh8vkxY.mjs";
7
7
  import styled from "styled-components";
8
- import { P as PLUGIN_ID } from "./index-Dof_eA3e.mjs";
8
+ import { P as PLUGIN_ID } from "./index-Bw7WjN5H.mjs";
9
9
  const ResponsiveMain = styled(Main)`
10
10
  & > div {
11
11
  padding: 16px !important;
@@ -50,7 +50,7 @@ const index = {
50
50
  },
51
51
  id: `${PLUGIN_ID}-settings`,
52
52
  to: `${PLUGIN_ID}/settings`,
53
- Component: () => import("./SettingsPage-Btz_5MuC.mjs").then((mod) => ({ default: mod.SettingsPage }))
53
+ Component: () => import("./SettingsPage-Qi0iMaWc.mjs").then((mod) => ({ default: mod.SettingsPage }))
54
54
  },
55
55
  {
56
56
  intlLabel: {
@@ -59,7 +59,7 @@ const index = {
59
59
  },
60
60
  id: `${PLUGIN_ID}-monitoring`,
61
61
  to: `${PLUGIN_ID}/monitoring`,
62
- Component: () => import("./MonitoringPage-Bbkoh6ih.mjs").then((mod) => ({ default: mod.MonitoringPage }))
62
+ Component: () => import("./MonitoringPage-DKfhYUgU.mjs").then((mod) => ({ default: mod.MonitoringPage }))
63
63
  }
64
64
  ]
65
65
  );
@@ -77,13 +77,26 @@ const index = {
77
77
  id: "socket-io-stats-widget",
78
78
  pluginId: PLUGIN_ID
79
79
  });
80
- console.log(`[${PLUGIN_ID}] [SUCCESS] Socket.IO Stats Widget registered`);
80
+ app.widgets.register({
81
+ icon: PluginIcon,
82
+ title: {
83
+ id: `${PLUGIN_ID}.widget.online-editors.title`,
84
+ defaultMessage: "Who's Online"
85
+ },
86
+ component: async () => {
87
+ const component = await import("./OnlineEditorsWidget-RcYLxQke.mjs");
88
+ return component.OnlineEditorsWidget;
89
+ },
90
+ id: "socket-io-online-editors-widget",
91
+ pluginId: PLUGIN_ID
92
+ });
93
+ console.log(`[${PLUGIN_ID}] [SUCCESS] Dashboard widgets registered`);
81
94
  }
82
95
  },
83
96
  async bootstrap(app) {
84
97
  console.log(`[${PLUGIN_ID}] [INFO] Bootstrapping plugin...`);
85
98
  try {
86
- const { default: LivePresencePanel } = await import("./LivePresencePanel-CIFG_05s.mjs");
99
+ const { default: LivePresencePanel } = await import("./LivePresencePanel-D_vzQr4B.mjs");
87
100
  const contentManagerPlugin = app.getPlugin("content-manager");
88
101
  if (contentManagerPlugin && contentManagerPlugin.apis) {
89
102
  contentManagerPlugin.apis.addEditViewSidePanel([LivePresencePanel]);
@@ -51,7 +51,7 @@ const index = {
51
51
  },
52
52
  id: `${PLUGIN_ID}-settings`,
53
53
  to: `${PLUGIN_ID}/settings`,
54
- Component: () => Promise.resolve().then(() => require("./SettingsPage-CsRazf0j.js")).then((mod) => ({ default: mod.SettingsPage }))
54
+ Component: () => Promise.resolve().then(() => require("./SettingsPage-0k9qPAJZ.js")).then((mod) => ({ default: mod.SettingsPage }))
55
55
  },
56
56
  {
57
57
  intlLabel: {
@@ -60,7 +60,7 @@ const index = {
60
60
  },
61
61
  id: `${PLUGIN_ID}-monitoring`,
62
62
  to: `${PLUGIN_ID}/monitoring`,
63
- Component: () => Promise.resolve().then(() => require("./MonitoringPage-9f4Gzd2X.js")).then((mod) => ({ default: mod.MonitoringPage }))
63
+ Component: () => Promise.resolve().then(() => require("./MonitoringPage-CYGqkzva.js")).then((mod) => ({ default: mod.MonitoringPage }))
64
64
  }
65
65
  ]
66
66
  );
@@ -78,13 +78,26 @@ const index = {
78
78
  id: "socket-io-stats-widget",
79
79
  pluginId: PLUGIN_ID
80
80
  });
81
- console.log(`[${PLUGIN_ID}] [SUCCESS] Socket.IO Stats Widget registered`);
81
+ app.widgets.register({
82
+ icon: PluginIcon,
83
+ title: {
84
+ id: `${PLUGIN_ID}.widget.online-editors.title`,
85
+ defaultMessage: "Who's Online"
86
+ },
87
+ component: async () => {
88
+ const component = await Promise.resolve().then(() => require("./OnlineEditorsWidget-Bf8hfVha.js"));
89
+ return component.OnlineEditorsWidget;
90
+ },
91
+ id: "socket-io-online-editors-widget",
92
+ pluginId: PLUGIN_ID
93
+ });
94
+ console.log(`[${PLUGIN_ID}] [SUCCESS] Dashboard widgets registered`);
82
95
  }
83
96
  },
84
97
  async bootstrap(app) {
85
98
  console.log(`[${PLUGIN_ID}] [INFO] Bootstrapping plugin...`);
86
99
  try {
87
- const { default: LivePresencePanel } = await Promise.resolve().then(() => require("./LivePresencePanel-2U3I3yL0.js"));
100
+ const { default: LivePresencePanel } = await Promise.resolve().then(() => require("./LivePresencePanel-BkeWL4kq.js"));
88
101
  const contentManagerPlugin = app.getPlugin("content-manager");
89
102
  if (contentManagerPlugin && contentManagerPlugin.apis) {
90
103
  contentManagerPlugin.apis.addEditViewSidePanel([LivePresencePanel]);
@@ -1,3 +1,3 @@
1
1
  "use strict";
2
- const index = require("../_chunks/index-BEZDDgvZ.js");
2
+ const index = require("../_chunks/index-DVNfszio.js");
3
3
  module.exports = index.index;
@@ -1,4 +1,4 @@
1
- import { i } from "../_chunks/index-Dof_eA3e.mjs";
1
+ import { i } from "../_chunks/index-Bw7WjN5H.mjs";
2
2
  export {
3
3
  i as default
4
4
  };
@@ -666,7 +666,9 @@ async function bootstrapIO$1({ strapi: strapi2 }) {
666
666
  });
667
667
  socket.on("get-entity-subscriptions", (callback) => {
668
668
  const rooms = Array.from(socket.rooms).filter((r) => r !== socket.id && r.includes(":")).map((room) => {
669
- const [uid, id] = room.split(":");
669
+ const lastColonIndex = room.lastIndexOf(":");
670
+ const uid = room.substring(0, lastColonIndex);
671
+ const id = room.substring(lastColonIndex + 1);
670
672
  return { uid, id, room };
671
673
  });
672
674
  if (callback) callback({ success: true, subscriptions: rooms });
@@ -1348,7 +1350,7 @@ const sessionTokens = /* @__PURE__ */ new Map();
1348
1350
  const activeSockets = /* @__PURE__ */ new Map();
1349
1351
  const refreshThrottle = /* @__PURE__ */ new Map();
1350
1352
  const SESSION_TTL = 10 * 60 * 1e3;
1351
- const REFRESH_COOLDOWN = 30 * 1e3;
1353
+ const REFRESH_COOLDOWN = 3 * 1e3;
1352
1354
  const CLEANUP_INTERVAL = 2 * 60 * 1e3;
1353
1355
  const hashToken = (token) => {
1354
1356
  return createHash("sha256").update(token).digest("hex");
@@ -1399,7 +1401,7 @@ var presence$3 = ({ strapi: strapi2 }) => ({
1399
1401
  userId: adminUser.id,
1400
1402
  user: {
1401
1403
  id: adminUser.id,
1402
- // Only store minimal user data needed for display
1404
+ email: adminUser.email,
1403
1405
  firstname: adminUser.firstname,
1404
1406
  lastname: adminUser.lastname
1405
1407
  },
@@ -1562,6 +1564,32 @@ var presence$3 = ({ strapi: strapi2 }) => ({
1562
1564
  strapi2.log.error("[plugin-io] Failed to invalidate user sessions:", error2);
1563
1565
  return ctx.internalServerError("Failed to invalidate sessions");
1564
1566
  }
1567
+ },
1568
+ /**
1569
+ * HTTP Handler: Gets all online users with their editing info
1570
+ * Used for the "Who's Online" dashboard widget
1571
+ * @param {object} ctx - Koa context
1572
+ */
1573
+ async getOnlineUsers(ctx) {
1574
+ const adminUser = ctx.state.user;
1575
+ if (!adminUser) {
1576
+ return ctx.unauthorized("Admin authentication required");
1577
+ }
1578
+ try {
1579
+ const presenceService = strapi2.plugin("io").service("presence");
1580
+ const onlineUsers = presenceService.getOnlineUsers();
1581
+ const counts = presenceService.getOnlineCounts();
1582
+ ctx.body = {
1583
+ data: {
1584
+ users: onlineUsers,
1585
+ counts,
1586
+ timestamp: Date.now()
1587
+ }
1588
+ };
1589
+ } catch (error2) {
1590
+ strapi2.log.error("[plugin-io] Failed to get online users:", error2);
1591
+ return ctx.internalServerError("Failed to get online users");
1592
+ }
1565
1593
  }
1566
1594
  });
1567
1595
  const settings$2 = settings$3;
@@ -1671,6 +1699,15 @@ var admin$1 = {
1671
1699
  config: {
1672
1700
  policies: ["admin::isAuthenticatedAdmin"]
1673
1701
  }
1702
+ },
1703
+ // Who's Online: Get all online users with editing info
1704
+ {
1705
+ method: "GET",
1706
+ path: "/online-users",
1707
+ handler: "presence.getOnlineUsers",
1708
+ config: {
1709
+ policies: ["admin::isAuthenticatedAdmin"]
1710
+ }
1674
1711
  }
1675
1712
  ]
1676
1713
  };
@@ -30550,7 +30587,10 @@ var presence$1 = ({ strapi: strapi2 }) => {
30550
30587
  */
30551
30588
  registerConnection(socketId, user = null) {
30552
30589
  const settings2 = getPresenceSettings();
30553
- if (!settings2.enabled) return;
30590
+ if (!settings2.enabled) {
30591
+ strapi2.log.warn(`socket.io: Presence disabled, skipping registration for ${socketId}`);
30592
+ return;
30593
+ }
30554
30594
  activeConnections.set(socketId, {
30555
30595
  user,
30556
30596
  entities: /* @__PURE__ */ new Map(),
@@ -30558,7 +30598,8 @@ var presence$1 = ({ strapi: strapi2 }) => {
30558
30598
  lastSeen: Date.now(),
30559
30599
  connectedAt: Date.now()
30560
30600
  });
30561
- strapi2.log.debug(`socket.io: Presence registered for socket ${socketId}`);
30601
+ const username = user?.username || user?.firstname || "anonymous";
30602
+ strapi2.log.info(`socket.io: Presence registered for ${username} (socket: ${socketId}, total: ${activeConnections.size})`);
30562
30603
  },
30563
30604
  /**
30564
30605
  * Unregisters a socket connection and cleans up all entity presence
@@ -30569,7 +30610,9 @@ var presence$1 = ({ strapi: strapi2 }) => {
30569
30610
  if (!connection) return;
30570
30611
  if (connection.entities) {
30571
30612
  for (const entityKey of connection.entities.keys()) {
30572
- const [uid, documentId] = entityKey.split(":");
30613
+ const lastColonIndex = entityKey.lastIndexOf(":");
30614
+ const uid = entityKey.substring(0, lastColonIndex);
30615
+ const documentId = entityKey.substring(lastColonIndex + 1);
30573
30616
  await this.leaveEntity(socketId, uid, documentId, false);
30574
30617
  }
30575
30618
  }
@@ -30804,6 +30847,90 @@ var presence$1 = ({ strapi: strapi2 }) => {
30804
30847
  timestamp: Date.now()
30805
30848
  });
30806
30849
  }
30850
+ },
30851
+ /**
30852
+ * Gets all online users with their currently editing entities
30853
+ * Used for the "Who's Online" dashboard widget
30854
+ * @returns {Array} List of online users with their editing info
30855
+ */
30856
+ getOnlineUsers() {
30857
+ const users = [];
30858
+ const now = Date.now();
30859
+ for (const [socketId, connection] of activeConnections) {
30860
+ if (!connection.user) continue;
30861
+ const editingEntities = [];
30862
+ if (connection.entities) {
30863
+ for (const [entityKey, joinedAt] of connection.entities) {
30864
+ const lastColonIndex = entityKey.lastIndexOf(":");
30865
+ const uid = entityKey.substring(0, lastColonIndex);
30866
+ const documentId = entityKey.substring(lastColonIndex + 1);
30867
+ let contentTypeName = uid;
30868
+ try {
30869
+ const contentType = strapi2.contentTypes[uid];
30870
+ if (contentType?.info?.displayName) {
30871
+ contentTypeName = contentType.info.displayName;
30872
+ } else if (contentType?.info?.singularName) {
30873
+ contentTypeName = contentType.info.singularName;
30874
+ }
30875
+ } catch (e) {
30876
+ }
30877
+ editingEntities.push({
30878
+ uid,
30879
+ documentId,
30880
+ contentTypeName,
30881
+ joinedAt,
30882
+ editingFor: Math.floor((now - joinedAt) / 1e3)
30883
+ // seconds
30884
+ });
30885
+ }
30886
+ }
30887
+ users.push({
30888
+ socketId,
30889
+ user: {
30890
+ id: connection.user.id,
30891
+ username: connection.user.username,
30892
+ email: connection.user.email,
30893
+ firstname: connection.user.firstname,
30894
+ lastname: connection.user.lastname,
30895
+ isAdmin: connection.user.isAdmin || false
30896
+ },
30897
+ connectedAt: connection.connectedAt,
30898
+ lastSeen: connection.lastSeen,
30899
+ onlineFor: Math.floor((now - connection.connectedAt) / 1e3),
30900
+ // seconds
30901
+ editingEntities,
30902
+ isEditing: editingEntities.length > 0
30903
+ });
30904
+ }
30905
+ users.sort((a, b) => {
30906
+ if (a.isEditing && !b.isEditing) return -1;
30907
+ if (!a.isEditing && b.isEditing) return 1;
30908
+ return b.connectedAt - a.connectedAt;
30909
+ });
30910
+ return users;
30911
+ },
30912
+ /**
30913
+ * Gets count of online users
30914
+ * @returns {object} Online user counts
30915
+ */
30916
+ getOnlineCounts() {
30917
+ let total = 0;
30918
+ let admins = 0;
30919
+ let users = 0;
30920
+ let editing = 0;
30921
+ for (const connection of activeConnections.values()) {
30922
+ if (!connection.user) continue;
30923
+ total++;
30924
+ if (connection.user.isAdmin) {
30925
+ admins++;
30926
+ } else {
30927
+ users++;
30928
+ }
30929
+ if (connection.entities?.size > 0) {
30930
+ editing++;
30931
+ }
30932
+ }
30933
+ return { total, admins, users, editing };
30807
30934
  }
30808
30935
  };
30809
30936
  };
@@ -31020,7 +31147,9 @@ var preview$1 = ({ strapi: strapi2 }) => {
31020
31147
  getActivePreviewEntities() {
31021
31148
  const entities = [];
31022
31149
  for (const [entityKey, subscribers] of previewSubscribers) {
31023
- const [uid, documentId] = entityKey.split(":");
31150
+ const lastColonIndex = entityKey.lastIndexOf(":");
31151
+ const uid = entityKey.substring(0, lastColonIndex);
31152
+ const documentId = entityKey.substring(lastColonIndex + 1);
31024
31153
  entities.push({
31025
31154
  uid,
31026
31155
  documentId,
@@ -634,7 +634,9 @@ async function bootstrapIO$1({ strapi: strapi2 }) {
634
634
  });
635
635
  socket.on("get-entity-subscriptions", (callback) => {
636
636
  const rooms = Array.from(socket.rooms).filter((r) => r !== socket.id && r.includes(":")).map((room) => {
637
- const [uid, id] = room.split(":");
637
+ const lastColonIndex = room.lastIndexOf(":");
638
+ const uid = room.substring(0, lastColonIndex);
639
+ const id = room.substring(lastColonIndex + 1);
638
640
  return { uid, id, room };
639
641
  });
640
642
  if (callback) callback({ success: true, subscriptions: rooms });
@@ -1316,7 +1318,7 @@ const sessionTokens = /* @__PURE__ */ new Map();
1316
1318
  const activeSockets = /* @__PURE__ */ new Map();
1317
1319
  const refreshThrottle = /* @__PURE__ */ new Map();
1318
1320
  const SESSION_TTL = 10 * 60 * 1e3;
1319
- const REFRESH_COOLDOWN = 30 * 1e3;
1321
+ const REFRESH_COOLDOWN = 3 * 1e3;
1320
1322
  const CLEANUP_INTERVAL = 2 * 60 * 1e3;
1321
1323
  const hashToken = (token) => {
1322
1324
  return createHash("sha256").update(token).digest("hex");
@@ -1367,7 +1369,7 @@ var presence$3 = ({ strapi: strapi2 }) => ({
1367
1369
  userId: adminUser.id,
1368
1370
  user: {
1369
1371
  id: adminUser.id,
1370
- // Only store minimal user data needed for display
1372
+ email: adminUser.email,
1371
1373
  firstname: adminUser.firstname,
1372
1374
  lastname: adminUser.lastname
1373
1375
  },
@@ -1530,6 +1532,32 @@ var presence$3 = ({ strapi: strapi2 }) => ({
1530
1532
  strapi2.log.error("[plugin-io] Failed to invalidate user sessions:", error2);
1531
1533
  return ctx.internalServerError("Failed to invalidate sessions");
1532
1534
  }
1535
+ },
1536
+ /**
1537
+ * HTTP Handler: Gets all online users with their editing info
1538
+ * Used for the "Who's Online" dashboard widget
1539
+ * @param {object} ctx - Koa context
1540
+ */
1541
+ async getOnlineUsers(ctx) {
1542
+ const adminUser = ctx.state.user;
1543
+ if (!adminUser) {
1544
+ return ctx.unauthorized("Admin authentication required");
1545
+ }
1546
+ try {
1547
+ const presenceService = strapi2.plugin("io").service("presence");
1548
+ const onlineUsers = presenceService.getOnlineUsers();
1549
+ const counts = presenceService.getOnlineCounts();
1550
+ ctx.body = {
1551
+ data: {
1552
+ users: onlineUsers,
1553
+ counts,
1554
+ timestamp: Date.now()
1555
+ }
1556
+ };
1557
+ } catch (error2) {
1558
+ strapi2.log.error("[plugin-io] Failed to get online users:", error2);
1559
+ return ctx.internalServerError("Failed to get online users");
1560
+ }
1533
1561
  }
1534
1562
  });
1535
1563
  const settings$2 = settings$3;
@@ -1639,6 +1667,15 @@ var admin$1 = {
1639
1667
  config: {
1640
1668
  policies: ["admin::isAuthenticatedAdmin"]
1641
1669
  }
1670
+ },
1671
+ // Who's Online: Get all online users with editing info
1672
+ {
1673
+ method: "GET",
1674
+ path: "/online-users",
1675
+ handler: "presence.getOnlineUsers",
1676
+ config: {
1677
+ policies: ["admin::isAuthenticatedAdmin"]
1678
+ }
1642
1679
  }
1643
1680
  ]
1644
1681
  };
@@ -30518,7 +30555,10 @@ var presence$1 = ({ strapi: strapi2 }) => {
30518
30555
  */
30519
30556
  registerConnection(socketId, user = null) {
30520
30557
  const settings2 = getPresenceSettings();
30521
- if (!settings2.enabled) return;
30558
+ if (!settings2.enabled) {
30559
+ strapi2.log.warn(`socket.io: Presence disabled, skipping registration for ${socketId}`);
30560
+ return;
30561
+ }
30522
30562
  activeConnections.set(socketId, {
30523
30563
  user,
30524
30564
  entities: /* @__PURE__ */ new Map(),
@@ -30526,7 +30566,8 @@ var presence$1 = ({ strapi: strapi2 }) => {
30526
30566
  lastSeen: Date.now(),
30527
30567
  connectedAt: Date.now()
30528
30568
  });
30529
- strapi2.log.debug(`socket.io: Presence registered for socket ${socketId}`);
30569
+ const username = user?.username || user?.firstname || "anonymous";
30570
+ strapi2.log.info(`socket.io: Presence registered for ${username} (socket: ${socketId}, total: ${activeConnections.size})`);
30530
30571
  },
30531
30572
  /**
30532
30573
  * Unregisters a socket connection and cleans up all entity presence
@@ -30537,7 +30578,9 @@ var presence$1 = ({ strapi: strapi2 }) => {
30537
30578
  if (!connection) return;
30538
30579
  if (connection.entities) {
30539
30580
  for (const entityKey of connection.entities.keys()) {
30540
- const [uid, documentId] = entityKey.split(":");
30581
+ const lastColonIndex = entityKey.lastIndexOf(":");
30582
+ const uid = entityKey.substring(0, lastColonIndex);
30583
+ const documentId = entityKey.substring(lastColonIndex + 1);
30541
30584
  await this.leaveEntity(socketId, uid, documentId, false);
30542
30585
  }
30543
30586
  }
@@ -30772,6 +30815,90 @@ var presence$1 = ({ strapi: strapi2 }) => {
30772
30815
  timestamp: Date.now()
30773
30816
  });
30774
30817
  }
30818
+ },
30819
+ /**
30820
+ * Gets all online users with their currently editing entities
30821
+ * Used for the "Who's Online" dashboard widget
30822
+ * @returns {Array} List of online users with their editing info
30823
+ */
30824
+ getOnlineUsers() {
30825
+ const users = [];
30826
+ const now = Date.now();
30827
+ for (const [socketId, connection] of activeConnections) {
30828
+ if (!connection.user) continue;
30829
+ const editingEntities = [];
30830
+ if (connection.entities) {
30831
+ for (const [entityKey, joinedAt] of connection.entities) {
30832
+ const lastColonIndex = entityKey.lastIndexOf(":");
30833
+ const uid = entityKey.substring(0, lastColonIndex);
30834
+ const documentId = entityKey.substring(lastColonIndex + 1);
30835
+ let contentTypeName = uid;
30836
+ try {
30837
+ const contentType = strapi2.contentTypes[uid];
30838
+ if (contentType?.info?.displayName) {
30839
+ contentTypeName = contentType.info.displayName;
30840
+ } else if (contentType?.info?.singularName) {
30841
+ contentTypeName = contentType.info.singularName;
30842
+ }
30843
+ } catch (e) {
30844
+ }
30845
+ editingEntities.push({
30846
+ uid,
30847
+ documentId,
30848
+ contentTypeName,
30849
+ joinedAt,
30850
+ editingFor: Math.floor((now - joinedAt) / 1e3)
30851
+ // seconds
30852
+ });
30853
+ }
30854
+ }
30855
+ users.push({
30856
+ socketId,
30857
+ user: {
30858
+ id: connection.user.id,
30859
+ username: connection.user.username,
30860
+ email: connection.user.email,
30861
+ firstname: connection.user.firstname,
30862
+ lastname: connection.user.lastname,
30863
+ isAdmin: connection.user.isAdmin || false
30864
+ },
30865
+ connectedAt: connection.connectedAt,
30866
+ lastSeen: connection.lastSeen,
30867
+ onlineFor: Math.floor((now - connection.connectedAt) / 1e3),
30868
+ // seconds
30869
+ editingEntities,
30870
+ isEditing: editingEntities.length > 0
30871
+ });
30872
+ }
30873
+ users.sort((a, b) => {
30874
+ if (a.isEditing && !b.isEditing) return -1;
30875
+ if (!a.isEditing && b.isEditing) return 1;
30876
+ return b.connectedAt - a.connectedAt;
30877
+ });
30878
+ return users;
30879
+ },
30880
+ /**
30881
+ * Gets count of online users
30882
+ * @returns {object} Online user counts
30883
+ */
30884
+ getOnlineCounts() {
30885
+ let total = 0;
30886
+ let admins = 0;
30887
+ let users = 0;
30888
+ let editing = 0;
30889
+ for (const connection of activeConnections.values()) {
30890
+ if (!connection.user) continue;
30891
+ total++;
30892
+ if (connection.user.isAdmin) {
30893
+ admins++;
30894
+ } else {
30895
+ users++;
30896
+ }
30897
+ if (connection.entities?.size > 0) {
30898
+ editing++;
30899
+ }
30900
+ }
30901
+ return { total, admins, users, editing };
30775
30902
  }
30776
30903
  };
30777
30904
  };
@@ -30988,7 +31115,9 @@ var preview$1 = ({ strapi: strapi2 }) => {
30988
31115
  getActivePreviewEntities() {
30989
31116
  const entities = [];
30990
31117
  for (const [entityKey, subscribers] of previewSubscribers) {
30991
- const [uid, documentId] = entityKey.split(":");
31118
+ const lastColonIndex = entityKey.lastIndexOf(":");
31119
+ const uid = entityKey.substring(0, lastColonIndex);
31120
+ const documentId = entityKey.substring(lastColonIndex + 1);
30992
31121
  entities.push({
30993
31122
  uid,
30994
31123
  documentId,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package",
3
3
  "name": "@strapi-community/plugin-io",
4
- "version": "5.2.0",
4
+ "version": "5.3.0",
5
5
  "description": "A plugin for Strapi CMS that provides the ability for Socket IO integration",
6
6
  "keywords": [
7
7
  "strapi",