@promptbook/cli 0.112.0-101 → 0.112.0-102

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 (78) hide show
  1. package/apps/agents-server/README.md +6 -0
  2. package/apps/agents-server/package.json +1 -1
  3. package/apps/agents-server/scripts/prerender-homepage.js +76 -1
  4. package/apps/agents-server/src/app/actions.ts +0 -6
  5. package/apps/agents-server/src/app/admin/about/page.tsx +1 -1
  6. package/apps/agents-server/src/app/admin/login-methods/shibboleth/page.tsx +365 -0
  7. package/apps/agents-server/src/app/admin/servers/ServersRegistryTable.tsx +3 -3
  8. package/apps/agents-server/src/app/admin/update/UpdateClient.tsx +12 -3
  9. package/apps/agents-server/src/app/admin/usage/UsageClientTimelineChart.tsx +1 -1
  10. package/apps/agents-server/src/app/admin/users/[userId]/UserDetailClient.tsx +21 -14
  11. package/apps/agents-server/src/app/agents/[agentName]/chat/AgentChatPageLayout.tsx +2 -2
  12. package/apps/agents-server/src/app/agents/[agentName]/chat/AgentChatSidebarDefault.tsx +11 -7
  13. package/apps/agents-server/src/app/api/admin/cli-access/route.ts +27 -123
  14. package/apps/agents-server/src/app/api/admin/code-runners/authentication/route.ts +33 -125
  15. package/apps/agents-server/src/app/api/auth/login/route.ts +0 -10
  16. package/apps/agents-server/src/app/api/auth/shibboleth/acs/route.ts +77 -57
  17. package/apps/agents-server/src/app/api/auth/shibboleth/login/route.ts +57 -33
  18. package/apps/agents-server/src/app/api/auth/shibboleth/metadata/route.ts +4 -29
  19. package/apps/agents-server/src/app/api/auth/shibboleth/status/route.ts +17 -0
  20. package/apps/agents-server/src/app/api/upload/route.ts +230 -18
  21. package/apps/agents-server/src/app/api/users/[username]/route.ts +1 -1
  22. package/apps/agents-server/src/app/api/users/route.ts +5 -5
  23. package/apps/agents-server/src/app/dashboard/page.tsx +1 -1
  24. package/apps/agents-server/src/app/docs/[docId]/page.tsx +1 -1
  25. package/apps/agents-server/src/app/docs/page.tsx +1 -1
  26. package/apps/agents-server/src/app/globals.css +100 -0
  27. package/apps/agents-server/src/app/layout.tsx +7 -0
  28. package/apps/agents-server/src/app/recycle-bin/page.tsx +1 -1
  29. package/apps/agents-server/src/app/system/settings/KeybindingsSettingsClient.tsx +13 -7
  30. package/apps/agents-server/src/components/AdminTerminal/useAdminTerminalSession.ts +29 -1
  31. package/apps/agents-server/src/components/AgentProfile/AgentProfile.tsx +3 -3
  32. package/apps/agents-server/src/components/AgentProfile/AgentProfileImage.tsx +8 -2
  33. package/apps/agents-server/src/components/DocsToolbar/DocsToolbar.tsx +4 -4
  34. package/apps/agents-server/src/components/DocumentationContent/DocumentationContent.tsx +9 -9
  35. package/apps/agents-server/src/components/Footer/Footer.tsx +7 -7
  36. package/apps/agents-server/src/components/Header/Header.tsx +24 -4
  37. package/apps/agents-server/src/components/Header/HeaderTypes.ts +6 -0
  38. package/apps/agents-server/src/components/Header/buildHeaderSystemMenuItems.ts +51 -1
  39. package/apps/agents-server/src/components/Homepage/Card.tsx +1 -1
  40. package/apps/agents-server/src/components/Homepage/Section.tsx +3 -1
  41. package/apps/agents-server/src/components/LayoutWrapper/LayoutWrapper.tsx +12 -1
  42. package/apps/agents-server/src/components/LoginForm/LoginForm.tsx +100 -149
  43. package/apps/agents-server/src/components/Skeleton/ConsolePageLoadingSkeleton.tsx +1 -1
  44. package/apps/agents-server/src/components/Skeleton/DocumentationRouteLoadingSkeleton.tsx +1 -1
  45. package/apps/agents-server/src/components/Skeleton/HomepageLoadingSkeleton.tsx +1 -1
  46. package/apps/agents-server/src/components/UsersList/UsersList.tsx +20 -4
  47. package/apps/agents-server/src/components/UsersList/useUsersAdmin.ts +3 -0
  48. package/apps/agents-server/src/constants/shibbolethAuth.ts +139 -0
  49. package/apps/agents-server/src/database/metadataDefaults.ts +54 -80
  50. package/apps/agents-server/src/database/migrate.ts +30 -1
  51. package/apps/agents-server/src/database/migrations/2026-06-0100-shibboleth-auth.sql +136 -0
  52. package/apps/agents-server/src/database/sqlite/$provideLocalSqliteSupabase.ts +88 -36
  53. package/apps/agents-server/src/languages/ServerTranslationKeys.ts +4 -2
  54. package/apps/agents-server/src/languages/translations/czech.yaml +4 -2
  55. package/apps/agents-server/src/languages/translations/english.yaml +5 -3
  56. package/apps/agents-server/src/tools/$provideCdnForServer.ts +69 -23
  57. package/apps/agents-server/src/utils/cdn/classes/DigitalOceanSpaces.ts +54 -6
  58. package/apps/agents-server/src/utils/cdn/classes/TrackedFilesStorage.ts +4 -6
  59. package/apps/agents-server/src/utils/cdn/resolveCdnStorageProvider.ts +40 -0
  60. package/apps/agents-server/src/utils/chatExport/renderHtmlToPdfOnServer.ts +11 -0
  61. package/apps/agents-server/src/utils/createAdminTerminalRouteHandlers.ts +264 -0
  62. package/apps/agents-server/src/utils/shareTargetPayloads.ts +11 -10
  63. package/apps/agents-server/src/utils/shibbolethAuthentication.ts +729 -621
  64. package/apps/agents-server/src/utils/upload/createBookEditorUploadHandler.ts +137 -19
  65. package/esm/index.es.js +1 -1
  66. package/esm/src/book-components/Chat/MarkdownContent/MarkdownContent.d.ts +1 -0
  67. package/esm/src/version.d.ts +1 -1
  68. package/package.json +2 -2
  69. package/src/book-components/Chat/MarkdownContent/MarkdownContent.tsx +65 -4
  70. package/src/other/templates/getTemplatesPipelineCollection.ts +788 -719
  71. package/src/version.ts +2 -2
  72. package/src/versions.txt +1 -0
  73. package/umd/index.umd.js +1 -1
  74. package/umd/src/book-components/Chat/MarkdownContent/MarkdownContent.d.ts +1 -0
  75. package/umd/src/version.d.ts +1 -1
  76. package/apps/agents-server/src/app/api/auth/methods/route.ts +0 -44
  77. package/apps/agents-server/src/constants/authenticationMethods.ts +0 -74
  78. package/apps/agents-server/src/constants/shibbolethAuthentication.ts +0 -107
@@ -41,6 +41,12 @@ ptbk agents-server start --agent github-copilot --model gpt-5.4 --thinking-level
41
41
  <a id="agents-server-env-admin-password"></a>
42
42
  - `ADMIN_PASSWORD`: Password for the built-in `admin` login on a self-hosted Agents Server. Choose a private value before using the admin UI.
43
43
 
44
+ <a id="agents-server-env-next-public-cdn-storage-provider"></a>
45
+ - `NEXT_PUBLIC_CDN_STORAGE_PROVIDER`: File storage provider for uploads. Use `s3` for S3-compatible storage such as the VPS installer's self-contained MinIO service or external S3, and `vercel` for Vercel Blob.
46
+
47
+ <a id="agents-server-env-s3-cdn"></a>
48
+ - `CDN_BUCKET`, `CDN_ENDPOINT`, `CDN_ACCESS_KEY_ID`, `CDN_SECRET_ACCESS_KEY`, `NEXT_PUBLIC_CDN_PUBLIC_URL`, and `NEXT_PUBLIC_CDN_PATH_PREFIX`: S3-compatible upload configuration used when `NEXT_PUBLIC_CDN_STORAGE_PROVIDER=s3`.
49
+
44
50
  ## Creating servers
45
51
 
46
52
  When creating new Agents server, search across the repository for [☁]
@@ -7,7 +7,7 @@
7
7
  "dev": "next dev -p 4440",
8
8
  "test": "npm run lint && npm run test-build && npm run test-e2e",
9
9
  "test-e2e": "playwright test",
10
- "pretest-build": "npx kill-port 4021",
10
+ "pretest-build": "npx kill-port 4021 4440 || exit 0",
11
11
  "test-build": "node -r ./scripts/ignore-kill-eperm.js ../../node_modules/next/dist/bin/next build && node ./scripts/prerender-homepage.js",
12
12
  "build": "node -r ./scripts/ignore-kill-eperm.js ../../node_modules/next/dist/bin/next build && node ./scripts/prerender-homepage.js",
13
13
  "start": "next start -p 4440",
@@ -45,6 +45,10 @@ const WAIT_TIMEOUT_MS = 15000;
45
45
  * Timeout for the final homepage download once the server is confirmed ready.
46
46
  */
47
47
  const HOME_REQUEST_TIMEOUT_MS = 30000;
48
+ /**
49
+ * Timeout for stopping the temporary production server before escalating cleanup.
50
+ */
51
+ const STOP_SERVER_TIMEOUT_MS = 10000;
48
52
  /**
49
53
  * When enabled, homepage prerender failures abort the whole build.
50
54
  */
@@ -136,6 +140,65 @@ function waitForServerReady(serverProcess) {
136
140
  });
137
141
  }
138
142
 
143
+ /**
144
+ * Waits briefly for the temporary production server to exit after cleanup starts.
145
+ *
146
+ * @param serverProcess - Spawned `next start` child process.
147
+ * @param gracefulExit - Promise resolved by the child `exit` event.
148
+ * @returns Whether the child exited within the timeout window.
149
+ */
150
+ async function waitForServerStop(serverProcess, gracefulExit) {
151
+ if (serverProcess.exitCode !== null || serverProcess.signalCode !== null) {
152
+ return true;
153
+ }
154
+
155
+ return await Promise.race([
156
+ gracefulExit.then(() => true),
157
+ new Promise((resolve) => setTimeout(() => resolve(false), STOP_SERVER_TIMEOUT_MS)),
158
+ ]);
159
+ }
160
+
161
+ /**
162
+ * Force-stops the temporary production server when normal shutdown does not finish in time.
163
+ *
164
+ * Windows can leave `next start` alive after an `EPERM` kill attempt, so use `taskkill`
165
+ * against the exact PID before giving up on cleanup.
166
+ *
167
+ * @param serverProcess - Spawned `next start` child process.
168
+ */
169
+ async function forceStopServer(serverProcess) {
170
+ if (serverProcess.exitCode !== null || serverProcess.signalCode !== null) {
171
+ return;
172
+ }
173
+
174
+ if (process.platform !== 'win32') {
175
+ try {
176
+ serverProcess.kill('SIGKILL');
177
+ } catch (error) {
178
+ if (!isIgnorableStopServerError(error)) {
179
+ throw error;
180
+ }
181
+ }
182
+ return;
183
+ }
184
+
185
+ await new Promise((resolve, reject) => {
186
+ const taskKillProcess = spawn('taskkill', ['/PID', String(serverProcess.pid), '/T', '/F'], {
187
+ stdio: 'ignore',
188
+ });
189
+
190
+ taskKillProcess.on('error', reject);
191
+ taskKillProcess.on('exit', (code) => {
192
+ if (code === 0 || serverProcess.exitCode !== null || serverProcess.signalCode !== null) {
193
+ resolve();
194
+ return;
195
+ }
196
+
197
+ reject(new Error(`Failed to stop temporary production server process ${serverProcess.pid} with taskkill.`));
198
+ });
199
+ });
200
+ }
201
+
139
202
  /**
140
203
  * Runs the production server for the built app, captures `/`, and keeps the HTML.
141
204
  */
@@ -196,7 +259,19 @@ async function prerenderHomePage() {
196
259
  } finally {
197
260
  detachStopServerHandlers();
198
261
  stopServer();
199
- await gracefulExit;
262
+
263
+ if (!(await waitForServerStop(serverProcess, gracefulExit))) {
264
+ console.warn(
265
+ `Timed out waiting ${STOP_SERVER_TIMEOUT_MS}ms for the temporary production server to stop. Trying a forceful shutdown.`,
266
+ );
267
+ await forceStopServer(serverProcess);
268
+
269
+ if (!(await waitForServerStop(serverProcess, gracefulExit))) {
270
+ console.warn(
271
+ `Timed out waiting ${STOP_SERVER_TIMEOUT_MS}ms for the temporary production server after forceful shutdown.`,
272
+ );
273
+ }
274
+ }
200
275
  }
201
276
  }
202
277
 
@@ -6,7 +6,6 @@ import { revalidatePath } from 'next/cache';
6
6
  import { string_agent_permanent_id } from '../../../../src/types/typeAliases';
7
7
  import { DEFAULT_NAME_POOL, NAME_POOL_METADATA_KEY, parseNamePool } from '../constants/namePool';
8
8
  import { NEW_AGENT_WIZZARD_METADATA_KEY, parseNewAgentWizardMode } from '../constants/newAgentWizard';
9
- import { AUTHENTICATION_METHODS_METADATA_KEY, isAuthenticationMethodEnabled } from '../constants/authenticationMethods';
10
9
  import { getMetadata } from '../database/getMetadata';
11
10
  import { $provideAgentCollectionForServer } from '../tools/$provideAgentCollectionForServer';
12
11
  import { type AgentVisibility, parseAgentVisibility } from '../utils/agentVisibility';
@@ -106,11 +105,6 @@ export async function loginAction(formData: FormData) {
106
105
 
107
106
  console.info(`Login attempt for user: ${username}`);
108
107
 
109
- if (!isAuthenticationMethodEnabled(await getMetadata(AUTHENTICATION_METHODS_METADATA_KEY), 'PASSWORD')) {
110
- console.info('Password login rejected because PASSWORD authentication is disabled in metadata.');
111
- return { success: false, message: 'Password login is disabled on this server.' };
112
- }
113
-
114
108
  const user = await authenticateUser(username, password);
115
109
 
116
110
  if (user) {
@@ -413,7 +413,7 @@ export default async function AdminAboutPage() {
413
413
  ];
414
414
 
415
415
  return (
416
- <div className="min-h-screen bg-gradient-to-br from-blue-50 via-white to-purple-50">
416
+ <div className="min-h-screen bg-gradient-to-br from-blue-50 via-white to-purple-50 dark:from-slate-950 dark:via-slate-950 dark:to-slate-900">
417
417
  <div className="container mx-auto px-4 py-16">
418
418
  <Card className="mb-10 space-y-4">
419
419
  <div>
@@ -0,0 +1,365 @@
1
+ import Link from 'next/link';
2
+ import { headers } from 'next/headers';
3
+ import { AlertTriangle, CheckCircle2, ExternalLink, XCircle } from 'lucide-react';
4
+ import { $getTableName } from '../../../../database/$getTableName';
5
+ import { $provideSupabaseForServer } from '../../../../database/$provideSupabaseForServer';
6
+ import { ForbiddenPage } from '../../../../components/ForbiddenPage/ForbiddenPage';
7
+ import { isUserAdmin } from '../../../../utils/isUserAdmin';
8
+ import {
9
+ getShibbolethAuthenticationAttemptTableName,
10
+ getShibbolethUserIdentityTableName,
11
+ resolveShibbolethAuthenticationConfiguration,
12
+ type ShibbolethAuthenticationAttemptRow,
13
+ type ShibbolethUserIdentityRow,
14
+ } from '../../../../utils/shibbolethAuthentication';
15
+
16
+ /**
17
+ * User row subset displayed on the Shibboleth dashboard.
18
+ */
19
+ type DashboardUserRow = {
20
+ readonly id: number;
21
+ readonly username: string;
22
+ readonly email?: string | null;
23
+ readonly displayName?: string | null;
24
+ readonly authenticationProvider?: string | null;
25
+ readonly isAdmin: boolean;
26
+ };
27
+
28
+ /**
29
+ * Builds an absolute URL for Shibboleth route configuration inside server components.
30
+ */
31
+ async function getDashboardRequestUrl(): Promise<string> {
32
+ const headerStore = await headers();
33
+ const forwardedProtocol = headerStore.get('x-forwarded-proto')?.split(',')[0]?.trim() || 'http';
34
+ const forwardedHost = headerStore.get('x-forwarded-host') || headerStore.get('host') || 'localhost';
35
+ return `${forwardedProtocol}://${forwardedHost}/admin/login-methods/shibboleth`;
36
+ }
37
+
38
+ /**
39
+ * Loads recent Shibboleth authentication attempts for the dashboard.
40
+ */
41
+ async function loadShibbolethAuthenticationAttempts(): Promise<ReadonlyArray<ShibbolethAuthenticationAttemptRow>> {
42
+ const supabase = $provideSupabaseForServer();
43
+ const { data, error } = await supabase
44
+ .from(await getShibbolethAuthenticationAttemptTableName())
45
+ .select('*')
46
+ .order('createdAt', { ascending: false })
47
+ .limit(100);
48
+
49
+ if (error) {
50
+ console.error('Failed to load Shibboleth authentication attempts:', error);
51
+ return [];
52
+ }
53
+
54
+ return (data as ShibbolethAuthenticationAttemptRow[] | null) || [];
55
+ }
56
+
57
+ /**
58
+ * Loads Shibboleth identities and their linked users for the dashboard.
59
+ */
60
+ async function loadShibbolethIdentitiesWithUsers(): Promise<{
61
+ readonly identities: ReadonlyArray<ShibbolethUserIdentityRow>;
62
+ readonly usersById: ReadonlyMap<number, DashboardUserRow>;
63
+ }> {
64
+ const supabase = $provideSupabaseForServer();
65
+ const { data: identities, error } = await supabase
66
+ .from(await getShibbolethUserIdentityTableName())
67
+ .select('*')
68
+ .order('lastLoggedInAt', { ascending: false, nullsFirst: false });
69
+
70
+ if (error) {
71
+ console.error('Failed to load Shibboleth identities:', error);
72
+ return {
73
+ identities: [],
74
+ usersById: new Map(),
75
+ };
76
+ }
77
+
78
+ const identityRows = (identities as ShibbolethUserIdentityRow[] | null) || [];
79
+ const userIds = Array.from(new Set(identityRows.map((identity) => identity.userId)));
80
+
81
+ if (userIds.length === 0) {
82
+ return {
83
+ identities: identityRows,
84
+ usersById: new Map(),
85
+ };
86
+ }
87
+
88
+ const { data: users, error: usersError } = await supabase
89
+ .from(await $getTableName('User'))
90
+ .select('*')
91
+ .in('id', userIds);
92
+
93
+ if (usersError) {
94
+ console.error('Failed to load Shibboleth users:', usersError);
95
+ return {
96
+ identities: identityRows,
97
+ usersById: new Map(),
98
+ };
99
+ }
100
+
101
+ const usersById = new Map<number, DashboardUserRow>();
102
+ for (const user of (users as unknown as DashboardUserRow[] | null) || []) {
103
+ usersById.set(user.id, user);
104
+ }
105
+
106
+ return {
107
+ identities: identityRows,
108
+ usersById,
109
+ };
110
+ }
111
+
112
+ /**
113
+ * Formats an optional date-time value for the dashboard.
114
+ */
115
+ function formatDashboardDateTime(value: string | null | undefined): string {
116
+ if (!value) {
117
+ return 'Never';
118
+ }
119
+
120
+ return new Date(value).toLocaleString();
121
+ }
122
+
123
+ /**
124
+ * Handles Shibboleth login method dashboard page.
125
+ */
126
+ export default async function ShibbolethLoginMethodPage() {
127
+ const isAdmin = await isUserAdmin();
128
+
129
+ if (!isAdmin) {
130
+ return <ForbiddenPage />;
131
+ }
132
+
133
+ const requestUrl = await getDashboardRequestUrl();
134
+ const [configuration, attempts, identitiesWithUsers] = await Promise.all([
135
+ resolveShibbolethAuthenticationConfiguration({
136
+ requestUrl,
137
+ isIdentityProviderMetadataValidationEnabled: true,
138
+ }),
139
+ loadShibbolethAuthenticationAttempts(),
140
+ loadShibbolethIdentitiesWithUsers(),
141
+ ]);
142
+ const isWarningShown = configuration.isActive && !configuration.isConfigured;
143
+
144
+ return (
145
+ <div className="container mx-auto max-w-6xl px-4 py-8 space-y-8">
146
+ <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
147
+ <div>
148
+ <p className="text-sm font-medium text-gray-500">System / Login Methods</p>
149
+ <h1 className="text-3xl font-semibold text-gray-950">Shibboleth</h1>
150
+ <p className="mt-2 max-w-3xl text-sm text-gray-600">
151
+ Manage Shibboleth SAML login, review authentication attempts, and inspect linked users.
152
+ </p>
153
+ </div>
154
+ <Link
155
+ href="/admin/users"
156
+ className="inline-flex items-center justify-center rounded-md border border-gray-300 px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
157
+ >
158
+ Users
159
+ </Link>
160
+ </div>
161
+
162
+ {isWarningShown && (
163
+ <div className="flex gap-3 rounded-md border border-amber-300 bg-amber-50 p-4 text-amber-900">
164
+ <AlertTriangle className="mt-0.5 h-5 w-5 flex-none" />
165
+ <div className="space-y-1 text-sm">
166
+ <p className="font-semibold">
167
+ Shibboleth authentication is active but not configured correctly.
168
+ </p>
169
+ <p>{configuration.errors.join(' ')}</p>
170
+ <a href="#setup-instructions" className="font-medium underline">
171
+ Open setup instructions
172
+ </a>
173
+ </div>
174
+ </div>
175
+ )}
176
+
177
+ <section className="rounded-md border border-gray-200 bg-white p-5">
178
+ <div className="flex items-center gap-2">
179
+ {configuration.isActive ? (
180
+ <CheckCircle2 className="h-5 w-5 text-green-600" />
181
+ ) : (
182
+ <XCircle className="h-5 w-5 text-gray-400" />
183
+ )}
184
+ <h2 className="text-lg font-semibold text-gray-950">Configuration</h2>
185
+ </div>
186
+ <dl className="mt-4 grid gap-4 text-sm sm:grid-cols-2">
187
+ <div>
188
+ <dt className="font-medium text-gray-500">Active</dt>
189
+ <dd className="mt-1 text-gray-900">{configuration.isActive ? 'Yes' : 'No'}</dd>
190
+ </div>
191
+ <div>
192
+ <dt className="font-medium text-gray-500">Configured</dt>
193
+ <dd className="mt-1 text-gray-900">{configuration.isConfigured ? 'Yes' : 'No'}</dd>
194
+ </div>
195
+ <div>
196
+ <dt className="font-medium text-gray-500">EntityID</dt>
197
+ <dd className="mt-1 break-all font-mono text-xs text-gray-900">
198
+ {configuration.serviceProviderUrls.entityId}
199
+ </dd>
200
+ </div>
201
+ <div>
202
+ <dt className="font-medium text-gray-500">ACS URL</dt>
203
+ <dd className="mt-1 break-all font-mono text-xs text-gray-900">
204
+ {configuration.serviceProviderUrls.assertionConsumerServiceUrl}
205
+ </dd>
206
+ </div>
207
+ <div>
208
+ <dt className="font-medium text-gray-500">SP metadata</dt>
209
+ <dd className="mt-1 break-all text-xs">
210
+ <a
211
+ href={configuration.serviceProviderUrls.metadataUrl}
212
+ className="inline-flex items-center gap-1 font-mono text-blue-700 hover:text-blue-900"
213
+ >
214
+ {configuration.serviceProviderUrls.metadataUrl}
215
+ <ExternalLink className="h-3.5 w-3.5" />
216
+ </a>
217
+ </dd>
218
+ </div>
219
+ <div>
220
+ <dt className="font-medium text-gray-500">IdP metadata source</dt>
221
+ <dd className="mt-1 break-all text-xs text-gray-900">
222
+ {configuration.isIdentityProviderMetadataXmlConfigured
223
+ ? 'Metadata XML is stored directly in metadata configuration.'
224
+ : configuration.identityProviderMetadataUrl || 'Not set'}
225
+ </dd>
226
+ </div>
227
+ </dl>
228
+ {configuration.errors.length > 0 && (
229
+ <ul className="mt-4 list-disc space-y-1 pl-5 text-sm text-amber-800">
230
+ {configuration.errors.map((error) => (
231
+ <li key={error}>{error}</li>
232
+ ))}
233
+ </ul>
234
+ )}
235
+ </section>
236
+
237
+ <section id="setup-instructions" className="rounded-md border border-gray-200 bg-white p-5">
238
+ <h2 className="text-lg font-semibold text-gray-950">Setup Instructions</h2>
239
+ <ol className="mt-4 list-decimal space-y-3 pl-5 text-sm text-gray-700">
240
+ <li>
241
+ In{' '}
242
+ <Link href="/admin/metadata" className="text-blue-700 underline">
243
+ Metadata
244
+ </Link>
245
+ , set <code>IS_SHIBBOLETH_AUTH_ACTIVE</code> to <code>true</code>.
246
+ </li>
247
+ <li>
248
+ Set <code>SHIBBOLETH_IDP_METADATA_URL</code> to the university Identity Provider metadata URL.
249
+ For Silesian University use <code>https://idp-cro.slu.cz/idp/shibboleth</code>, or paste the XML
250
+ into <code>SHIBBOLETH_IDP_METADATA_XML</code>.
251
+ </li>
252
+ <li>
253
+ Send the Service Provider metadata URL above to the IdP administrator, or send the EntityID and
254
+ ACS URL shown in the configuration panel.
255
+ </li>
256
+ <li>
257
+ Ask the IdP administrator to release <code>mail</code>, <code>displayName</code>, and{' '}
258
+ <code>unstructuredName</code>. <code>eduPersonPrincipalName</code> can also be used as an
259
+ institutional identifier.
260
+ </li>
261
+ </ol>
262
+ </section>
263
+
264
+ <section className="rounded-md border border-gray-200 bg-white p-5">
265
+ <h2 className="text-lg font-semibold text-gray-950">Authentication Attempts</h2>
266
+ <div className="mt-4 overflow-x-auto">
267
+ <table className="min-w-full divide-y divide-gray-200 text-sm">
268
+ <thead>
269
+ <tr className="text-left text-xs font-semibold uppercase tracking-wide text-gray-500">
270
+ <th className="py-2 pr-4">Time</th>
271
+ <th className="py-2 pr-4">Status</th>
272
+ <th className="py-2 pr-4">Stage</th>
273
+ <th className="py-2 pr-4">Email</th>
274
+ <th className="py-2 pr-4">Name</th>
275
+ <th className="py-2 pr-4">Error</th>
276
+ </tr>
277
+ </thead>
278
+ <tbody className="divide-y divide-gray-100">
279
+ {attempts.length === 0 ? (
280
+ <tr>
281
+ <td className="py-3 text-gray-500" colSpan={6}>
282
+ No Shibboleth authentication attempts recorded yet.
283
+ </td>
284
+ </tr>
285
+ ) : (
286
+ attempts.map((attempt) => (
287
+ <tr key={attempt.id}>
288
+ <td className="whitespace-nowrap py-2 pr-4 text-gray-700">
289
+ {formatDashboardDateTime(attempt.createdAt)}
290
+ </td>
291
+ <td className="py-2 pr-4 font-medium text-gray-900">{attempt.status}</td>
292
+ <td className="py-2 pr-4 text-gray-700">{attempt.stage}</td>
293
+ <td className="py-2 pr-4 text-gray-700">{attempt.email || '-'}</td>
294
+ <td className="py-2 pr-4 text-gray-700">{attempt.displayName || '-'}</td>
295
+ <td className="max-w-xs py-2 pr-4 text-gray-600">
296
+ {attempt.errorMessage || '-'}
297
+ </td>
298
+ </tr>
299
+ ))
300
+ )}
301
+ </tbody>
302
+ </table>
303
+ </div>
304
+ </section>
305
+
306
+ <section className="rounded-md border border-gray-200 bg-white p-5">
307
+ <h2 className="text-lg font-semibold text-gray-950">Shibboleth Users</h2>
308
+ <div className="mt-4 overflow-x-auto">
309
+ <table className="min-w-full divide-y divide-gray-200 text-sm">
310
+ <thead>
311
+ <tr className="text-left text-xs font-semibold uppercase tracking-wide text-gray-500">
312
+ <th className="py-2 pr-4">User</th>
313
+ <th className="py-2 pr-4">Email</th>
314
+ <th className="py-2 pr-4">Display Name</th>
315
+ <th className="py-2 pr-4">Institutional ID</th>
316
+ <th className="py-2 pr-4">Logins</th>
317
+ <th className="py-2 pr-4">Last Login</th>
318
+ </tr>
319
+ </thead>
320
+ <tbody className="divide-y divide-gray-100">
321
+ {identitiesWithUsers.identities.length === 0 ? (
322
+ <tr>
323
+ <td className="py-3 text-gray-500" colSpan={6}>
324
+ No Shibboleth users have logged in yet.
325
+ </td>
326
+ </tr>
327
+ ) : (
328
+ identitiesWithUsers.identities.map((identity) => {
329
+ const user = identitiesWithUsers.usersById.get(identity.userId);
330
+ return (
331
+ <tr key={identity.id}>
332
+ <td className="py-2 pr-4">
333
+ {user ? (
334
+ <Link
335
+ href={`/admin/users/${encodeURIComponent(user.username)}`}
336
+ className="font-medium text-blue-700 hover:text-blue-900"
337
+ >
338
+ {user.username}
339
+ </Link>
340
+ ) : (
341
+ <span className="text-gray-500">
342
+ Missing user #{identity.userId}
343
+ </span>
344
+ )}
345
+ </td>
346
+ <td className="py-2 pr-4 text-gray-700">{identity.email}</td>
347
+ <td className="py-2 pr-4 text-gray-700">{identity.displayName || '-'}</td>
348
+ <td className="py-2 pr-4 text-gray-700">
349
+ {identity.unstructuredName || identity.eduPersonPrincipalName || '-'}
350
+ </td>
351
+ <td className="py-2 pr-4 text-gray-700">{identity.loginCount}</td>
352
+ <td className="whitespace-nowrap py-2 pr-4 text-gray-700">
353
+ {formatDashboardDateTime(identity.lastLoggedInAt)}
354
+ </td>
355
+ </tr>
356
+ );
357
+ })
358
+ )}
359
+ </tbody>
360
+ </table>
361
+ </div>
362
+ </section>
363
+ </div>
364
+ );
365
+ }
@@ -17,7 +17,7 @@ import {
17
17
  * @private function of <ServersClient/>
18
18
  */
19
19
  const INPUT_CLASS_NAME =
20
- 'w-full rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200';
20
+ 'w-full rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200 dark:border-slate-600 dark:bg-slate-950 dark:text-slate-100 dark:focus:border-sky-400 dark:focus:ring-sky-500/30';
21
21
 
22
22
  /**
23
23
  * Shared secondary button styling used by the registry table.
@@ -25,7 +25,7 @@ const INPUT_CLASS_NAME =
25
25
  * @private function of <ServersClient/>
26
26
  */
27
27
  const SECONDARY_BUTTON_CLASS_NAME =
28
- 'inline-flex items-center gap-2 rounded-md border border-gray-300 bg-white px-3 py-2 text-sm font-semibold text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-60';
28
+ 'inline-flex items-center gap-2 rounded-md border border-gray-300 bg-white px-3 py-2 text-sm font-semibold text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-60 dark:border-slate-600 dark:bg-slate-950 dark:text-slate-100 dark:hover:bg-slate-800';
29
29
 
30
30
  /**
31
31
  * Shared primary button styling used by the registry table.
@@ -196,7 +196,7 @@ function ServersRegistryTableRow(props: ServersRegistryTableRowProps) {
196
196
 
197
197
  return (
198
198
  <>
199
- <tr className={isCurrent ? 'bg-blue-50/40' : 'hover:bg-gray-50'}>
199
+ <tr className={isCurrent ? 'bg-blue-50/40 dark:bg-sky-950/45' : 'hover:bg-gray-50 dark:hover:bg-slate-900/80'}>
200
200
  <td className="px-4 py-3 align-top">
201
201
  <input
202
202
  type="text"
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { CheckCircle2, Download, Loader2, RefreshCcw, Rocket, Server, TriangleAlert } from 'lucide-react';
4
4
  import { useCallback, useEffect, useMemo, useState } from 'react';
5
+ import { AdminXtermTerminal } from '../../../components/AdminTerminal/AdminXtermTerminal';
5
6
  import { Card } from '../../../components/Homepage/Card';
6
7
 
7
8
  /**
@@ -121,6 +122,9 @@ export function UpdateClient() {
121
122
  const isEnvironmentSwitchRequired =
122
123
  Boolean(selectedEnvironment) && selectedEnvironment?.id !== overview?.currentEnvironment.id;
123
124
  const isUpdateRunning = overview?.job.status === 'running';
125
+ const updateTerminalId = `standalone-vps-update:${overview?.job.startedAt || overview?.job.finishedAt || overview?.job.status || 'loading'}`;
126
+ const updateTerminalEmptyState =
127
+ isLoading && !overview ? 'Loading update log...' : 'No persisted update log output yet.';
124
128
 
125
129
  /**
126
130
  * Starts one detached update run for the selected environment.
@@ -406,9 +410,14 @@ export function UpdateClient() {
406
410
  <span className="ml-2 font-mono text-slate-700">{overview.job.logFilePath}</span>
407
411
  </div>
408
412
  )}
409
- <pre className="max-h-[28rem] overflow-auto rounded-xl border border-slate-200 bg-slate-950 p-4 text-xs text-slate-100">
410
- {overview?.job.logTail || 'No persisted update log output yet.'}
411
- </pre>
413
+ <AdminXtermTerminal
414
+ terminalId={updateTerminalId}
415
+ output={overview?.job.logTail || ''}
416
+ emptyState={updateTerminalEmptyState}
417
+ isReadOnly
418
+ isPlainTextOutput
419
+ ariaLabel="Standalone VPS update log"
420
+ />
412
421
  </div>
413
422
  </Card>
414
423
  </div>
@@ -81,7 +81,7 @@ export function UsageClientTimelineChart(props: UsageClientTimelineChartProps) {
81
81
 
82
82
  return (
83
83
  <div>
84
- <div className="w-full overflow-x-auto rounded-lg border border-gray-100 bg-gradient-to-br from-slate-50 via-white to-blue-50 p-2">
84
+ <div className="w-full overflow-x-auto rounded-lg border border-gray-100 bg-gradient-to-br from-slate-50 via-white to-blue-50 p-2 dark:from-slate-950 dark:via-slate-950 dark:to-slate-900">
85
85
  <svg
86
86
  width="100%"
87
87
  viewBox={`0 0 ${chartGeometry.width} ${chartGeometry.height}`}
@@ -86,33 +86,40 @@ export function UserDetailClient({ userId }: UserDetailClientProps) {
86
86
  {t('users.adminRole')}
87
87
  </span>
88
88
  )}
89
+ {user.authenticationProvider?.includes('SHIBBOLETH') && (
90
+ <Link
91
+ href="/admin/login-methods/shibboleth"
92
+ className="ml-2 inline-block bg-emerald-100 text-emerald-800 text-xs px-2 py-1 rounded mt-1 hover:bg-emerald-200"
93
+ >
94
+ Shibboleth
95
+ </Link>
96
+ )}
89
97
  <p className="text-gray-500 text-sm mt-2">
90
98
  {t('users.idLabel')}: {user.id}
91
99
  </p>
100
+ {user.displayName && (
101
+ <p className="text-gray-500 text-sm mt-1">Display name: {user.displayName}</p>
102
+ )}
103
+ {user.email && <p className="text-gray-500 text-sm mt-1">Email: {user.email}</p>}
104
+ {user.authenticationProvider && (
105
+ <p className="text-gray-500 text-sm mt-1">
106
+ Authentication: {user.authenticationProvider}
107
+ </p>
108
+ )}
92
109
  <p className="text-gray-500 text-sm mt-1">
93
110
  {t('users.createdAtLabel')}:{' '}
94
- {user.createdAt
95
- ? new Date(user.createdAt).toLocaleString()
96
- : t('users.unknownValue')}
111
+ {user.createdAt ? new Date(user.createdAt).toLocaleString() : t('users.unknownValue')}
97
112
  </p>
98
113
  <p className="text-gray-500 text-sm mt-1">
99
114
  {t('users.lastUpdatedLabel')}:{' '}
100
- {user.updatedAt
101
- ? new Date(user.updatedAt).toLocaleString()
102
- : t('users.unknownValue')}
115
+ {user.updatedAt ? new Date(user.updatedAt).toLocaleString() : t('users.unknownValue')}
103
116
  </p>
104
117
  </div>
105
118
  <div className="space-x-2">
106
- <button
107
- onClick={handleToggleAdmin}
108
- className="text-sm text-blue-600 hover:text-blue-800"
109
- >
119
+ <button onClick={handleToggleAdmin} className="text-sm text-blue-600 hover:text-blue-800">
110
120
  {user.isAdmin ? t('users.removeAdmin') : t('users.makeAdmin')}
111
121
  </button>
112
- <button
113
- onClick={handleDelete}
114
- className="text-sm text-red-600 hover:text-red-800"
115
- >
122
+ <button onClick={handleDelete} className="text-sm text-red-600 hover:text-red-800">
116
123
  {t('users.deleteUserAction')}
117
124
  </button>
118
125
  </div>