@promptbook/cli 0.112.0-138 → 0.112.0-140

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 (56) hide show
  1. package/README.md +1 -1
  2. package/apps/agents-server/README.md +9 -0
  3. package/apps/agents-server/src/app/actions.ts +18 -7
  4. package/apps/agents-server/src/app/admin/login-methods/shibboleth/page.tsx +7 -17
  5. package/apps/agents-server/src/app/admin/metadata/MetadataClient.tsx +395 -30
  6. package/apps/agents-server/src/app/api/agents/export/route.ts +28 -2
  7. package/apps/agents-server/src/app/api/auth/change-password/route.ts +53 -6
  8. package/apps/agents-server/src/app/api/auth/login/route.ts +17 -8
  9. package/apps/agents-server/src/app/api/chat/export/pdf/route.ts +167 -2
  10. package/apps/agents-server/src/app/api/emails/incoming/sendgrid/route.ts +24 -2
  11. package/apps/agents-server/src/app/api/metadata/import/route.ts +2 -1
  12. package/apps/agents-server/src/app/api/users/[username]/route.ts +3 -2
  13. package/apps/agents-server/src/app/api/users/route.ts +5 -4
  14. package/apps/agents-server/src/components/Homepage/AgentsList.tsx +1 -13
  15. package/apps/agents-server/src/components/Homepage/AgentsListHeader.tsx +3 -19
  16. package/apps/agents-server/src/components/Homepage/AgentsListListView.tsx +6 -0
  17. package/apps/agents-server/src/components/Homepage/AgentsListViewContent.tsx +6 -0
  18. package/apps/agents-server/src/components/Homepage/SortableFolderCard.tsx +7 -1
  19. package/apps/agents-server/src/components/Homepage/useAgentsListImportExportState.ts +45 -43
  20. package/apps/agents-server/src/components/Homepage/useAgentsListState.ts +1 -3
  21. package/apps/agents-server/src/components/UsersList/useUsersAdmin.ts +2 -10
  22. package/apps/agents-server/src/database/seedCoreAgents.ts +8 -7
  23. package/apps/agents-server/src/message-providers/email/sendgrid/verifySendgridInboundParseWebhook.ts +345 -0
  24. package/apps/agents-server/src/utils/agentsTransfer/createAgentsExportZipStream.ts +15 -2
  25. package/apps/agents-server/src/utils/authenticateUser.ts +110 -3
  26. package/apps/agents-server/src/utils/authenticationAttemptRateLimit.ts +504 -0
  27. package/apps/agents-server/src/utils/backup/createBooksBackupZipStream.ts +76 -5
  28. package/apps/agents-server/src/utils/chatExport/renderHtmlToPdfOnServer.ts +32 -0
  29. package/apps/agents-server/src/utils/chatExport/sanitizeChatPdfExportHtml.ts +193 -0
  30. package/apps/agents-server/src/utils/currentUserIdentity.ts +22 -9
  31. package/apps/agents-server/src/utils/externalChatRunner/processExternalUserChatJob.ts +5 -0
  32. package/apps/agents-server/src/utils/getUserById.ts +19 -5
  33. package/apps/agents-server/src/utils/localChatRunner/processLocalUserChatJob.ts +5 -0
  34. package/apps/agents-server/src/utils/metadataConfigurationTransfer.ts +32 -16
  35. package/apps/agents-server/src/utils/publicUser.ts +59 -0
  36. package/apps/agents-server/src/utils/shibbolethAuthentication.ts +17 -9
  37. package/apps/agents-server/src/utils/userChat/createRunUserChatJobPersistenceController.ts +17 -11
  38. package/apps/agents-server/src/utils/userChat/createUserChatHarnessProgressCard.ts +99 -0
  39. package/apps/agents-server/src/utils/userChat/createUserChatRunnerProgressCard.ts +63 -0
  40. package/apps/agents-server/src/utils/userChat/userChatMessageLifecycle.ts +0 -3
  41. package/apps/agents-server/src/utils/vpsConfiguration.ts +3 -0
  42. package/esm/index.es.js +14 -5
  43. package/esm/index.es.js.map +1 -1
  44. package/esm/src/book-components/Chat/utils/isVisibleChatToolCall.d.ts +10 -0
  45. package/esm/src/version.d.ts +1 -1
  46. package/package.json +1 -1
  47. package/src/book-components/Chat/Chat/createChatMessageToolCallRenderModel.ts +2 -9
  48. package/src/book-components/Chat/utils/isVisibleChatToolCall.ts +23 -0
  49. package/src/other/templates/getTemplatesPipelineCollection.ts +805 -736
  50. package/src/version.ts +2 -2
  51. package/src/versions.txt +2 -1
  52. package/umd/index.umd.js +14 -5
  53. package/umd/index.umd.js.map +1 -1
  54. package/umd/src/book-components/Chat/utils/isVisibleChatToolCall.d.ts +10 -0
  55. package/umd/src/version.d.ts +1 -1
  56. package/apps/agents-server/src/app/api/long-streaming/route.ts +0 -23
@@ -1,14 +1,32 @@
1
- import { NextResponse } from 'next/server';
1
+ import { NextRequest, NextResponse } from 'next/server';
2
2
  import { $getTableName } from '../../../../database/$getTableName';
3
3
  import { $provideSupabaseForServer } from '../../../../database/$provideSupabaseForServer';
4
- import { AgentsServerDatabase } from '../../../../database/schema';
4
+ import type { AgentsServerDatabase } from '../../../../database/schema';
5
5
  import { getPasswordValidationMessage, hashPassword, verifyPassword } from '../../../../utils/auth';
6
+ import {
7
+ AUTHENTICATION_ATTEMPT_PURPOSES,
8
+ checkAuthenticationAttemptRateLimit,
9
+ createAuthenticationAttemptRateLimitResponse,
10
+ recordAuthenticationAttempt,
11
+ recordRateLimitedAuthenticationAttempt,
12
+ resolveAuthenticationAttemptRequestIp,
13
+ } from '../../../../utils/authenticationAttemptRateLimit';
6
14
  import { getCurrentUser } from '../../../../utils/getCurrentUser';
7
15
 
16
+ /**
17
+ * User row shape needed to verify and update a password change.
18
+ */
19
+ type ChangePasswordUserRow = Pick<AgentsServerDatabase['public']['Tables']['User']['Row'], 'id' | 'passwordHash'>;
20
+
21
+ /**
22
+ * Supabase projection for user fields needed by password changes.
23
+ */
24
+ const CHANGE_PASSWORD_USER_SELECT_COLUMNS = 'id, passwordHash';
25
+
8
26
  /**
9
27
  * Handles post.
10
28
  */
11
- export async function POST(request: Request) {
29
+ export async function POST(request: NextRequest) {
12
30
  try {
13
31
  const user = await getCurrentUser();
14
32
  if (!user) {
@@ -18,7 +36,12 @@ export async function POST(request: Request) {
18
36
  const body = await request.json();
19
37
  const { currentPassword, newPassword } = body;
20
38
 
21
- if (!currentPassword || !newPassword) {
39
+ if (
40
+ typeof currentPassword !== 'string' ||
41
+ typeof newPassword !== 'string' ||
42
+ !currentPassword ||
43
+ !newPassword
44
+ ) {
22
45
  return NextResponse.json({ error: 'Missing password fields' }, { status: 400 });
23
46
  }
24
47
 
@@ -34,10 +57,27 @@ export async function POST(request: Request) {
34
57
  );
35
58
  }
36
59
 
60
+ const requestIp = resolveAuthenticationAttemptRequestIp(request);
61
+ const rateLimitDecision = checkAuthenticationAttemptRateLimit({
62
+ requestIp,
63
+ username: user.username,
64
+ });
65
+
66
+ if (!rateLimitDecision.isAllowed) {
67
+ recordRateLimitedAuthenticationAttempt({
68
+ requestIp,
69
+ username: user.username,
70
+ purpose: AUTHENTICATION_ATTEMPT_PURPOSES.CHANGE_PASSWORD,
71
+ rejection: rateLimitDecision,
72
+ });
73
+
74
+ return createAuthenticationAttemptRateLimitResponse(rateLimitDecision);
75
+ }
76
+
37
77
  const supabase = $provideSupabaseForServer();
38
78
  const { data: userData, error: fetchError } = await supabase
39
79
  .from(await $getTableName('User'))
40
- .select('*')
80
+ .select(CHANGE_PASSWORD_USER_SELECT_COLUMNS)
41
81
  .eq('username', user.username)
42
82
  .single();
43
83
 
@@ -45,10 +85,17 @@ export async function POST(request: Request) {
45
85
  return NextResponse.json({ error: 'User not found' }, { status: 404 });
46
86
  }
47
87
 
48
- const userRow = userData as AgentsServerDatabase['public']['Tables']['User']['Row'];
88
+ const userRow = userData as ChangePasswordUserRow;
49
89
 
50
90
  // Verify current password
51
91
  const isValid = await verifyPassword(currentPassword, userRow.passwordHash);
92
+ recordAuthenticationAttempt({
93
+ requestIp,
94
+ username: user.username,
95
+ purpose: AUTHENTICATION_ATTEMPT_PURPOSES.CHANGE_PASSWORD,
96
+ isSuccessful: isValid,
97
+ });
98
+
52
99
  if (!isValid) {
53
100
  return NextResponse.json({ error: 'Invalid current password' }, { status: 401 });
54
101
  }
@@ -1,28 +1,37 @@
1
- import { authenticateUser } from '../../../../utils/authenticateUser';
1
+ import { authenticateUserWithRateLimit } from '../../../../utils/authenticateUser';
2
+ import {
3
+ createAuthenticationAttemptRateLimitResponse,
4
+ resolveAuthenticationAttemptRequestIp,
5
+ } from '../../../../utils/authenticationAttemptRateLimit';
2
6
  import { setSession } from '../../../../utils/session';
3
- import { NextResponse } from 'next/server';
7
+ import { NextRequest, NextResponse } from 'next/server';
4
8
 
5
9
  /**
6
10
  * Handles post.
7
11
  */
8
- export async function POST(request: Request) {
12
+ export async function POST(request: NextRequest) {
9
13
  try {
10
14
  const body = await request.json();
11
15
  const { username, password } = body;
12
16
 
13
- if (!username || !password) {
17
+ if (typeof username !== 'string' || typeof password !== 'string' || !username || !password) {
14
18
  return NextResponse.json({ error: 'Username and password are required' }, { status: 400 });
15
19
  }
16
20
 
17
- const user = await authenticateUser(username, password);
21
+ const authenticationResult = await authenticateUserWithRateLimit(username, password, {
22
+ requestIp: resolveAuthenticationAttemptRequestIp(request),
23
+ });
18
24
 
19
- if (user) {
20
- await setSession(user);
25
+ if (authenticationResult.isRateLimited) {
26
+ return createAuthenticationAttemptRateLimitResponse(authenticationResult.rateLimitRejection);
27
+ }
28
+
29
+ if (authenticationResult.user) {
30
+ await setSession(authenticationResult.user);
21
31
  return NextResponse.json({ success: true });
22
32
  } else {
23
33
  return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 });
24
34
  }
25
-
26
35
  } catch (error) {
27
36
  console.error('Login error:', error);
28
37
  return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
@@ -3,13 +3,37 @@ import type { ChatMessage } from '../../../../../../../../src/book-components/Ch
3
3
  import type { ChatParticipant } from '../../../../../../../../src/book-components/Chat/types/ChatParticipant';
4
4
  import { createChatExportFilename } from '../../../../../../../../src/book-components/Chat/save/_common/createChatExportFilename';
5
5
  import { NextRequest, NextResponse } from 'next/server';
6
+ import { spaceTrim } from 'spacetrim';
7
+ import { getCurrentUser, type UserInfo } from '@/src/utils/getCurrentUser';
6
8
  import { renderHtmlToPdfOnServer } from '@/src/utils/chatExport/renderHtmlToPdfOnServer';
9
+ import { sanitizeChatPdfExportHtml } from '@/src/utils/chatExport/sanitizeChatPdfExportHtml';
7
10
 
8
11
  /**
9
12
  * PDF export requires the Node.js runtime because it depends on Playwright.
10
13
  */
11
14
  export const runtime = 'nodejs';
12
15
 
16
+ /**
17
+ * Maximum chat PDF exports one signed-in user can request per rate-limit window.
18
+ *
19
+ * @private internal constant for POST /api/chat/export/pdf
20
+ */
21
+ const CHAT_PDF_EXPORT_MAX_REQUESTS_PER_WINDOW = 5;
22
+
23
+ /**
24
+ * Sliding-window length for chat PDF export rate limiting.
25
+ *
26
+ * @private internal constant for POST /api/chat/export/pdf
27
+ */
28
+ const CHAT_PDF_EXPORT_RATE_LIMIT_WINDOW_MS = 60_000;
29
+
30
+ /**
31
+ * In-memory PDF export request timestamps keyed by authenticated user.
32
+ *
33
+ * @private internal constant for POST /api/chat/export/pdf
34
+ */
35
+ const CHAT_PDF_EXPORT_RATE_LIMIT_BUCKETS: Map<string, Array<number>> = new Map();
36
+
13
37
  /**
14
38
  * Minimal request payload accepted by the chat PDF export endpoint.
15
39
  *
@@ -21,10 +45,48 @@ type ChatPdfExportRequestBody = {
21
45
  readonly participants: ReadonlyArray<ChatParticipant>;
22
46
  };
23
47
 
48
+ /**
49
+ * Result of the chat PDF export rate-limit check.
50
+ *
51
+ * @private internal type for POST /api/chat/export/pdf
52
+ */
53
+ type ChatPdfExportRateLimitResult = {
54
+ readonly isAllowed: boolean;
55
+ readonly retryAfterSeconds: number;
56
+ };
57
+
24
58
  /**
25
59
  * Builds a server-rendered PDF from the standalone HTML chat export.
26
60
  */
27
61
  export async function POST(request: NextRequest) {
62
+ const currentUser = await getCurrentUser();
63
+ if (!currentUser) {
64
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
65
+ }
66
+
67
+ const rateLimitCheck = checkChatPdfExportRateLimit(currentUser);
68
+ if (!rateLimitCheck.isAllowed) {
69
+ return NextResponse.json(
70
+ {
71
+ error: spaceTrim(`
72
+ Rate limit exceeded for \`PDF\` chat exports.
73
+
74
+ **Limit:** ${CHAT_PDF_EXPORT_MAX_REQUESTS_PER_WINDOW} requests per ${Math.round(
75
+ CHAT_PDF_EXPORT_RATE_LIMIT_WINDOW_MS / 1000,
76
+ )} seconds.
77
+
78
+ Try again in **${rateLimitCheck.retryAfterSeconds}** seconds.
79
+ `),
80
+ },
81
+ {
82
+ status: 429,
83
+ headers: {
84
+ 'Retry-After': String(rateLimitCheck.retryAfterSeconds),
85
+ },
86
+ },
87
+ );
88
+ }
89
+
28
90
  let requestBody: ChatPdfExportRequestBody;
29
91
 
30
92
  try {
@@ -45,15 +107,16 @@ export async function POST(request: NextRequest) {
45
107
  }
46
108
 
47
109
  try {
48
- const pdfBuffer = await renderHtmlToPdfOnServer(
110
+ const html = sanitizeChatPdfExportHtml(
49
111
  buildChatHtml(requestBody.title, requestBody.messages, requestBody.participants),
50
112
  );
113
+ const pdfBuffer = await renderHtmlToPdfOnServer(html);
51
114
  const filename = createChatExportFilename(requestBody.title, 'pdf');
52
115
 
53
116
  return new NextResponse(pdfBuffer, {
54
117
  headers: {
55
118
  'Content-Type': 'application/pdf',
56
- 'Content-Disposition': `attachment; filename="${filename}"`,
119
+ 'Content-Disposition': createChatPdfExportContentDisposition(filename),
57
120
  },
58
121
  });
59
122
  } catch (error) {
@@ -61,3 +124,105 @@ export async function POST(request: NextRequest) {
61
124
  return NextResponse.json({ error: 'Failed to export chat as PDF.' }, { status: 500 });
62
125
  }
63
126
  }
127
+
128
+ /**
129
+ * Applies a per-user sliding-window rate limit to Playwright-backed PDF exports.
130
+ *
131
+ * @param currentUser - Authenticated caller resolved from the request.
132
+ * @returns Decision and retry timing for the current attempt.
133
+ *
134
+ * @private internal helper for POST /api/chat/export/pdf
135
+ */
136
+ function checkChatPdfExportRateLimit(currentUser: UserInfo): ChatPdfExportRateLimitResult {
137
+ const bucketKey = resolveChatPdfExportRateLimitUserKey(currentUser);
138
+ const nowMs = Date.now();
139
+ const windowStartMs = nowMs - CHAT_PDF_EXPORT_RATE_LIMIT_WINDOW_MS;
140
+ const previousAttempts = CHAT_PDF_EXPORT_RATE_LIMIT_BUCKETS.get(bucketKey) || [];
141
+ const recentAttempts = previousAttempts.filter((attemptTimeMs) => attemptTimeMs > windowStartMs);
142
+
143
+ if (recentAttempts.length >= CHAT_PDF_EXPORT_MAX_REQUESTS_PER_WINDOW) {
144
+ const oldestRelevantAttemptMs = recentAttempts[0]!;
145
+ const retryAfterMs = Math.max(
146
+ 1_000,
147
+ oldestRelevantAttemptMs + CHAT_PDF_EXPORT_RATE_LIMIT_WINDOW_MS - nowMs,
148
+ );
149
+
150
+ CHAT_PDF_EXPORT_RATE_LIMIT_BUCKETS.set(bucketKey, recentAttempts);
151
+
152
+ return {
153
+ isAllowed: false,
154
+ retryAfterSeconds: Math.ceil(retryAfterMs / 1_000),
155
+ };
156
+ }
157
+
158
+ recentAttempts.push(nowMs);
159
+ CHAT_PDF_EXPORT_RATE_LIMIT_BUCKETS.set(bucketKey, recentAttempts);
160
+
161
+ return {
162
+ isAllowed: true,
163
+ retryAfterSeconds: 0,
164
+ };
165
+ }
166
+
167
+ /**
168
+ * Resolves a stable rate-limit bucket key for one authenticated user.
169
+ *
170
+ * @param currentUser - Authenticated caller resolved from the request.
171
+ * @returns Per-user bucket key.
172
+ *
173
+ * @private internal helper for POST /api/chat/export/pdf
174
+ */
175
+ function resolveChatPdfExportRateLimitUserKey(currentUser: UserInfo): string {
176
+ if (typeof currentUser.id === 'number' && Number.isFinite(currentUser.id)) {
177
+ return `user:${currentUser.id}`;
178
+ }
179
+
180
+ return `username:${currentUser.username.trim().toLowerCase()}`;
181
+ }
182
+
183
+ /**
184
+ * Builds a standards-compatible attachment header with both quoted ASCII
185
+ * fallback and RFC 5987 UTF-8 filename.
186
+ *
187
+ * @param filename - Sanitized chat export filename.
188
+ * @returns Value for the `Content-Disposition` header.
189
+ *
190
+ * @private internal helper for POST /api/chat/export/pdf
191
+ */
192
+ function createChatPdfExportContentDisposition(filename: string): string {
193
+ const fallbackFilename = createChatPdfExportFallbackFilename(filename);
194
+ const encodedFilename = encodeRFC5987Value(filename);
195
+
196
+ return `attachment; filename="${fallbackFilename}"; filename*=UTF-8''${encodedFilename}`;
197
+ }
198
+
199
+ /**
200
+ * Creates a quoted-string-safe ASCII fallback filename.
201
+ *
202
+ * @param filename - Preferred filename.
203
+ * @returns Filename safe for a quoted `Content-Disposition` parameter.
204
+ *
205
+ * @private internal helper for POST /api/chat/export/pdf
206
+ */
207
+ function createChatPdfExportFallbackFilename(filename: string): string {
208
+ const fallbackFilename = filename
209
+ .replace(/[^\x20-\x7e]/g, '_')
210
+ .replace(/[\r\n"\\/;]/g, '_')
211
+ .trim();
212
+
213
+ return fallbackFilename || 'chat-export.pdf';
214
+ }
215
+
216
+ /**
217
+ * Encodes a `Content-Disposition` filename according to RFC 5987.
218
+ *
219
+ * @param value - Raw filename value.
220
+ * @returns Percent-encoded filename.
221
+ *
222
+ * @private internal helper for POST /api/chat/export/pdf
223
+ */
224
+ function encodeRFC5987Value(value: string): string {
225
+ return encodeURIComponent(value).replace(/['()*]/g, (character) =>
226
+ `%${character.charCodeAt(0).toString(16).toUpperCase()}`,
227
+ );
228
+ }
@@ -1,6 +1,10 @@
1
1
  import { $getTableName } from '../../../../../database/$getTableName';
2
2
  import { $provideSupabaseForServer } from '../../../../../database/$provideSupabaseForServer';
3
3
  import { parseInboundSendgridEmail } from '../../../../../message-providers/email/sendgrid/parseInboundSendgridEmail';
4
+ import {
5
+ resolveSendgridInboundParseWebhookErrorStatus,
6
+ verifySendgridInboundParseWebhook,
7
+ } from '../../../../../message-providers/email/sendgrid/verifySendgridInboundParseWebhook';
4
8
  import { NextRequest, NextResponse } from 'next/server';
5
9
 
6
10
  /**
@@ -8,7 +12,10 @@ import { NextRequest, NextResponse } from 'next/server';
8
12
  */
9
13
  export async function POST(request: NextRequest) {
10
14
  try {
11
- const formData = await request.formData();
15
+ const rawBody = Buffer.from(await request.arrayBuffer());
16
+ verifySendgridInboundParseWebhook(request.headers, rawBody);
17
+
18
+ const formData = await createRequestFromRawBody(request, rawBody).formData();
12
19
  const rawEmail = formData.get('email');
13
20
 
14
21
  if (typeof rawEmail !== 'string') {
@@ -45,7 +52,22 @@ export async function POST(request: NextRequest) {
45
52
  console.error('Error processing inbound email', error);
46
53
  return NextResponse.json(
47
54
  { error: error instanceof Error ? error.message : 'Unknown error' },
48
- { status: 500 },
55
+ { status: resolveSendgridInboundParseWebhookErrorStatus(error) },
49
56
  );
50
57
  }
51
58
  }
59
+
60
+ /**
61
+ * Recreates a request from verified raw bytes so `formData()` can parse the multipart payload.
62
+ *
63
+ * @param request - Original incoming request.
64
+ * @param rawBody - Verified raw request body.
65
+ * @returns Request with an unread body stream.
66
+ */
67
+ function createRequestFromRawBody(request: NextRequest, rawBody: Buffer): Request {
68
+ return new Request(request.url, {
69
+ method: request.method,
70
+ headers: request.headers,
71
+ body: new Uint8Array(rawBody),
72
+ });
73
+ }
@@ -28,7 +28,8 @@ export async function POST(request: NextRequest) {
28
28
  }
29
29
 
30
30
  try {
31
- const importSummary = await importMetadataConfigurationPayload(payload);
31
+ const isDefaultResetSkipped = request.nextUrl.searchParams.get('isDefaultResetSkipped') === 'true';
32
+ const importSummary = await importMetadataConfigurationPayload(payload, { isDefaultResetSkipped });
32
33
  return NextResponse.json(importSummary);
33
34
  } catch (error) {
34
35
  if (error instanceof ParseError) {
@@ -3,6 +3,7 @@ import { NextResponse } from 'next/server';
3
3
  import { $provideSupabaseForServer } from '../../../../database/$provideSupabaseForServer';
4
4
  import { getPasswordValidationMessage, hashPassword } from '../../../../utils/auth';
5
5
  import { isUserAdmin } from '../../../../utils/isUserAdmin';
6
+ import { PUBLIC_USER_SELECT_COLUMNS, toPublicUser, type PublicUser } from '../../../../utils/publicUser';
6
7
 
7
8
  /**
8
9
  * Handles patch.
@@ -34,7 +35,7 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ us
34
35
  .from(await $getTableName('User'))
35
36
  .update(updates)
36
37
  .eq('username', usernameParam)
37
- .select('*')
38
+ .select(PUBLIC_USER_SELECT_COLUMNS)
38
39
  .single();
39
40
 
40
41
  if (error) {
@@ -45,7 +46,7 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ us
45
46
  return NextResponse.json({ error: 'User not found' }, { status: 404 });
46
47
  }
47
48
 
48
- return NextResponse.json(updatedUser);
49
+ return NextResponse.json(toPublicUser(updatedUser as unknown as PublicUser));
49
50
  } catch (error) {
50
51
  console.error('Update user error:', error);
51
52
  const passwordValidationMessage = getPasswordValidationMessage(error);
@@ -2,6 +2,7 @@ import { $getTableName } from '@/src/database/$getTableName';
2
2
  import { $provideSupabaseForServer } from '../../../database/$provideSupabaseForServer';
3
3
  import { getPasswordValidationMessage, hashPassword } from '../../../utils/auth';
4
4
  import { isUserAdmin } from '../../../utils/isUserAdmin';
5
+ import { PUBLIC_USER_SELECT_COLUMNS, toPublicUser, type PublicUser } from '../../../utils/publicUser';
5
6
  import { NextResponse } from 'next/server';
6
7
 
7
8
  /**
@@ -16,14 +17,14 @@ export async function GET() {
16
17
  const supabase = $provideSupabaseForServer();
17
18
  const { data: users, error } = await supabase
18
19
  .from(await $getTableName('User'))
19
- .select('*')
20
+ .select(PUBLIC_USER_SELECT_COLUMNS)
20
21
  .order('username');
21
22
 
22
23
  if (error) {
23
24
  throw error;
24
25
  }
25
26
 
26
- return NextResponse.json(users);
27
+ return NextResponse.json(((users || []) as unknown as PublicUser[]).map(toPublicUser));
27
28
  } catch (error) {
28
29
  console.error('List users error:', error);
29
30
  return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
@@ -58,7 +59,7 @@ export async function POST(request: Request) {
58
59
  createdAt: new Date().toISOString(),
59
60
  updatedAt: new Date().toISOString(),
60
61
  })
61
- .select('*')
62
+ .select(PUBLIC_USER_SELECT_COLUMNS)
62
63
  .single();
63
64
 
64
65
  if (error) {
@@ -69,7 +70,7 @@ export async function POST(request: Request) {
69
70
  throw error;
70
71
  }
71
72
 
72
- return NextResponse.json(newUser);
73
+ return NextResponse.json(toPublicUser(newUser as unknown as PublicUser));
73
74
  } catch (error) {
74
75
  console.error('Create user error:', error);
75
76
  const passwordValidationMessage = getPasswordValidationMessage(error);
@@ -6,7 +6,6 @@ import type { AgentOrganizationAgent, AgentOrganizationFolder } from '../../util
6
6
  import { AgentsListDialogs } from './AgentsListDialogs';
7
7
  import { AgentsListHeader } from './AgentsListHeader';
8
8
  import { AgentsListViewContent } from './AgentsListViewContent';
9
- import { AGENTS_IMPORT_FILE_INPUT_ACCEPT } from './useAgentsListImportExportState';
10
9
  import type { AgentWithVisibility } from './useFederatedAgents';
11
10
  import { useAgentsListState } from './useAgentsListState';
12
11
 
@@ -82,17 +81,6 @@ export function AgentsList(props: AgentsListProps) {
82
81
  onDragOver={isAdmin ? state.handleAgentsFileDragOver : undefined}
83
82
  onDrop={isAdmin ? state.handleAgentsFileDrop : undefined}
84
83
  >
85
- {isAdmin && (
86
- <input
87
- ref={state.importFileInputRef}
88
- type="file"
89
- multiple
90
- accept={AGENTS_IMPORT_FILE_INPUT_ACCEPT}
91
- className="hidden"
92
- onChange={(event) => void state.handleAgentsImportFileChange(event)}
93
- disabled={state.isAgentsImporting}
94
- />
95
- )}
96
84
  {isAdmin && state.isAgentsImportDragActive && (
97
85
  <div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center rounded-xl border-2 border-dashed border-blue-400 bg-blue-50/85 text-sm font-medium text-blue-800 shadow-sm">
98
86
  Drop books or ZIP archives to import agents
@@ -109,7 +97,6 @@ export function AgentsList(props: AgentsListProps) {
109
97
  isAgentsImporting={state.isAgentsImporting}
110
98
  onCreateFolder={state.handleCreateFolder}
111
99
  onExportAgents={() => void state.handleAgentsExport()}
112
- onImportAgents={state.handleAgentsImportClick}
113
100
  onNavigateToFolder={state.navigateToFolder}
114
101
  onSetViewMode={state.setViewMode}
115
102
  viewMode={state.viewMode}
@@ -138,6 +125,7 @@ export function AgentsList(props: AgentsListProps) {
138
125
  handleDragEnd={state.handleDragEnd}
139
126
  handleDragOver={state.handleDragOver}
140
127
  handleDragStart={state.handleDragStart}
128
+ handleAgentsFileDropIntoFolder={state.handleAgentsFileDropIntoFolder}
141
129
  handleFolderContextMenu={state.handleFolderContextMenu}
142
130
  handleRenameFolder={state.handleRenameFolder}
143
131
  handleRequestAgentVisibilityChange={state.handleRequestAgentVisibilityChange}
@@ -8,7 +8,6 @@ import {
8
8
  Grid,
9
9
  Map,
10
10
  Network,
11
- UploadIcon,
12
11
  type LucideIcon,
13
12
  } from 'lucide-react';
14
13
  import type { AgentOrganizationFolder } from '../../utils/agentOrganization/types';
@@ -70,13 +69,9 @@ type AgentsListHeaderProps = {
70
69
  */
71
70
  readonly isAdmin: boolean;
72
71
  /**
73
- * Downloads an agents export.
72
+ * Downloads an agents archive.
74
73
  */
75
74
  readonly onExportAgents: () => void;
76
- /**
77
- * Opens the agents import file picker.
78
- */
79
- readonly onImportAgents: () => void;
80
75
  /**
81
76
  * Opens the create-folder dialog.
82
77
  */
@@ -135,7 +130,6 @@ export function AgentsListHeader({
135
130
  isAgentsImporting,
136
131
  onCreateFolder,
137
132
  onExportAgents,
138
- onImportAgents,
139
133
  onNavigateToFolder,
140
134
  onSetViewMode,
141
135
  viewMode,
@@ -180,20 +174,10 @@ export function AgentsListHeader({
180
174
  onClick={onExportAgents}
181
175
  disabled={isAgentsExporting || isAgentsImporting}
182
176
  className="flex items-center gap-2 px-3 py-2 rounded-lg border border-gray-200 bg-white text-gray-700 hover:bg-gray-50 transition-colors text-sm disabled:cursor-not-allowed disabled:opacity-60"
183
- title="Export agents"
177
+ title="Download agents"
184
178
  >
185
179
  <DownloadIcon className="w-4 h-4" />
186
- {isAgentsExporting ? 'Exporting...' : 'Export'}
187
- </button>
188
- <button
189
- type="button"
190
- onClick={onImportAgents}
191
- disabled={isAgentsExporting || isAgentsImporting}
192
- className="flex items-center gap-2 px-3 py-2 rounded-lg border border-gray-200 bg-white text-gray-700 hover:bg-gray-50 transition-colors text-sm disabled:cursor-not-allowed disabled:opacity-60"
193
- title="Import agents"
194
- >
195
- <UploadIcon className="w-4 h-4" />
196
- {isAgentsImporting ? 'Importing...' : 'Import'}
180
+ {isAgentsExporting ? 'Downloading...' : 'Download'}
197
181
  </button>
198
182
  </>
199
183
  )}
@@ -101,6 +101,10 @@ type AgentsListListViewProps = {
101
101
  * dnd-kit drag-start handler.
102
102
  */
103
103
  readonly handleDragStart: ComponentProps<typeof DndContext>['onDragStart'];
104
+ /**
105
+ * Imports dropped book files or ZIP archives directly into one folder.
106
+ */
107
+ readonly handleAgentsFileDropIntoFolder: SortableFolderCardProps['onFileDrop'];
104
108
  /**
105
109
  * Handles folder context-menu requests.
106
110
  */
@@ -193,6 +197,7 @@ export function AgentsListListView({
193
197
  handleDragEnd,
194
198
  handleDragOver,
195
199
  handleDragStart,
200
+ handleAgentsFileDropIntoFolder,
196
201
  handleFolderContextMenu,
197
202
  handleRenameFolder,
198
203
  handleRequestAgentVisibilityChange,
@@ -239,6 +244,7 @@ export function AgentsListListView({
239
244
  onRename={canOrganize ? () => handleRenameFolder(folder.id) : undefined}
240
245
  onDelete={canOrganize ? () => handleDeleteFolder(folder.id) : undefined}
241
246
  onContextMenu={handleFolderContextMenu}
247
+ onFileDrop={isAdmin ? handleAgentsFileDropIntoFolder : undefined}
242
248
  dragHandleLabel={dragFolderLabel}
243
249
  allowFullCardDrag={allowFullCardDrag}
244
250
  />
@@ -111,6 +111,10 @@ type AgentsListViewContentProps = {
111
111
  * dnd-kit drag-start handler.
112
112
  */
113
113
  readonly handleDragStart: ComponentProps<typeof DndContext>['onDragStart'];
114
+ /**
115
+ * Imports dropped book files or ZIP archives directly into one folder.
116
+ */
117
+ readonly handleAgentsFileDropIntoFolder: ComponentProps<typeof AgentsListListView>['handleAgentsFileDropIntoFolder'];
114
118
  /**
115
119
  * Handles folder context-menu requests.
116
120
  */
@@ -269,6 +273,7 @@ export function AgentsListViewContent({
269
273
  handleDragEnd,
270
274
  handleDragOver,
271
275
  handleDragStart,
276
+ handleAgentsFileDropIntoFolder,
272
277
  handleFolderContextMenu,
273
278
  handleRenameFolder,
274
279
  handleRequestAgentVisibilityChange,
@@ -308,6 +313,7 @@ export function AgentsListViewContent({
308
313
  handleDragEnd={handleDragEnd}
309
314
  handleDragOver={handleDragOver}
310
315
  handleDragStart={handleDragStart}
316
+ handleAgentsFileDropIntoFolder={handleAgentsFileDropIntoFolder}
311
317
  handleFolderContextMenu={handleFolderContextMenu}
312
318
  handleRenameFolder={handleRenameFolder}
313
319
  handleRequestAgentVisibilityChange={handleRequestAgentVisibilityChange}
@@ -4,7 +4,7 @@ import { CSS } from '@dnd-kit/utilities';
4
4
  import { useSortable } from '@dnd-kit/sortable';
5
5
  import type { string_url } from '@promptbook-local/types';
6
6
  import type { AgentBasicInformation } from '../../../../../src/book-2.0/agent-source/AgentBasicInformation';
7
- import type { CSSProperties, MouseEvent } from 'react';
7
+ import type { CSSProperties, DragEvent, MouseEvent } from 'react';
8
8
  import type { AgentOrganizationFolder } from '../../utils/agentOrganization/types';
9
9
  import { FolderCard } from './FolderCard';
10
10
  import { buildCardDragProps, DragHandle } from './DragHandle';
@@ -61,6 +61,10 @@ export type SortableFolderCardProps = {
61
61
  * Context menu handler for the folder.
62
62
  */
63
63
  readonly onContextMenu?: (event: MouseEvent<HTMLDivElement>, folder: AgentOrganizationFolder) => void;
64
+ /**
65
+ * File-drop handler for importing agents directly into this folder.
66
+ */
67
+ readonly onFileDrop?: (event: DragEvent<HTMLDivElement>, folderId: number) => void;
64
68
  /**
65
69
  * Accessible label displayed for the drag handle.
66
70
  */
@@ -88,6 +92,7 @@ export function SortableFolderCard({
88
92
  onRename,
89
93
  onDelete,
90
94
  onContextMenu,
95
+ onFileDrop,
91
96
  dragHandleLabel,
92
97
  allowFullCardDrag,
93
98
  }: SortableFolderCardProps) {
@@ -125,6 +130,7 @@ export function SortableFolderCard({
125
130
  className={`relative ${canOrganize ? 'select-none' : ''} ${isDragging ? 'opacity-0' : ''} ${dropClasses}`}
126
131
  {...dragProps}
127
132
  onContextMenu={(event) => onContextMenu?.(event, folder)}
133
+ onDrop={(event) => onFileDrop?.(event, folder.id)}
128
134
  >
129
135
  <FolderCard
130
136
  folderName={folder.name}