session-wrap-dashboard 3.9.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/.env.example +6 -0
- package/coverage/base.css +224 -0
- package/coverage/block-navigation.js +87 -0
- package/coverage/coverage-final.json +6 -0
- package/coverage/favicon.png +0 -0
- package/coverage/index.html +146 -0
- package/coverage/prettify.css +1 -0
- package/coverage/prettify.js +2 -0
- package/coverage/sort-arrow-sprite.png +0 -0
- package/coverage/sorter.js +210 -0
- package/coverage/src/auth.ts.html +307 -0
- package/coverage/src/components/Header.tsx.html +262 -0
- package/coverage/src/components/Sidebar.tsx.html +262 -0
- package/coverage/src/components/index.html +131 -0
- package/coverage/src/hooks/index.html +131 -0
- package/coverage/src/hooks/useAuth.ts.html +292 -0
- package/coverage/src/hooks/useWorkspace.ts.html +475 -0
- package/coverage/src/index.html +116 -0
- package/index.html +13 -0
- package/package.json +49 -0
- package/playwright.config.ts +38 -0
- package/postcss.config.js +6 -0
- package/src/App.tsx +67 -0
- package/src/api.ts +130 -0
- package/src/auth.ts +74 -0
- package/src/components/AgentLeaderboard.tsx +84 -0
- package/src/components/AnalyticsDashboard.tsx +223 -0
- package/src/components/Header.tsx +62 -0
- package/src/components/IntegrationManager.tsx +292 -0
- package/src/components/RoleManager.tsx +230 -0
- package/src/components/Sidebar.tsx +62 -0
- package/src/components/TrendChart.tsx +92 -0
- package/src/components/WorkspaceSelector.tsx +157 -0
- package/src/components/__tests__/Header.test.tsx +64 -0
- package/src/components/__tests__/Sidebar.test.tsx +39 -0
- package/src/components/index.ts +8 -0
- package/src/hooks/__tests__/useAuth.test.ts +88 -0
- package/src/hooks/__tests__/useWorkspace.test.ts +101 -0
- package/src/hooks/index.ts +4 -0
- package/src/hooks/useAnalytics.ts +76 -0
- package/src/hooks/useApi.ts +62 -0
- package/src/hooks/useAuth.ts +69 -0
- package/src/hooks/useWorkspace.ts +130 -0
- package/src/index.css +57 -0
- package/src/main.tsx +13 -0
- package/src/pages/DashboardPage.tsx +13 -0
- package/src/pages/HomePage.tsx +156 -0
- package/src/pages/IntegrationsPage.tsx +9 -0
- package/src/pages/RolesPage.tsx +9 -0
- package/src/pages/SettingsPage.tsx +118 -0
- package/src/pages/WorkspacesPage.tsx +9 -0
- package/src/pages/index.ts +6 -0
- package/src/test/setup.ts +31 -0
- package/src/test/utils.tsx +15 -0
- package/src/types.ts +132 -0
- package/tailwind.config.js +11 -0
- package/tests/e2e/auth.spec.ts +42 -0
- package/tests/e2e/dashboard.spec.ts +52 -0
- package/tests/e2e/integrations.spec.ts +91 -0
- package/tests/e2e/workspaces.spec.ts +78 -0
- package/tsconfig.json +26 -0
- package/tsconfig.node.json +10 -0
- package/vite.config.ts +26 -0
- package/vitest.config.ts +31 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { memo } from 'react'
|
|
2
|
+
import { useAuth, useWorkspace } from '../hooks'
|
|
3
|
+
import { LogOut, User } from 'lucide-react'
|
|
4
|
+
|
|
5
|
+
interface HeaderProps {
|
|
6
|
+
title?: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const HeaderComponent = ({ title = 'Dashboard' }: HeaderProps) => {
|
|
10
|
+
const { user, logout } = useAuth()
|
|
11
|
+
const { currentWorkspace, workspaces, setCurrentWorkspace } = useWorkspace()
|
|
12
|
+
|
|
13
|
+
return (
|
|
14
|
+
<header className="bg-white border-b border-slate-200 px-6 py-4 shadow-sm">
|
|
15
|
+
<div className="flex justify-between items-center">
|
|
16
|
+
<div className="flex items-center gap-4">
|
|
17
|
+
<h2 className="text-lg font-semibold text-slate-900">{title}</h2>
|
|
18
|
+
|
|
19
|
+
{/* Workspace Selector */}
|
|
20
|
+
<select
|
|
21
|
+
value={currentWorkspace?.id || ''}
|
|
22
|
+
onChange={(e) => {
|
|
23
|
+
const workspace = workspaces.find(w => w.id === e.target.value)
|
|
24
|
+
setCurrentWorkspace(workspace || null)
|
|
25
|
+
}}
|
|
26
|
+
className="input text-sm"
|
|
27
|
+
>
|
|
28
|
+
<option value="" disabled>
|
|
29
|
+
Select workspace...
|
|
30
|
+
</option>
|
|
31
|
+
{workspaces.map((ws) => (
|
|
32
|
+
<option key={ws.id} value={ws.id}>
|
|
33
|
+
{ws.name}
|
|
34
|
+
</option>
|
|
35
|
+
))}
|
|
36
|
+
</select>
|
|
37
|
+
</div>
|
|
38
|
+
|
|
39
|
+
{/* User Menu */}
|
|
40
|
+
<div className="flex items-center gap-4">
|
|
41
|
+
<div className="flex items-center gap-2 text-sm text-slate-600">
|
|
42
|
+
<User size={16} />
|
|
43
|
+
<span>{user?.github_login}</span>
|
|
44
|
+
</div>
|
|
45
|
+
|
|
46
|
+
<button
|
|
47
|
+
onClick={() => {
|
|
48
|
+
logout()
|
|
49
|
+
window.location.href = '/login'
|
|
50
|
+
}}
|
|
51
|
+
className="btn btn-secondary btn-sm flex items-center gap-2"
|
|
52
|
+
>
|
|
53
|
+
<LogOut size={16} />
|
|
54
|
+
Logout
|
|
55
|
+
</button>
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
58
|
+
</header>
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const Header = memo(HeaderComponent)
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react'
|
|
2
|
+
import { useWorkspace } from '../hooks'
|
|
3
|
+
import { integrationAPI } from '../api'
|
|
4
|
+
import { Trash2, Plus, Check, AlertCircle } from 'lucide-react'
|
|
5
|
+
|
|
6
|
+
interface Integration {
|
|
7
|
+
id: string
|
|
8
|
+
service_name: 'slack' | 'github' | 'jira'
|
|
9
|
+
is_active: boolean
|
|
10
|
+
created_at: string
|
|
11
|
+
updated_at: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const ServiceConfig = {
|
|
15
|
+
slack: {
|
|
16
|
+
name: 'Slack',
|
|
17
|
+
icon: '💬',
|
|
18
|
+
description: 'Send notifications to Slack channels',
|
|
19
|
+
fields: [
|
|
20
|
+
{ name: 'webhook_url', label: 'Webhook URL', type: 'password' },
|
|
21
|
+
{ name: 'channel_id', label: 'Channel ID', type: 'text' }
|
|
22
|
+
]
|
|
23
|
+
},
|
|
24
|
+
github: {
|
|
25
|
+
name: 'GitHub',
|
|
26
|
+
icon: '🐙',
|
|
27
|
+
description: 'Sync tasks with GitHub Issues',
|
|
28
|
+
fields: [
|
|
29
|
+
{ name: 'api_token', label: 'API Token', type: 'password' },
|
|
30
|
+
{ name: 'owner', label: 'Repository Owner', type: 'text' },
|
|
31
|
+
{ name: 'repo', label: 'Repository Name', type: 'text' }
|
|
32
|
+
]
|
|
33
|
+
},
|
|
34
|
+
jira: {
|
|
35
|
+
name: 'Jira',
|
|
36
|
+
icon: '📊',
|
|
37
|
+
description: 'Integrate with Jira projects',
|
|
38
|
+
fields: [
|
|
39
|
+
{ name: 'api_token', label: 'API Token', type: 'password' },
|
|
40
|
+
{ name: 'host', label: 'Jira Host', type: 'text' },
|
|
41
|
+
{ name: 'email', label: 'Email', type: 'email' },
|
|
42
|
+
{ name: 'project_key', label: 'Project Key', type: 'text' }
|
|
43
|
+
]
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const IntegrationManager = () => {
|
|
48
|
+
const { currentWorkspace, isLoading: wsLoading } = useWorkspace()
|
|
49
|
+
const [integrations, setIntegrations] = useState<Integration[]>([])
|
|
50
|
+
const [isLoading, setIsLoading] = useState(false)
|
|
51
|
+
const [error, setError] = useState<string | null>(null)
|
|
52
|
+
const [showSetupForm, setShowSetupForm] = useState<'slack' | 'github' | 'jira' | null>(null)
|
|
53
|
+
const [formData, setFormData] = useState<Record<string, string>>({})
|
|
54
|
+
const [isSubmitting, setIsSubmitting] = useState(false)
|
|
55
|
+
|
|
56
|
+
// Load integrations
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
if (!currentWorkspace) return
|
|
59
|
+
|
|
60
|
+
const loadIntegrations = async () => {
|
|
61
|
+
try {
|
|
62
|
+
setIsLoading(true)
|
|
63
|
+
const res = await integrationAPI.list(currentWorkspace.id)
|
|
64
|
+
setIntegrations(res.data.integrations || [])
|
|
65
|
+
} catch (err) {
|
|
66
|
+
console.error('Failed to load integrations:', err)
|
|
67
|
+
} finally {
|
|
68
|
+
setIsLoading(false)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
loadIntegrations()
|
|
73
|
+
}, [currentWorkspace])
|
|
74
|
+
|
|
75
|
+
if (!currentWorkspace) {
|
|
76
|
+
return (
|
|
77
|
+
<div className="flex items-center justify-center h-64">
|
|
78
|
+
<p className="text-slate-600">Please select a workspace</p>
|
|
79
|
+
</div>
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (wsLoading || isLoading) {
|
|
84
|
+
return (
|
|
85
|
+
<div className="flex items-center justify-center h-64">
|
|
86
|
+
<div className="loading-spinner"></div>
|
|
87
|
+
</div>
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const handleSetup = async (e: React.FormEvent, service: 'slack' | 'github' | 'jira') => {
|
|
92
|
+
e.preventDefault()
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
setIsSubmitting(true)
|
|
96
|
+
setError(null)
|
|
97
|
+
|
|
98
|
+
await integrationAPI.setup(currentWorkspace.id, service, formData)
|
|
99
|
+
|
|
100
|
+
// Reload integrations
|
|
101
|
+
const res = await integrationAPI.list(currentWorkspace.id)
|
|
102
|
+
setIntegrations(res.data.integrations || [])
|
|
103
|
+
|
|
104
|
+
setShowSetupForm(null)
|
|
105
|
+
setFormData({})
|
|
106
|
+
} catch (err) {
|
|
107
|
+
setError(err instanceof Error ? err.message : 'Failed to setup integration')
|
|
108
|
+
} finally {
|
|
109
|
+
setIsSubmitting(false)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const handleToggle = async (integrationId: string) => {
|
|
114
|
+
try {
|
|
115
|
+
await integrationAPI.toggle(integrationId)
|
|
116
|
+
const res = await integrationAPI.list(currentWorkspace.id)
|
|
117
|
+
setIntegrations(res.data.integrations || [])
|
|
118
|
+
} catch (err) {
|
|
119
|
+
setError(err instanceof Error ? err.message : 'Failed to toggle integration')
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const handleDelete = async (integrationId: string) => {
|
|
124
|
+
if (!window.confirm('Are you sure you want to remove this integration?')) return
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
await integrationAPI.delete(integrationId)
|
|
128
|
+
const res = await integrationAPI.list(currentWorkspace.id)
|
|
129
|
+
setIntegrations(res.data.integrations || [])
|
|
130
|
+
} catch (err) {
|
|
131
|
+
setError(err instanceof Error ? err.message : 'Failed to delete integration')
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const handleTestIntegration = async (service: 'slack' | 'github' | 'jira') => {
|
|
136
|
+
try {
|
|
137
|
+
if (service === 'slack') {
|
|
138
|
+
await integrationAPI.testSlack(currentWorkspace.id)
|
|
139
|
+
} else if (service === 'github') {
|
|
140
|
+
await integrationAPI.testGitHub(currentWorkspace.id)
|
|
141
|
+
} else if (service === 'jira') {
|
|
142
|
+
await integrationAPI.testJira(currentWorkspace.id)
|
|
143
|
+
}
|
|
144
|
+
alert('Test successful!')
|
|
145
|
+
} catch (err) {
|
|
146
|
+
setError(err instanceof Error ? err.message : 'Test failed')
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const getIntegration = (service: 'slack' | 'github' | 'jira') => {
|
|
151
|
+
return integrations.find((i) => i.service_name === service)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return (
|
|
155
|
+
<div className="space-y-6">
|
|
156
|
+
<div>
|
|
157
|
+
<h1 className="text-2xl font-bold text-slate-900">Integrations</h1>
|
|
158
|
+
<p className="text-slate-600 mt-2">
|
|
159
|
+
Connect with external tools and services to enhance your workflow
|
|
160
|
+
</p>
|
|
161
|
+
</div>
|
|
162
|
+
|
|
163
|
+
{error && (
|
|
164
|
+
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded flex gap-2">
|
|
165
|
+
<AlertCircle size={18} className="flex-shrink-0 mt-0.5" />
|
|
166
|
+
<div>
|
|
167
|
+
<p className="font-medium">Error</p>
|
|
168
|
+
<p className="text-sm">{error}</p>
|
|
169
|
+
</div>
|
|
170
|
+
</div>
|
|
171
|
+
)}
|
|
172
|
+
|
|
173
|
+
{/* Integration Cards Grid */}
|
|
174
|
+
<div className="grid grid-cols-3 gap-6">
|
|
175
|
+
{(Object.entries(ServiceConfig) as [string, any][]).map(([key, config]) => {
|
|
176
|
+
const integration = getIntegration(key as any)
|
|
177
|
+
const isConfigured = !!integration
|
|
178
|
+
|
|
179
|
+
return (
|
|
180
|
+
<div key={key} className="card space-y-4">
|
|
181
|
+
<div className="flex items-start justify-between">
|
|
182
|
+
<div>
|
|
183
|
+
<div className="text-3xl mb-2">{config.icon}</div>
|
|
184
|
+
<h3 className="text-lg font-semibold text-slate-900">{config.name}</h3>
|
|
185
|
+
<p className="text-sm text-slate-600 mt-1">{config.description}</p>
|
|
186
|
+
</div>
|
|
187
|
+
|
|
188
|
+
{isConfigured && (
|
|
189
|
+
<div className="bg-green-100 text-green-700 px-3 py-1 rounded-full text-xs font-medium flex items-center gap-1">
|
|
190
|
+
<Check size={12} />
|
|
191
|
+
Connected
|
|
192
|
+
</div>
|
|
193
|
+
)}
|
|
194
|
+
</div>
|
|
195
|
+
|
|
196
|
+
<div className="space-y-2 flex-1">
|
|
197
|
+
{isConfigured && (
|
|
198
|
+
<>
|
|
199
|
+
<button
|
|
200
|
+
onClick={() => handleTestIntegration(key as any)}
|
|
201
|
+
className="btn btn-secondary w-full text-sm"
|
|
202
|
+
>
|
|
203
|
+
Test Connection
|
|
204
|
+
</button>
|
|
205
|
+
<button
|
|
206
|
+
onClick={() => handleToggle(integration.id)}
|
|
207
|
+
className={`btn w-full text-sm ${
|
|
208
|
+
integration.is_active
|
|
209
|
+
? 'bg-yellow-100 text-yellow-800 hover:bg-yellow-200'
|
|
210
|
+
: 'bg-green-100 text-green-800 hover:bg-green-200'
|
|
211
|
+
}`}
|
|
212
|
+
>
|
|
213
|
+
{integration.is_active ? 'Disable' : 'Enable'}
|
|
214
|
+
</button>
|
|
215
|
+
</>
|
|
216
|
+
)}
|
|
217
|
+
|
|
218
|
+
<button
|
|
219
|
+
onClick={() => {
|
|
220
|
+
setShowSetupForm(key as any)
|
|
221
|
+
setFormData({})
|
|
222
|
+
}}
|
|
223
|
+
className="btn btn-primary w-full text-sm flex items-center justify-center gap-2"
|
|
224
|
+
>
|
|
225
|
+
<Plus size={14} />
|
|
226
|
+
{isConfigured ? 'Update' : 'Connect'}
|
|
227
|
+
</button>
|
|
228
|
+
|
|
229
|
+
{isConfigured && (
|
|
230
|
+
<button
|
|
231
|
+
onClick={() => handleDelete(integration.id)}
|
|
232
|
+
className="btn btn-secondary w-full text-sm text-red-600 hover:bg-red-50"
|
|
233
|
+
>
|
|
234
|
+
<Trash2 size={14} className="mx-auto" />
|
|
235
|
+
</button>
|
|
236
|
+
)}
|
|
237
|
+
</div>
|
|
238
|
+
</div>
|
|
239
|
+
)
|
|
240
|
+
})}
|
|
241
|
+
</div>
|
|
242
|
+
|
|
243
|
+
{/* Setup Form Modal */}
|
|
244
|
+
{showSetupForm && (
|
|
245
|
+
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
|
246
|
+
<div className="bg-white rounded-lg p-6 max-w-md w-full mx-4 max-h-screen overflow-y-auto">
|
|
247
|
+
<h2 className="text-xl font-bold mb-4">
|
|
248
|
+
Configure {ServiceConfig[showSetupForm].name}
|
|
249
|
+
</h2>
|
|
250
|
+
|
|
251
|
+
<form
|
|
252
|
+
onSubmit={(e) => handleSetup(e, showSetupForm)}
|
|
253
|
+
className="space-y-4"
|
|
254
|
+
>
|
|
255
|
+
{ServiceConfig[showSetupForm].fields.map((field: any) => (
|
|
256
|
+
<div key={field.name}>
|
|
257
|
+
<label className="block text-sm font-medium text-slate-700 mb-2">
|
|
258
|
+
{field.label}
|
|
259
|
+
</label>
|
|
260
|
+
<input
|
|
261
|
+
type={field.type}
|
|
262
|
+
value={formData[field.name] || ''}
|
|
263
|
+
onChange={(e) =>
|
|
264
|
+
setFormData({ ...formData, [field.name]: e.target.value })
|
|
265
|
+
}
|
|
266
|
+
className="input w-full"
|
|
267
|
+
placeholder={field.label}
|
|
268
|
+
disabled={isSubmitting}
|
|
269
|
+
/>
|
|
270
|
+
</div>
|
|
271
|
+
))}
|
|
272
|
+
|
|
273
|
+
<div className="flex gap-3 pt-4">
|
|
274
|
+
<button type="submit" disabled={isSubmitting} className="btn btn-primary flex-1">
|
|
275
|
+
{isSubmitting ? 'Saving...' : 'Save'}
|
|
276
|
+
</button>
|
|
277
|
+
<button
|
|
278
|
+
type="button"
|
|
279
|
+
onClick={() => setShowSetupForm(null)}
|
|
280
|
+
disabled={isSubmitting}
|
|
281
|
+
className="btn btn-secondary flex-1"
|
|
282
|
+
>
|
|
283
|
+
Cancel
|
|
284
|
+
</button>
|
|
285
|
+
</div>
|
|
286
|
+
</form>
|
|
287
|
+
</div>
|
|
288
|
+
</div>
|
|
289
|
+
)}
|
|
290
|
+
</div>
|
|
291
|
+
)
|
|
292
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { useWorkspace } from '../hooks'
|
|
3
|
+
import { Trash2, Edit2, Plus } from 'lucide-react'
|
|
4
|
+
|
|
5
|
+
export const RoleManager = () => {
|
|
6
|
+
const { currentWorkspace, members, roles, addMember, removeMember, updateMember, isLoading } =
|
|
7
|
+
useWorkspace()
|
|
8
|
+
const [showAddForm, setShowAddForm] = useState(false)
|
|
9
|
+
const [editingId, setEditingId] = useState<string | null>(null)
|
|
10
|
+
const [formData, setFormData] = useState({ userId: '', roleName: '' })
|
|
11
|
+
const [isSubmitting, setIsSubmitting] = useState(false)
|
|
12
|
+
const [error, setError] = useState<string | null>(null)
|
|
13
|
+
|
|
14
|
+
if (!currentWorkspace) {
|
|
15
|
+
return (
|
|
16
|
+
<div className="flex items-center justify-center h-64">
|
|
17
|
+
<p className="text-slate-600">Please select a workspace</p>
|
|
18
|
+
</div>
|
|
19
|
+
)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (isLoading) {
|
|
23
|
+
return (
|
|
24
|
+
<div className="flex items-center justify-center h-64">
|
|
25
|
+
<div className="loading-spinner"></div>
|
|
26
|
+
</div>
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const handleAddMember = async (e: React.FormEvent) => {
|
|
31
|
+
e.preventDefault()
|
|
32
|
+
if (!formData.userId || !formData.roleName) {
|
|
33
|
+
setError('Please fill in all fields')
|
|
34
|
+
return
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
setIsSubmitting(true)
|
|
39
|
+
setError(null)
|
|
40
|
+
await addMember(formData.userId, formData.roleName)
|
|
41
|
+
setFormData({ userId: '', roleName: '' })
|
|
42
|
+
setShowAddForm(false)
|
|
43
|
+
} catch (err) {
|
|
44
|
+
setError(err instanceof Error ? err.message : 'Failed to add member')
|
|
45
|
+
} finally {
|
|
46
|
+
setIsSubmitting(false)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const handleUpdateRole = async (memberId: string, newRole: string) => {
|
|
51
|
+
try {
|
|
52
|
+
setError(null)
|
|
53
|
+
await updateMember(memberId, newRole)
|
|
54
|
+
setEditingId(null)
|
|
55
|
+
} catch (err) {
|
|
56
|
+
setError(err instanceof Error ? err.message : 'Failed to update role')
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const handleRemoveMember = async (memberId: string) => {
|
|
61
|
+
if (!window.confirm('Are you sure you want to remove this member?')) return
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
setError(null)
|
|
65
|
+
await removeMember(memberId)
|
|
66
|
+
} catch (err) {
|
|
67
|
+
setError(err instanceof Error ? err.message : 'Failed to remove member')
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<div className="space-y-6">
|
|
73
|
+
<div className="flex justify-between items-center">
|
|
74
|
+
<div>
|
|
75
|
+
<h1 className="text-2xl font-bold text-slate-900">Roles & Members</h1>
|
|
76
|
+
<p className="text-slate-600 mt-2">Manage workspace members and their permissions</p>
|
|
77
|
+
</div>
|
|
78
|
+
<button
|
|
79
|
+
onClick={() => setShowAddForm(true)}
|
|
80
|
+
className="btn btn-primary flex items-center gap-2"
|
|
81
|
+
>
|
|
82
|
+
<Plus size={18} />
|
|
83
|
+
Add Member
|
|
84
|
+
</button>
|
|
85
|
+
</div>
|
|
86
|
+
|
|
87
|
+
{error && (
|
|
88
|
+
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
|
|
89
|
+
{error}
|
|
90
|
+
</div>
|
|
91
|
+
)}
|
|
92
|
+
|
|
93
|
+
{/* Members Table */}
|
|
94
|
+
<div className="card">
|
|
95
|
+
{members.length === 0 ? (
|
|
96
|
+
<div className="text-center py-12">
|
|
97
|
+
<p className="text-slate-600">No members yet. Add one to get started.</p>
|
|
98
|
+
</div>
|
|
99
|
+
) : (
|
|
100
|
+
<div className="overflow-x-auto">
|
|
101
|
+
<table className="w-full">
|
|
102
|
+
<thead>
|
|
103
|
+
<tr className="border-b border-slate-200">
|
|
104
|
+
<th className="text-left py-3 px-4 font-semibold text-slate-700">Login</th>
|
|
105
|
+
<th className="text-left py-3 px-4 font-semibold text-slate-700">Email</th>
|
|
106
|
+
<th className="text-left py-3 px-4 font-semibold text-slate-700">Role</th>
|
|
107
|
+
<th className="text-center py-3 px-4 font-semibold text-slate-700">Actions</th>
|
|
108
|
+
</tr>
|
|
109
|
+
</thead>
|
|
110
|
+
<tbody>
|
|
111
|
+
{members.map((member) => (
|
|
112
|
+
<tr key={member.id} className="border-b border-slate-100 hover:bg-slate-50">
|
|
113
|
+
<td className="py-3 px-4">
|
|
114
|
+
<div className="flex items-center gap-2">
|
|
115
|
+
{member.avatar_url && (
|
|
116
|
+
<img
|
|
117
|
+
src={member.avatar_url}
|
|
118
|
+
alt={member.github_login}
|
|
119
|
+
className="w-8 h-8 rounded-full"
|
|
120
|
+
/>
|
|
121
|
+
)}
|
|
122
|
+
<span className="font-medium text-slate-900">
|
|
123
|
+
{member.github_login}
|
|
124
|
+
</span>
|
|
125
|
+
</div>
|
|
126
|
+
</td>
|
|
127
|
+
<td className="py-3 px-4 text-slate-600">{member.email}</td>
|
|
128
|
+
<td className="py-3 px-4">
|
|
129
|
+
{editingId === member.id ? (
|
|
130
|
+
<select
|
|
131
|
+
value={member.roles[0] || ''}
|
|
132
|
+
onChange={(e) => handleUpdateRole(member.id, e.target.value)}
|
|
133
|
+
className="input text-sm"
|
|
134
|
+
>
|
|
135
|
+
{roles.map((role) => (
|
|
136
|
+
<option key={role.id} value={role.name}>
|
|
137
|
+
{role.name}
|
|
138
|
+
</option>
|
|
139
|
+
))}
|
|
140
|
+
</select>
|
|
141
|
+
) : (
|
|
142
|
+
<span className="badge badge-success">{member.roles[0] || 'N/A'}</span>
|
|
143
|
+
)}
|
|
144
|
+
</td>
|
|
145
|
+
<td className="py-3 px-4">
|
|
146
|
+
<div className="flex items-center justify-center gap-2">
|
|
147
|
+
<button
|
|
148
|
+
onClick={() =>
|
|
149
|
+
setEditingId(editingId === member.id ? null : member.id)
|
|
150
|
+
}
|
|
151
|
+
className="text-blue-600 hover:text-blue-700 p-2"
|
|
152
|
+
title="Edit role"
|
|
153
|
+
>
|
|
154
|
+
<Edit2 size={16} />
|
|
155
|
+
</button>
|
|
156
|
+
<button
|
|
157
|
+
onClick={() => handleRemoveMember(member.id)}
|
|
158
|
+
className="text-red-600 hover:text-red-700 p-2"
|
|
159
|
+
title="Remove member"
|
|
160
|
+
>
|
|
161
|
+
<Trash2 size={16} />
|
|
162
|
+
</button>
|
|
163
|
+
</div>
|
|
164
|
+
</td>
|
|
165
|
+
</tr>
|
|
166
|
+
))}
|
|
167
|
+
</tbody>
|
|
168
|
+
</table>
|
|
169
|
+
</div>
|
|
170
|
+
)}
|
|
171
|
+
</div>
|
|
172
|
+
|
|
173
|
+
{/* Add Member Modal */}
|
|
174
|
+
{showAddForm && (
|
|
175
|
+
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
|
176
|
+
<div className="bg-white rounded-lg p-6 max-w-md w-full mx-4">
|
|
177
|
+
<h2 className="text-xl font-bold mb-4">Add Member</h2>
|
|
178
|
+
|
|
179
|
+
<form onSubmit={handleAddMember} className="space-y-4">
|
|
180
|
+
<div>
|
|
181
|
+
<label className="block text-sm font-medium text-slate-700 mb-2">
|
|
182
|
+
User ID
|
|
183
|
+
</label>
|
|
184
|
+
<input
|
|
185
|
+
type="text"
|
|
186
|
+
value={formData.userId}
|
|
187
|
+
onChange={(e) => setFormData({ ...formData, userId: e.target.value })}
|
|
188
|
+
className="input w-full"
|
|
189
|
+
placeholder="GitHub user ID"
|
|
190
|
+
disabled={isSubmitting}
|
|
191
|
+
/>
|
|
192
|
+
</div>
|
|
193
|
+
|
|
194
|
+
<div>
|
|
195
|
+
<label className="block text-sm font-medium text-slate-700 mb-2">Role</label>
|
|
196
|
+
<select
|
|
197
|
+
value={formData.roleName}
|
|
198
|
+
onChange={(e) => setFormData({ ...formData, roleName: e.target.value })}
|
|
199
|
+
className="input w-full"
|
|
200
|
+
disabled={isSubmitting}
|
|
201
|
+
>
|
|
202
|
+
<option value="">Select a role</option>
|
|
203
|
+
{roles.map((role) => (
|
|
204
|
+
<option key={role.id} value={role.name}>
|
|
205
|
+
{role.name}
|
|
206
|
+
</option>
|
|
207
|
+
))}
|
|
208
|
+
</select>
|
|
209
|
+
</div>
|
|
210
|
+
|
|
211
|
+
<div className="flex gap-3 pt-4">
|
|
212
|
+
<button type="submit" disabled={isSubmitting} className="btn btn-primary flex-1">
|
|
213
|
+
{isSubmitting ? 'Adding...' : 'Add'}
|
|
214
|
+
</button>
|
|
215
|
+
<button
|
|
216
|
+
type="button"
|
|
217
|
+
onClick={() => setShowAddForm(false)}
|
|
218
|
+
disabled={isSubmitting}
|
|
219
|
+
className="btn btn-secondary flex-1"
|
|
220
|
+
>
|
|
221
|
+
Cancel
|
|
222
|
+
</button>
|
|
223
|
+
</div>
|
|
224
|
+
</form>
|
|
225
|
+
</div>
|
|
226
|
+
</div>
|
|
227
|
+
)}
|
|
228
|
+
</div>
|
|
229
|
+
)
|
|
230
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { memo } from 'react'
|
|
2
|
+
import { Link, useLocation } from 'react-router-dom'
|
|
3
|
+
import {
|
|
4
|
+
Home,
|
|
5
|
+
BarChart3,
|
|
6
|
+
Folder,
|
|
7
|
+
Shield,
|
|
8
|
+
Zap,
|
|
9
|
+
Settings
|
|
10
|
+
} from 'lucide-react'
|
|
11
|
+
|
|
12
|
+
const navigationItems = [
|
|
13
|
+
{ path: '/', label: 'Home', icon: Home },
|
|
14
|
+
{ path: '/dashboard', label: 'Analytics', icon: BarChart3 },
|
|
15
|
+
{ path: '/workspaces', label: 'Workspaces', icon: Folder },
|
|
16
|
+
{ path: '/roles', label: 'Roles & Members', icon: Shield },
|
|
17
|
+
{ path: '/integrations', label: 'Integrations', icon: Zap },
|
|
18
|
+
{ path: '/settings', label: 'Settings', icon: Settings }
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
const SidebarComponent = () => {
|
|
22
|
+
const location = useLocation()
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<aside className="w-64 bg-slate-900 text-white p-4 border-r border-slate-800 flex flex-col h-screen">
|
|
26
|
+
{/* Logo */}
|
|
27
|
+
<div className="mb-8">
|
|
28
|
+
<h1 className="text-xl font-bold flex items-center gap-2">
|
|
29
|
+
<span className="text-blue-400">🎯</span> Session Wrap
|
|
30
|
+
</h1>
|
|
31
|
+
</div>
|
|
32
|
+
|
|
33
|
+
{/* Navigation */}
|
|
34
|
+
<nav className="space-y-2 flex-1">
|
|
35
|
+
{navigationItems.map(({ path, label, icon: Icon }) => {
|
|
36
|
+
const isActive = location.pathname === path
|
|
37
|
+
return (
|
|
38
|
+
<Link
|
|
39
|
+
key={path}
|
|
40
|
+
to={path}
|
|
41
|
+
className={`flex items-center gap-3 px-4 py-2 rounded-lg transition-colors ${
|
|
42
|
+
isActive
|
|
43
|
+
? 'bg-blue-600 text-white'
|
|
44
|
+
: 'text-slate-300 hover:bg-slate-800'
|
|
45
|
+
}`}
|
|
46
|
+
>
|
|
47
|
+
<Icon size={18} />
|
|
48
|
+
<span>{label}</span>
|
|
49
|
+
</Link>
|
|
50
|
+
)
|
|
51
|
+
})}
|
|
52
|
+
</nav>
|
|
53
|
+
|
|
54
|
+
{/* Footer */}
|
|
55
|
+
<div className="border-t border-slate-700 pt-4 text-xs text-slate-400">
|
|
56
|
+
<p>v3.9.0</p>
|
|
57
|
+
</div>
|
|
58
|
+
</aside>
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const Sidebar = memo(SidebarComponent)
|