@strapi-community/plugin-io 5.0.6 → 5.2.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 +174 -1
- package/dist/_chunks/LivePresencePanel-2U3I3yL0.js +419 -0
- package/dist/_chunks/LivePresencePanel-CIFG_05s.mjs +417 -0
- package/dist/_chunks/{MonitoringPage-HxHK1nFr.js → MonitoringPage-9f4Gzd2X.js} +1 -1
- package/dist/_chunks/{MonitoringPage-DLZdTZpg.mjs → MonitoringPage-Bbkoh6ih.mjs} +1 -1
- package/dist/_chunks/{SettingsPage-DBIu309c.mjs → SettingsPage-Btz_5MuC.mjs} +250 -148
- package/dist/_chunks/{SettingsPage-88RdJkLy.js → SettingsPage-CsRazf0j.js} +250 -148
- package/dist/_chunks/{index-BVQ20t1c.js → index-BEZDDgvZ.js} +15 -5
- package/dist/_chunks/{index-DLXtrAtk.mjs → index-Dof_eA3e.mjs} +15 -5
- package/dist/admin/index.js +1 -1
- package/dist/admin/index.mjs +1 -1
- package/dist/server/index.js +1373 -60
- package/dist/server/index.mjs +1372 -59
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -39,6 +39,12 @@ Add real-time capabilities to your Strapi application with WebSocket support. Au
|
|
|
39
39
|
- **Role-Based Access Control** - Built-in permission checks for JWT and API tokens
|
|
40
40
|
- **Multi-Client Support** - Handle 2500+ concurrent connections efficiently
|
|
41
41
|
|
|
42
|
+
### Live Presence (NEW)
|
|
43
|
+
- **Real-Time Presence Awareness** - See who else is editing the same content
|
|
44
|
+
- **Typing Indicator** - See when someone is typing and in which field
|
|
45
|
+
- **Admin Panel Sidebar** - Live presence panel integrated into Content Manager
|
|
46
|
+
- **Session-Based Auth** - Secure admin authentication for Socket.IO connections
|
|
47
|
+
|
|
42
48
|
### Developer Experience
|
|
43
49
|
- **Visual Admin Panel** - Configure everything through the Strapi admin interface
|
|
44
50
|
- **TypeScript Support** - Full type definitions for IntelliSense
|
|
@@ -764,6 +770,130 @@ Configure permissions in the Strapi admin panel:
|
|
|
764
770
|
|
|
765
771
|
---
|
|
766
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
|
+
|
|
767
897
|
## Admin Panel
|
|
768
898
|
|
|
769
899
|
The plugin provides a full admin interface for configuration and monitoring.
|
|
@@ -833,6 +963,39 @@ Navigate to **Settings > Socket.IO > Monitoring** for live statistics:
|
|
|
833
963
|
- Send test events
|
|
834
964
|
- Reset statistics
|
|
835
965
|
|
|
966
|
+
### Live Presence Panel
|
|
967
|
+
|
|
968
|
+
When editing content in the Content Manager, a **Live Presence** panel appears in the sidebar showing:
|
|
969
|
+
|
|
970
|
+
- **Connection Status** - Live indicator showing real-time sync is active
|
|
971
|
+
- **Active Editors** - List of other users editing the same content
|
|
972
|
+
- **Typing Indicator** - Shows when someone is typing and in which field
|
|
973
|
+
|
|
974
|
+
**How It Works:**
|
|
975
|
+
|
|
976
|
+
1. When you open a content entry, the panel connects via Socket.IO
|
|
977
|
+
2. Other editors on the same entry appear in the panel
|
|
978
|
+
3. Typing in any field broadcasts a typing indicator to others
|
|
979
|
+
4. When you leave, others are notified
|
|
980
|
+
|
|
981
|
+
**Example Display:**
|
|
982
|
+
|
|
983
|
+
```
|
|
984
|
+
+-----------------------------+
|
|
985
|
+
| Live Presence |
|
|
986
|
+
+-----------------------------+
|
|
987
|
+
| [*] Live |
|
|
988
|
+
| Real-time sync active |
|
|
989
|
+
+-----------------------------+
|
|
990
|
+
| ALSO EDITING (1) |
|
|
991
|
+
| +-------------------------+ |
|
|
992
|
+
| | SA Sarah Admin | |
|
|
993
|
+
| | Typing in: title | |
|
|
994
|
+
| | [Typing...] | |
|
|
995
|
+
| +-------------------------+ |
|
|
996
|
+
+-----------------------------+
|
|
997
|
+
```
|
|
998
|
+
|
|
836
999
|
---
|
|
837
1000
|
|
|
838
1001
|
## Monitoring Service
|
|
@@ -1282,7 +1445,17 @@ Copyright (c) 2024 Strapi Community
|
|
|
1282
1445
|
|
|
1283
1446
|
## Changelog
|
|
1284
1447
|
|
|
1285
|
-
### v5.
|
|
1448
|
+
### v5.1.0 (Latest)
|
|
1449
|
+
- **Live Presence System** - Real-time presence awareness in Content Manager
|
|
1450
|
+
- **Typing Indicator** - See when others are typing and in which field
|
|
1451
|
+
- **Admin Panel Sidebar** - Live presence panel integrated into edit view
|
|
1452
|
+
- **Admin Session Authentication** - Secure session tokens for Socket.IO
|
|
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
|
|
1457
|
+
|
|
1458
|
+
### v5.0.0
|
|
1286
1459
|
- Strapi v5 support
|
|
1287
1460
|
- Package renamed to `@strapi-community/plugin-io`
|
|
1288
1461
|
- Enhanced TypeScript support
|
|
@@ -0,0 +1,419 @@
|
|
|
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 index = require("./index-DkTxsEqL.js");
|
|
6
|
+
const designSystem = require("@strapi/design-system");
|
|
7
|
+
const admin = require("@strapi/strapi/admin");
|
|
8
|
+
const styled = require("styled-components");
|
|
9
|
+
const socket_ioClient = require("socket.io-client");
|
|
10
|
+
const index$1 = require("./index-BEZDDgvZ.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% {
|
|
15
|
+
box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.2);
|
|
16
|
+
transform: scale(1);
|
|
17
|
+
}
|
|
18
|
+
50% {
|
|
19
|
+
box-shadow: 0 0 0 6px rgba(34, 197, 94, 0.1);
|
|
20
|
+
transform: scale(1.1);
|
|
21
|
+
}
|
|
22
|
+
`;
|
|
23
|
+
const StatusCard = styled__default.default.div`
|
|
24
|
+
background: ${(props) => props.theme.colors.neutral0};
|
|
25
|
+
border: 1px solid ${({ $status, theme }) => $status === "connected" ? "rgba(34, 197, 94, 0.3)" : $status === "error" ? "rgba(239, 68, 68, 0.3)" : theme.colors.neutral200};
|
|
26
|
+
border-radius: 10px;
|
|
27
|
+
padding: 14px 16px;
|
|
28
|
+
display: flex;
|
|
29
|
+
align-items: center;
|
|
30
|
+
gap: 12px;
|
|
31
|
+
`;
|
|
32
|
+
const StatusDot = styled__default.default.div`
|
|
33
|
+
width: 12px;
|
|
34
|
+
height: 12px;
|
|
35
|
+
border-radius: 50%;
|
|
36
|
+
flex-shrink: 0;
|
|
37
|
+
background: ${({ $status }) => $status === "connected" ? "#22c55e" : $status === "connecting" ? "#f59e0b" : $status === "error" ? "#ef4444" : "#94a3b8"};
|
|
38
|
+
|
|
39
|
+
${({ $status }) => $status === "connected" && styled.css`
|
|
40
|
+
animation: ${pulse} 2s ease-in-out infinite;
|
|
41
|
+
`}
|
|
42
|
+
`;
|
|
43
|
+
const StatusText = styled__default.default.div`
|
|
44
|
+
display: flex;
|
|
45
|
+
flex-direction: column;
|
|
46
|
+
gap: 2px;
|
|
47
|
+
`;
|
|
48
|
+
const StatusLabel = styled__default.default.span`
|
|
49
|
+
font-size: 14px;
|
|
50
|
+
font-weight: 600;
|
|
51
|
+
color: ${({ $status, theme }) => $status === "connected" ? theme.colors.success600 : $status === "connecting" ? theme.colors.warning600 : $status === "error" ? theme.colors.danger600 : theme.colors.neutral600};
|
|
52
|
+
`;
|
|
53
|
+
const StatusSubtext = styled__default.default.span`
|
|
54
|
+
font-size: 12px;
|
|
55
|
+
color: ${(props) => props.theme.colors.neutral500};
|
|
56
|
+
`;
|
|
57
|
+
const SectionTitle = styled__default.default.div`
|
|
58
|
+
font-size: 11px;
|
|
59
|
+
font-weight: 600;
|
|
60
|
+
color: ${(props) => props.theme.colors.neutral600};
|
|
61
|
+
text-transform: uppercase;
|
|
62
|
+
letter-spacing: 0.5px;
|
|
63
|
+
margin-bottom: 10px;
|
|
64
|
+
`;
|
|
65
|
+
const EditorItem = styled__default.default.div`
|
|
66
|
+
display: flex;
|
|
67
|
+
align-items: center;
|
|
68
|
+
gap: 12px;
|
|
69
|
+
padding: 12px 14px;
|
|
70
|
+
background: ${(props) => props.theme.colors.neutral0};
|
|
71
|
+
border-radius: 10px;
|
|
72
|
+
border: 1px solid ${(props) => props.theme.colors.neutral150};
|
|
73
|
+
transition: all 0.2s ease;
|
|
74
|
+
|
|
75
|
+
&:hover {
|
|
76
|
+
border-color: ${(props) => props.theme.colors.primary200};
|
|
77
|
+
box-shadow: 0 2px 8px rgba(73, 69, 255, 0.08);
|
|
78
|
+
transform: translateY(-1px);
|
|
79
|
+
}
|
|
80
|
+
`;
|
|
81
|
+
const EDITOR_COLORS = [
|
|
82
|
+
"linear-gradient(135deg, #4945ff 0%, #7b79ff 100%)",
|
|
83
|
+
"linear-gradient(135deg, #3b82f6 0%, #60a5fa 100%)",
|
|
84
|
+
"linear-gradient(135deg, #10b981 0%, #34d399 100%)",
|
|
85
|
+
"linear-gradient(135deg, #f59e0b 0%, #fbbf24 100%)",
|
|
86
|
+
"linear-gradient(135deg, #ef4444 0%, #f87171 100%)",
|
|
87
|
+
"linear-gradient(135deg, #ec4899 0%, #f472b6 100%)"
|
|
88
|
+
];
|
|
89
|
+
const EditorAvatar = styled__default.default.div`
|
|
90
|
+
width: 36px;
|
|
91
|
+
height: 36px;
|
|
92
|
+
border-radius: 50%;
|
|
93
|
+
background: ${({ $color }) => $color || EDITOR_COLORS[0]};
|
|
94
|
+
color: white;
|
|
95
|
+
font-size: 12px;
|
|
96
|
+
font-weight: 700;
|
|
97
|
+
display: flex;
|
|
98
|
+
align-items: center;
|
|
99
|
+
justify-content: center;
|
|
100
|
+
flex-shrink: 0;
|
|
101
|
+
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
|
102
|
+
`;
|
|
103
|
+
const EditorInfo = styled__default.default.div`
|
|
104
|
+
flex: 1;
|
|
105
|
+
min-width: 0;
|
|
106
|
+
display: flex;
|
|
107
|
+
flex-direction: column;
|
|
108
|
+
gap: 2px;
|
|
109
|
+
`;
|
|
110
|
+
const EditorName = styled__default.default.span`
|
|
111
|
+
font-size: 13px;
|
|
112
|
+
font-weight: 600;
|
|
113
|
+
color: ${(props) => props.theme.colors.neutral800};
|
|
114
|
+
white-space: nowrap;
|
|
115
|
+
overflow: hidden;
|
|
116
|
+
text-overflow: ellipsis;
|
|
117
|
+
`;
|
|
118
|
+
const EditorEmail = styled__default.default.span`
|
|
119
|
+
font-size: 11px;
|
|
120
|
+
color: ${(props) => props.theme.colors.neutral500};
|
|
121
|
+
white-space: nowrap;
|
|
122
|
+
overflow: hidden;
|
|
123
|
+
text-overflow: ellipsis;
|
|
124
|
+
`;
|
|
125
|
+
const EditingBadge = styled__default.default.span`
|
|
126
|
+
font-size: 10px;
|
|
127
|
+
font-weight: 600;
|
|
128
|
+
color: #166534;
|
|
129
|
+
background: #dcfce7;
|
|
130
|
+
padding: 4px 8px;
|
|
131
|
+
border-radius: 12px;
|
|
132
|
+
flex-shrink: 0;
|
|
133
|
+
`;
|
|
134
|
+
const TypingBadge = styled__default.default.span`
|
|
135
|
+
font-size: 10px;
|
|
136
|
+
font-weight: 600;
|
|
137
|
+
color: #92400e;
|
|
138
|
+
background: #fef3c7;
|
|
139
|
+
padding: 4px 8px;
|
|
140
|
+
border-radius: 12px;
|
|
141
|
+
flex-shrink: 0;
|
|
142
|
+
`;
|
|
143
|
+
const EmptyState = styled__default.default.div`
|
|
144
|
+
text-align: center;
|
|
145
|
+
padding: 16px;
|
|
146
|
+
background: ${(props) => props.theme.colors.neutral100};
|
|
147
|
+
border-radius: 10px;
|
|
148
|
+
border: 1px dashed ${(props) => props.theme.colors.neutral300};
|
|
149
|
+
`;
|
|
150
|
+
const EmptyText = styled__default.default.span`
|
|
151
|
+
font-size: 13px;
|
|
152
|
+
color: ${(props) => props.theme.colors.neutral500};
|
|
153
|
+
`;
|
|
154
|
+
const getEditorInitials = (user = {}) => {
|
|
155
|
+
const first = (user.firstname?.[0] || user.username?.[0] || user.email?.[0] || "?").toUpperCase();
|
|
156
|
+
const last = (user.lastname?.[0] || "").toUpperCase();
|
|
157
|
+
return `${first}${last}`.trim();
|
|
158
|
+
};
|
|
159
|
+
const getEditorName = (user = {}) => {
|
|
160
|
+
if (user.firstname) {
|
|
161
|
+
return `${user.firstname} ${user.lastname || ""}`.trim();
|
|
162
|
+
}
|
|
163
|
+
return user.username || user.email || "Unknown";
|
|
164
|
+
};
|
|
165
|
+
const LivePresencePanel = ({ documentId, model, document }) => {
|
|
166
|
+
const { formatMessage } = index.useIntl();
|
|
167
|
+
const { post } = admin.useFetchClient();
|
|
168
|
+
const t = (id, defaultMessage, values) => formatMessage({ id: `${index$1.PLUGIN_ID}.${id}`, defaultMessage }, values);
|
|
169
|
+
const socketRef = React.useRef(null);
|
|
170
|
+
const [sessionData, setSessionData] = React.useState(null);
|
|
171
|
+
const [presenceState, setPresenceState] = React.useState({
|
|
172
|
+
status: "initializing",
|
|
173
|
+
editors: [],
|
|
174
|
+
typingUsers: [],
|
|
175
|
+
error: null
|
|
176
|
+
});
|
|
177
|
+
const uid = model?.uid || model;
|
|
178
|
+
React.useEffect(() => {
|
|
179
|
+
if (!uid || !documentId) {
|
|
180
|
+
setPresenceState((prev) => ({ ...prev, status: "disconnected", error: "No content" }));
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
let cancelled = false;
|
|
184
|
+
let refreshTimeoutId = null;
|
|
185
|
+
const getSession = async (isRefresh = false) => {
|
|
186
|
+
try {
|
|
187
|
+
if (!isRefresh) {
|
|
188
|
+
setPresenceState((prev) => ({ ...prev, status: "requesting" }));
|
|
189
|
+
}
|
|
190
|
+
const { data } = await post(`/${index$1.PLUGIN_ID}/presence/session`, {});
|
|
191
|
+
if (cancelled) return;
|
|
192
|
+
if (!data || !data.token) {
|
|
193
|
+
throw new Error("Invalid session response");
|
|
194
|
+
}
|
|
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
|
+
});
|
|
199
|
+
setSessionData(data);
|
|
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
|
+
}
|
|
215
|
+
} catch (error2) {
|
|
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
|
+
}
|
|
224
|
+
console.error(`[${index$1.PLUGIN_ID}] Failed to get presence session:`, error2);
|
|
225
|
+
setPresenceState((prev) => ({
|
|
226
|
+
...prev,
|
|
227
|
+
status: "error",
|
|
228
|
+
error: error2.message || "Failed to get session"
|
|
229
|
+
}));
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
getSession();
|
|
233
|
+
return () => {
|
|
234
|
+
cancelled = true;
|
|
235
|
+
if (refreshTimeoutId) {
|
|
236
|
+
clearTimeout(refreshTimeoutId);
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
}, [uid, documentId, post]);
|
|
240
|
+
React.useEffect(() => {
|
|
241
|
+
if (!sessionData?.token || !uid || !documentId) {
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
const socketUrl = sessionData.wsUrl || `${window.location.protocol}//${window.location.host}`;
|
|
245
|
+
const socket = socket_ioClient.io(socketUrl, {
|
|
246
|
+
path: sessionData.wsPath || "/socket.io",
|
|
247
|
+
transports: ["websocket", "polling"],
|
|
248
|
+
auth: {
|
|
249
|
+
token: sessionData.token,
|
|
250
|
+
strategy: "admin-jwt",
|
|
251
|
+
isAdmin: true
|
|
252
|
+
},
|
|
253
|
+
reconnection: true,
|
|
254
|
+
reconnectionAttempts: 3
|
|
255
|
+
});
|
|
256
|
+
socketRef.current = socket;
|
|
257
|
+
let lastTypingEmit = 0;
|
|
258
|
+
const TYPING_THROTTLE = 2e3;
|
|
259
|
+
const getFieldName = (element) => {
|
|
260
|
+
const name = element.name || element.id || "";
|
|
261
|
+
const label = element.closest("label") || document.querySelector(`label[for="${element.id}"]`);
|
|
262
|
+
if (label) {
|
|
263
|
+
return label.textContent?.trim() || name;
|
|
264
|
+
}
|
|
265
|
+
const fieldWrapper = element.closest('[class*="Field"]');
|
|
266
|
+
if (fieldWrapper) {
|
|
267
|
+
const labelEl = fieldWrapper.querySelector('label, [class*="Label"]');
|
|
268
|
+
if (labelEl) {
|
|
269
|
+
return labelEl.textContent?.trim() || name;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return name || "unknown field";
|
|
273
|
+
};
|
|
274
|
+
const handleInput = (event) => {
|
|
275
|
+
const target = event.target;
|
|
276
|
+
if (!["INPUT", "TEXTAREA"].includes(target.tagName)) return;
|
|
277
|
+
const isInContentManager = target.closest('[class*="ContentLayout"]') || target.closest("main");
|
|
278
|
+
if (!isInContentManager) return;
|
|
279
|
+
const now = Date.now();
|
|
280
|
+
if (now - lastTypingEmit < TYPING_THROTTLE) return;
|
|
281
|
+
lastTypingEmit = now;
|
|
282
|
+
const fieldName = getFieldName(target);
|
|
283
|
+
if (socket.connected) {
|
|
284
|
+
socket.emit("presence:typing", { uid, documentId, fieldName });
|
|
285
|
+
console.log(`[${index$1.PLUGIN_ID}] Typing in field: ${fieldName}`);
|
|
286
|
+
}
|
|
287
|
+
};
|
|
288
|
+
if (typeof document !== "undefined" && typeof document.addEventListener === "function") {
|
|
289
|
+
document.addEventListener("input", handleInput, true);
|
|
290
|
+
}
|
|
291
|
+
socket.on("connect", () => {
|
|
292
|
+
console.log(`[${index$1.PLUGIN_ID}] Presence socket connected`);
|
|
293
|
+
setPresenceState((prev) => ({ ...prev, status: "connected", error: null }));
|
|
294
|
+
socket.emit("presence:join", { uid, documentId }, (response) => {
|
|
295
|
+
if (response?.success) {
|
|
296
|
+
setPresenceState((prev) => ({
|
|
297
|
+
...prev,
|
|
298
|
+
editors: (response.editors || []).map((e) => ({
|
|
299
|
+
...e,
|
|
300
|
+
isCurrentUser: e.socketId === socket.id
|
|
301
|
+
}))
|
|
302
|
+
}));
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
socket.on("disconnect", () => {
|
|
307
|
+
setPresenceState((prev) => ({ ...prev, status: "disconnected" }));
|
|
308
|
+
});
|
|
309
|
+
socket.on("connect_error", (err) => {
|
|
310
|
+
console.warn(`[${index$1.PLUGIN_ID}] Presence socket error:`, err.message);
|
|
311
|
+
setPresenceState((prev) => ({ ...prev, status: "error", error: err.message }));
|
|
312
|
+
});
|
|
313
|
+
socket.on("presence:update", (data) => {
|
|
314
|
+
if (data.uid === uid && data.documentId === documentId) {
|
|
315
|
+
setPresenceState((prev) => ({
|
|
316
|
+
...prev,
|
|
317
|
+
editors: (data.editors || []).map((e) => ({
|
|
318
|
+
...e,
|
|
319
|
+
isCurrentUser: e.socketId === socket.id
|
|
320
|
+
}))
|
|
321
|
+
}));
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
socket.on("presence:typing", (data) => {
|
|
325
|
+
if (data.uid === uid && data.documentId === documentId) {
|
|
326
|
+
setPresenceState((prev) => {
|
|
327
|
+
const newTyping = [...prev.typingUsers.filter((t2) => t2.user?.id !== data.user?.id)];
|
|
328
|
+
newTyping.push({ user: data.user, fieldName: data.fieldName, timestamp: Date.now() });
|
|
329
|
+
return { ...prev, typingUsers: newTyping };
|
|
330
|
+
});
|
|
331
|
+
setTimeout(() => {
|
|
332
|
+
setPresenceState((prev) => ({
|
|
333
|
+
...prev,
|
|
334
|
+
typingUsers: prev.typingUsers.filter((t2) => t2.user?.id !== data.user?.id)
|
|
335
|
+
}));
|
|
336
|
+
}, 3e3);
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
const heartbeat = setInterval(() => {
|
|
340
|
+
if (socket.connected) {
|
|
341
|
+
socket.emit("presence:heartbeat");
|
|
342
|
+
}
|
|
343
|
+
}, 3e4);
|
|
344
|
+
return () => {
|
|
345
|
+
clearInterval(heartbeat);
|
|
346
|
+
if (typeof document !== "undefined" && typeof document.removeEventListener === "function") {
|
|
347
|
+
document.removeEventListener("input", handleInput, true);
|
|
348
|
+
}
|
|
349
|
+
if (socket.connected) {
|
|
350
|
+
socket.emit("presence:leave", { uid, documentId });
|
|
351
|
+
}
|
|
352
|
+
socket.disconnect();
|
|
353
|
+
socketRef.current = null;
|
|
354
|
+
};
|
|
355
|
+
}, [sessionData, uid, documentId]);
|
|
356
|
+
const { status, editors, typingUsers, error } = presenceState;
|
|
357
|
+
const otherEditors = React.useMemo(() => {
|
|
358
|
+
return editors.filter((e) => !e.isCurrentUser);
|
|
359
|
+
}, [editors]);
|
|
360
|
+
const getUserTypingInfo = React.useCallback((userId) => {
|
|
361
|
+
const typing = typingUsers.find((t2) => t2.user?.id === userId);
|
|
362
|
+
return typing || null;
|
|
363
|
+
}, [typingUsers]);
|
|
364
|
+
React.useCallback((userId) => {
|
|
365
|
+
return typingUsers.some((t2) => t2.user?.id === userId);
|
|
366
|
+
}, [typingUsers]);
|
|
367
|
+
const statusLabel = React.useMemo(() => {
|
|
368
|
+
switch (status) {
|
|
369
|
+
case "connected":
|
|
370
|
+
return t("presence.live", "Live");
|
|
371
|
+
case "connecting":
|
|
372
|
+
return t("presence.connecting", "Connecting...");
|
|
373
|
+
case "requesting":
|
|
374
|
+
return t("presence.requesting", "Authenticating...");
|
|
375
|
+
case "initializing":
|
|
376
|
+
return t("presence.initializing", "Initializing...");
|
|
377
|
+
case "error":
|
|
378
|
+
return t("presence.error", "Connection Error");
|
|
379
|
+
case "disconnected":
|
|
380
|
+
return t("presence.disconnected", "Disconnected");
|
|
381
|
+
default:
|
|
382
|
+
return t("presence.offline", "Offline");
|
|
383
|
+
}
|
|
384
|
+
}, [status, t]);
|
|
385
|
+
const isConnected = status === "connected";
|
|
386
|
+
console.log(`[${index$1.PLUGIN_ID}] LivePresencePanel render:`, { uid, documentId, status, editors: otherEditors.length });
|
|
387
|
+
return {
|
|
388
|
+
title: t("presence.title", "Live Presence"),
|
|
389
|
+
content: /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Flex, { direction: "column", gap: 4, alignItems: "stretch", style: { width: "100%" }, children: [
|
|
390
|
+
/* @__PURE__ */ jsxRuntime.jsxs(StatusCard, { $status: status, children: [
|
|
391
|
+
/* @__PURE__ */ jsxRuntime.jsx(StatusDot, { $status: status }),
|
|
392
|
+
/* @__PURE__ */ jsxRuntime.jsxs(StatusText, { children: [
|
|
393
|
+
/* @__PURE__ */ jsxRuntime.jsx(StatusLabel, { $status: status, children: statusLabel }),
|
|
394
|
+
/* @__PURE__ */ jsxRuntime.jsx(StatusSubtext, { children: isConnected ? t("presence.realtimeActive", "Real-time sync active") : error || t("presence.establishing", "Establishing connection...") })
|
|
395
|
+
] })
|
|
396
|
+
] }),
|
|
397
|
+
isConnected && otherEditors.length > 0 && /* @__PURE__ */ jsxRuntime.jsxs("div", { children: [
|
|
398
|
+
/* @__PURE__ */ jsxRuntime.jsx(SectionTitle, { children: t("presence.activeEditors", "Also Editing ({count})", { count: otherEditors.length }) }),
|
|
399
|
+
/* @__PURE__ */ jsxRuntime.jsx(designSystem.Flex, { direction: "column", gap: 2, alignItems: "stretch", children: otherEditors.map((editor, idx) => {
|
|
400
|
+
const user = editor.user || {};
|
|
401
|
+
const typingInfo = getUserTypingInfo(user.id);
|
|
402
|
+
return /* @__PURE__ */ jsxRuntime.jsxs(EditorItem, { children: [
|
|
403
|
+
/* @__PURE__ */ jsxRuntime.jsx(EditorAvatar, { $color: EDITOR_COLORS[idx % EDITOR_COLORS.length], children: getEditorInitials(user) }),
|
|
404
|
+
/* @__PURE__ */ jsxRuntime.jsxs(EditorInfo, { children: [
|
|
405
|
+
/* @__PURE__ */ jsxRuntime.jsx(EditorName, { children: getEditorName(user) }),
|
|
406
|
+
typingInfo?.fieldName ? /* @__PURE__ */ jsxRuntime.jsxs(EditorEmail, { children: [
|
|
407
|
+
"Typing in: ",
|
|
408
|
+
typingInfo.fieldName
|
|
409
|
+
] }) : user.email && user.firstname ? /* @__PURE__ */ jsxRuntime.jsx(EditorEmail, { children: user.email }) : null
|
|
410
|
+
] }),
|
|
411
|
+
typingInfo ? /* @__PURE__ */ jsxRuntime.jsx(TypingBadge, { children: t("presence.typing", "Typing...") }) : /* @__PURE__ */ jsxRuntime.jsx(EditingBadge, { children: t("presence.editing", "Editing") })
|
|
412
|
+
] }, editor.socketId || idx);
|
|
413
|
+
}) })
|
|
414
|
+
] }),
|
|
415
|
+
isConnected && otherEditors.length === 0 && /* @__PURE__ */ jsxRuntime.jsx(EmptyState, { children: /* @__PURE__ */ jsxRuntime.jsx(EmptyText, { children: t("presence.workingAlone", "You are the only editor") }) })
|
|
416
|
+
] })
|
|
417
|
+
};
|
|
418
|
+
};
|
|
419
|
+
exports.default = LivePresencePanel;
|