@chaaskit/client 0.1.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/dist/favicon.svg +11 -0
  2. package/dist/index.html +17 -0
  3. package/dist/lib/LoadingSkeletons-IcIC2JPq.js +132 -0
  4. package/dist/lib/LoadingSkeletons-IcIC2JPq.js.map +1 -0
  5. package/dist/lib/ServerThemeProvider-DNF0LAyk.js +42 -0
  6. package/dist/lib/ServerThemeProvider-DNF0LAyk.js.map +1 -0
  7. package/dist/lib/extensions.js +10 -0
  8. package/dist/lib/extensions.js.map +1 -0
  9. package/dist/lib/favicon.svg +11 -0
  10. package/dist/lib/index.js +74126 -0
  11. package/dist/lib/index.js.map +1 -0
  12. package/dist/lib/logo.svg +12 -0
  13. package/dist/lib/routes/AcceptInviteRoute.js +19 -0
  14. package/dist/lib/routes/AcceptInviteRoute.js.map +1 -0
  15. package/dist/lib/routes/AdminDashboardRoute.js +19 -0
  16. package/dist/lib/routes/AdminDashboardRoute.js.map +1 -0
  17. package/dist/lib/routes/AdminTeamRoute.js +19 -0
  18. package/dist/lib/routes/AdminTeamRoute.js.map +1 -0
  19. package/dist/lib/routes/AdminTeamsRoute.js +19 -0
  20. package/dist/lib/routes/AdminTeamsRoute.js.map +1 -0
  21. package/dist/lib/routes/AdminUsersRoute.js +19 -0
  22. package/dist/lib/routes/AdminUsersRoute.js.map +1 -0
  23. package/dist/lib/routes/ApiKeysRoute.js +19 -0
  24. package/dist/lib/routes/ApiKeysRoute.js.map +1 -0
  25. package/dist/lib/routes/AutomationsRoute.js +19 -0
  26. package/dist/lib/routes/AutomationsRoute.js.map +1 -0
  27. package/dist/lib/routes/ChatRoute.js +19 -0
  28. package/dist/lib/routes/ChatRoute.js.map +1 -0
  29. package/dist/lib/routes/DocumentsRoute.js +19 -0
  30. package/dist/lib/routes/DocumentsRoute.js.map +1 -0
  31. package/dist/lib/routes/OAuthConsentRoute.js +19 -0
  32. package/dist/lib/routes/OAuthConsentRoute.js.map +1 -0
  33. package/dist/lib/routes/PricingRoute.js +19 -0
  34. package/dist/lib/routes/PricingRoute.js.map +1 -0
  35. package/dist/lib/routes/PrivacyRoute.js +19 -0
  36. package/dist/lib/routes/PrivacyRoute.js.map +1 -0
  37. package/dist/lib/routes/TeamSettingsRoute.js +19 -0
  38. package/dist/lib/routes/TeamSettingsRoute.js.map +1 -0
  39. package/dist/lib/routes/TermsRoute.js +19 -0
  40. package/dist/lib/routes/TermsRoute.js.map +1 -0
  41. package/dist/lib/routes/VerifyEmailRoute.js +19 -0
  42. package/dist/lib/routes/VerifyEmailRoute.js.map +1 -0
  43. package/dist/lib/routes.js +79 -0
  44. package/dist/lib/routes.js.map +1 -0
  45. package/dist/lib/ssr-utils.js +29 -0
  46. package/dist/lib/ssr-utils.js.map +1 -0
  47. package/dist/lib/ssr.js +60 -0
  48. package/dist/lib/ssr.js.map +1 -0
  49. package/dist/lib/styles.css +2410 -0
  50. package/dist/lib/useExtensions-B5nX_8XD.js +155 -0
  51. package/dist/lib/useExtensions-B5nX_8XD.js.map +1 -0
  52. package/dist/logo.svg +12 -0
  53. package/package.json +84 -0
  54. package/src/components/AgentSelector.tsx +90 -0
  55. package/src/components/BranchModal.tsx +129 -0
  56. package/src/components/ClientOnly.tsx +27 -0
  57. package/src/components/ExportMenu.tsx +122 -0
  58. package/src/components/LoadingSkeletons.tsx +110 -0
  59. package/src/components/MCPCredentialsSection.tsx +309 -0
  60. package/src/components/MentionChip.tsx +149 -0
  61. package/src/components/MentionDropdown.tsx +175 -0
  62. package/src/components/MentionInput.tsx +293 -0
  63. package/src/components/MessageItem.tsx +300 -0
  64. package/src/components/MessageList.tsx +159 -0
  65. package/src/components/OAuthAppsSection.tsx +124 -0
  66. package/src/components/ProjectFolder.tsx +141 -0
  67. package/src/components/ProjectModal.tsx +296 -0
  68. package/src/components/SSRMessageList.tsx +153 -0
  69. package/src/components/SearchModal.tsx +173 -0
  70. package/src/components/SettingsModal.tsx +412 -0
  71. package/src/components/ShareModal.tsx +280 -0
  72. package/src/components/Sidebar.tsx +491 -0
  73. package/src/components/TeamSwitcher.tsx +273 -0
  74. package/src/components/ToolCallDisplay.tsx +473 -0
  75. package/src/components/ToolConfirmationModal.tsx +130 -0
  76. package/src/components/UsageChart.tsx +177 -0
  77. package/src/components/content/CodeBlock.tsx +69 -0
  78. package/src/components/content/MarkdownRenderer.tsx +64 -0
  79. package/src/components/content/SSRMarkdownRenderer.tsx +158 -0
  80. package/src/contexts/AuthContext.tsx +119 -0
  81. package/src/contexts/ConfigContext.tsx +214 -0
  82. package/src/contexts/ProjectContext.tsx +167 -0
  83. package/src/contexts/ServerConfigProvider.tsx +41 -0
  84. package/src/contexts/ServerThemeProvider.tsx +47 -0
  85. package/src/contexts/TeamContext.tsx +255 -0
  86. package/src/contexts/ThemeContext.tsx +113 -0
  87. package/src/extensions/index.ts +15 -0
  88. package/src/extensions/registry.ts +187 -0
  89. package/src/extensions/useExtensions.ts +52 -0
  90. package/src/hooks/useAppPath.ts +34 -0
  91. package/src/hooks/useBasePath.ts +13 -0
  92. package/src/hooks/useKeyboardShortcuts.ts +50 -0
  93. package/src/hooks/useMentionSearch.ts +106 -0
  94. package/src/index.tsx +116 -0
  95. package/src/layouts/MainLayout.tsx +98 -0
  96. package/src/pages/AcceptInvitePage.tsx +175 -0
  97. package/src/pages/AdminDashboardPage.tsx +362 -0
  98. package/src/pages/AdminTeamPage.tsx +304 -0
  99. package/src/pages/AdminTeamsPage.tsx +242 -0
  100. package/src/pages/AdminUsersPage.tsx +385 -0
  101. package/src/pages/ApiKeysPage.tsx +449 -0
  102. package/src/pages/ChatPage.tsx +310 -0
  103. package/src/pages/DocumentsPage.tsx +577 -0
  104. package/src/pages/LoginPage.tsx +232 -0
  105. package/src/pages/OAuthConsentPage.tsx +234 -0
  106. package/src/pages/PricingPage.tsx +314 -0
  107. package/src/pages/PrivacyPage.tsx +65 -0
  108. package/src/pages/RegisterPage.tsx +153 -0
  109. package/src/pages/ScheduledPromptsPage.tsx +702 -0
  110. package/src/pages/SharedThreadPage.tsx +116 -0
  111. package/src/pages/TeamSettingsPage.tsx +1085 -0
  112. package/src/pages/TermsPage.tsx +82 -0
  113. package/src/pages/VerifyEmailPage.tsx +202 -0
  114. package/src/routes/AcceptInviteRoute.tsx +24 -0
  115. package/src/routes/AdminDashboardRoute.tsx +24 -0
  116. package/src/routes/AdminTeamRoute.tsx +24 -0
  117. package/src/routes/AdminTeamsRoute.tsx +24 -0
  118. package/src/routes/AdminUsersRoute.tsx +24 -0
  119. package/src/routes/ApiKeysRoute.tsx +24 -0
  120. package/src/routes/AutomationsRoute.tsx +24 -0
  121. package/src/routes/ChatRoute.tsx +28 -0
  122. package/src/routes/DocumentsRoute.tsx +24 -0
  123. package/src/routes/OAuthConsentRoute.tsx +24 -0
  124. package/src/routes/PricingRoute.tsx +24 -0
  125. package/src/routes/PrivacyRoute.tsx +24 -0
  126. package/src/routes/TeamSettingsRoute.tsx +24 -0
  127. package/src/routes/TermsRoute.tsx +24 -0
  128. package/src/routes/VerifyEmailRoute.tsx +24 -0
  129. package/src/routes/index.ts +57 -0
  130. package/src/ssr-utils.tsx +84 -0
  131. package/src/ssr.ts +123 -0
  132. package/src/stores/chatStore.ts +670 -0
  133. package/src/styles/index.css +254 -0
  134. package/src/utils/api.ts +78 -0
  135. package/src/vite-env.d.ts +13 -0
@@ -0,0 +1,1085 @@
1
+ import { useState, useEffect } from 'react';
2
+ import { useParams, useNavigate, Link, Navigate, useSearchParams } from 'react-router';
3
+ import { CreditCard, ExternalLink, Loader2, X, MessageSquare } from 'lucide-react';
4
+ import { useTeam } from '../contexts/TeamContext';
5
+ import { useAuth } from '../contexts/AuthContext';
6
+ import { useConfig } from '../contexts/ConfigContext';
7
+ import { useAppPath } from '../hooks/useAppPath';
8
+ import { api } from '../utils/api';
9
+ import type { TeamRole, TeamStats, TeamActivityItem, TeamStatsResponse } from '@chaaskit/shared';
10
+
11
+ interface SlackIntegrationStatus {
12
+ connected: boolean;
13
+ integration?: {
14
+ id: string;
15
+ workspaceId: string;
16
+ workspaceName: string;
17
+ notificationChannel: string | null;
18
+ aiChatEnabled: boolean;
19
+ status: string;
20
+ statusMessage: string | null;
21
+ installedAt: string;
22
+ };
23
+ }
24
+
25
+ interface TeamSubscription {
26
+ plan: string;
27
+ planName: string;
28
+ messagesThisMonth: number;
29
+ monthlyLimit: number;
30
+ credits: number;
31
+ hasStripeCustomer: boolean;
32
+ }
33
+
34
+ export default function TeamSettingsPage() {
35
+ const { teamId } = useParams<{ teamId: string }>();
36
+ const navigate = useNavigate();
37
+ const appPath = useAppPath();
38
+ const [searchParams] = useSearchParams();
39
+ const { user } = useAuth();
40
+ const config = useConfig();
41
+ const paymentsEnabled = config.payments?.enabled ?? false;
42
+ const {
43
+ teams,
44
+ currentTeam,
45
+ isLoadingTeamDetails,
46
+ loadTeamDetails,
47
+ updateTeam,
48
+ archiveTeam,
49
+ inviteMember,
50
+ removeMember,
51
+ updateMemberRole,
52
+ cancelInvite,
53
+ leaveTeam,
54
+ } = useTeam();
55
+
56
+ const [teamName, setTeamName] = useState('');
57
+ const [teamContext, setTeamContext] = useState('');
58
+ const [inviteEmail, setInviteEmail] = useState('');
59
+ const [inviteRole, setInviteRole] = useState<'admin' | 'member' | 'viewer'>('member');
60
+ const [inviteUrl, setInviteUrl] = useState('');
61
+ const [error, setError] = useState('');
62
+ const [success, setSuccess] = useState('');
63
+ const [isSubmitting, setIsSubmitting] = useState(false);
64
+ const [isSavingContext, setIsSavingContext] = useState(false);
65
+ const [stats, setStats] = useState<TeamStats | null>(null);
66
+ const [activity, setActivity] = useState<TeamActivityItem[]>([]);
67
+ const [isLoadingStats, setIsLoadingStats] = useState(true);
68
+ const [subscription, setSubscription] = useState<TeamSubscription | null>(null);
69
+ const [isLoadingSubscription, setIsLoadingSubscription] = useState(true);
70
+ const [isBillingLoading, setIsBillingLoading] = useState(false);
71
+
72
+ // Slack integration state
73
+ const [slackStatus, setSlackStatus] = useState<SlackIntegrationStatus | null>(null);
74
+ const [isLoadingSlack, setIsLoadingSlack] = useState(true);
75
+ const [slackAiChatEnabled, setSlackAiChatEnabled] = useState(true);
76
+ const [slackNotificationChannel, setSlackNotificationChannel] = useState('');
77
+ const [isSavingSlack, setIsSavingSlack] = useState(false);
78
+ const [isDisconnectingSlack, setIsDisconnectingSlack] = useState(false);
79
+
80
+ const currentTeamRole = teams.find((t) => t.id === teamId)?.role;
81
+ const isOwner = currentTeamRole === 'owner';
82
+ const isAdmin = currentTeamRole === 'owner' || currentTeamRole === 'admin';
83
+ const teamsEnabled = config.teams?.enabled ?? false;
84
+ const slackEnabled = (config as unknown as { slack?: { enabled: boolean } }).slack?.enabled ?? false;
85
+
86
+ useEffect(() => {
87
+ if (teamId && teamsEnabled) {
88
+ loadTeamDetails(teamId);
89
+ }
90
+ }, [teamId, teamsEnabled, loadTeamDetails]);
91
+
92
+ useEffect(() => {
93
+ if (currentTeam) {
94
+ setTeamName(currentTeam.name);
95
+ setTeamContext(currentTeam.context || '');
96
+ }
97
+ }, [currentTeam]);
98
+
99
+ useEffect(() => {
100
+ if (teamId && teamsEnabled) {
101
+ setIsLoadingStats(true);
102
+ api.get<TeamStatsResponse>(`/api/teams/${teamId}/stats`)
103
+ .then(res => {
104
+ setStats(res.stats);
105
+ setActivity(res.recentActivity);
106
+ })
107
+ .catch(() => {
108
+ // Silently fail - stats are not critical
109
+ })
110
+ .finally(() => setIsLoadingStats(false));
111
+ }
112
+ }, [teamId, teamsEnabled]);
113
+
114
+ // Load team subscription data
115
+ useEffect(() => {
116
+ if (teamId && teamsEnabled && paymentsEnabled) {
117
+ setIsLoadingSubscription(true);
118
+ api.get<TeamSubscription>(`/api/payments/team/${teamId}/subscription`)
119
+ .then(setSubscription)
120
+ .catch(() => {
121
+ // Silently fail - subscription may not exist
122
+ })
123
+ .finally(() => setIsLoadingSubscription(false));
124
+ } else {
125
+ setIsLoadingSubscription(false);
126
+ }
127
+ }, [teamId, teamsEnabled, paymentsEnabled]);
128
+
129
+ // Load Slack integration status
130
+ useEffect(() => {
131
+ if (teamId && teamsEnabled && slackEnabled) {
132
+ setIsLoadingSlack(true);
133
+ api.get<SlackIntegrationStatus>(`/api/slack/${teamId}/status`)
134
+ .then(data => {
135
+ setSlackStatus(data);
136
+ if (data.integration) {
137
+ setSlackAiChatEnabled(data.integration.aiChatEnabled);
138
+ setSlackNotificationChannel(data.integration.notificationChannel || '');
139
+ }
140
+ })
141
+ .catch(() => {
142
+ setSlackStatus({ connected: false });
143
+ })
144
+ .finally(() => setIsLoadingSlack(false));
145
+ } else {
146
+ setIsLoadingSlack(false);
147
+ setSlackStatus(null);
148
+ }
149
+ }, [teamId, teamsEnabled, slackEnabled]);
150
+
151
+ // Check for Slack connection status in URL params
152
+ useEffect(() => {
153
+ const slackParam = searchParams.get('slack');
154
+ if (slackParam === 'connected') {
155
+ setSuccess('Slack connected successfully!');
156
+ // Reload slack status
157
+ if (teamId) {
158
+ api.get<SlackIntegrationStatus>(`/api/slack/${teamId}/status`)
159
+ .then(data => {
160
+ setSlackStatus(data);
161
+ if (data.integration) {
162
+ setSlackAiChatEnabled(data.integration.aiChatEnabled);
163
+ setSlackNotificationChannel(data.integration.notificationChannel || '');
164
+ }
165
+ })
166
+ .catch(() => {});
167
+ }
168
+ } else if (slackParam === 'error') {
169
+ const message = searchParams.get('message');
170
+ setError(`Failed to connect Slack: ${message || 'Unknown error'}`);
171
+ }
172
+ }, [searchParams, teamId]);
173
+
174
+ // Check for payment success/failure in URL params
175
+ useEffect(() => {
176
+ const payment = searchParams.get('payment');
177
+ const credits = searchParams.get('credits');
178
+
179
+ if (payment === 'success') {
180
+ setSuccess('Plan upgraded successfully!');
181
+ // Reload subscription data
182
+ if (teamId) {
183
+ api.get<TeamSubscription>(`/api/payments/team/${teamId}/subscription`)
184
+ .then(setSubscription)
185
+ .catch(() => {});
186
+ }
187
+ } else if (payment === 'cancelled') {
188
+ setError('Payment was cancelled');
189
+ } else if (credits === 'success') {
190
+ setSuccess('Credits purchased successfully!');
191
+ // Reload subscription data
192
+ if (teamId) {
193
+ api.get<TeamSubscription>(`/api/payments/team/${teamId}/subscription`)
194
+ .then(setSubscription)
195
+ .catch(() => {});
196
+ }
197
+ } else if (credits === 'cancelled') {
198
+ setError('Credits purchase was cancelled');
199
+ }
200
+ }, [searchParams, teamId]);
201
+
202
+ // Redirect if teams feature is disabled
203
+ if (!teamsEnabled) {
204
+ return <Navigate to={appPath('/')} replace />;
205
+ }
206
+
207
+ const handleUpdateTeam = async (e: React.FormEvent) => {
208
+ e.preventDefault();
209
+ if (!teamId) return;
210
+
211
+ setError('');
212
+ setSuccess('');
213
+ setIsSubmitting(true);
214
+
215
+ try {
216
+ await updateTeam(teamId, { name: teamName });
217
+ setSuccess('Team name updated successfully');
218
+ } catch (err) {
219
+ setError(err instanceof Error ? err.message : 'Failed to update team');
220
+ } finally {
221
+ setIsSubmitting(false);
222
+ }
223
+ };
224
+
225
+ const handleUpdateContext = async (e: React.FormEvent) => {
226
+ e.preventDefault();
227
+ if (!teamId) return;
228
+
229
+ setError('');
230
+ setSuccess('');
231
+ setIsSavingContext(true);
232
+
233
+ try {
234
+ await updateTeam(teamId, { context: teamContext || null });
235
+ setSuccess('Team context updated successfully');
236
+ } catch (err) {
237
+ setError(err instanceof Error ? err.message : 'Failed to update team context');
238
+ } finally {
239
+ setIsSavingContext(false);
240
+ }
241
+ };
242
+
243
+ const handleInvite = async (e: React.FormEvent) => {
244
+ e.preventDefault();
245
+ if (!teamId) return;
246
+
247
+ setError('');
248
+ setSuccess('');
249
+ setInviteUrl('');
250
+ setIsSubmitting(true);
251
+
252
+ try {
253
+ const result = await inviteMember(teamId, inviteEmail, inviteRole);
254
+ setInviteUrl(result.inviteUrl);
255
+ setInviteEmail('');
256
+ setSuccess('Invite sent successfully');
257
+ } catch (err) {
258
+ setError(err instanceof Error ? err.message : 'Failed to send invite');
259
+ } finally {
260
+ setIsSubmitting(false);
261
+ }
262
+ };
263
+
264
+ const handleRemoveMember = async (userId: string) => {
265
+ if (!teamId) return;
266
+ if (!confirm('Are you sure you want to remove this member?')) return;
267
+
268
+ setError('');
269
+ setSuccess('');
270
+
271
+ try {
272
+ await removeMember(teamId, userId);
273
+ setSuccess('Member removed successfully');
274
+ } catch (err) {
275
+ setError(err instanceof Error ? err.message : 'Failed to remove member');
276
+ }
277
+ };
278
+
279
+ const handleUpdateRole = async (userId: string, role: TeamRole) => {
280
+ if (!teamId) return;
281
+
282
+ setError('');
283
+ setSuccess('');
284
+
285
+ try {
286
+ await updateMemberRole(teamId, userId, role);
287
+ setSuccess('Role updated successfully');
288
+ } catch (err) {
289
+ setError(err instanceof Error ? err.message : 'Failed to update role');
290
+ }
291
+ };
292
+
293
+ const handleCancelInvite = async (inviteId: string) => {
294
+ if (!teamId) return;
295
+ if (!confirm('Are you sure you want to cancel this invite?')) return;
296
+
297
+ setError('');
298
+ setSuccess('');
299
+
300
+ try {
301
+ await cancelInvite(teamId, inviteId);
302
+ setSuccess('Invite cancelled');
303
+ } catch (err) {
304
+ setError(err instanceof Error ? err.message : 'Failed to cancel invite');
305
+ }
306
+ };
307
+
308
+ const handleArchiveTeam = async () => {
309
+ if (!teamId) return;
310
+ if (!confirm('Are you sure you want to archive this team? Team threads will be preserved but the team will be hidden.')) return;
311
+
312
+ setError('');
313
+
314
+ try {
315
+ await archiveTeam(teamId);
316
+ navigate(appPath('/'));
317
+ } catch (err) {
318
+ setError(err instanceof Error ? err.message : 'Failed to archive team');
319
+ }
320
+ };
321
+
322
+ const handleLeaveTeam = async () => {
323
+ if (!teamId) return;
324
+ if (!confirm('Are you sure you want to leave this team?')) return;
325
+
326
+ setError('');
327
+
328
+ try {
329
+ await leaveTeam(teamId);
330
+ navigate(appPath('/'));
331
+ } catch (err) {
332
+ setError(err instanceof Error ? err.message : 'Failed to leave team');
333
+ }
334
+ };
335
+
336
+ const copyInviteUrl = () => {
337
+ navigator.clipboard.writeText(inviteUrl);
338
+ setSuccess('Invite link copied to clipboard');
339
+ };
340
+
341
+ const handleConnectSlack = () => {
342
+ // Redirect to Slack OAuth install
343
+ window.location.href = `/api/slack/install/${teamId}`;
344
+ };
345
+
346
+ const handleDisconnectSlack = async () => {
347
+ if (!teamId) return;
348
+ if (!confirm('Are you sure you want to disconnect Slack? You will need to reconnect to use Slack features.')) return;
349
+
350
+ setError('');
351
+ setIsDisconnectingSlack(true);
352
+
353
+ try {
354
+ await api.delete(`/api/slack/${teamId}`);
355
+ setSlackStatus({ connected: false });
356
+ setSuccess('Slack disconnected successfully');
357
+ } catch (err) {
358
+ setError(err instanceof Error ? err.message : 'Failed to disconnect Slack');
359
+ } finally {
360
+ setIsDisconnectingSlack(false);
361
+ }
362
+ };
363
+
364
+ const handleSaveSlackSettings = async () => {
365
+ if (!teamId) return;
366
+
367
+ setError('');
368
+ setIsSavingSlack(true);
369
+
370
+ try {
371
+ await api.patch(`/api/slack/${teamId}/settings`, {
372
+ aiChatEnabled: slackAiChatEnabled,
373
+ notificationChannel: slackNotificationChannel || null,
374
+ });
375
+ setSuccess('Slack settings updated');
376
+ } catch (err) {
377
+ setError(err instanceof Error ? err.message : 'Failed to update Slack settings');
378
+ } finally {
379
+ setIsSavingSlack(false);
380
+ }
381
+ };
382
+
383
+ const handleUpgradeTeamPlan = () => {
384
+ navigate(appPath(`/pricing?teamId=${teamId}`));
385
+ };
386
+
387
+ const handleOpenTeamBillingPortal = async () => {
388
+ if (!teamId) return;
389
+ setIsBillingLoading(true);
390
+ try {
391
+ const response = await fetch(`/api/payments/team/${teamId}/billing-portal`, {
392
+ method: 'POST',
393
+ credentials: 'include',
394
+ });
395
+ if (response.ok) {
396
+ const { url } = await response.json();
397
+ window.location.href = url;
398
+ } else {
399
+ const data = await response.json();
400
+ setError(data.error || 'Failed to open billing portal');
401
+ }
402
+ } catch (err) {
403
+ setError(err instanceof Error ? err.message : 'Failed to open billing portal');
404
+ } finally {
405
+ setIsBillingLoading(false);
406
+ }
407
+ };
408
+
409
+ const formatRelativeTime = (date: Date | string) => {
410
+ const now = new Date();
411
+ const then = new Date(date);
412
+ const diffMs = now.getTime() - then.getTime();
413
+ const diffMins = Math.floor(diffMs / 60000);
414
+ const diffHours = Math.floor(diffMs / 3600000);
415
+ const diffDays = Math.floor(diffMs / 86400000);
416
+
417
+ if (diffMins < 1) return 'Just now';
418
+ if (diffMins < 60) return `${diffMins}m ago`;
419
+ if (diffHours < 24) return `${diffHours}h ago`;
420
+ if (diffDays === 1) return 'Yesterday';
421
+ if (diffDays < 7) return `${diffDays}d ago`;
422
+ return then.toLocaleDateString();
423
+ };
424
+
425
+ if (isLoadingTeamDetails || !currentTeam) {
426
+ return (
427
+ <div className="min-h-screen bg-background p-4 sm:p-8">
428
+ <div className="mx-auto max-w-3xl">
429
+ <div className="animate-pulse">
430
+ <div className="h-8 w-48 bg-background-secondary rounded mb-6 sm:mb-8" />
431
+ <div className="h-64 bg-background-secondary rounded" />
432
+ </div>
433
+ </div>
434
+ </div>
435
+ );
436
+ }
437
+
438
+ return (
439
+ <div className="min-h-screen bg-background p-4 sm:p-8">
440
+ <div className="mx-auto max-w-3xl">
441
+ <div className="flex items-center justify-between mb-6 sm:mb-8">
442
+ <h1 className="text-xl sm:text-2xl font-bold text-text-primary">
443
+ Team Settings
444
+ </h1>
445
+ <Link
446
+ to={appPath('/')}
447
+ className="flex items-center justify-center rounded-lg p-2 text-text-muted hover:text-text-primary hover:bg-background-secondary"
448
+ aria-label="Close"
449
+ >
450
+ <X size={20} />
451
+ </Link>
452
+ </div>
453
+
454
+ {error && (
455
+ <div className="mb-6 rounded-lg bg-error/10 p-4 text-sm text-error">
456
+ {error}
457
+ </div>
458
+ )}
459
+
460
+ {success && (
461
+ <div className="mb-6 rounded-lg bg-success/10 p-4 text-sm text-success">
462
+ {success}
463
+ </div>
464
+ )}
465
+
466
+ {/* Team Stats */}
467
+ <div className="mb-6">
468
+ {isLoadingStats ? (
469
+ <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
470
+ {[...Array(4)].map((_, i) => (
471
+ <div key={i} className="rounded-lg bg-background-secondary p-6 animate-pulse">
472
+ <div className="h-4 w-20 bg-background rounded mb-2" />
473
+ <div className="h-8 w-12 bg-background rounded" />
474
+ </div>
475
+ ))}
476
+ </div>
477
+ ) : stats && (
478
+ <>
479
+ <div className="grid grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4 mb-6">
480
+ <div className="rounded-lg bg-background-secondary p-4 sm:p-6">
481
+ <div className="text-xs sm:text-sm text-text-muted mb-1">Threads</div>
482
+ <div className="text-xl sm:text-2xl font-semibold text-text-primary">{stats.totalThreads}</div>
483
+ {stats.threadsThisMonth > 0 && (
484
+ <div className="text-xs text-text-muted mt-1">+{stats.threadsThisMonth} this month</div>
485
+ )}
486
+ </div>
487
+ <div className="rounded-lg bg-background-secondary p-4 sm:p-6">
488
+ <div className="text-xs sm:text-sm text-text-muted mb-1">Messages</div>
489
+ <div className="text-xl sm:text-2xl font-semibold text-text-primary">{stats.totalMessages}</div>
490
+ </div>
491
+ <div className="rounded-lg bg-background-secondary p-4 sm:p-6">
492
+ <div className="text-xs sm:text-sm text-text-muted mb-1">This Month</div>
493
+ <div className="text-xl sm:text-2xl font-semibold text-text-primary">{stats.messagesThisMonth}</div>
494
+ <div className="text-xs text-text-muted mt-1">messages</div>
495
+ </div>
496
+ <div className="rounded-lg bg-background-secondary p-4 sm:p-6">
497
+ <div className="text-xs sm:text-sm text-text-muted mb-1">Members</div>
498
+ <div className="text-xl sm:text-2xl font-semibold text-text-primary">{stats.memberCount}</div>
499
+ </div>
500
+ </div>
501
+
502
+ {/* Recent Activity */}
503
+ <div className="rounded-lg bg-background-secondary p-4 sm:p-6">
504
+ <h2 className="text-base sm:text-lg font-semibold text-text-primary mb-4">Recent Activity</h2>
505
+ {activity.length === 0 ? (
506
+ <div className="text-text-muted text-sm">No recent activity</div>
507
+ ) : (
508
+ <div className="space-y-2">
509
+ {activity.map((item, index) => (
510
+ <div
511
+ key={`${item.type}-${item.timestamp}-${index}`}
512
+ className="flex flex-col sm:flex-row sm:items-center sm:justify-between p-3 rounded-lg bg-background gap-2"
513
+ >
514
+ <div className="flex items-center gap-3 min-w-0">
515
+ {item.type === 'thread_created' ? (
516
+ <svg className="w-5 h-5 flex-shrink-0 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
517
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
518
+ </svg>
519
+ ) : (
520
+ <svg className="w-5 h-5 flex-shrink-0 text-success" fill="none" stroke="currentColor" viewBox="0 0 24 24">
521
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z" />
522
+ </svg>
523
+ )}
524
+ <div className="text-sm truncate">
525
+ <span className="text-text-primary">{item.user.name || item.user.email}</span>
526
+ <span className="text-text-muted">
527
+ {item.type === 'thread_created'
528
+ ? ` created "${item.details}"`
529
+ : ' joined the team'}
530
+ </span>
531
+ </div>
532
+ </div>
533
+ <div className="text-xs sm:text-sm text-text-muted flex-shrink-0 pl-8 sm:pl-0">
534
+ {formatRelativeTime(item.timestamp)}
535
+ </div>
536
+ </div>
537
+ ))}
538
+ </div>
539
+ )}
540
+ </div>
541
+ </>
542
+ )}
543
+ </div>
544
+
545
+ {/* Team Plan (Billing) */}
546
+ {paymentsEnabled && isAdmin && (
547
+ <div className="mb-6 rounded-lg bg-background-secondary p-4 sm:p-6">
548
+ <div className="flex items-center gap-2 mb-4">
549
+ <CreditCard className="w-5 h-5 text-primary" />
550
+ <h2 className="text-base sm:text-lg font-semibold text-text-primary">Team Plan</h2>
551
+ </div>
552
+
553
+ {isLoadingSubscription ? (
554
+ <div className="flex items-center justify-center py-8">
555
+ <Loader2 className="h-6 w-6 animate-spin text-primary" />
556
+ </div>
557
+ ) : subscription ? (
558
+ <>
559
+ <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 mb-4">
560
+ <div className="text-sm">
561
+ <span className="text-text-secondary">Current Plan: </span>
562
+ <span className="font-medium text-text-primary">{subscription.planName}</span>
563
+ </div>
564
+ <span className="self-start sm:self-auto rounded-full bg-primary/10 px-2.5 py-0.5 text-xs font-medium text-primary">
565
+ {subscription.plan}
566
+ </span>
567
+ </div>
568
+
569
+ {/* Usage Progress */}
570
+ <div className="space-y-2 mb-4">
571
+ <div className="flex justify-between text-sm">
572
+ <span className="text-text-secondary">Messages this month</span>
573
+ <span className="text-text-primary">
574
+ {subscription.messagesThisMonth.toLocaleString()}
575
+ {subscription.monthlyLimit > 0 ? (
576
+ <span className="text-text-muted"> / {subscription.monthlyLimit.toLocaleString()}</span>
577
+ ) : (
578
+ <span className="text-text-muted"> / Unlimited</span>
579
+ )}
580
+ </span>
581
+ </div>
582
+
583
+ {subscription.monthlyLimit > 0 && (
584
+ <div className="h-2 overflow-hidden rounded-full bg-background">
585
+ <div
586
+ className={`h-full rounded-full transition-all ${
587
+ subscription.messagesThisMonth / subscription.monthlyLimit > 0.9
588
+ ? 'bg-error'
589
+ : subscription.messagesThisMonth / subscription.monthlyLimit > 0.7
590
+ ? 'bg-warning'
591
+ : 'bg-primary'
592
+ }`}
593
+ style={{
594
+ width: `${Math.min(100, (subscription.messagesThisMonth / subscription.monthlyLimit) * 100)}%`,
595
+ }}
596
+ />
597
+ </div>
598
+ )}
599
+
600
+ {subscription.credits > 0 && (
601
+ <div className="flex justify-between text-sm mt-2">
602
+ <span className="text-text-secondary">Credits remaining</span>
603
+ <span className="text-text-primary">{subscription.credits.toLocaleString()}</span>
604
+ </div>
605
+ )}
606
+ </div>
607
+
608
+ {/* Billing Actions */}
609
+ <div className="flex flex-wrap gap-2">
610
+ <button
611
+ onClick={handleUpgradeTeamPlan}
612
+ className="flex items-center gap-1.5 rounded-lg bg-primary px-3 py-2 text-sm font-medium text-white hover:bg-primary-hover"
613
+ >
614
+ <CreditCard size={14} />
615
+ {subscription.plan === 'free' ? 'Upgrade Team Plan' : 'Change Plan'}
616
+ </button>
617
+ {subscription.hasStripeCustomer && (
618
+ <button
619
+ onClick={handleOpenTeamBillingPortal}
620
+ disabled={isBillingLoading}
621
+ className="flex items-center gap-1.5 rounded-lg border border-border bg-background px-3 py-2 text-sm font-medium text-text-secondary hover:text-text-primary hover:bg-background-secondary disabled:opacity-50"
622
+ >
623
+ {isBillingLoading ? (
624
+ <Loader2 size={14} className="animate-spin" />
625
+ ) : (
626
+ <ExternalLink size={14} />
627
+ )}
628
+ Manage Subscription
629
+ </button>
630
+ )}
631
+ </div>
632
+
633
+ <p className="mt-4 text-xs text-text-muted">
634
+ Team plans provide a shared message pool for all team members.
635
+ Personal plans still apply to personal chats.
636
+ </p>
637
+ </>
638
+ ) : (
639
+ <div className="text-center py-4">
640
+ <p className="text-text-secondary mb-4">
641
+ Your team is on the free plan. Upgrade to get a shared message pool for all members.
642
+ </p>
643
+ <button
644
+ onClick={handleUpgradeTeamPlan}
645
+ className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary-hover"
646
+ >
647
+ Upgrade Team Plan
648
+ </button>
649
+ </div>
650
+ )}
651
+ </div>
652
+ )}
653
+
654
+ {/* Slack Integration */}
655
+ {slackEnabled && isAdmin && (
656
+ <div className="mb-6 rounded-lg bg-background-secondary p-4 sm:p-6">
657
+ <div className="flex items-center gap-2 mb-4">
658
+ <MessageSquare className="w-5 h-5 text-primary" />
659
+ <h2 className="text-base sm:text-lg font-semibold text-text-primary">Slack Integration</h2>
660
+ </div>
661
+
662
+ {isLoadingSlack ? (
663
+ <div className="flex items-center justify-center py-8">
664
+ <Loader2 className="h-6 w-6 animate-spin text-primary" />
665
+ </div>
666
+ ) : slackStatus?.connected ? (
667
+ <>
668
+ {/* Connected state */}
669
+ <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 mb-4">
670
+ <div className="text-sm">
671
+ <span className="text-text-secondary">Connected to: </span>
672
+ <span className="font-medium text-text-primary">{slackStatus.integration?.workspaceName}</span>
673
+ </div>
674
+ <span className="self-start sm:self-auto rounded-full bg-success/10 px-2.5 py-0.5 text-xs font-medium text-success">
675
+ Active
676
+ </span>
677
+ </div>
678
+
679
+ {/* Settings */}
680
+ <div className="space-y-4 mb-4">
681
+ {/* AI Chat Toggle */}
682
+ <div className="flex items-center justify-between">
683
+ <div>
684
+ <div className="text-sm font-medium text-text-primary">AI Chat</div>
685
+ <div className="text-xs text-text-muted">Respond to @mentions with AI</div>
686
+ </div>
687
+ <button
688
+ onClick={() => setSlackAiChatEnabled(!slackAiChatEnabled)}
689
+ className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
690
+ slackAiChatEnabled ? 'bg-primary' : 'bg-border'
691
+ }`}
692
+ >
693
+ <span
694
+ className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
695
+ slackAiChatEnabled ? 'translate-x-6' : 'translate-x-1'
696
+ }`}
697
+ />
698
+ </button>
699
+ </div>
700
+
701
+ {/* Notification Channel */}
702
+ <div>
703
+ <label className="block text-sm font-medium text-text-primary mb-1">
704
+ Notification Channel
705
+ </label>
706
+ <input
707
+ type="text"
708
+ value={slackNotificationChannel}
709
+ onChange={(e) => setSlackNotificationChannel(e.target.value)}
710
+ placeholder="#general"
711
+ className="w-full rounded-lg border border-input-border bg-input-background px-3 py-2 text-sm text-text-primary placeholder-text-muted focus:border-primary focus:outline-none"
712
+ />
713
+ <p className="text-xs text-text-muted mt-1">
714
+ Channel where team notifications will be sent (e.g., thread shared, new members)
715
+ </p>
716
+ </div>
717
+ </div>
718
+
719
+ {/* Save button */}
720
+ <div className="flex flex-wrap gap-2">
721
+ <button
722
+ onClick={handleSaveSlackSettings}
723
+ disabled={isSavingSlack}
724
+ className="flex items-center gap-1.5 rounded-lg bg-primary px-3 py-2 text-sm font-medium text-white hover:bg-primary-hover disabled:opacity-50"
725
+ >
726
+ {isSavingSlack ? (
727
+ <Loader2 size={14} className="animate-spin" />
728
+ ) : null}
729
+ Save Settings
730
+ </button>
731
+ <button
732
+ onClick={handleDisconnectSlack}
733
+ disabled={isDisconnectingSlack}
734
+ className="flex items-center gap-1.5 rounded-lg border border-error bg-transparent px-3 py-2 text-sm font-medium text-error hover:bg-error/10 disabled:opacity-50"
735
+ >
736
+ {isDisconnectingSlack ? (
737
+ <Loader2 size={14} className="animate-spin" />
738
+ ) : null}
739
+ Disconnect
740
+ </button>
741
+ </div>
742
+
743
+ <p className="mt-4 text-xs text-text-muted">
744
+ Connected since {new Date(slackStatus.integration?.installedAt || '').toLocaleDateString()}
745
+ </p>
746
+ </>
747
+ ) : (
748
+ <>
749
+ {/* Not connected state */}
750
+ <p className="text-sm text-text-secondary mb-4">
751
+ Connect your team's Slack workspace to chat with your AI assistant directly from Slack.
752
+ Team members can @mention the bot in any channel to get instant responses.
753
+ </p>
754
+ <button
755
+ onClick={handleConnectSlack}
756
+ className="flex items-center gap-2 rounded-lg px-4 py-2 font-medium text-white"
757
+ style={{ backgroundColor: '#4A154B' }}
758
+ >
759
+ <svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
760
+ <path d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zM6.313 15.165a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zM8.834 6.313a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zM18.956 8.834a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zM17.688 8.834a2.528 2.528 0 0 1-2.523 2.521 2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312zM15.165 18.956a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52zM15.165 17.688a2.527 2.527 0 0 1-2.52-2.523 2.526 2.526 0 0 1 2.52-2.52h6.313A2.527 2.527 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.523h-6.313z"/>
761
+ </svg>
762
+ Add to Slack
763
+ </button>
764
+ </>
765
+ )}
766
+ </div>
767
+ )}
768
+
769
+ {/* Team Name */}
770
+ <div className="mb-6 rounded-lg bg-background-secondary p-4 sm:p-6">
771
+ <h2 className="text-base sm:text-lg font-semibold text-text-primary mb-4">
772
+ Team Name
773
+ </h2>
774
+ <form onSubmit={handleUpdateTeam} className="flex flex-col sm:flex-row gap-3 sm:gap-4">
775
+ <input
776
+ type="text"
777
+ value={teamName}
778
+ onChange={(e) => setTeamName(e.target.value)}
779
+ disabled={!isAdmin}
780
+ className="flex-1 rounded-lg border border-input-border bg-input-background px-3 sm:px-4 py-2 text-text-primary focus:border-primary focus:outline-none disabled:opacity-50"
781
+ />
782
+ {isAdmin && (
783
+ <button
784
+ type="submit"
785
+ disabled={isSubmitting || teamName === currentTeam.name}
786
+ className="rounded-lg bg-primary px-4 py-2 font-medium text-white hover:bg-primary-hover disabled:opacity-50"
787
+ >
788
+ Save
789
+ </button>
790
+ )}
791
+ </form>
792
+ </div>
793
+
794
+ {/* Team Context */}
795
+ {isAdmin && (
796
+ <div className="mb-6 rounded-lg bg-background-secondary p-4 sm:p-6">
797
+ <h2 className="text-base sm:text-lg font-semibold text-text-primary mb-2">
798
+ Team Context
799
+ </h2>
800
+ <p className="text-xs sm:text-sm text-text-muted mb-4">
801
+ Additional context that will be provided to the AI assistant for all team conversations.
802
+ This is shared across all team members.
803
+ </p>
804
+ <form onSubmit={handleUpdateContext}>
805
+ <textarea
806
+ value={teamContext}
807
+ onChange={(e) => setTeamContext(e.target.value)}
808
+ placeholder="e.g., Project guidelines, coding standards, team preferences..."
809
+ rows={4}
810
+ className="w-full rounded-lg border border-input-border bg-input-background px-3 sm:px-4 py-3 text-sm sm:text-base text-text-primary placeholder-text-muted focus:border-primary focus:outline-none resize-y"
811
+ />
812
+ <div className="mt-4 flex justify-end">
813
+ <button
814
+ type="submit"
815
+ disabled={isSavingContext || teamContext === (currentTeam.context || '')}
816
+ className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary-hover disabled:opacity-50"
817
+ >
818
+ {isSavingContext ? 'Saving...' : 'Save Context'}
819
+ </button>
820
+ </div>
821
+ </form>
822
+ </div>
823
+ )}
824
+
825
+ {/* Invite Members */}
826
+ {isAdmin && (
827
+ <div className="mb-6 rounded-lg bg-background-secondary p-4 sm:p-6">
828
+ <h2 className="text-base sm:text-lg font-semibold text-text-primary mb-4">
829
+ Invite Members
830
+ </h2>
831
+ <form onSubmit={handleInvite} className="space-y-3 sm:space-y-0 sm:flex sm:gap-4 mb-4">
832
+ <input
833
+ type="email"
834
+ placeholder="Email address"
835
+ value={inviteEmail}
836
+ onChange={(e) => setInviteEmail(e.target.value)}
837
+ required
838
+ className="w-full sm:flex-1 rounded-lg border border-input-border bg-input-background px-3 sm:px-4 py-2 text-text-primary focus:border-primary focus:outline-none"
839
+ />
840
+ <div className="flex gap-3 sm:gap-4">
841
+ <select
842
+ value={inviteRole}
843
+ onChange={(e) => setInviteRole(e.target.value as 'admin' | 'member' | 'viewer')}
844
+ className="flex-1 sm:flex-none rounded-lg border border-input-border bg-input-background px-3 sm:px-4 py-2 text-text-primary focus:border-primary focus:outline-none"
845
+ >
846
+ <option value="admin">Admin</option>
847
+ <option value="member">Member</option>
848
+ <option value="viewer">Viewer</option>
849
+ </select>
850
+ <button
851
+ type="submit"
852
+ disabled={isSubmitting}
853
+ className="rounded-lg bg-primary px-4 py-2 font-medium text-white hover:bg-primary-hover disabled:opacity-50"
854
+ >
855
+ Invite
856
+ </button>
857
+ </div>
858
+ </form>
859
+
860
+ {inviteUrl && (
861
+ <div className="flex items-center gap-2 p-3 rounded-lg bg-background">
862
+ <input
863
+ type="text"
864
+ value={inviteUrl}
865
+ readOnly
866
+ className="flex-1 bg-transparent text-xs sm:text-sm text-text-secondary min-w-0"
867
+ />
868
+ <button
869
+ onClick={copyInviteUrl}
870
+ className="flex-shrink-0 text-sm text-primary hover:underline"
871
+ >
872
+ Copy
873
+ </button>
874
+ </div>
875
+ )}
876
+
877
+ {/* Pending Invites */}
878
+ {currentTeam.invites && currentTeam.invites.length > 0 && (
879
+ <div className="mt-4">
880
+ <h3 className="text-sm font-medium text-text-muted mb-2">
881
+ Pending Invites
882
+ </h3>
883
+ <div className="space-y-2">
884
+ {currentTeam.invites.map((invite) => (
885
+ <div
886
+ key={invite.id}
887
+ className="flex items-center justify-between p-3 rounded-lg bg-background"
888
+ >
889
+ <div>
890
+ <span className="text-text-primary">{invite.email}</span>
891
+ <span className="ml-2 text-xs text-text-muted capitalize">
892
+ ({invite.role})
893
+ </span>
894
+ </div>
895
+ <button
896
+ onClick={() => handleCancelInvite(invite.id)}
897
+ className="text-sm text-error hover:underline"
898
+ >
899
+ Cancel
900
+ </button>
901
+ </div>
902
+ ))}
903
+ </div>
904
+ </div>
905
+ )}
906
+ </div>
907
+ )}
908
+
909
+ {/* Members */}
910
+ <div className="mb-6 rounded-lg bg-background-secondary p-4 sm:p-6">
911
+ <h2 className="text-base sm:text-lg font-semibold text-text-primary mb-4">
912
+ Members ({currentTeam.members.length})
913
+ </h2>
914
+ <div className="space-y-3">
915
+ {currentTeam.members.map((member) => (
916
+ <div
917
+ key={member.id}
918
+ className="p-3 rounded-lg bg-background"
919
+ >
920
+ {/* Desktop View */}
921
+ <div className="hidden sm:flex items-center justify-between">
922
+ <div className="flex items-center gap-3">
923
+ {member.user?.avatarUrl ? (
924
+ <img
925
+ src={member.user.avatarUrl}
926
+ alt=""
927
+ className="w-8 h-8 rounded-full"
928
+ />
929
+ ) : (
930
+ <div className="w-8 h-8 rounded-full bg-primary flex items-center justify-center text-white text-sm">
931
+ {(member.user?.name || member.user?.email || '?')[0].toUpperCase()}
932
+ </div>
933
+ )}
934
+ <div>
935
+ <div className="text-text-primary">
936
+ {member.user?.name || member.user?.email}
937
+ {member.userId === user?.id && (
938
+ <span className="ml-2 text-xs text-text-muted">(you)</span>
939
+ )}
940
+ </div>
941
+ {member.user?.name && (
942
+ <div className="text-xs text-text-muted">
943
+ {member.user.email}
944
+ </div>
945
+ )}
946
+ </div>
947
+ </div>
948
+ <div className="flex items-center gap-3">
949
+ {isOwner && member.userId !== user?.id && member.role !== 'owner' ? (
950
+ <select
951
+ value={member.role}
952
+ onChange={(e) => handleUpdateRole(member.userId, e.target.value as TeamRole)}
953
+ className="rounded border border-input-border bg-input-background px-2 py-1 text-sm text-text-primary"
954
+ >
955
+ <option value="admin">Admin</option>
956
+ <option value="member">Member</option>
957
+ <option value="viewer">Viewer</option>
958
+ </select>
959
+ ) : (
960
+ <span className="text-sm text-text-muted capitalize">
961
+ {member.role}
962
+ </span>
963
+ )}
964
+ {isAdmin && member.userId !== user?.id && member.role !== 'owner' && (
965
+ <button
966
+ onClick={() => handleRemoveMember(member.userId)}
967
+ className="text-sm text-error hover:underline"
968
+ >
969
+ Remove
970
+ </button>
971
+ )}
972
+ </div>
973
+ </div>
974
+
975
+ {/* Mobile View */}
976
+ <div className="sm:hidden">
977
+ <div className="flex items-center justify-between">
978
+ <div className="flex items-center gap-3 min-w-0">
979
+ {member.user?.avatarUrl ? (
980
+ <img
981
+ src={member.user.avatarUrl}
982
+ alt=""
983
+ className="w-10 h-10 rounded-full flex-shrink-0"
984
+ />
985
+ ) : (
986
+ <div className="w-10 h-10 rounded-full bg-primary flex items-center justify-center text-white text-sm flex-shrink-0">
987
+ {(member.user?.name || member.user?.email || '?')[0].toUpperCase()}
988
+ </div>
989
+ )}
990
+ <div className="min-w-0">
991
+ <div className="text-sm font-medium text-text-primary truncate">
992
+ {member.user?.name || member.user?.email}
993
+ {member.userId === user?.id && (
994
+ <span className="ml-1 text-xs text-text-muted">(you)</span>
995
+ )}
996
+ </div>
997
+ {member.user?.name && (
998
+ <div className="text-xs text-text-muted truncate">
999
+ {member.user.email}
1000
+ </div>
1001
+ )}
1002
+ </div>
1003
+ </div>
1004
+ <span className="text-xs text-text-muted capitalize flex-shrink-0 ml-2">
1005
+ {member.role}
1006
+ </span>
1007
+ </div>
1008
+ {isOwner && member.userId !== user?.id && member.role !== 'owner' && (
1009
+ <div className="flex items-center gap-2 mt-2 pt-2 border-t border-border">
1010
+ <select
1011
+ value={member.role}
1012
+ onChange={(e) => handleUpdateRole(member.userId, e.target.value as TeamRole)}
1013
+ className="flex-1 rounded border border-input-border bg-input-background px-2 py-1 text-xs text-text-primary"
1014
+ >
1015
+ <option value="admin">Admin</option>
1016
+ <option value="member">Member</option>
1017
+ <option value="viewer">Viewer</option>
1018
+ </select>
1019
+ <button
1020
+ onClick={() => handleRemoveMember(member.userId)}
1021
+ className="text-xs text-error hover:underline"
1022
+ >
1023
+ Remove
1024
+ </button>
1025
+ </div>
1026
+ )}
1027
+ {isAdmin && !isOwner && member.userId !== user?.id && member.role !== 'owner' && (
1028
+ <div className="flex justify-end mt-2 pt-2 border-t border-border">
1029
+ <button
1030
+ onClick={() => handleRemoveMember(member.userId)}
1031
+ className="text-xs text-error hover:underline"
1032
+ >
1033
+ Remove
1034
+ </button>
1035
+ </div>
1036
+ )}
1037
+ </div>
1038
+ </div>
1039
+ ))}
1040
+ </div>
1041
+ </div>
1042
+
1043
+ {/* Danger Zone */}
1044
+ <div className="rounded-lg border border-error bg-error/10 p-4 sm:p-6">
1045
+ <h2 className="text-base sm:text-lg font-semibold text-error mb-4">
1046
+ Danger Zone
1047
+ </h2>
1048
+ <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
1049
+ {isOwner ? (
1050
+ <>
1051
+ <div>
1052
+ <p className="text-sm sm:text-base text-text-primary">Archive this team</p>
1053
+ <p className="text-xs sm:text-sm text-text-muted">
1054
+ Team threads will be preserved but the team will be hidden
1055
+ </p>
1056
+ </div>
1057
+ <button
1058
+ onClick={handleArchiveTeam}
1059
+ className="self-start sm:self-auto rounded-lg bg-error px-4 py-2 text-sm font-medium text-white hover:bg-error/90"
1060
+ >
1061
+ Archive Team
1062
+ </button>
1063
+ </>
1064
+ ) : (
1065
+ <>
1066
+ <div>
1067
+ <p className="text-sm sm:text-base text-text-primary">Leave this team</p>
1068
+ <p className="text-xs sm:text-sm text-text-muted">
1069
+ You will lose access to team threads
1070
+ </p>
1071
+ </div>
1072
+ <button
1073
+ onClick={handleLeaveTeam}
1074
+ className="self-start sm:self-auto rounded-lg bg-error px-4 py-2 text-sm font-medium text-white hover:bg-error/90"
1075
+ >
1076
+ Leave Team
1077
+ </button>
1078
+ </>
1079
+ )}
1080
+ </div>
1081
+ </div>
1082
+ </div>
1083
+ </div>
1084
+ );
1085
+ }