@qwickapps/server 1.5.1 → 1.6.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.
Files changed (135) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/dist/core/control-panel.d.ts.map +1 -1
  3. package/dist/core/control-panel.js +41 -0
  4. package/dist/core/control-panel.js.map +1 -1
  5. package/dist/core/guards.d.ts.map +1 -1
  6. package/dist/core/guards.js +77 -0
  7. package/dist/core/guards.js.map +1 -1
  8. package/dist/core/health-manager.d.ts +4 -0
  9. package/dist/core/health-manager.d.ts.map +1 -1
  10. package/dist/core/health-manager.js +6 -1
  11. package/dist/core/health-manager.js.map +1 -1
  12. package/dist/core/plugin-registry.d.ts +55 -5
  13. package/dist/core/plugin-registry.d.ts.map +1 -1
  14. package/dist/core/plugin-registry.js +57 -19
  15. package/dist/core/plugin-registry.js.map +1 -1
  16. package/dist/core/types.d.ts +2 -0
  17. package/dist/core/types.d.ts.map +1 -1
  18. package/dist/index.d.ts +2 -2
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +3 -1
  21. package/dist/index.js.map +1 -1
  22. package/dist/plugins/api-keys/api-keys-plugin.d.ts +46 -0
  23. package/dist/plugins/api-keys/api-keys-plugin.d.ts.map +1 -0
  24. package/dist/plugins/api-keys/api-keys-plugin.js +329 -0
  25. package/dist/plugins/api-keys/api-keys-plugin.js.map +1 -0
  26. package/dist/plugins/api-keys/index.d.ts +14 -0
  27. package/dist/plugins/api-keys/index.d.ts.map +1 -0
  28. package/dist/plugins/api-keys/index.js +17 -0
  29. package/dist/plugins/api-keys/index.js.map +1 -0
  30. package/dist/plugins/api-keys/middleware/bearer-token-auth.d.ts +74 -0
  31. package/dist/plugins/api-keys/middleware/bearer-token-auth.d.ts.map +1 -0
  32. package/dist/plugins/api-keys/middleware/bearer-token-auth.js +201 -0
  33. package/dist/plugins/api-keys/middleware/bearer-token-auth.js.map +1 -0
  34. package/dist/plugins/api-keys/middleware/index.d.ts +7 -0
  35. package/dist/plugins/api-keys/middleware/index.d.ts.map +1 -0
  36. package/dist/plugins/api-keys/middleware/index.js +7 -0
  37. package/dist/plugins/api-keys/middleware/index.js.map +1 -0
  38. package/dist/plugins/api-keys/stores/index.d.ts +7 -0
  39. package/dist/plugins/api-keys/stores/index.d.ts.map +1 -0
  40. package/dist/plugins/api-keys/stores/index.js +7 -0
  41. package/dist/plugins/api-keys/stores/index.js.map +1 -0
  42. package/dist/plugins/api-keys/stores/postgres-store.d.ts +34 -0
  43. package/dist/plugins/api-keys/stores/postgres-store.d.ts.map +1 -0
  44. package/dist/plugins/api-keys/stores/postgres-store.js +360 -0
  45. package/dist/plugins/api-keys/stores/postgres-store.js.map +1 -0
  46. package/dist/plugins/api-keys/types.d.ts +268 -0
  47. package/dist/plugins/api-keys/types.d.ts.map +1 -0
  48. package/dist/plugins/api-keys/types.js +56 -0
  49. package/dist/plugins/api-keys/types.js.map +1 -0
  50. package/dist/plugins/auth/auth-plugin.d.ts.map +1 -1
  51. package/dist/plugins/auth/auth-plugin.js +17 -1
  52. package/dist/plugins/auth/auth-plugin.js.map +1 -1
  53. package/dist/plugins/auth/auth-plugin.test.js +133 -0
  54. package/dist/plugins/auth/auth-plugin.test.js.map +1 -1
  55. package/dist/plugins/auth/env-config.d.ts.map +1 -1
  56. package/dist/plugins/auth/env-config.js +6 -2
  57. package/dist/plugins/auth/env-config.js.map +1 -1
  58. package/dist/plugins/auth/types.d.ts +10 -0
  59. package/dist/plugins/auth/types.d.ts.map +1 -1
  60. package/dist/plugins/auth/types.js.map +1 -1
  61. package/dist/plugins/devices/__tests__/token-utils.test.js +4 -2
  62. package/dist/plugins/devices/__tests__/token-utils.test.js.map +1 -1
  63. package/dist/plugins/frontend-app-plugin.d.ts.map +1 -1
  64. package/dist/plugins/frontend-app-plugin.js +21 -4
  65. package/dist/plugins/frontend-app-plugin.js.map +1 -1
  66. package/dist/plugins/index.d.ts +2 -0
  67. package/dist/plugins/index.d.ts.map +1 -1
  68. package/dist/plugins/index.js +2 -0
  69. package/dist/plugins/index.js.map +1 -1
  70. package/dist/plugins/qwickbrain/index.d.ts +25 -0
  71. package/dist/plugins/qwickbrain/index.d.ts.map +1 -0
  72. package/dist/plugins/qwickbrain/index.js +24 -0
  73. package/dist/plugins/qwickbrain/index.js.map +1 -0
  74. package/dist/plugins/qwickbrain/qwickbrain-plugin.d.ts +23 -0
  75. package/dist/plugins/qwickbrain/qwickbrain-plugin.d.ts.map +1 -0
  76. package/dist/plugins/qwickbrain/qwickbrain-plugin.js +528 -0
  77. package/dist/plugins/qwickbrain/qwickbrain-plugin.js.map +1 -0
  78. package/dist/plugins/qwickbrain/types.d.ts +131 -0
  79. package/dist/plugins/qwickbrain/types.d.ts.map +1 -0
  80. package/dist/plugins/qwickbrain/types.js +9 -0
  81. package/dist/plugins/qwickbrain/types.js.map +1 -0
  82. package/dist/plugins/users/__tests__/postgres-store.test.js +1 -0
  83. package/dist/plugins/users/__tests__/postgres-store.test.js.map +1 -1
  84. package/dist/plugins/users/__tests__/users-plugin.test.js +3 -0
  85. package/dist/plugins/users/__tests__/users-plugin.test.js.map +1 -1
  86. package/dist/plugins/users/stores/postgres-store.d.ts.map +1 -1
  87. package/dist/plugins/users/stores/postgres-store.js +59 -1
  88. package/dist/plugins/users/stores/postgres-store.js.map +1 -1
  89. package/dist/plugins/users/types.d.ts +22 -0
  90. package/dist/plugins/users/types.d.ts.map +1 -1
  91. package/dist-ui/assets/index-5nX8fM1a.js +469 -0
  92. package/dist-ui/assets/index-5nX8fM1a.js.map +1 -0
  93. package/dist-ui/index.html +1 -1
  94. package/dist-ui-lib/api/controlPanelApi.d.ts +68 -0
  95. package/dist-ui-lib/components/index.d.ts +2 -1
  96. package/dist-ui-lib/index.js +2642 -2281
  97. package/dist-ui-lib/index.js.map +1 -1
  98. package/dist-ui-lib/pages/APIKeysPage.d.ts +13 -0
  99. package/dist-ui-lib/pages/AcceptInvitationPage.d.ts +28 -0
  100. package/package.json +3 -2
  101. package/src/core/control-panel.ts +47 -0
  102. package/src/core/guards.ts +89 -0
  103. package/src/core/health-manager.ts +6 -1
  104. package/src/core/plugin-registry.ts +123 -25
  105. package/src/core/types.ts +2 -0
  106. package/src/index.ts +11 -0
  107. package/src/plugins/api-keys/api-keys-plugin.ts +397 -0
  108. package/src/plugins/api-keys/index.ts +49 -0
  109. package/src/plugins/api-keys/middleware/bearer-token-auth.ts +250 -0
  110. package/src/plugins/api-keys/middleware/index.ts +12 -0
  111. package/src/plugins/api-keys/stores/index.ts +7 -0
  112. package/src/plugins/api-keys/stores/postgres-store.ts +487 -0
  113. package/src/plugins/api-keys/types.ts +243 -0
  114. package/src/plugins/auth/auth-plugin.test.ts +167 -0
  115. package/src/plugins/auth/auth-plugin.ts +17 -1
  116. package/src/plugins/auth/env-config.ts +6 -2
  117. package/src/plugins/auth/types.ts +10 -0
  118. package/src/plugins/devices/__tests__/token-utils.test.ts +4 -2
  119. package/src/plugins/frontend-app-plugin.ts +24 -4
  120. package/src/plugins/index.ts +15 -0
  121. package/src/plugins/qwickbrain/index.ts +33 -0
  122. package/src/plugins/qwickbrain/qwickbrain-plugin.ts +642 -0
  123. package/src/plugins/qwickbrain/types.ts +146 -0
  124. package/src/plugins/users/__tests__/postgres-store.test.ts +1 -0
  125. package/src/plugins/users/__tests__/users-plugin.test.ts +3 -0
  126. package/src/plugins/users/stores/postgres-store.ts +69 -0
  127. package/src/plugins/users/types.ts +25 -0
  128. package/ui/src/App.tsx +6 -1
  129. package/ui/src/api/controlPanelApi.ts +206 -37
  130. package/ui/src/components/index.ts +6 -0
  131. package/ui/src/pages/APIKeysPage.tsx +661 -0
  132. package/ui/src/pages/AcceptInvitationPage.tsx +169 -0
  133. package/ui/src/pages/UsersPage.tsx +225 -2
  134. package/dist-ui/assets/index-CynOqPkb.js +0 -469
  135. package/dist-ui/assets/index-CynOqPkb.js.map +0 -1
@@ -0,0 +1,169 @@
1
+ /**
2
+ * AcceptInvitationPage Component
3
+ *
4
+ * Standalone page for users to accept invitations and activate their accounts.
5
+ * Can be used in control panel or frontend applications.
6
+ *
7
+ * Copyright (c) 2025 QwickApps.com. All rights reserved.
8
+ */
9
+
10
+ import { useState, useEffect } from 'react';
11
+ import { Box, Card, CardContent, Alert, CircularProgress } from '@mui/material';
12
+ import { Text, Button } from '@qwickapps/react-framework';
13
+ import CheckCircleIcon from '@mui/icons-material/CheckCircle';
14
+ import ErrorIcon from '@mui/icons-material/Error';
15
+ import { api, type User } from '../api/controlPanelApi';
16
+
17
+ export interface AcceptInvitationPageProps {
18
+ /** Invitation token (if not provided, will extract from URL) */
19
+ token?: string;
20
+ /** Title text */
21
+ title?: string;
22
+ /** Subtitle text */
23
+ subtitle?: string;
24
+ /** Success message */
25
+ successMessage?: string;
26
+ /** URL to redirect to after successful activation (optional) */
27
+ redirectUrl?: string;
28
+ /** Label for the redirect button */
29
+ redirectLabel?: string;
30
+ /** Callback when invitation is accepted successfully */
31
+ onSuccess?: (user: User) => void;
32
+ /** Callback when invitation acceptance fails */
33
+ onError?: (error: string) => void;
34
+ }
35
+
36
+ export function AcceptInvitationPage({
37
+ token: tokenProp,
38
+ title = 'Accept Invitation',
39
+ subtitle = 'Activate your account',
40
+ successMessage = 'Your account has been activated successfully!',
41
+ redirectUrl,
42
+ redirectLabel = 'Go to App',
43
+ onSuccess,
44
+ onError,
45
+ }: AcceptInvitationPageProps) {
46
+ const [loading, setLoading] = useState(true);
47
+ const [error, setError] = useState<string | null>(null);
48
+ const [success, setSuccess] = useState(false);
49
+ const [user, setUser] = useState<User | null>(null);
50
+
51
+ useEffect(() => {
52
+ const acceptInvitation = async () => {
53
+ // Get token from prop or URL query parameter
54
+ let inviteToken = tokenProp;
55
+ if (!inviteToken) {
56
+ const params = new URLSearchParams(window.location.search);
57
+ inviteToken = params.get('token') || '';
58
+ }
59
+
60
+ if (!inviteToken) {
61
+ setError('No invitation token provided');
62
+ setLoading(false);
63
+ onError?.('No invitation token provided');
64
+ return;
65
+ }
66
+
67
+ try {
68
+ const result = await api.acceptInvitation(inviteToken);
69
+ setUser(result.user);
70
+ setSuccess(true);
71
+ onSuccess?.(result.user);
72
+ } catch (err) {
73
+ const errorMessage = err instanceof Error ? err.message : 'Failed to accept invitation';
74
+ setError(errorMessage);
75
+ onError?.(errorMessage);
76
+ } finally {
77
+ setLoading(false);
78
+ }
79
+ };
80
+
81
+ acceptInvitation();
82
+ }, [tokenProp, onSuccess, onError]);
83
+
84
+ const handleRedirect = () => {
85
+ if (redirectUrl) {
86
+ window.location.href = redirectUrl;
87
+ }
88
+ };
89
+
90
+ return (
91
+ <Box
92
+ sx={{
93
+ minHeight: '100vh',
94
+ display: 'flex',
95
+ alignItems: 'center',
96
+ justifyContent: 'center',
97
+ bgcolor: 'var(--theme-background)',
98
+ p: 3,
99
+ }}
100
+ >
101
+ <Card sx={{ maxWidth: 500, width: '100%', bgcolor: 'var(--theme-surface)' }}>
102
+ <CardContent sx={{ p: 4 }}>
103
+ <Box sx={{ textAlign: 'center', mb: 4 }}>
104
+ <Text variant="h4" content={title} customColor="var(--theme-text-primary)" style={{ marginBottom: '8px' }} />
105
+ <Text variant="body2" content={subtitle} customColor="var(--theme-text-secondary)" />
106
+ </Box>
107
+
108
+ {loading && (
109
+ <Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2, py: 4 }}>
110
+ <CircularProgress />
111
+ <Text variant="body2" content="Activating your account..." customColor="var(--theme-text-secondary)" />
112
+ </Box>
113
+ )}
114
+
115
+ {error && !loading && (
116
+ <Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 3 }}>
117
+ <ErrorIcon sx={{ fontSize: 64, color: 'var(--theme-error)' }} />
118
+ <Alert severity="error" sx={{ width: '100%' }}>
119
+ {error}
120
+ </Alert>
121
+ <Text
122
+ variant="body2"
123
+ content="The invitation may have expired or is invalid. Please contact support."
124
+ customColor="var(--theme-text-secondary)"
125
+ style={{ textAlign: 'center' }}
126
+ />
127
+ </Box>
128
+ )}
129
+
130
+ {success && !loading && (
131
+ <Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 3 }}>
132
+ <CheckCircleIcon sx={{ fontSize: 64, color: 'var(--theme-success)' }} />
133
+ <Alert severity="success" sx={{ width: '100%' }}>
134
+ {successMessage}
135
+ </Alert>
136
+
137
+ {user && (
138
+ <Box sx={{ width: '100%', textAlign: 'center' }}>
139
+ <Text
140
+ variant="body1"
141
+ content={`Welcome, ${user.name || user.email}!`}
142
+ customColor="var(--theme-text-primary)"
143
+ fontWeight="500"
144
+ style={{ marginBottom: '4px' }}
145
+ />
146
+ <Text
147
+ variant="body2"
148
+ content="Your account is now active and ready to use."
149
+ customColor="var(--theme-text-secondary)"
150
+ />
151
+ </Box>
152
+ )}
153
+
154
+ {redirectUrl && (
155
+ <Button
156
+ variant="primary"
157
+ label={redirectLabel}
158
+ icon="arrow_forward"
159
+ onClick={handleRedirect}
160
+ fullWidth
161
+ />
162
+ )}
163
+ </Box>
164
+ )}
165
+ </CardContent>
166
+ </Card>
167
+ </Box>
168
+ );
169
+ }
@@ -38,6 +38,7 @@ import LocalOfferIcon from '@mui/icons-material/LocalOffer';
38
38
  import BlockIcon from '@mui/icons-material/Block';
39
39
  import CheckCircleIcon from '@mui/icons-material/CheckCircle';
40
40
  import DeleteIcon from '@mui/icons-material/Delete';
41
+ import ContentCopyIcon from '@mui/icons-material/ContentCopy';
41
42
  import {
42
43
  api,
43
44
  type User,
@@ -93,6 +94,10 @@ export function UsersPage({
93
94
  const [bans, setBans] = useState<Ban[]>([]);
94
95
  const [bansTotal, setBansTotal] = useState(0);
95
96
 
97
+ // Invitations state
98
+ const [invitations, setInvitations] = useState<User[]>([]);
99
+ const [invitationsTotal, setInvitationsTotal] = useState(0);
100
+
96
101
  // Shared state
97
102
  const [loading, setLoading] = useState(true);
98
103
  const [error, setError] = useState<string | null>(null);
@@ -106,6 +111,16 @@ export function UsersPage({
106
111
  expiresAt: '',
107
112
  });
108
113
 
114
+ // Invite dialog state
115
+ const [inviteDialogOpen, setInviteDialogOpen] = useState(false);
116
+ const [newInvite, setNewInvite] = useState({
117
+ email: '',
118
+ name: '',
119
+ role: '',
120
+ expiresInDays: 7,
121
+ });
122
+ const [inviteResult, setInviteResult] = useState<{ token: string; inviteLink: string } | null>(null);
123
+
109
124
  // Entitlements lookup state
110
125
  const [entitlementsDialogOpen, setEntitlementsDialogOpen] = useState(false);
111
126
  const [entitlementsSearch, setEntitlementsSearch] = useState('');
@@ -192,6 +207,23 @@ export function UsersPage({
192
207
  }
193
208
  }, [features.bans]);
194
209
 
210
+ // Fetch invitations
211
+ const fetchInvitations = useCallback(async () => {
212
+ if (!features.users) return;
213
+
214
+ setLoading(true);
215
+ try {
216
+ const data = await api.getInvitations();
217
+ setInvitations(data.users || []);
218
+ setInvitationsTotal(data.total);
219
+ setError(null);
220
+ } catch (err) {
221
+ setError(err instanceof Error ? err.message : 'Failed to fetch invitations');
222
+ } finally {
223
+ setLoading(false);
224
+ }
225
+ }, [features.users]);
226
+
195
227
  // Initial fetch and tab-based fetching
196
228
  useEffect(() => {
197
229
  if (!featuresLoaded) return;
@@ -200,8 +232,10 @@ export function UsersPage({
200
232
  fetchUsers();
201
233
  } else if (activeTab === 1 && features.bans) {
202
234
  fetchBans();
235
+ } else if (activeTab === 2 && features.users) {
236
+ fetchInvitations();
203
237
  }
204
- }, [activeTab, featuresLoaded, features.users, features.bans, fetchUsers, fetchBans]);
238
+ }, [activeTab, featuresLoaded, features.users, features.bans, fetchUsers, fetchBans, fetchInvitations]);
205
239
 
206
240
  // Fetch bans count for stats (only on initial load)
207
241
  useEffect(() => {
@@ -248,6 +282,36 @@ export function UsersPage({
248
282
  }
249
283
  };
250
284
 
285
+ // Invite handlers
286
+ const handleInviteUser = async () => {
287
+ try {
288
+ const result = await api.inviteUser({
289
+ email: newInvite.email,
290
+ name: newInvite.name || undefined,
291
+ role: newInvite.role || undefined,
292
+ expiresInDays: newInvite.expiresInDays,
293
+ });
294
+ setInviteResult({ token: result.token, inviteLink: result.inviteLink });
295
+ setSuccess('User invitation created successfully');
296
+ fetchUsers();
297
+ } catch (err) {
298
+ setError(err instanceof Error ? err.message : 'Failed to invite user');
299
+ }
300
+ };
301
+
302
+ const handleCopyInviteLink = () => {
303
+ if (inviteResult) {
304
+ navigator.clipboard.writeText(inviteResult.inviteLink);
305
+ setSuccess('Invite link copied to clipboard');
306
+ }
307
+ };
308
+
309
+ const handleCloseInviteDialog = () => {
310
+ setInviteDialogOpen(false);
311
+ setNewInvite({ email: '', name: '', role: '', expiresInDays: 7 });
312
+ setInviteResult(null);
313
+ };
314
+
251
315
  // Entitlements handlers
252
316
  const handleEntitlementsSearch = async () => {
253
317
  if (!entitlementsSearch.trim()) {
@@ -362,6 +426,7 @@ export function UsersPage({
362
426
  const tabs: { label: string; count?: number }[] = [];
363
427
  if (features.users) tabs.push({ label: 'Users', count: usersTotal });
364
428
  if (features.bans) tabs.push({ label: 'Banned', count: bansTotal });
429
+ if (features.users) tabs.push({ label: 'Invitations', count: invitationsTotal });
365
430
 
366
431
  if (!featuresLoaded) {
367
432
  return (
@@ -380,6 +445,14 @@ export function UsersPage({
380
445
  </Box>
381
446
  <Box sx={{ display: 'flex', gap: 1 }}>
382
447
  {headerActions}
448
+ {features.users && (
449
+ <Button
450
+ variant="primary"
451
+ icon="person_add"
452
+ label="Invite User"
453
+ onClick={() => setInviteDialogOpen(true)}
454
+ />
455
+ )}
383
456
  {features.entitlements && (
384
457
  <Button
385
458
  variant="outlined"
@@ -390,7 +463,7 @@ export function UsersPage({
390
463
  )}
391
464
  {features.bans && (
392
465
  <Button
393
- variant="primary"
466
+ variant="outlined"
394
467
  color="error"
395
468
  icon="block"
396
469
  label="Ban User"
@@ -645,9 +718,159 @@ export function UsersPage({
645
718
  </Table>
646
719
  </TableContainer>
647
720
  )}
721
+
722
+ {/* Invitations Tab */}
723
+ {activeTab === 2 && features.users && (
724
+ <TableContainer>
725
+ <Table>
726
+ <TableHead>
727
+ <TableRow>
728
+ <TableCell sx={{ color: 'var(--theme-text-secondary)', borderColor: 'var(--theme-border)' }}>Email</TableCell>
729
+ <TableCell sx={{ color: 'var(--theme-text-secondary)', borderColor: 'var(--theme-border)' }}>Name</TableCell>
730
+ <TableCell sx={{ color: 'var(--theme-text-secondary)', borderColor: 'var(--theme-border)' }}>Created</TableCell>
731
+ <TableCell sx={{ color: 'var(--theme-text-secondary)', borderColor: 'var(--theme-border)' }}>Expires</TableCell>
732
+ <TableCell sx={{ color: 'var(--theme-text-secondary)', borderColor: 'var(--theme-border)' }}>Status</TableCell>
733
+ </TableRow>
734
+ </TableHead>
735
+ <TableBody>
736
+ {invitations.map((invitation) => {
737
+ const isExpired = invitation.invitation_expires_at && new Date(invitation.invitation_expires_at) < new Date();
738
+ return (
739
+ <TableRow key={invitation.id}>
740
+ <TableCell sx={{ color: 'var(--theme-text-primary)', borderColor: 'var(--theme-border)' }}>
741
+ <Text variant="body1" content={invitation.email} fontWeight="500" />
742
+ </TableCell>
743
+ <TableCell sx={{ color: 'var(--theme-text-primary)', borderColor: 'var(--theme-border)' }}>
744
+ {invitation.name || '--'}
745
+ </TableCell>
746
+ <TableCell sx={{ color: 'var(--theme-text-secondary)', borderColor: 'var(--theme-border)' }}>
747
+ {formatDate(invitation.created_at)}
748
+ </TableCell>
749
+ <TableCell sx={{ color: 'var(--theme-text-secondary)', borderColor: 'var(--theme-border)' }}>
750
+ {formatDate(invitation.invitation_expires_at)}
751
+ </TableCell>
752
+ <TableCell sx={{ borderColor: 'var(--theme-border)' }}>
753
+ <Chip
754
+ size="small"
755
+ label={isExpired ? 'Expired' : 'Pending'}
756
+ sx={{
757
+ bgcolor: isExpired ? 'var(--theme-error)20' : 'var(--theme-warning)20',
758
+ color: isExpired ? 'var(--theme-error)' : 'var(--theme-warning)',
759
+ }}
760
+ />
761
+ </TableCell>
762
+ </TableRow>
763
+ );
764
+ })}
765
+ {invitations.length === 0 && !loading && (
766
+ <TableRow>
767
+ <TableCell colSpan={5} align="center" sx={{ py: 4, color: 'var(--theme-text-secondary)' }}>
768
+ No pending invitations
769
+ </TableCell>
770
+ </TableRow>
771
+ )}
772
+ </TableBody>
773
+ </Table>
774
+ </TableContainer>
775
+ )}
648
776
  </CardContent>
649
777
  </Card>
650
778
 
779
+ {/* Invite User Dialog */}
780
+ {features.users && (
781
+ <Dialog
782
+ open={inviteDialogOpen}
783
+ onClose={handleCloseInviteDialog}
784
+ maxWidth="sm"
785
+ fullWidth
786
+ >
787
+ <DialogTitle>Invite User</DialogTitle>
788
+ <DialogContent>
789
+ {!inviteResult ? (
790
+ <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
791
+ <TextField
792
+ label="Email"
793
+ fullWidth
794
+ required
795
+ value={newInvite.email}
796
+ onChange={(e) => setNewInvite({ ...newInvite, email: e.target.value })}
797
+ placeholder="user@example.com"
798
+ type="email"
799
+ />
800
+ <TextField
801
+ label="Name (Optional)"
802
+ fullWidth
803
+ value={newInvite.name}
804
+ onChange={(e) => setNewInvite({ ...newInvite, name: e.target.value })}
805
+ placeholder="Enter user's full name"
806
+ />
807
+ <TextField
808
+ label="Role (Optional)"
809
+ fullWidth
810
+ value={newInvite.role}
811
+ onChange={(e) => setNewInvite({ ...newInvite, role: e.target.value })}
812
+ placeholder="e.g., admin, editor, viewer"
813
+ helperText="Stored in user metadata for your app to use"
814
+ />
815
+ <TextField
816
+ label="Invitation Expiry"
817
+ type="number"
818
+ fullWidth
819
+ value={newInvite.expiresInDays}
820
+ onChange={(e) => setNewInvite({ ...newInvite, expiresInDays: parseInt(e.target.value) || 7 })}
821
+ InputProps={{
822
+ endAdornment: <InputAdornment position="end">days</InputAdornment>,
823
+ }}
824
+ helperText="How many days until the invitation expires"
825
+ />
826
+ </Box>
827
+ ) : (
828
+ <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
829
+ <Alert severity="success">
830
+ Invitation created successfully! Share this link with the user:
831
+ </Alert>
832
+ <TextField
833
+ label="Invitation Link"
834
+ fullWidth
835
+ value={inviteResult.inviteLink}
836
+ InputProps={{
837
+ readOnly: true,
838
+ endAdornment: (
839
+ <InputAdornment position="end">
840
+ <Tooltip title="Copy to clipboard">
841
+ <IconButton onClick={handleCopyInviteLink} edge="end">
842
+ <ContentCopyIcon />
843
+ </IconButton>
844
+ </Tooltip>
845
+ </InputAdornment>
846
+ ),
847
+ }}
848
+ helperText="Click the icon to copy the link to clipboard"
849
+ />
850
+ <Alert severity="info">
851
+ The user will need to visit this link to activate their account.
852
+ </Alert>
853
+ </Box>
854
+ )}
855
+ </DialogContent>
856
+ <DialogActions>
857
+ <Button
858
+ variant="text"
859
+ label="Close"
860
+ onClick={handleCloseInviteDialog}
861
+ />
862
+ {!inviteResult && (
863
+ <Button
864
+ variant="primary"
865
+ label="Create Invitation"
866
+ onClick={handleInviteUser}
867
+ disabled={!newInvite.email}
868
+ />
869
+ )}
870
+ </DialogActions>
871
+ </Dialog>
872
+ )}
873
+
651
874
  {/* Ban User Dialog */}
652
875
  {features.bans && (
653
876
  <Dialog