@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.
- package/dist/favicon.svg +11 -0
- package/dist/index.html +17 -0
- package/dist/lib/LoadingSkeletons-IcIC2JPq.js +132 -0
- package/dist/lib/LoadingSkeletons-IcIC2JPq.js.map +1 -0
- package/dist/lib/ServerThemeProvider-DNF0LAyk.js +42 -0
- package/dist/lib/ServerThemeProvider-DNF0LAyk.js.map +1 -0
- package/dist/lib/extensions.js +10 -0
- package/dist/lib/extensions.js.map +1 -0
- package/dist/lib/favicon.svg +11 -0
- package/dist/lib/index.js +74126 -0
- package/dist/lib/index.js.map +1 -0
- package/dist/lib/logo.svg +12 -0
- package/dist/lib/routes/AcceptInviteRoute.js +19 -0
- package/dist/lib/routes/AcceptInviteRoute.js.map +1 -0
- package/dist/lib/routes/AdminDashboardRoute.js +19 -0
- package/dist/lib/routes/AdminDashboardRoute.js.map +1 -0
- package/dist/lib/routes/AdminTeamRoute.js +19 -0
- package/dist/lib/routes/AdminTeamRoute.js.map +1 -0
- package/dist/lib/routes/AdminTeamsRoute.js +19 -0
- package/dist/lib/routes/AdminTeamsRoute.js.map +1 -0
- package/dist/lib/routes/AdminUsersRoute.js +19 -0
- package/dist/lib/routes/AdminUsersRoute.js.map +1 -0
- package/dist/lib/routes/ApiKeysRoute.js +19 -0
- package/dist/lib/routes/ApiKeysRoute.js.map +1 -0
- package/dist/lib/routes/AutomationsRoute.js +19 -0
- package/dist/lib/routes/AutomationsRoute.js.map +1 -0
- package/dist/lib/routes/ChatRoute.js +19 -0
- package/dist/lib/routes/ChatRoute.js.map +1 -0
- package/dist/lib/routes/DocumentsRoute.js +19 -0
- package/dist/lib/routes/DocumentsRoute.js.map +1 -0
- package/dist/lib/routes/OAuthConsentRoute.js +19 -0
- package/dist/lib/routes/OAuthConsentRoute.js.map +1 -0
- package/dist/lib/routes/PricingRoute.js +19 -0
- package/dist/lib/routes/PricingRoute.js.map +1 -0
- package/dist/lib/routes/PrivacyRoute.js +19 -0
- package/dist/lib/routes/PrivacyRoute.js.map +1 -0
- package/dist/lib/routes/TeamSettingsRoute.js +19 -0
- package/dist/lib/routes/TeamSettingsRoute.js.map +1 -0
- package/dist/lib/routes/TermsRoute.js +19 -0
- package/dist/lib/routes/TermsRoute.js.map +1 -0
- package/dist/lib/routes/VerifyEmailRoute.js +19 -0
- package/dist/lib/routes/VerifyEmailRoute.js.map +1 -0
- package/dist/lib/routes.js +79 -0
- package/dist/lib/routes.js.map +1 -0
- package/dist/lib/ssr-utils.js +29 -0
- package/dist/lib/ssr-utils.js.map +1 -0
- package/dist/lib/ssr.js +60 -0
- package/dist/lib/ssr.js.map +1 -0
- package/dist/lib/styles.css +2410 -0
- package/dist/lib/useExtensions-B5nX_8XD.js +155 -0
- package/dist/lib/useExtensions-B5nX_8XD.js.map +1 -0
- package/dist/logo.svg +12 -0
- package/package.json +84 -0
- package/src/components/AgentSelector.tsx +90 -0
- package/src/components/BranchModal.tsx +129 -0
- package/src/components/ClientOnly.tsx +27 -0
- package/src/components/ExportMenu.tsx +122 -0
- package/src/components/LoadingSkeletons.tsx +110 -0
- package/src/components/MCPCredentialsSection.tsx +309 -0
- package/src/components/MentionChip.tsx +149 -0
- package/src/components/MentionDropdown.tsx +175 -0
- package/src/components/MentionInput.tsx +293 -0
- package/src/components/MessageItem.tsx +300 -0
- package/src/components/MessageList.tsx +159 -0
- package/src/components/OAuthAppsSection.tsx +124 -0
- package/src/components/ProjectFolder.tsx +141 -0
- package/src/components/ProjectModal.tsx +296 -0
- package/src/components/SSRMessageList.tsx +153 -0
- package/src/components/SearchModal.tsx +173 -0
- package/src/components/SettingsModal.tsx +412 -0
- package/src/components/ShareModal.tsx +280 -0
- package/src/components/Sidebar.tsx +491 -0
- package/src/components/TeamSwitcher.tsx +273 -0
- package/src/components/ToolCallDisplay.tsx +473 -0
- package/src/components/ToolConfirmationModal.tsx +130 -0
- package/src/components/UsageChart.tsx +177 -0
- package/src/components/content/CodeBlock.tsx +69 -0
- package/src/components/content/MarkdownRenderer.tsx +64 -0
- package/src/components/content/SSRMarkdownRenderer.tsx +158 -0
- package/src/contexts/AuthContext.tsx +119 -0
- package/src/contexts/ConfigContext.tsx +214 -0
- package/src/contexts/ProjectContext.tsx +167 -0
- package/src/contexts/ServerConfigProvider.tsx +41 -0
- package/src/contexts/ServerThemeProvider.tsx +47 -0
- package/src/contexts/TeamContext.tsx +255 -0
- package/src/contexts/ThemeContext.tsx +113 -0
- package/src/extensions/index.ts +15 -0
- package/src/extensions/registry.ts +187 -0
- package/src/extensions/useExtensions.ts +52 -0
- package/src/hooks/useAppPath.ts +34 -0
- package/src/hooks/useBasePath.ts +13 -0
- package/src/hooks/useKeyboardShortcuts.ts +50 -0
- package/src/hooks/useMentionSearch.ts +106 -0
- package/src/index.tsx +116 -0
- package/src/layouts/MainLayout.tsx +98 -0
- package/src/pages/AcceptInvitePage.tsx +175 -0
- package/src/pages/AdminDashboardPage.tsx +362 -0
- package/src/pages/AdminTeamPage.tsx +304 -0
- package/src/pages/AdminTeamsPage.tsx +242 -0
- package/src/pages/AdminUsersPage.tsx +385 -0
- package/src/pages/ApiKeysPage.tsx +449 -0
- package/src/pages/ChatPage.tsx +310 -0
- package/src/pages/DocumentsPage.tsx +577 -0
- package/src/pages/LoginPage.tsx +232 -0
- package/src/pages/OAuthConsentPage.tsx +234 -0
- package/src/pages/PricingPage.tsx +314 -0
- package/src/pages/PrivacyPage.tsx +65 -0
- package/src/pages/RegisterPage.tsx +153 -0
- package/src/pages/ScheduledPromptsPage.tsx +702 -0
- package/src/pages/SharedThreadPage.tsx +116 -0
- package/src/pages/TeamSettingsPage.tsx +1085 -0
- package/src/pages/TermsPage.tsx +82 -0
- package/src/pages/VerifyEmailPage.tsx +202 -0
- package/src/routes/AcceptInviteRoute.tsx +24 -0
- package/src/routes/AdminDashboardRoute.tsx +24 -0
- package/src/routes/AdminTeamRoute.tsx +24 -0
- package/src/routes/AdminTeamsRoute.tsx +24 -0
- package/src/routes/AdminUsersRoute.tsx +24 -0
- package/src/routes/ApiKeysRoute.tsx +24 -0
- package/src/routes/AutomationsRoute.tsx +24 -0
- package/src/routes/ChatRoute.tsx +28 -0
- package/src/routes/DocumentsRoute.tsx +24 -0
- package/src/routes/OAuthConsentRoute.tsx +24 -0
- package/src/routes/PricingRoute.tsx +24 -0
- package/src/routes/PrivacyRoute.tsx +24 -0
- package/src/routes/TeamSettingsRoute.tsx +24 -0
- package/src/routes/TermsRoute.tsx +24 -0
- package/src/routes/VerifyEmailRoute.tsx +24 -0
- package/src/routes/index.ts +57 -0
- package/src/ssr-utils.tsx +84 -0
- package/src/ssr.ts +123 -0
- package/src/stores/chatStore.ts +670 -0
- package/src/styles/index.css +254 -0
- package/src/utils/api.ts +78 -0
- package/src/vite-env.d.ts +13 -0
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback } from 'react';
|
|
2
|
+
import { Link } from 'react-router';
|
|
3
|
+
import { LayoutDashboard, X, Users, Building2 } from 'lucide-react';
|
|
4
|
+
import { useAppPath } from '../hooks/useAppPath';
|
|
5
|
+
import { api } from '../utils/api';
|
|
6
|
+
import type { AdminTeam, AdminTeamsResponse } from '@chaaskit/shared';
|
|
7
|
+
|
|
8
|
+
export default function AdminTeamsPage() {
|
|
9
|
+
const appPath = useAppPath();
|
|
10
|
+
const [teams, setTeams] = useState<AdminTeam[]>([]);
|
|
11
|
+
const [total, setTotal] = useState(0);
|
|
12
|
+
const [page, setPage] = useState(1);
|
|
13
|
+
const [pageSize] = useState(20);
|
|
14
|
+
const [search, setSearch] = useState('');
|
|
15
|
+
const [searchInput, setSearchInput] = useState('');
|
|
16
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
17
|
+
const [error, setError] = useState('');
|
|
18
|
+
|
|
19
|
+
const loadTeams = useCallback(async () => {
|
|
20
|
+
setIsLoading(true);
|
|
21
|
+
setError('');
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
const params = new URLSearchParams({
|
|
25
|
+
page: page.toString(),
|
|
26
|
+
pageSize: pageSize.toString(),
|
|
27
|
+
});
|
|
28
|
+
if (search) {
|
|
29
|
+
params.set('search', search);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const response = await api.get<AdminTeamsResponse>(`/api/admin/teams?${params}`);
|
|
33
|
+
setTeams(response.teams);
|
|
34
|
+
setTotal(response.total);
|
|
35
|
+
} catch (err) {
|
|
36
|
+
setError(err instanceof Error ? err.message : 'Failed to load teams');
|
|
37
|
+
} finally {
|
|
38
|
+
setIsLoading(false);
|
|
39
|
+
}
|
|
40
|
+
}, [page, pageSize, search]);
|
|
41
|
+
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
loadTeams();
|
|
44
|
+
}, [loadTeams]);
|
|
45
|
+
|
|
46
|
+
const handleSearch = (e: React.FormEvent) => {
|
|
47
|
+
e.preventDefault();
|
|
48
|
+
setPage(1);
|
|
49
|
+
setSearch(searchInput);
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const totalPages = Math.ceil(total / pageSize);
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<div className="min-h-screen bg-background p-4 sm:p-8">
|
|
56
|
+
<div className="mx-auto max-w-6xl">
|
|
57
|
+
{/* Header */}
|
|
58
|
+
<div className="flex items-center justify-between mb-4">
|
|
59
|
+
<h1 className="text-xl sm:text-2xl font-bold text-text-primary">
|
|
60
|
+
Admin
|
|
61
|
+
</h1>
|
|
62
|
+
<Link
|
|
63
|
+
to={appPath('/')}
|
|
64
|
+
className="flex items-center justify-center rounded-lg p-2 text-text-muted hover:text-text-primary hover:bg-background-secondary"
|
|
65
|
+
aria-label="Close"
|
|
66
|
+
>
|
|
67
|
+
<X size={20} />
|
|
68
|
+
</Link>
|
|
69
|
+
</div>
|
|
70
|
+
|
|
71
|
+
{/* Tab Navigation */}
|
|
72
|
+
<div className="flex items-center gap-2 mb-6 sm:mb-8">
|
|
73
|
+
<Link
|
|
74
|
+
to={appPath('/admin')}
|
|
75
|
+
className="flex items-center gap-1.5 rounded-full bg-background-secondary px-4 py-2 text-sm font-medium text-text-secondary hover:bg-background-secondary/80"
|
|
76
|
+
>
|
|
77
|
+
<LayoutDashboard size={16} />
|
|
78
|
+
Overview
|
|
79
|
+
</Link>
|
|
80
|
+
<Link
|
|
81
|
+
to={appPath('/admin/users')}
|
|
82
|
+
className="flex items-center gap-1.5 rounded-full bg-background-secondary px-4 py-2 text-sm font-medium text-text-secondary hover:bg-background-secondary/80"
|
|
83
|
+
>
|
|
84
|
+
<Users size={16} />
|
|
85
|
+
Users
|
|
86
|
+
</Link>
|
|
87
|
+
<Link
|
|
88
|
+
to={appPath('/admin/teams')}
|
|
89
|
+
className="flex items-center gap-1.5 rounded-full bg-primary px-4 py-2 text-sm font-medium text-white"
|
|
90
|
+
>
|
|
91
|
+
<Building2 size={16} />
|
|
92
|
+
Teams
|
|
93
|
+
</Link>
|
|
94
|
+
</div>
|
|
95
|
+
|
|
96
|
+
{error && (
|
|
97
|
+
<div className="mb-6 rounded-lg bg-error/10 p-4 text-sm text-error">
|
|
98
|
+
{error}
|
|
99
|
+
</div>
|
|
100
|
+
)}
|
|
101
|
+
|
|
102
|
+
{/* Search */}
|
|
103
|
+
<div className="mb-6 rounded-lg bg-background-secondary p-4">
|
|
104
|
+
<form onSubmit={handleSearch} className="flex gap-2 sm:gap-4">
|
|
105
|
+
<input
|
|
106
|
+
type="text"
|
|
107
|
+
placeholder="Search by team name..."
|
|
108
|
+
value={searchInput}
|
|
109
|
+
onChange={(e) => setSearchInput(e.target.value)}
|
|
110
|
+
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"
|
|
111
|
+
/>
|
|
112
|
+
<button
|
|
113
|
+
type="submit"
|
|
114
|
+
className="rounded-lg bg-primary px-3 sm:px-4 py-2 font-medium text-white hover:bg-primary-hover"
|
|
115
|
+
>
|
|
116
|
+
Search
|
|
117
|
+
</button>
|
|
118
|
+
</form>
|
|
119
|
+
</div>
|
|
120
|
+
|
|
121
|
+
{/* Teams List */}
|
|
122
|
+
<div className="rounded-lg bg-background-secondary overflow-hidden">
|
|
123
|
+
{/* Header - Hidden on mobile */}
|
|
124
|
+
<div className="hidden sm:block px-4 py-3 bg-background">
|
|
125
|
+
<div className="grid grid-cols-12 gap-4 text-sm font-medium text-text-muted">
|
|
126
|
+
<div className="col-span-4">Team</div>
|
|
127
|
+
<div className="col-span-2">Members</div>
|
|
128
|
+
<div className="col-span-2">Threads</div>
|
|
129
|
+
<div className="col-span-4">Created</div>
|
|
130
|
+
</div>
|
|
131
|
+
</div>
|
|
132
|
+
|
|
133
|
+
{/* Body */}
|
|
134
|
+
<div className="divide-y divide-background">
|
|
135
|
+
{isLoading ? (
|
|
136
|
+
<div className="px-4 py-8 text-center text-text-muted">
|
|
137
|
+
Loading...
|
|
138
|
+
</div>
|
|
139
|
+
) : teams.length === 0 ? (
|
|
140
|
+
<div className="px-4 py-8 text-center text-text-muted">
|
|
141
|
+
No teams found
|
|
142
|
+
</div>
|
|
143
|
+
) : (
|
|
144
|
+
teams.map((team) => (
|
|
145
|
+
<Link
|
|
146
|
+
key={team.id}
|
|
147
|
+
to={`/admin/teams/${team.id}`}
|
|
148
|
+
className="block px-4 py-3 hover:bg-background/50"
|
|
149
|
+
>
|
|
150
|
+
{/* Desktop View */}
|
|
151
|
+
<div className="hidden sm:grid grid-cols-12 gap-4 items-center">
|
|
152
|
+
{/* Team Name */}
|
|
153
|
+
<div className="col-span-4">
|
|
154
|
+
<div className="flex items-center gap-3">
|
|
155
|
+
<div className="w-8 h-8 rounded-full bg-primary flex items-center justify-center text-white text-sm font-medium">
|
|
156
|
+
{team.name[0].toUpperCase()}
|
|
157
|
+
</div>
|
|
158
|
+
<div>
|
|
159
|
+
<div className="text-sm font-medium text-text-primary">
|
|
160
|
+
{team.name}
|
|
161
|
+
</div>
|
|
162
|
+
{team.archivedAt && (
|
|
163
|
+
<span className="text-xs text-text-muted">
|
|
164
|
+
(archived)
|
|
165
|
+
</span>
|
|
166
|
+
)}
|
|
167
|
+
</div>
|
|
168
|
+
</div>
|
|
169
|
+
</div>
|
|
170
|
+
|
|
171
|
+
{/* Members */}
|
|
172
|
+
<div className="col-span-2 text-sm text-text-primary">
|
|
173
|
+
{team.memberCount}
|
|
174
|
+
</div>
|
|
175
|
+
|
|
176
|
+
{/* Threads */}
|
|
177
|
+
<div className="col-span-2 text-sm text-text-primary">
|
|
178
|
+
{team.threadCount}
|
|
179
|
+
</div>
|
|
180
|
+
|
|
181
|
+
{/* Created */}
|
|
182
|
+
<div className="col-span-4 text-sm text-text-muted">
|
|
183
|
+
{new Date(team.createdAt).toLocaleDateString()}
|
|
184
|
+
</div>
|
|
185
|
+
</div>
|
|
186
|
+
|
|
187
|
+
{/* Mobile View */}
|
|
188
|
+
<div className="sm:hidden">
|
|
189
|
+
<div className="flex items-center justify-between">
|
|
190
|
+
<div className="flex items-center gap-3">
|
|
191
|
+
<div className="w-10 h-10 rounded-full bg-primary flex items-center justify-center text-white text-sm font-medium">
|
|
192
|
+
{team.name[0].toUpperCase()}
|
|
193
|
+
</div>
|
|
194
|
+
<div>
|
|
195
|
+
<div className="text-sm font-medium text-text-primary">
|
|
196
|
+
{team.name}
|
|
197
|
+
</div>
|
|
198
|
+
<div className="text-xs text-text-muted">
|
|
199
|
+
{team.memberCount} members · {team.threadCount} threads
|
|
200
|
+
{team.archivedAt && ' · archived'}
|
|
201
|
+
</div>
|
|
202
|
+
</div>
|
|
203
|
+
</div>
|
|
204
|
+
<div className="text-xs text-text-muted">
|
|
205
|
+
{new Date(team.createdAt).toLocaleDateString()}
|
|
206
|
+
</div>
|
|
207
|
+
</div>
|
|
208
|
+
</div>
|
|
209
|
+
</Link>
|
|
210
|
+
))
|
|
211
|
+
)}
|
|
212
|
+
</div>
|
|
213
|
+
|
|
214
|
+
{/* Pagination */}
|
|
215
|
+
{totalPages > 1 && (
|
|
216
|
+
<div className="flex items-center justify-between px-4 py-3 bg-background">
|
|
217
|
+
<div className="text-xs sm:text-sm text-text-muted">
|
|
218
|
+
{(page - 1) * pageSize + 1}-{Math.min(page * pageSize, total)} of {total}
|
|
219
|
+
</div>
|
|
220
|
+
<div className="flex gap-2">
|
|
221
|
+
<button
|
|
222
|
+
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
|
223
|
+
disabled={page === 1}
|
|
224
|
+
className="rounded bg-background-secondary px-3 py-1 text-sm text-text-primary hover:bg-background-secondary/80 disabled:opacity-50"
|
|
225
|
+
>
|
|
226
|
+
Prev
|
|
227
|
+
</button>
|
|
228
|
+
<button
|
|
229
|
+
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
|
230
|
+
disabled={page === totalPages}
|
|
231
|
+
className="rounded bg-background-secondary px-3 py-1 text-sm text-text-primary hover:bg-background-secondary/80 disabled:opacity-50"
|
|
232
|
+
>
|
|
233
|
+
Next
|
|
234
|
+
</button>
|
|
235
|
+
</div>
|
|
236
|
+
</div>
|
|
237
|
+
)}
|
|
238
|
+
</div>
|
|
239
|
+
</div>
|
|
240
|
+
</div>
|
|
241
|
+
);
|
|
242
|
+
}
|
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback } from 'react';
|
|
2
|
+
import { Link } from 'react-router';
|
|
3
|
+
import { LayoutDashboard, X, Users, Building2 } from 'lucide-react';
|
|
4
|
+
import { useConfig } from '../contexts/ConfigContext';
|
|
5
|
+
import { useAppPath } from '../hooks/useAppPath';
|
|
6
|
+
import { api } from '../utils/api';
|
|
7
|
+
import { useAuth } from '../contexts/AuthContext';
|
|
8
|
+
import type { AdminUser, AdminUsersResponse } from '@chaaskit/shared';
|
|
9
|
+
|
|
10
|
+
export default function AdminUsersPage() {
|
|
11
|
+
const { user: currentUser } = useAuth();
|
|
12
|
+
const config = useConfig();
|
|
13
|
+
const appPath = useAppPath();
|
|
14
|
+
const [users, setUsers] = useState<AdminUser[]>([]);
|
|
15
|
+
const [total, setTotal] = useState(0);
|
|
16
|
+
const [page, setPage] = useState(1);
|
|
17
|
+
const [pageSize] = useState(20);
|
|
18
|
+
const [search, setSearch] = useState('');
|
|
19
|
+
const [searchInput, setSearchInput] = useState('');
|
|
20
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
21
|
+
const [error, setError] = useState('');
|
|
22
|
+
const [success, setSuccess] = useState('');
|
|
23
|
+
|
|
24
|
+
const loadUsers = useCallback(async () => {
|
|
25
|
+
setIsLoading(true);
|
|
26
|
+
setError('');
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
const params = new URLSearchParams({
|
|
30
|
+
page: page.toString(),
|
|
31
|
+
pageSize: pageSize.toString(),
|
|
32
|
+
});
|
|
33
|
+
if (search) {
|
|
34
|
+
params.set('search', search);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const response = await api.get<AdminUsersResponse>(`/api/admin/users?${params}`);
|
|
38
|
+
setUsers(response.users);
|
|
39
|
+
setTotal(response.total);
|
|
40
|
+
} catch (err) {
|
|
41
|
+
setError(err instanceof Error ? err.message : 'Failed to load users');
|
|
42
|
+
} finally {
|
|
43
|
+
setIsLoading(false);
|
|
44
|
+
}
|
|
45
|
+
}, [page, pageSize, search]);
|
|
46
|
+
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
loadUsers();
|
|
49
|
+
}, [loadUsers]);
|
|
50
|
+
|
|
51
|
+
const handleSearch = (e: React.FormEvent) => {
|
|
52
|
+
e.preventDefault();
|
|
53
|
+
setPage(1);
|
|
54
|
+
setSearch(searchInput);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const handleToggleAdmin = async (userId: string, currentIsAdmin: boolean) => {
|
|
58
|
+
if (userId === currentUser?.id) {
|
|
59
|
+
setError('Cannot modify your own admin status');
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const action = currentIsAdmin ? 'remove admin status from' : 'make';
|
|
64
|
+
if (!confirm(`Are you sure you want to ${action} this user ${currentIsAdmin ? '' : 'an admin'}?`)) {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
setError('');
|
|
69
|
+
setSuccess('');
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
await api.patch(`/api/admin/users/${userId}`, { isAdmin: !currentIsAdmin });
|
|
73
|
+
setSuccess(`User ${currentIsAdmin ? 'demoted' : 'promoted'} successfully`);
|
|
74
|
+
loadUsers();
|
|
75
|
+
} catch (err) {
|
|
76
|
+
setError(err instanceof Error ? err.message : 'Failed to update user');
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const handleChangePlan = async (userId: string, plan: string) => {
|
|
81
|
+
setError('');
|
|
82
|
+
setSuccess('');
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
await api.patch(`/api/admin/users/${userId}`, { plan });
|
|
86
|
+
setSuccess('Plan updated successfully');
|
|
87
|
+
loadUsers();
|
|
88
|
+
} catch (err) {
|
|
89
|
+
setError(err instanceof Error ? err.message : 'Failed to update plan');
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const totalPages = Math.ceil(total / pageSize);
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<div className="min-h-screen bg-background p-4 sm:p-8">
|
|
97
|
+
<div className="mx-auto max-w-6xl">
|
|
98
|
+
{/* Header */}
|
|
99
|
+
<div className="flex items-center justify-between mb-4">
|
|
100
|
+
<h1 className="text-xl sm:text-2xl font-bold text-text-primary">
|
|
101
|
+
Admin
|
|
102
|
+
</h1>
|
|
103
|
+
<Link
|
|
104
|
+
to={appPath('/')}
|
|
105
|
+
className="flex items-center justify-center rounded-lg p-2 text-text-muted hover:text-text-primary hover:bg-background-secondary"
|
|
106
|
+
aria-label="Close"
|
|
107
|
+
>
|
|
108
|
+
<X size={20} />
|
|
109
|
+
</Link>
|
|
110
|
+
</div>
|
|
111
|
+
|
|
112
|
+
{/* Tab Navigation */}
|
|
113
|
+
<div className="flex items-center gap-2 mb-6 sm:mb-8">
|
|
114
|
+
<Link
|
|
115
|
+
to={appPath('/admin')}
|
|
116
|
+
className="flex items-center gap-1.5 rounded-full bg-background-secondary px-4 py-2 text-sm font-medium text-text-secondary hover:bg-background-secondary/80"
|
|
117
|
+
>
|
|
118
|
+
<LayoutDashboard size={16} />
|
|
119
|
+
Overview
|
|
120
|
+
</Link>
|
|
121
|
+
<Link
|
|
122
|
+
to={appPath('/admin/users')}
|
|
123
|
+
className="flex items-center gap-1.5 rounded-full bg-primary px-4 py-2 text-sm font-medium text-white"
|
|
124
|
+
>
|
|
125
|
+
<Users size={16} />
|
|
126
|
+
Users
|
|
127
|
+
</Link>
|
|
128
|
+
{config.teams?.enabled && (
|
|
129
|
+
<Link
|
|
130
|
+
to={appPath('/admin/teams')}
|
|
131
|
+
className="flex items-center gap-1.5 rounded-full bg-background-secondary px-4 py-2 text-sm font-medium text-text-secondary hover:bg-background-secondary/80"
|
|
132
|
+
>
|
|
133
|
+
<Building2 size={16} />
|
|
134
|
+
Teams
|
|
135
|
+
</Link>
|
|
136
|
+
)}
|
|
137
|
+
</div>
|
|
138
|
+
|
|
139
|
+
{error && (
|
|
140
|
+
<div className="mb-6 rounded-lg bg-error/10 p-4 text-sm text-error">
|
|
141
|
+
{error}
|
|
142
|
+
</div>
|
|
143
|
+
)}
|
|
144
|
+
|
|
145
|
+
{success && (
|
|
146
|
+
<div className="mb-6 rounded-lg bg-success/10 p-4 text-sm text-success">
|
|
147
|
+
{success}
|
|
148
|
+
</div>
|
|
149
|
+
)}
|
|
150
|
+
|
|
151
|
+
{/* Search */}
|
|
152
|
+
<div className="mb-6 rounded-lg bg-background-secondary p-4">
|
|
153
|
+
<form onSubmit={handleSearch} className="flex gap-4">
|
|
154
|
+
<input
|
|
155
|
+
type="text"
|
|
156
|
+
placeholder="Search by email or name..."
|
|
157
|
+
value={searchInput}
|
|
158
|
+
onChange={(e) => setSearchInput(e.target.value)}
|
|
159
|
+
className="flex-1 rounded-lg border border-input-border bg-input-background px-4 py-2 text-text-primary focus:border-primary focus:outline-none"
|
|
160
|
+
/>
|
|
161
|
+
<button
|
|
162
|
+
type="submit"
|
|
163
|
+
className="rounded-lg bg-primary px-4 py-2 font-medium text-white hover:bg-primary-hover"
|
|
164
|
+
>
|
|
165
|
+
Search
|
|
166
|
+
</button>
|
|
167
|
+
</form>
|
|
168
|
+
</div>
|
|
169
|
+
|
|
170
|
+
{/* Users List */}
|
|
171
|
+
<div className="rounded-lg bg-background-secondary overflow-hidden">
|
|
172
|
+
{/* Header - Hidden on mobile */}
|
|
173
|
+
<div className="hidden md:block px-4 py-3 bg-background">
|
|
174
|
+
<div className="grid grid-cols-12 gap-4 text-sm font-medium text-text-muted">
|
|
175
|
+
<div className="col-span-3">User</div>
|
|
176
|
+
<div className="col-span-1">Plan</div>
|
|
177
|
+
<div className="col-span-1">Messages</div>
|
|
178
|
+
<div className="col-span-3">Teams</div>
|
|
179
|
+
<div className="col-span-1">Admin</div>
|
|
180
|
+
<div className="col-span-3">Joined</div>
|
|
181
|
+
</div>
|
|
182
|
+
</div>
|
|
183
|
+
|
|
184
|
+
{/* Body */}
|
|
185
|
+
<div className="divide-y divide-background">
|
|
186
|
+
{isLoading ? (
|
|
187
|
+
<div className="px-4 py-8 text-center text-text-muted">
|
|
188
|
+
Loading...
|
|
189
|
+
</div>
|
|
190
|
+
) : users.length === 0 ? (
|
|
191
|
+
<div className="px-4 py-8 text-center text-text-muted">
|
|
192
|
+
No users found
|
|
193
|
+
</div>
|
|
194
|
+
) : (
|
|
195
|
+
users.map((user) => (
|
|
196
|
+
<div
|
|
197
|
+
key={user.id}
|
|
198
|
+
className="px-4 py-3 hover:bg-background/50"
|
|
199
|
+
>
|
|
200
|
+
{/* Desktop View */}
|
|
201
|
+
<div className="hidden md:grid grid-cols-12 gap-4 items-center">
|
|
202
|
+
{/* User Info */}
|
|
203
|
+
<div className="col-span-3 flex items-center gap-3">
|
|
204
|
+
{user.avatarUrl ? (
|
|
205
|
+
<img
|
|
206
|
+
src={user.avatarUrl}
|
|
207
|
+
alt=""
|
|
208
|
+
className="w-8 h-8 rounded-full"
|
|
209
|
+
/>
|
|
210
|
+
) : (
|
|
211
|
+
<div className="w-8 h-8 rounded-full bg-primary flex items-center justify-center text-white text-sm font-medium">
|
|
212
|
+
{(user.name || user.email)[0].toUpperCase()}
|
|
213
|
+
</div>
|
|
214
|
+
)}
|
|
215
|
+
<div className="min-w-0">
|
|
216
|
+
<div className="text-sm text-text-primary truncate">
|
|
217
|
+
{user.name || '-'}
|
|
218
|
+
{user.id === currentUser?.id && (
|
|
219
|
+
<span className="ml-2 text-xs text-text-muted">(you)</span>
|
|
220
|
+
)}
|
|
221
|
+
</div>
|
|
222
|
+
<div className="text-xs text-text-muted truncate">
|
|
223
|
+
{user.email}
|
|
224
|
+
{user.oauthProvider && (
|
|
225
|
+
<span className="ml-1 capitalize">({user.oauthProvider})</span>
|
|
226
|
+
)}
|
|
227
|
+
</div>
|
|
228
|
+
</div>
|
|
229
|
+
</div>
|
|
230
|
+
|
|
231
|
+
{/* Plan */}
|
|
232
|
+
<div className="col-span-1">
|
|
233
|
+
<select
|
|
234
|
+
value={user.plan}
|
|
235
|
+
onChange={(e) => handleChangePlan(user.id, e.target.value)}
|
|
236
|
+
className="w-full rounded border border-input-border bg-input-background px-2 py-1 text-xs text-text-primary"
|
|
237
|
+
>
|
|
238
|
+
<option value="free">Free</option>
|
|
239
|
+
<option value="basic">Basic</option>
|
|
240
|
+
<option value="pro">Pro</option>
|
|
241
|
+
<option value="enterprise">Enterprise</option>
|
|
242
|
+
</select>
|
|
243
|
+
</div>
|
|
244
|
+
|
|
245
|
+
{/* Messages */}
|
|
246
|
+
<div className="col-span-1 text-sm text-text-primary">
|
|
247
|
+
{user.messagesThisMonth}
|
|
248
|
+
</div>
|
|
249
|
+
|
|
250
|
+
{/* Teams */}
|
|
251
|
+
<div className="col-span-3">
|
|
252
|
+
{user.teams && user.teams.length > 0 ? (
|
|
253
|
+
<div className="flex flex-wrap gap-1">
|
|
254
|
+
{user.teams.map((team) => (
|
|
255
|
+
<Link
|
|
256
|
+
key={team.id}
|
|
257
|
+
to={`/admin/teams/${team.id}`}
|
|
258
|
+
className="inline-flex items-center rounded-full bg-background px-2 py-0.5 text-xs text-text-secondary hover:bg-primary/10 hover:text-primary"
|
|
259
|
+
title={`Role: ${team.role}`}
|
|
260
|
+
>
|
|
261
|
+
{team.name}
|
|
262
|
+
<span className="ml-1 text-text-muted">
|
|
263
|
+
({team.role})
|
|
264
|
+
</span>
|
|
265
|
+
</Link>
|
|
266
|
+
))}
|
|
267
|
+
</div>
|
|
268
|
+
) : (
|
|
269
|
+
<span className="text-xs text-text-muted">—</span>
|
|
270
|
+
)}
|
|
271
|
+
</div>
|
|
272
|
+
|
|
273
|
+
{/* Admin */}
|
|
274
|
+
<div className="col-span-1">
|
|
275
|
+
<button
|
|
276
|
+
onClick={() => handleToggleAdmin(user.id, user.isAdmin)}
|
|
277
|
+
disabled={user.id === currentUser?.id}
|
|
278
|
+
className={`px-2 py-1 rounded text-xs font-medium ${
|
|
279
|
+
user.isAdmin
|
|
280
|
+
? 'bg-primary/10 text-primary'
|
|
281
|
+
: 'bg-background text-text-muted'
|
|
282
|
+
} ${user.id === currentUser?.id ? 'cursor-not-allowed opacity-50' : 'hover:opacity-80'}`}
|
|
283
|
+
>
|
|
284
|
+
{user.isAdmin ? 'Admin' : 'User'}
|
|
285
|
+
</button>
|
|
286
|
+
</div>
|
|
287
|
+
|
|
288
|
+
{/* Joined */}
|
|
289
|
+
<div className="col-span-3 text-sm text-text-muted">
|
|
290
|
+
{new Date(user.createdAt).toLocaleDateString()}
|
|
291
|
+
</div>
|
|
292
|
+
</div>
|
|
293
|
+
|
|
294
|
+
{/* Mobile View - Card layout */}
|
|
295
|
+
<div className="md:hidden space-y-3">
|
|
296
|
+
<div className="flex items-center justify-between">
|
|
297
|
+
<div className="flex items-center gap-3">
|
|
298
|
+
{user.avatarUrl ? (
|
|
299
|
+
<img
|
|
300
|
+
src={user.avatarUrl}
|
|
301
|
+
alt=""
|
|
302
|
+
className="w-10 h-10 rounded-full"
|
|
303
|
+
/>
|
|
304
|
+
) : (
|
|
305
|
+
<div className="w-10 h-10 rounded-full bg-primary flex items-center justify-center text-white text-sm font-medium">
|
|
306
|
+
{(user.name || user.email)[0].toUpperCase()}
|
|
307
|
+
</div>
|
|
308
|
+
)}
|
|
309
|
+
<div className="min-w-0">
|
|
310
|
+
<div className="text-sm font-medium text-text-primary truncate">
|
|
311
|
+
{user.name || '-'}
|
|
312
|
+
{user.id === currentUser?.id && (
|
|
313
|
+
<span className="ml-2 text-xs text-text-muted">(you)</span>
|
|
314
|
+
)}
|
|
315
|
+
</div>
|
|
316
|
+
<div className="text-xs text-text-muted truncate">
|
|
317
|
+
{user.email}
|
|
318
|
+
</div>
|
|
319
|
+
</div>
|
|
320
|
+
</div>
|
|
321
|
+
<button
|
|
322
|
+
onClick={() => handleToggleAdmin(user.id, user.isAdmin)}
|
|
323
|
+
disabled={user.id === currentUser?.id}
|
|
324
|
+
className={`px-2 py-1 rounded text-xs font-medium ${
|
|
325
|
+
user.isAdmin
|
|
326
|
+
? 'bg-primary/10 text-primary'
|
|
327
|
+
: 'bg-background text-text-muted'
|
|
328
|
+
} ${user.id === currentUser?.id ? 'cursor-not-allowed opacity-50' : ''}`}
|
|
329
|
+
>
|
|
330
|
+
{user.isAdmin ? 'Admin' : 'User'}
|
|
331
|
+
</button>
|
|
332
|
+
</div>
|
|
333
|
+
<div className="flex items-center justify-between text-xs">
|
|
334
|
+
<div className="flex items-center gap-3">
|
|
335
|
+
<select
|
|
336
|
+
value={user.plan}
|
|
337
|
+
onChange={(e) => handleChangePlan(user.id, e.target.value)}
|
|
338
|
+
className="rounded border border-input-border bg-input-background px-2 py-1 text-xs text-text-primary"
|
|
339
|
+
>
|
|
340
|
+
<option value="free">Free</option>
|
|
341
|
+
<option value="basic">Basic</option>
|
|
342
|
+
<option value="pro">Pro</option>
|
|
343
|
+
<option value="enterprise">Enterprise</option>
|
|
344
|
+
</select>
|
|
345
|
+
<span className="text-text-muted">{user.messagesThisMonth} msgs</span>
|
|
346
|
+
</div>
|
|
347
|
+
<span className="text-text-muted">
|
|
348
|
+
{new Date(user.createdAt).toLocaleDateString()}
|
|
349
|
+
</span>
|
|
350
|
+
</div>
|
|
351
|
+
</div>
|
|
352
|
+
</div>
|
|
353
|
+
))
|
|
354
|
+
)}
|
|
355
|
+
</div>
|
|
356
|
+
|
|
357
|
+
{/* Pagination */}
|
|
358
|
+
{totalPages > 1 && (
|
|
359
|
+
<div className="flex items-center justify-between px-4 py-3 bg-background">
|
|
360
|
+
<div className="text-sm text-text-muted">
|
|
361
|
+
Showing {(page - 1) * pageSize + 1} to {Math.min(page * pageSize, total)} of {total} users
|
|
362
|
+
</div>
|
|
363
|
+
<div className="flex gap-2">
|
|
364
|
+
<button
|
|
365
|
+
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
|
366
|
+
disabled={page === 1}
|
|
367
|
+
className="rounded bg-background-secondary px-3 py-1 text-sm text-text-primary hover:bg-background-secondary/80 disabled:opacity-50"
|
|
368
|
+
>
|
|
369
|
+
Previous
|
|
370
|
+
</button>
|
|
371
|
+
<button
|
|
372
|
+
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
|
373
|
+
disabled={page === totalPages}
|
|
374
|
+
className="rounded bg-background-secondary px-3 py-1 text-sm text-text-primary hover:bg-background-secondary/80 disabled:opacity-50"
|
|
375
|
+
>
|
|
376
|
+
Next
|
|
377
|
+
</button>
|
|
378
|
+
</div>
|
|
379
|
+
</div>
|
|
380
|
+
)}
|
|
381
|
+
</div>
|
|
382
|
+
</div>
|
|
383
|
+
</div>
|
|
384
|
+
);
|
|
385
|
+
}
|