Compare commits

...

71 Commits

Author SHA1 Message Date
github-actions[bot]
0167667aa4 chore: add VERSION v0.4.10.3 2025-11-13 12:56:30 +00:00
Michel Roegl-Brunner
2a0738e102 Merge pull request #316 from community-scripts/fix/310
Fix script slug matching to prevent false positives
2025-11-13 13:46:37 +01:00
Michel Roegl-Brunner
a45f9ae5e6 Fix script slug matching to prevent false positives
- Remove normalized slug/name matching fallback that caused false matches
- Use exact slug-to-slug matching as primary method
- Only use install basename matching for edge cases
- Prevents 'docker-vm' from appearing when only 'docker' is downloaded
2025-11-13 13:37:50 +01:00
Michel Roegl-Brunner
dab2da4b70 Merge pull request #305 from community-scripts/dependabot/npm_and_yarn/jsdom-27.1.0
build(deps-dev): Bump jsdom from 27.0.1 to 27.1.0
2025-11-13 13:27:32 +01:00
Michel Roegl-Brunner
fe00be2e16 Merge pull request #309 from community-scripts/dependabot/npm_and_yarn/tailwindcss-4.1.17
build(deps-dev): Bump tailwindcss from 4.1.16 to 4.1.17
2025-11-13 13:27:24 +01:00
Michel Roegl-Brunner
f88479f6f6 Merge pull request #315 from community-scripts/MickLesk-patch-2
Bump tools.func - includes refactor: deb822 | new functions: setup_mariadb_db, setup_postgresql_db | fixes: setup_uv output, setup_rust (update-handling), setup_java (output bugfix)
2025-11-13 13:26:56 +01:00
Michel Roegl-Brunner
4692c84f7e Merge pull request #314 from community-scripts/MickLesk-patch-1
core.func: push changes from upstream
2025-11-13 13:26:27 +01:00
CanbiZ
36033add28 Bump tools.func: include:
Refactor service stopping logic to handle failures and improve readability. Update repository setup function to streamline parameter validation and cleanup.
2025-11-13 11:44:19 +01:00
CanbiZ
f092de2934 Update core.func 2025-11-13 09:41:42 +01:00
CanbiZ
c92c737c2b core.func: push changes from upstream
Added cleanup_lxc function to manage cache and temporary files for various package managers and programming environments.
2025-11-13 09:41:02 +01:00
dependabot[bot]
70314fdb6d build(deps-dev): Bump tailwindcss from 4.1.16 to 4.1.17
Bumps [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) from 4.1.16 to 4.1.17.
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.1.17/packages/tailwindcss)

---
updated-dependencies:
- dependency-name: tailwindcss
  dependency-version: 4.1.17
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-10 19:38:24 +00:00
dependabot[bot]
735b3dcfa0 build(deps-dev): Bump jsdom from 27.0.1 to 27.1.0
Bumps [jsdom](https://github.com/jsdom/jsdom) from 27.0.1 to 27.1.0.
- [Release notes](https://github.com/jsdom/jsdom/releases)
- [Changelog](https://github.com/jsdom/jsdom/blob/main/Changelog.md)
- [Commits](https://github.com/jsdom/jsdom/compare/27.0.1...27.1.0)

---
updated-dependencies:
- dependency-name: jsdom
  dependency-version: 27.1.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-10 19:37:27 +00:00
Michel Roegl-Brunner
815024fbc0 Merge pull request #304 from community-scripts/fix/275
fix: support IPv6 link-local addresses with zone identifiers
2025-11-10 15:42:30 +01:00
Michel Roegl-Brunner
03fd7bd1e2 fix: support IPv6 link-local addresses with zone identifiers in server settings
- Updated validateServerAddress to handle IPv6 addresses with zone identifiers (e.g., fe80::...%eth0)
- Added validation for zone identifier (interface name)
- Updated placeholder text to include link-local address example
- Fixes issue where link-local addresses like fe80::be24:11ff:fe28:30db%eth0 were rejected
2025-11-10 15:39:31 +01:00
Michel Roegl-Brunner
09868ba651 Merge pull request #303 from community-scripts/fix/289
fix: Add 'Delete only from DB' option for duplicate detected scripts
2025-11-10 15:37:12 +01:00
Michel Roegl-Brunner
b192c46d8d fix: Add 'Delete only from DB' option for duplicate detected scripts
- Add 'Delete only from DB' option in Actions dropdown for SSH scripts with container_id
- Place option after 'Destroy' with separator to distinguish from destructive action
- Update handleDeleteScript to use confirmation modal for SSH scripts
- Modal clearly states it only deletes database record, container remains intact
- Allows users to clean up duplicate script entries without destroying containers
- Fixes issue where duplicates could only be removed by destroying the host
2025-11-10 15:34:59 +01:00
github-actions[bot]
8c474785a0 chore: add VERSION v0.4.10.2 (#302)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-11-10 13:03:08 +00:00
github-actions[bot]
7bf67091b7 chore: add VERSION v0.4.10.2 (#301)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-11-10 13:01:48 +00:00
Michel Roegl-Brunner
c28e1d8dae Update update.sh to not exclude files in srcipts/core from update 2025-11-10 14:00:23 +01:00
Michel Roegl-Brunner
e0a7a44470 Merge pull request #299 from community-scripts/fix/bump_build.func
Fix/bump build.func
2025-11-10 13:50:34 +01:00
Michel Roegl-Brunner
430355aa4b Fix dependencies 2025-11-10 13:49:11 +01:00
Michel Roegl-Brunner
baedcf8581 Fix dependencie issue 2025-11-10 13:36:08 +01:00
Michel Roegl-Brunner
2735d7ecc9 Add missing api.func 2025-11-10 13:32:10 +01:00
Michel Roegl-Brunner
e478a66c75 Hotfix: upload corrected build.func 2025-11-10 13:29:59 +01:00
dependabot[bot]
a9f23f2d28 build(deps-dev): Bump @vitest/coverage-v8 from 3.2.4 to 4.0.8 (#291)
Bumps [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) from 3.2.4 to 4.0.8.
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.0.8/packages/coverage-v8)

---
updated-dependencies:
- dependency-name: "@vitest/coverage-v8"
  dependency-version: 4.0.8
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-10 12:27:21 +01:00
dependabot[bot]
3ec045de25 build(deps): Bump next from 15.5.6 to 16.0.1 (#293)
Bumps [next](https://github.com/vercel/next.js) from 15.5.6 to 16.0.1.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v15.5.6...v16.0.1)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 16.0.1
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-10 12:27:12 +01:00
github-actions[bot]
d9ceac03a2 chore: add VERSION v0.4.10.1 (#298)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-11-10 11:10:00 +00:00
Michel Roegl-Brunner
fbc59d8964 Merge pull request #297 from community-scripts/fix/auth
Fix auth cookie secure flag for HTTP in production
2025-11-10 12:07:46 +01:00
Michel Roegl-Brunner
8c27eacff7 Fix auth cookie secure flag for HTTP in production
- Changed cookie secure flag to check actual request protocol instead of NODE_ENV
- Cookies now work correctly in production when accessing over HTTP
- Fixes authentication redirect issue in production mode
2025-11-10 12:05:47 +01:00
Michel Roegl-Brunner
86056c984d Fix auth loop 2025-11-10 11:57:18 +01:00
Michel Roegl-Brunner
8af011a191 Merge pull request #292 from community-scripts/dependabot/npm_and_yarn/eslint-config-next-16.0.1
build(deps-dev): Bump eslint-config-next from 16.0.0 to 16.0.1
2025-11-10 11:11:01 +01:00
Michel Roegl-Brunner
1dcf4159d6 Merge pull request #294 from community-scripts/dependabot/npm_and_yarn/prisma-6.19.0
build(deps-dev): Bump prisma from 6.18.0 to 6.19.0
2025-11-10 11:10:51 +01:00
Michel Roegl-Brunner
84f664af96 Merge pull request #295 from community-scripts/dependabot/npm_and_yarn/lucide-react-0.553.0
build(deps): Bump lucide-react from 0.548.0 to 0.553.0
2025-11-10 11:10:40 +01:00
dependabot[bot]
5bb10145ad build(deps): Bump lucide-react from 0.548.0 to 0.553.0
Bumps [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) from 0.548.0 to 0.553.0.
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/0.553.0/packages/lucide-react)

---
updated-dependencies:
- dependency-name: lucide-react
  dependency-version: 0.553.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-07 19:24:34 +00:00
dependabot[bot]
08fe6a7843 build(deps-dev): Bump prisma from 6.18.0 to 6.19.0
Bumps [prisma](https://github.com/prisma/prisma/tree/HEAD/packages/cli) from 6.18.0 to 6.19.0.
- [Release notes](https://github.com/prisma/prisma/releases)
- [Commits](https://github.com/prisma/prisma/commits/6.19.0/packages/cli)

---
updated-dependencies:
- dependency-name: prisma
  dependency-version: 6.19.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-07 19:24:24 +00:00
dependabot[bot]
ed3b4c7b19 build(deps-dev): Bump eslint-config-next from 16.0.0 to 16.0.1
Bumps [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next) from 16.0.0 to 16.0.1.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/commits/v16.0.1/packages/eslint-config-next)

---
updated-dependencies:
- dependency-name: eslint-config-next
  dependency-version: 16.0.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-07 19:23:42 +00:00
github-actions[bot]
930254d4cb chore: add VERSION v0.4.10 (#288)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-11-07 14:00:20 +00:00
Michel Roegl-Brunner
9e450ecbd1 Merge pull request #287 from community-scripts/feat/250_chose_version
feat: Add alpine variant support for LXC scripts
2025-11-07 14:55:21 +01:00
Michel Roegl-Brunner
cf3f9a5479 feat: Add version toggle in TextViewer for default/alpine variants
- Add version selection toggle (Default/Alpine) in TextViewer component
- Load both default and alpine versions of CT and install scripts
- Display correct script content based on selected version
- Pass script object to TextViewer to detect alpine variants
- Show toggle buttons only when alpine variant exists
2025-11-07 14:51:12 +01:00
Michel Roegl-Brunner
6ad18e185e feat: Add alpine variant support for LXC scripts
- Download alpine install scripts (alpine-{slug}-install.sh) when alpine variant exists
- Add ScriptVersionModal component for version selection (default/alpine)
- Update ScriptDetailModal to show version selection before server selection
- Update script execution to use selected version type
- Support downloading both default and alpine variants of scripts
2025-11-07 14:44:34 +01:00
Michel Roegl-Brunner
42fbdc87da Merge pull request #286 from community-scripts/feat/252_sessions
feat: Add persistent session authentication with configurable duration
2025-11-07 13:35:26 +01:00
Michel Roegl-Brunner
8e2286d847 feat: Add persistent session authentication with configurable duration
- Implement persistent session authentication with httpOnly cookies
- Add configurable session duration (1-365 days) in settings
- Add session expiration display in settings modal
- Add logout button next to theme toggle
- Enhance token verification to return expiration time
- Add retry logic for failed auth checks
- Add comprehensive authentication documentation to help modal
- Improve session restoration on page load
2025-11-07 13:31:16 +01:00
Michel Roegl-Brunner
73776ec6ac Merge pull request #285 from community-scripts/feat/allow_fdqn_and_ipv6_for_servers
feat: Add support for FQDN, IPv4, and IPv6 addresses in server settings
2025-11-07 13:20:15 +01:00
Michel Roegl-Brunner
596887d19c feat: Add support for FQDN, IPv4, and IPv6 addresses in server settings
- Replace IPv4-only validation with comprehensive address validation
- Support IPv4 addresses (e.g., 192.168.1.1)
- Support IPv6 addresses including compressed format (e.g., ::1, 2001:db8::1)
- Support FQDN/hostnames (e.g., server.example.com, localhost)
- Update UI label from 'IP Address' to 'Host/IP Address'
- Update placeholder text with examples for all supported formats
- Update error messages to reflect new validation capabilities
2025-11-07 13:17:42 +01:00
Michel Roegl-Brunner
0676772992 Merge pull request #283 from community-scripts/feat/delete_downloaded_scripts
Add delete script functionality
2025-11-07 13:12:38 +01:00
Michel Roegl-Brunner
72a9ea52b0 Merge pull request #284 from community-scripts/bump/tools.func
Bump tools.func - update helper functions and package management utilities
2025-11-07 13:12:27 +01:00
Michel Roegl-Brunner
826ee26cdf Bump tools.func - update helper functions and package management utilities 2025-11-07 13:11:47 +01:00
Michel Roegl-Brunner
82d14f1fb1 Add delete script functionality
- Add deleteScript method to ScriptDownloaderService (both .ts and .js)
- Add deleteScript API endpoint to scripts router
- Add delete button to ScriptDetailModal with confirmation modal
- Use ConfirmationModal component instead of plain window.confirm
- Delete button only shows when script files exist locally
- Includes proper error handling and success/error messages
2025-11-07 13:10:37 +01:00
Michel Roegl-Brunner
220b610466 Merge pull request #282 from community-scripts/fix/273
Fix x button vertical centering in filter scripts search bar
2025-11-07 13:04:45 +01:00
Michel Roegl-Brunner
e460a05f91 Fix x button vertical centering in filter scripts search bar
- Removed size='icon' constraint that limited button height
- Changed positioning to use inset-y-0 with h-full to match input field height
- Added flex items-center justify-center for proper icon centering
- Fixes hover background height mismatch between light and dark themes
2025-11-07 13:03:22 +01:00
Michel Roegl-Brunner
21f723bff6 Merge pull request #281 from community-scripts/fix/270
Add minimize buttons to FilterBar and Newest Scripts sections
2025-11-07 13:00:20 +01:00
Michel Roegl-Brunner
45ba67c827 Add minimize buttons to FilterBar and Newest Scripts sections
- Add minimize/collapse functionality to FilterBar component
- Add minimize/collapse functionality to Newest Scripts section
- Hide Newest Scripts section when user is searching, filtering, or viewing a category
- Both sections can now be minimized to save screen space
2025-11-07 12:58:22 +01:00
Michel Roegl-Brunner
61904f2936 Merge pull request #280 from community-scripts/fix/269
Add Interface Port display to script detail modal header
2025-11-07 12:55:47 +01:00
Michel Roegl-Brunner
237aee9c46 Add Interface Port display to script detail modal header
- Display port in header next to script name for better visibility
- Position port close to name/logo section
- Keep port display in Basic Information section as well
- Style port with badge-like appearance for prominence
2025-11-07 12:53:54 +01:00
Michel Roegl-Brunner
6bd402abea Merge pull request #279 from community-scripts/fix/262
Fix stale LXC entries and improve orphaned script cleanup
2025-11-07 12:50:35 +01:00
Michel Roegl-Brunner
bd3ca74175 Fix stale LXC entries and improve orphaned script cleanup
- Improved cleanupOrphanedScripts to use pct list for more reliable container verification
- Added batch processing by server for better efficiency
- Added double-check with config file existence before deletion
- Added manual cleanup button in Installed Scripts tab for on-demand cleanup
- Improved error handling and logging throughout cleanup process
- Fixes issue where deleted containers (like Planka) were still showing in the UI
2025-11-07 12:47:19 +01:00
Michel Roegl-Brunner
9fc61bb416 Merge pull request #278 from community-scripts/fix/253
Fix: Include 'New' scripts in Download All Filtered when no filters are active
2025-11-07 12:44:32 +01:00
Michel Roegl-Brunner
75d52c771f Fix: Include 'New' scripts in Download All Filtered when no filters are active
- Modified handleDownloadAllFiltered to combine filteredScripts and newestScripts when no filters are active
- Ensures all scripts including those in the 'Newest Scripts' carousel are downloaded
- Maintains existing behavior when filters are active
- Fixes issue where 'New' scripts were excluded from downloads
2025-11-07 12:42:57 +01:00
Michel Roegl-Brunner
c868b008c5 Merge pull request #277 from community-scripts/fix/251
Fix Install button not appearing for VM and tools scripts
2025-11-07 12:41:34 +01:00
Michel Roegl-Brunner
7f1ef91db9 Fix vitest dependency version mismatch
- Downgrade @vitest/coverage-v8 from ^4.0.4 to ^3.2.4 to match vitest@^3.2.4
- Resolves npm ci dependency conflict error
2025-11-07 12:39:46 +01:00
Michel Roegl-Brunner
2ee5f73117 Fix Install button not appearing for VM and tools scripts
- Update checkScriptExists to preserve subdirectory structure for tools and VM scripts
- Set ctExists=true for tools and VM scripts when files exist (not just CT scripts)
- Add missing compareScriptContent method to JavaScript version
- Remove all VW script references as they don't exist
- Fixes issue where Install button only showed Load Script button after downloading VM/tools scripts
2025-11-07 12:36:43 +01:00
dependabot[bot]
881456e74e build(deps): Bump @prisma/client from 6.17.1 to 6.18.0 (#256)
Bumps [@prisma/client](https://github.com/prisma/prisma/tree/HEAD/packages/client) from 6.17.1 to 6.18.0.
- [Release notes](https://github.com/prisma/prisma/releases)
- [Commits](https://github.com/prisma/prisma/commits/6.18.0/packages/client)

---
updated-dependencies:
- dependency-name: "@prisma/client"
  dependency-version: 6.18.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-27 13:16:36 -07:00
dependabot[bot]
650afd5872 build(deps-dev): Bump @vitest/coverage-v8 from 4.0.3 to 4.0.4 (#255)
Bumps [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) from 4.0.3 to 4.0.4.
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.0.4/packages/coverage-v8)

---
updated-dependencies:
- dependency-name: "@vitest/coverage-v8"
  dependency-version: 4.0.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-27 13:16:27 -07:00
dependabot[bot]
f0dc393358 build(deps-dev): Bump @vitest/coverage-v8 from 3.2.4 to 4.0.3 (#243)
Bumps [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) from 3.2.4 to 4.0.3.
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.0.3/packages/coverage-v8)

---
updated-dependencies:
- dependency-name: "@vitest/coverage-v8"
  dependency-version: 4.0.3
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-24 22:11:55 -07:00
Michel Roegl-Brunner
db72fea774 Merge pull request #241 from community-scripts/dependabot/npm_and_yarn/lucide-react-0.548.0 2025-10-24 23:48:47 +02:00
Michel Roegl-Brunner
24896f9c0d Merge pull request #245 from community-scripts/dependabot/npm_and_yarn/tailwindcss/postcss-4.1.16 2025-10-24 23:48:28 +02:00
Michel Roegl-Brunner
bf434e3b39 Merge pull request #242 from community-scripts/dependabot/npm_and_yarn/vitejs/plugin-react-5.1.0 2025-10-24 23:48:15 +02:00
github-actions[bot]
7699858a35 chore: add VERSION v0.4.9 (#248)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-10-24 20:39:11 +00:00
dependabot[bot]
962a3b9908 build(deps-dev): Bump @tailwindcss/postcss from 4.1.15 to 4.1.16
Bumps [@tailwindcss/postcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-postcss) from 4.1.15 to 4.1.16.
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.1.16/packages/@tailwindcss-postcss)

---
updated-dependencies:
- dependency-name: "@tailwindcss/postcss"
  dependency-version: 4.1.16
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-24 19:38:29 +00:00
dependabot[bot]
a1fe386efd build(deps-dev): Bump @vitejs/plugin-react from 5.0.4 to 5.1.0
Bumps [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) from 5.0.4 to 5.1.0.
- [Release notes](https://github.com/vitejs/vite-plugin-react/releases)
- [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite-plugin-react/commits/plugin-react@5.1.0/packages/plugin-react)

---
updated-dependencies:
- dependency-name: "@vitejs/plugin-react"
  dependency-version: 5.1.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-24 19:37:55 +00:00
dependabot[bot]
aa76e17e25 build(deps): Bump lucide-react from 0.546.0 to 0.548.0
Bumps [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) from 0.546.0 to 0.548.0.
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/0.548.0/packages/lucide-react)

---
updated-dependencies:
- dependency-name: lucide-react
  dependency-version: 0.548.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-24 19:37:40 +00:00
30 changed files with 4238 additions and 1419 deletions

View File

@@ -1 +1 @@
0.4.8
0.4.10.3

View File

@@ -52,6 +52,14 @@ const config = {
}
return config;
},
// Ignore ESLint errors during build (they can be fixed separately)
eslint: {
ignoreDuringBuilds: true,
},
// Ignore TypeScript errors during build (they can be fixed separately)
typescript: {
ignoreBuildErrors: true,
},
};
export default config;

442
package-lock.json generated
View File

@@ -8,7 +8,7 @@
"name": "pve-scripts-local",
"version": "0.1.0",
"dependencies": {
"@prisma/client": "^6.17.1",
"@prisma/client": "^6.18.0",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-slot": "^1.2.3",
"@t3-oss/env-nextjs": "^0.13.8",
@@ -29,8 +29,8 @@
"cron-validator": "^1.2.0",
"dotenv": "^17.2.3",
"jsonwebtoken": "^9.0.2",
"lucide-react": "^0.546.0",
"next": "^15.5.6",
"lucide-react": "^0.553.0",
"next": "^15.1.6",
"node-cron": "^3.0.3",
"node-pty": "^1.0.0",
"react": "^19.0.0",
@@ -48,7 +48,7 @@
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.1",
"@tailwindcss/postcss": "^4.1.15",
"@tailwindcss/postcss": "^4.1.16",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
@@ -59,22 +59,29 @@
"@types/node-cron": "^3.0.11",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.2.2",
"@vitejs/plugin-react": "^5.0.2",
"@vitejs/plugin-react": "^5.1.0",
"@vitest/coverage-v8": "^3.2.4",
"@vitest/ui": "^3.2.4",
"eslint": "^9.38.0",
"eslint-config-next": "^16.0.0",
"jsdom": "^27.0.1",
"eslint-config-next": "^15.1.6",
"jsdom": "^27.1.0",
"postcss": "^8.5.3",
"prettier": "^3.5.3",
"prettier-plugin-tailwindcss": "^0.7.1",
"prisma": "^6.18.0",
"tailwindcss": "^4.1.16",
"prisma": "^6.19.0",
"tailwindcss": "^4.1.17",
"typescript": "^5.8.2",
"typescript-eslint": "^8.46.2",
"vitest": "^3.2.4"
}
},
"node_modules/@acemir/cssom": {
"version": "0.9.23",
"resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.23.tgz",
"integrity": "sha512-2kJ1HxBKzPLbmhZpxBiTZggjtgCwKg1ma5RHShxvd6zgqhDEdEkzpiwe7jLkI2p2BrZvFCXIihdoMkl1H39VnA==",
"dev": true,
"license": "MIT"
},
"node_modules/@adobe/css-tools": {
"version": "4.4.4",
"resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz",
@@ -134,9 +141,9 @@
}
},
"node_modules/@asamuzakjp/dom-selector": {
"version": "6.7.2",
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.2.tgz",
"integrity": "sha512-ccKogJI+0aiDhOahdjANIc9SDixSud1gbwdVrhn7kMopAtLXqsz9MKmQQtIl6Y5aC2IYq+j4dz/oedL2AVMmVQ==",
"version": "6.7.4",
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.4.tgz",
"integrity": "sha512-buQDjkm+wDPXd6c13534URWZqbz0RP5PAhXZ+LIoa5LgwInT9HVJvGIJivg75vi8I13CxDGdTnz+aY5YUJlIAA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -317,9 +324,9 @@
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz",
"integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==",
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
"dev": true,
"license": "MIT",
"engines": {
@@ -351,13 +358,13 @@
}
},
"node_modules/@babel/parser": {
"version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz",
"integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==",
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz",
"integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.28.4"
"@babel/types": "^7.28.5"
},
"bin": {
"parser": "bin/babel-parser.js"
@@ -442,14 +449,14 @@
}
},
"node_modules/@babel/types": {
"version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz",
"integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==",
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz",
"integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.27.1",
"@babel/helper-validator-identifier": "^7.27.1"
"@babel/helper-validator-identifier": "^7.28.5"
},
"engines": {
"node": ">=6.9.0"
@@ -561,9 +568,9 @@
}
},
"node_modules/@csstools/css-syntax-patches-for-csstree": {
"version": "1.0.14",
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.14.tgz",
"integrity": "sha512-zSlIxa20WvMojjpCSy8WrNpcZ61RqfTfX3XTaOeVlGJrt/8HF3YbzgFZa01yTbT4GWQLwfTcC3EB8i3XnB647Q==",
"version": "1.0.15",
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.15.tgz",
"integrity": "sha512-q0p6zkVq2lJnmzZVPR33doA51G7YOja+FBvRdp5ISIthL0MtFCgYHHhR563z9WFGxcOn0WfjSkPDJ5Qig3H3Sw==",
"dev": true,
"funding": [
{
@@ -578,9 +585,6 @@
"license": "MIT-0",
"engines": {
"node": ">=18"
},
"peerDependencies": {
"postcss": "^8.4"
}
},
"node_modules/@csstools/css-tokenizer": {
@@ -1838,9 +1842,9 @@
"license": "MIT"
},
"node_modules/@next/eslint-plugin-next": {
"version": "16.0.0",
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.0.0.tgz",
"integrity": "sha512-IB7RzmmtrPOrpAgEBR1PIQPD0yea5lggh5cq54m51jHjjljU80Ia+czfxJYMlSDl1DPvpzb8S9TalCc0VMo9Hw==",
"version": "15.5.6",
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-15.5.6.tgz",
"integrity": "sha512-YxDvsT2fwy1j5gMqk3ppXlsgDopHnkM4BoxSVASbvvgh5zgsK8lvWerDzPip8k3WVzsTZ1O7A7si1KNfN4OZfQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2042,9 +2046,9 @@
"license": "MIT"
},
"node_modules/@prisma/client": {
"version": "6.17.1",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.17.1.tgz",
"integrity": "sha512-zL58jbLzYamjnNnmNA51IOZdbk5ci03KviXCuB0Tydc9btH2kDWsi1pQm2VecviRTM7jGia0OPPkgpGnT3nKvw==",
"version": "6.18.0",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.18.0.tgz",
"integrity": "sha512-jnL2I9gDnPnw4A+4h5SuNn8Gc+1mL1Z79U/3I9eE2gbxJG1oSA+62ByPW4xkeDgwE0fqMzzpAZ7IHxYnLZ4iQA==",
"hasInstallScript": true,
"license": "Apache-2.0",
"engines": {
@@ -2064,9 +2068,9 @@
}
},
"node_modules/@prisma/config": {
"version": "6.18.0",
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.18.0.tgz",
"integrity": "sha512-rgFzspCpwsE+q3OF/xkp0fI2SJ3PfNe9LLMmuSVbAZ4nN66WfBiKqJKo/hLz3ysxiPQZf8h1SMf2ilqPMeWATQ==",
"version": "6.19.0",
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.19.0.tgz",
"integrity": "sha512-zwCayme+NzI/WfrvFEtkFhhOaZb/hI+X8TTjzjJ252VbPxAl2hWHK5NMczmnG9sXck2lsXrxIZuK524E25UNmg==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
@@ -2077,53 +2081,53 @@
}
},
"node_modules/@prisma/debug": {
"version": "6.18.0",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.18.0.tgz",
"integrity": "sha512-PMVPMmxPj0ps1VY75DIrT430MoOyQx9hmm174k6cmLZpcI95rAPXOQ+pp8ANQkJtNyLVDxnxVJ0QLbrm/ViBcg==",
"version": "6.19.0",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.0.tgz",
"integrity": "sha512-8hAdGG7JmxrzFcTzXZajlQCidX0XNkMJkpqtfbLV54wC6LSSX6Vni25W/G+nAANwLnZ2TmwkfIuWetA7jJxJFA==",
"devOptional": true,
"license": "Apache-2.0"
},
"node_modules/@prisma/engines": {
"version": "6.18.0",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.18.0.tgz",
"integrity": "sha512-i5RzjGF/ex6AFgqEe2o1IW8iIxJGYVQJVRau13kHPYEL1Ck8Zvwuzamqed/1iIljs5C7L+Opiz5TzSsUebkriA==",
"version": "6.19.0",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.0.tgz",
"integrity": "sha512-pMRJ+1S6NVdXoB8QJAPIGpKZevFjxhKt0paCkRDTZiczKb7F4yTgRP8M4JdVkpQwmaD4EoJf6qA+p61godDokw==",
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "6.18.0",
"@prisma/engines-version": "6.18.0-8.34b5a692b7bd79939a9a2c3ef97d816e749cda2f",
"@prisma/fetch-engine": "6.18.0",
"@prisma/get-platform": "6.18.0"
"@prisma/debug": "6.19.0",
"@prisma/engines-version": "6.19.0-26.2ba551f319ab1df4bc874a89965d8b3641056773",
"@prisma/fetch-engine": "6.19.0",
"@prisma/get-platform": "6.19.0"
}
},
"node_modules/@prisma/engines-version": {
"version": "6.18.0-8.34b5a692b7bd79939a9a2c3ef97d816e749cda2f",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.18.0-8.34b5a692b7bd79939a9a2c3ef97d816e749cda2f.tgz",
"integrity": "sha512-T7Af4QsJQnSgWN1zBbX+Cha5t4qjHRxoeoWpK4JugJzG/ipmmDMY5S+O0N1ET6sCBNVkf6lz+Y+ZNO9+wFU8pQ==",
"version": "6.19.0-26.2ba551f319ab1df4bc874a89965d8b3641056773",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.19.0-26.2ba551f319ab1df4bc874a89965d8b3641056773.tgz",
"integrity": "sha512-gV7uOBQfAFlWDvPJdQxMT1aSRur3a0EkU/6cfbAC5isV67tKDWUrPauyaHNpB+wN1ebM4A9jn/f4gH+3iHSYSQ==",
"devOptional": true,
"license": "Apache-2.0"
},
"node_modules/@prisma/fetch-engine": {
"version": "6.18.0",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.18.0.tgz",
"integrity": "sha512-TdaBvTtBwP3IoqVYoGIYpD4mWlk0pJpjTJjir/xLeNWlwog7Sl3bD2J0jJ8+5+q/6RBg+acb9drsv5W6lqae7A==",
"version": "6.19.0",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.0.tgz",
"integrity": "sha512-OOx2Lda0DGrZ1rodADT06ZGqHzr7HY7LNMaFE2Vp8dp146uJld58sRuasdX0OiwpHgl8SqDTUKHNUyzEq7pDdQ==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "6.18.0",
"@prisma/engines-version": "6.18.0-8.34b5a692b7bd79939a9a2c3ef97d816e749cda2f",
"@prisma/get-platform": "6.18.0"
"@prisma/debug": "6.19.0",
"@prisma/engines-version": "6.19.0-26.2ba551f319ab1df4bc874a89965d8b3641056773",
"@prisma/get-platform": "6.19.0"
}
},
"node_modules/@prisma/get-platform": {
"version": "6.18.0",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.18.0.tgz",
"integrity": "sha512-uXNJCJGhxTCXo2B25Ta91Rk1/Nmlqg9p7G9GKh8TPhxvAyXCvMNQoogj4JLEUy+3ku8g59cpyQIKFhqY2xO2bg==",
"version": "6.19.0",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.0.tgz",
"integrity": "sha512-ym85WDO2yDhC3fIXHWYpG3kVMBA49cL1XD2GCsCF8xbwoy2OkDQY44gEbAt2X46IQ4Apq9H6g0Ex1iFfPqEkHA==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "6.18.0"
"@prisma/debug": "6.19.0"
}
},
"node_modules/@radix-ui/primitive": {
@@ -2660,9 +2664,9 @@
"license": "MIT"
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.38",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.38.tgz",
"integrity": "sha512-N/ICGKleNhA5nc9XXQG/kkKHJ7S55u0x0XUJbbkmdCnFuoRkM1Il12q9q0eX19+M7KKUEPw/daUPIRnxhcxAIw==",
"version": "1.0.0-beta.43",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.43.tgz",
"integrity": "sha512-5Uxg7fQUCmfhax7FJke2+8B6cqgeUJUD9o2uXIKXhD+mG0mL6NObmVoi9wXEU1tY89mZKgAYA6fTbftx3q2ZPQ==",
"dev": true,
"license": "MIT"
},
@@ -2981,6 +2985,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/@rushstack/eslint-patch": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.14.1.tgz",
"integrity": "sha512-jGTk8UD/RdjsNZW8qq10r0RBvxL8OWtoT+kImlzPDFilmozzM+9QmIJsmze9UiSBrFU45ZxhTYBypn9q9z/VfQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@standard-schema/spec": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
@@ -3053,56 +3064,56 @@
}
},
"node_modules/@tailwindcss/node": {
"version": "4.1.15",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.15.tgz",
"integrity": "sha512-HF4+7QxATZWY3Jr8OlZrBSXmwT3Watj0OogeDvdUY/ByXJHQ+LBtqA2brDb3sBxYslIFx6UP94BJ4X6a4L9Bmw==",
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.16.tgz",
"integrity": "sha512-BX5iaSsloNuvKNHRN3k2RcCuTEgASTo77mofW0vmeHkfrDWaoFAFvNHpEgtu0eqyypcyiBkDWzSMxJhp3AUVcw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/remapping": "^2.3.4",
"enhanced-resolve": "^5.18.3",
"jiti": "^2.6.0",
"jiti": "^2.6.1",
"lightningcss": "1.30.2",
"magic-string": "^0.30.19",
"source-map-js": "^1.2.1",
"tailwindcss": "4.1.15"
"tailwindcss": "4.1.16"
}
},
"node_modules/@tailwindcss/node/node_modules/tailwindcss": {
"version": "4.1.15",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.15.tgz",
"integrity": "sha512-k2WLnWkYFkdpRv+Oby3EBXIyQC8/s1HOFMBUViwtAh6Z5uAozeUSMQlIsn/c6Q2iJzqG6aJT3wdPaRNj70iYxQ==",
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.16.tgz",
"integrity": "sha512-pONL5awpaQX4LN5eiv7moSiSPd/DLDzKVRJz8Q9PgzmAdd1R4307GQS2ZpfiN7ZmekdQrfhZZiSE5jkLR4WNaA==",
"dev": true,
"license": "MIT"
},
"node_modules/@tailwindcss/oxide": {
"version": "4.1.15",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.15.tgz",
"integrity": "sha512-krhX+UOOgnsUuks2SR7hFafXmLQrKxB4YyRTERuCE59JlYL+FawgaAlSkOYmDRJdf1Q+IFNDMl9iRnBW7QBDfQ==",
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.16.tgz",
"integrity": "sha512-2OSv52FRuhdlgyOQqgtQHuCgXnS8nFSYRp2tJ+4WZXKgTxqPy7SMSls8c3mPT5pkZ17SBToGM5LHEJBO7miEdg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 10"
},
"optionalDependencies": {
"@tailwindcss/oxide-android-arm64": "4.1.15",
"@tailwindcss/oxide-darwin-arm64": "4.1.15",
"@tailwindcss/oxide-darwin-x64": "4.1.15",
"@tailwindcss/oxide-freebsd-x64": "4.1.15",
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.15",
"@tailwindcss/oxide-linux-arm64-gnu": "4.1.15",
"@tailwindcss/oxide-linux-arm64-musl": "4.1.15",
"@tailwindcss/oxide-linux-x64-gnu": "4.1.15",
"@tailwindcss/oxide-linux-x64-musl": "4.1.15",
"@tailwindcss/oxide-wasm32-wasi": "4.1.15",
"@tailwindcss/oxide-win32-arm64-msvc": "4.1.15",
"@tailwindcss/oxide-win32-x64-msvc": "4.1.15"
"@tailwindcss/oxide-android-arm64": "4.1.16",
"@tailwindcss/oxide-darwin-arm64": "4.1.16",
"@tailwindcss/oxide-darwin-x64": "4.1.16",
"@tailwindcss/oxide-freebsd-x64": "4.1.16",
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.16",
"@tailwindcss/oxide-linux-arm64-gnu": "4.1.16",
"@tailwindcss/oxide-linux-arm64-musl": "4.1.16",
"@tailwindcss/oxide-linux-x64-gnu": "4.1.16",
"@tailwindcss/oxide-linux-x64-musl": "4.1.16",
"@tailwindcss/oxide-wasm32-wasi": "4.1.16",
"@tailwindcss/oxide-win32-arm64-msvc": "4.1.16",
"@tailwindcss/oxide-win32-x64-msvc": "4.1.16"
}
},
"node_modules/@tailwindcss/oxide-android-arm64": {
"version": "4.1.15",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.15.tgz",
"integrity": "sha512-TkUkUgAw8At4cBjCeVCRMc/guVLKOU1D+sBPrHt5uVcGhlbVKxrCaCW9OKUIBv1oWkjh4GbunD/u/Mf0ql6kEA==",
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.16.tgz",
"integrity": "sha512-8+ctzkjHgwDJ5caq9IqRSgsP70xhdhJvm+oueS/yhD5ixLhqTw9fSL1OurzMUhBwE5zK26FXLCz2f/RtkISqHA==",
"cpu": [
"arm64"
],
@@ -3117,9 +3128,9 @@
}
},
"node_modules/@tailwindcss/oxide-darwin-arm64": {
"version": "4.1.15",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.15.tgz",
"integrity": "sha512-xt5XEJpn2piMSfvd1UFN6jrWXyaKCwikP4Pidcf+yfHTSzSpYhG3dcMktjNkQO3JiLCp+0bG0HoWGvz97K162w==",
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.16.tgz",
"integrity": "sha512-C3oZy5042v2FOALBZtY0JTDnGNdS6w7DxL/odvSny17ORUnaRKhyTse8xYi3yKGyfnTUOdavRCdmc8QqJYwFKA==",
"cpu": [
"arm64"
],
@@ -3134,9 +3145,9 @@
}
},
"node_modules/@tailwindcss/oxide-darwin-x64": {
"version": "4.1.15",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.15.tgz",
"integrity": "sha512-TnWaxP6Bx2CojZEXAV2M01Yl13nYPpp0EtGpUrY+LMciKfIXiLL2r/SiSRpagE5Fp2gX+rflp/Os1VJDAyqymg==",
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.16.tgz",
"integrity": "sha512-vjrl/1Ub9+JwU6BP0emgipGjowzYZMjbWCDqwA2Z4vCa+HBSpP4v6U2ddejcHsolsYxwL5r4bPNoamlV0xDdLg==",
"cpu": [
"x64"
],
@@ -3151,9 +3162,9 @@
}
},
"node_modules/@tailwindcss/oxide-freebsd-x64": {
"version": "4.1.15",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.15.tgz",
"integrity": "sha512-quISQDWqiB6Cqhjc3iWptXVZHNVENsWoI77L1qgGEHNIdLDLFnw3/AfY7DidAiiCIkGX/MjIdB3bbBZR/G2aJg==",
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.16.tgz",
"integrity": "sha512-TSMpPYpQLm+aR1wW5rKuUuEruc/oOX3C7H0BTnPDn7W/eMw8W+MRMpiypKMkXZfwH8wqPIRKppuZoedTtNj2tg==",
"cpu": [
"x64"
],
@@ -3168,9 +3179,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
"version": "4.1.15",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.15.tgz",
"integrity": "sha512-ObG76+vPlab65xzVUQbExmDU9FIeYLQ5k2LrQdR2Ud6hboR+ZobXpDoKEYXf/uOezOfIYmy2Ta3w0ejkTg9yxg==",
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.16.tgz",
"integrity": "sha512-p0GGfRg/w0sdsFKBjMYvvKIiKy/LNWLWgV/plR4lUgrsxFAoQBFrXkZ4C0w8IOXfslB9vHK/JGASWD2IefIpvw==",
"cpu": [
"arm"
],
@@ -3185,9 +3196,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
"version": "4.1.15",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.15.tgz",
"integrity": "sha512-4WbBacRmk43pkb8/xts3wnOZMDKsPFyEH/oisCm2q3aLZND25ufvJKcDUpAu0cS+CBOL05dYa8D4U5OWECuH/Q==",
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.16.tgz",
"integrity": "sha512-DoixyMmTNO19rwRPdqviTrG1rYzpxgyYJl8RgQvdAQUzxC1ToLRqtNJpU/ATURSKgIg6uerPw2feW0aS8SNr/w==",
"cpu": [
"arm64"
],
@@ -3202,9 +3213,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
"version": "4.1.15",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.15.tgz",
"integrity": "sha512-AbvmEiteEj1nf42nE8skdHv73NoR+EwXVSgPY6l39X12Ex8pzOwwfi3Kc8GAmjsnsaDEbk+aj9NyL3UeyHcTLg==",
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.16.tgz",
"integrity": "sha512-H81UXMa9hJhWhaAUca6bU2wm5RRFpuHImrwXBUvPbYb+3jo32I9VIwpOX6hms0fPmA6f2pGVlybO6qU8pF4fzQ==",
"cpu": [
"arm64"
],
@@ -3219,9 +3230,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
"version": "4.1.15",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.15.tgz",
"integrity": "sha512-+rzMVlvVgrXtFiS+ES78yWgKqpThgV19ISKD58Ck+YO5pO5KjyxLt7AWKsWMbY0R9yBDC82w6QVGz837AKQcHg==",
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.16.tgz",
"integrity": "sha512-ZGHQxDtFC2/ruo7t99Qo2TTIvOERULPl5l0K1g0oK6b5PGqjYMga+FcY1wIUnrUxY56h28FxybtDEla+ICOyew==",
"cpu": [
"x64"
],
@@ -3236,9 +3247,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
"version": "4.1.15",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.15.tgz",
"integrity": "sha512-fPdEy7a8eQN9qOIK3Em9D3TO1z41JScJn8yxl/76mp4sAXFDfV4YXxsiptJcOwy6bGR+70ZSwFIZhTXzQeqwQg==",
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.16.tgz",
"integrity": "sha512-Oi1tAaa0rcKf1Og9MzKeINZzMLPbhxvm7rno5/zuP1WYmpiG0bEHq4AcRUiG2165/WUzvxkW4XDYCscZWbTLZw==",
"cpu": [
"x64"
],
@@ -3253,9 +3264,9 @@
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
"version": "4.1.15",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.15.tgz",
"integrity": "sha512-sJ4yd6iXXdlgIMfIBXuVGp/NvmviEoMVWMOAGxtxhzLPp9LOj5k0pMEMZdjeMCl4C6Up+RM8T3Zgk+BMQ0bGcQ==",
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.16.tgz",
"integrity": "sha512-B01u/b8LteGRwucIBmCQ07FVXLzImWESAIMcUU6nvFt/tYsQ6IHz8DmZ5KtvmwxD+iTYBtM1xwoGXswnlu9v0Q==",
"bundleDependencies": [
"@napi-rs/wasm-runtime",
"@emnapi/core",
@@ -3343,9 +3354,9 @@
"optional": true
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.1.15",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.15.tgz",
"integrity": "sha512-sJGE5faXnNQ1iXeqmRin7Ds/ru2fgCiaQZQQz3ZGIDtvbkeV85rAZ0QJFMDg0FrqsffZG96H1U9AQlNBRLsHVg==",
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.16.tgz",
"integrity": "sha512-zX+Q8sSkGj6HKRTMJXuPvOcP8XfYON24zJBRPlszcH1Np7xuHXhWn8qfFjIujVzvH3BHU+16jBXwgpl20i+v9A==",
"cpu": [
"arm64"
],
@@ -3360,9 +3371,9 @@
}
},
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
"version": "4.1.15",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.15.tgz",
"integrity": "sha512-NLeHE7jUV6HcFKS504bpOohyi01zPXi2PXmjFfkzTph8xRxDdxkRsXm/xDO5uV5K3brrE1cCwbUYmFUSHR3u1w==",
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.16.tgz",
"integrity": "sha512-m5dDFJUEejbFqP+UXVstd4W/wnxA4F61q8SoL+mqTypId2T2ZpuxosNSgowiCnLp2+Z+rivdU0AqpfgiD7yCBg==",
"cpu": [
"x64"
],
@@ -3377,23 +3388,23 @@
}
},
"node_modules/@tailwindcss/postcss": {
"version": "4.1.15",
"resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.15.tgz",
"integrity": "sha512-IZh8IT76KujRz6d15wZw4eoeViT4TqmzVWNNfpuNCTKiaZUwgr5vtPqO4HjuYDyx3MgGR5qgPt1HMzTeLJyA3g==",
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.16.tgz",
"integrity": "sha512-Qn3SFGPXYQMKR/UtqS+dqvPrzEeBZHrFA92maT4zijCVggdsXnDBMsPFJo1eArX3J+O+Gi+8pV4PkqjLCNBk3A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"@tailwindcss/node": "4.1.15",
"@tailwindcss/oxide": "4.1.15",
"@tailwindcss/node": "4.1.16",
"@tailwindcss/oxide": "4.1.16",
"postcss": "^8.4.41",
"tailwindcss": "4.1.15"
"tailwindcss": "4.1.16"
}
},
"node_modules/@tailwindcss/postcss/node_modules/tailwindcss": {
"version": "4.1.15",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.15.tgz",
"integrity": "sha512-k2WLnWkYFkdpRv+Oby3EBXIyQC8/s1HOFMBUViwtAh6Z5uAozeUSMQlIsn/c6Q2iJzqG6aJT3wdPaRNj70iYxQ==",
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.16.tgz",
"integrity": "sha512-pONL5awpaQX4LN5eiv7moSiSPd/DLDzKVRJz8Q9PgzmAdd1R4307GQS2ZpfiN7ZmekdQrfhZZiSE5jkLR4WNaA==",
"dev": true,
"license": "MIT"
},
@@ -4384,18 +4395,18 @@
]
},
"node_modules/@vitejs/plugin-react": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.0.4.tgz",
"integrity": "sha512-La0KD0vGkVkSk6K+piWDKRUyg8Rl5iAIKRMH0vMJI0Eg47bq1eOxmoObAaQG37WMW9MSyk7Cs8EIWwJC1PtzKA==",
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.0.tgz",
"integrity": "sha512-4LuWrg7EKWgQaMJfnN+wcmbAW+VSsCmqGohftWjuct47bv8uE4n/nPpq4XjJPsxgq00GGG5J8dvBczp8uxScew==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/core": "^7.28.4",
"@babel/plugin-transform-react-jsx-self": "^7.27.1",
"@babel/plugin-transform-react-jsx-source": "^7.27.1",
"@rolldown/pluginutils": "1.0.0-beta.38",
"@rolldown/pluginutils": "1.0.0-beta.43",
"@types/babel__core": "^7.20.5",
"react-refresh": "^0.17.0"
"react-refresh": "^0.18.0"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
@@ -4882,13 +4893,13 @@
"license": "MIT"
},
"node_modules/ast-v8-to-istanbul": {
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.5.tgz",
"integrity": "sha512-9SdXjNheSiE8bALAQCQQuT6fgQaoxJh7IRYrRGZ8/9nv8WhJeC1aXAwN8TbaOssGOukUvyvnkgD9+Yuykvl1aA==",
"version": "0.3.8",
"resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.8.tgz",
"integrity": "sha512-szgSZqUxI5T8mLKvS7WTjF9is+MVbOeLADU73IseOcrqhxr/VAvy6wfoVE39KnKzA7JRhjF5eUagNlHwvZPlKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.30",
"@jridgewell/trace-mapping": "^0.3.31",
"estree-walker": "^3.0.3",
"js-tokens": "^9.0.1"
}
@@ -5494,9 +5505,9 @@
}
},
"node_modules/cssstyle": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.1.tgz",
"integrity": "sha512-g5PC9Aiph9eiczFpcgUhd9S4UUO3F+LHGRIi5NUMZ+4xtoIYbHNZwZnWA2JsFGe8OU8nl4WyaEFiZuGuxlutJQ==",
"version": "5.3.3",
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.3.tgz",
"integrity": "sha512-OytmFH+13/QXONJcC75QNdMtKpceNk3u8ThBjyyYjkEcy/ekBwR1mMAuNvi3gdBPW3N5TlCzQ0WZw8H0lN/bDw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -6181,24 +6192,25 @@
}
},
"node_modules/eslint-config-next": {
"version": "16.0.0",
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.0.0.tgz",
"integrity": "sha512-DWKT1YAO9ex2rK0/EeiPpKU++ghTiG59z6m08/ReLRECOYIaEv17maSCYT8zmFQLwIrY5lhJ+iaJPQdT4sJd4g==",
"version": "15.5.6",
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-15.5.6.tgz",
"integrity": "sha512-cGr3VQlPsZBEv8rtYp4BpG1KNXDqGvPo9VC1iaCgIA11OfziC/vczng+TnAS3WpRIR3Q5ye/6yl+CRUuZ1fPGg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@next/eslint-plugin-next": "16.0.0",
"@next/eslint-plugin-next": "15.5.6",
"@rushstack/eslint-patch": "^1.10.3",
"@typescript-eslint/eslint-plugin": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0",
"@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0",
"eslint-import-resolver-node": "^0.3.6",
"eslint-import-resolver-typescript": "^3.5.2",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-jsx-a11y": "^6.10.0",
"eslint-plugin-react": "^7.37.0",
"eslint-plugin-react-hooks": "^7.0.0",
"globals": "16.4.0",
"typescript-eslint": "^8.46.0"
"eslint-plugin-react-hooks": "^5.0.0"
},
"peerDependencies": {
"eslint": ">=9.0.0",
"eslint": "^7.23.0 || ^8.0.0 || ^9.0.0",
"typescript": ">=3.3.1"
},
"peerDependenciesMeta": {
@@ -6207,19 +6219,6 @@
}
}
},
"node_modules/eslint-config-next/node_modules/globals": {
"version": "16.4.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-16.4.0.tgz",
"integrity": "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/eslint-import-resolver-node": {
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz",
@@ -6423,20 +6422,13 @@
}
},
"node_modules/eslint-plugin-react-hooks": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.0.tgz",
"integrity": "sha512-fNXaOwvKwq2+pXiRpXc825Vd63+KM4DLL40Rtlycb8m7fYpp6efrTp1sa6ZbP/Ap58K2bEKFXRmhURE+CJAQWw==",
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz",
"integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/core": "^7.24.4",
"@babel/parser": "^7.24.4",
"hermes-parser": "^0.25.1",
"zod": "^3.22.4 || ^4.0.0",
"zod-validation-error": "^3.0.3 || ^4.0.0"
},
"engines": {
"node": ">=18"
"node": ">=10"
},
"peerDependencies": {
"eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0"
@@ -7289,23 +7281,6 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/hermes-estree": {
"version": "0.25.1",
"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
"integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==",
"dev": true,
"license": "MIT"
},
"node_modules/hermes-parser": {
"version": "0.25.1",
"resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz",
"integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==",
"dev": true,
"license": "MIT",
"dependencies": {
"hermes-estree": "0.25.1"
}
},
"node_modules/highlight.js": {
"version": "10.7.3",
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz",
@@ -8093,14 +8068,15 @@
}
},
"node_modules/jsdom": {
"version": "27.0.1",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.0.1.tgz",
"integrity": "sha512-SNSQteBL1IlV2zqhwwolaG9CwhIhTvVHWg3kTss/cLE7H/X4644mtPQqYvCfsSrGQWt9hSZcgOXX8bOZaMN+kA==",
"version": "27.1.0",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.1.0.tgz",
"integrity": "sha512-Pcfm3eZ+eO4JdZCXthW9tCDT3nF4K+9dmeZ+5X39n+Kqz0DDIABRP5CAEOHRFZk8RGuC2efksTJxrjp8EXCunQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/dom-selector": "^6.7.2",
"cssstyle": "^5.3.1",
"@acemir/cssom": "^0.9.19",
"@asamuzakjp/dom-selector": "^6.7.3",
"cssstyle": "^5.3.2",
"data-urls": "^6.0.0",
"decimal.js": "^10.6.0",
"html-encoding-sniffer": "^4.0.0",
@@ -8108,7 +8084,6 @@
"https-proxy-agent": "^7.0.6",
"is-potential-custom-element-name": "^1.0.1",
"parse5": "^8.0.0",
"rrweb-cssom": "^0.8.0",
"saxes": "^6.0.0",
"symbol-tree": "^3.2.4",
"tough-cookie": "^6.0.0",
@@ -8121,7 +8096,7 @@
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": ">=20"
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
},
"peerDependencies": {
"canvas": "^3.0.0"
@@ -8675,9 +8650,9 @@
}
},
"node_modules/lucide-react": {
"version": "0.546.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.546.0.tgz",
"integrity": "sha512-Z94u6fKT43lKeYHiVyvyR8fT7pwCzDu7RyMPpTvh054+xahSgj4HFQ+NmflvzdXsoAjYGdCguGaFKYuvq0ThCQ==",
"version": "0.553.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.553.0.tgz",
"integrity": "sha512-BRgX5zrWmNy/lkVAe0dXBgd7XQdZ3HTf+Hwe3c9WK6dqgnj9h+hxV+MDncM88xDWlCq27+TKvHGE70ViODNILw==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
@@ -9904,11 +9879,14 @@
}
},
"node_modules/nypm/node_modules/tinyexec": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.1.tgz",
"integrity": "sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw==",
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
"integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==",
"devOptional": true,
"license": "MIT"
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
@@ -10460,15 +10438,15 @@
}
},
"node_modules/prisma": {
"version": "6.18.0",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.18.0.tgz",
"integrity": "sha512-bXWy3vTk8mnRmT+SLyZBQoC2vtV9Z8u7OHvEu+aULYxwiop/CPiFZ+F56KsNRNf35jw+8wcu8pmLsjxpBxAO9g==",
"version": "6.19.0",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.0.tgz",
"integrity": "sha512-F3eX7K+tWpkbhl3l4+VkFtrwJlLXbAM+f9jolgoUZbFcm1DgHZ4cq9AgVEgUym2au5Ad/TDLN8lg83D+M10ycw==",
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/config": "6.18.0",
"@prisma/engines": "6.18.0"
"@prisma/config": "6.19.0",
"@prisma/engines": "6.19.0"
},
"bin": {
"prisma": "build/index.js"
@@ -10645,9 +10623,9 @@
}
},
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
"integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
"version": "0.18.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
"integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -11180,13 +11158,6 @@
"fsevents": "~2.3.2"
}
},
"node_modules/rrweb-cssom": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz",
"integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==",
"dev": true,
"license": "MIT"
},
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
@@ -11601,9 +11572,9 @@
"license": "MIT"
},
"node_modules/std-env": {
"version": "3.9.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz",
"integrity": "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==",
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
"dev": true,
"license": "MIT"
},
@@ -11996,9 +11967,9 @@
}
},
"node_modules/tailwindcss": {
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.16.tgz",
"integrity": "sha512-pONL5awpaQX4LN5eiv7moSiSPd/DLDzKVRJz8Q9PgzmAdd1R4307GQS2ZpfiN7ZmekdQrfhZZiSE5jkLR4WNaA==",
"version": "4.1.17",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz",
"integrity": "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==",
"license": "MIT"
},
"node_modules/tapable": {
@@ -13252,19 +13223,6 @@
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/zod-validation-error": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz",
"integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18.0.0"
},
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
}
},
"node_modules/zwitch": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",

View File

@@ -8,7 +8,7 @@
"check": "next lint && tsc --noEmit",
"dev": "next dev",
"dev:server": "node server.js",
"dev:next": "next dev --turbo",
"dev:next": "next dev",
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
"format:write": "prettier --write \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
"lint": "next lint",
@@ -22,7 +22,7 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@prisma/client": "^6.17.1",
"@prisma/client": "^6.18.0",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-slot": "^1.2.3",
"@t3-oss/env-nextjs": "^0.13.8",
@@ -43,8 +43,8 @@
"cron-validator": "^1.2.0",
"dotenv": "^17.2.3",
"jsonwebtoken": "^9.0.2",
"lucide-react": "^0.546.0",
"next": "^15.5.6",
"lucide-react": "^0.553.0",
"next": "^15.1.6",
"node-cron": "^3.0.3",
"node-pty": "^1.0.0",
"react": "^19.0.0",
@@ -62,7 +62,7 @@
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.1",
"@tailwindcss/postcss": "^4.1.15",
"@tailwindcss/postcss": "^4.1.16",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
@@ -73,17 +73,17 @@
"@types/node-cron": "^3.0.11",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.2.2",
"@vitejs/plugin-react": "^5.0.2",
"@vitejs/plugin-react": "^5.1.0",
"@vitest/coverage-v8": "^3.2.4",
"@vitest/ui": "^3.2.4",
"eslint": "^9.38.0",
"eslint-config-next": "^16.0.0",
"jsdom": "^27.0.1",
"eslint-config-next": "^15.1.6",
"jsdom": "^27.1.0",
"postcss": "^8.5.3",
"prettier": "^3.5.3",
"prettier-plugin-tailwindcss": "^0.7.1",
"prisma": "^6.18.0",
"tailwindcss": "^4.1.16",
"prisma": "^6.19.0",
"tailwindcss": "^4.1.17",
"typescript": "^5.8.2",
"typescript-eslint": "^8.46.2",
"vitest": "^3.2.4"

132
scripts/core/api.func Normal file
View File

@@ -0,0 +1,132 @@
# Copyright (c) 2021-2025 community-scripts ORG
# Author: michelroegl-brunner
# License: MIT | https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/LICENSE
post_to_api() {
if ! command -v curl &>/dev/null; then
return
fi
if [ "$DIAGNOSTICS" = "no" ]; then
return
fi
if [ -z "$RANDOM_UUID" ]; then
return
fi
local API_URL="http://api.community-scripts.org/upload"
local pve_version="not found"
pve_version=$(pveversion | awk -F'[/ ]' '{print $2}')
JSON_PAYLOAD=$(
cat <<EOF
{
"ct_type": $CT_TYPE,
"type":"lxc",
"disk_size": $DISK_SIZE,
"core_count": $CORE_COUNT,
"ram_size": $RAM_SIZE,
"os_type": "$var_os",
"os_version": "$var_version",
"disableip6": "",
"nsapp": "$NSAPP",
"method": "$METHOD(PVE-Local)",
"pve_version": "$pve_version",
"status": "installing",
"random_id": "$RANDOM_UUID"
}
EOF
)
if [[ "$DIAGNOSTICS" == "yes" ]]; then
RESPONSE=$(curl -s -w "%{http_code}" -L -X POST "$API_URL" --post301 --post302 \
-H "Content-Type: application/json" \
-d "$JSON_PAYLOAD") || true
fi
}
post_to_api_vm() {
if [[ ! -f /usr/local/community-scripts/diagnostics ]]; then
return
fi
DIAGNOSTICS=$(grep -i "^DIAGNOSTICS=" /usr/local/community-scripts/diagnostics | awk -F'=' '{print $2}')
if ! command -v curl &>/dev/null; then
return
fi
if [ "$DIAGNOSTICS" = "no" ]; then
return
fi
if [ -z "$RANDOM_UUID" ]; then
return
fi
local API_URL="http://api.community-scripts.org/upload"
local pve_version="not found"
pve_version=$(pveversion | awk -F'[/ ]' '{print $2}')
DISK_SIZE_API=${DISK_SIZE%G}
JSON_PAYLOAD=$(
cat <<EOF
{
"ct_type": 2,
"type":"vm",
"disk_size": $DISK_SIZE_API,
"core_count": $CORE_COUNT,
"ram_size": $RAM_SIZE,
"os_type": "$var_os",
"os_version": "$var_version",
"disableip6": "",
"nsapp": "$NSAPP",
"method": "$METHOD(PVE-Local)",
"pve_version": "$pve_version",
"status": "installing",
"random_id": "$RANDOM_UUID"
}
EOF
)
if [[ "$DIAGNOSTICS" == "yes" ]]; then
RESPONSE=$(curl -s -w "%{http_code}" -L -X POST "$API_URL" --post301 --post302 \
-H "Content-Type: application/json" \
-d "$JSON_PAYLOAD") || true
fi
}
POST_UPDATE_DONE=false
post_update_to_api() {
if ! command -v curl &>/dev/null; then
return
fi
if [ "$POST_UPDATE_DONE" = true ]; then
return 0
fi
local API_URL="http://api.community-scripts.org/upload/updatestatus"
local status="${1:-failed}"
local error="${2:-No error message}"
JSON_PAYLOAD=$(
cat <<EOF
{
"status": "$status",
"error": "$error",
"random_id": "$RANDOM_UUID"
}
EOF
)
if [[ "$DIAGNOSTICS" == "yes" ]]; then
RESPONSE=$(curl -s -w "%{http_code}" -L -X POST "$API_URL" --post301 --post302 \
-H "Content-Type: application/json" \
-d "$JSON_PAYLOAD") || true
fi
POST_UPDATE_DONE=true
}

View File

@@ -17,9 +17,9 @@ variables() {
# Get absolute path to core directory
CORE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$CORE_DIR/api.func"
source "$CORE_DIR/core.func"
load_functions
# This function enables error handling in the script by setting options and defining a trap for the ERR signal.
catch_errors() {
set -Eeo pipefail
@@ -33,15 +33,15 @@ error_handler() {
local line_number="$1"
local command="$2"
local error_message="${RD}[ERROR]${CL} in line ${RD}$line_number${CL}: exit code ${RD}$exit_code${CL}: while executing command ${YW}$command${CL}"
post_update_to_api "failed" "${command}"
echo -e "\n$error_message\n"
}
# Check if the shell is using bash
# Check if the current shell is using bash
shell_check() {
if [[ "$(basename "$SHELL")" != "bash" ]]; then
if [[ "$(ps -p $$ -o comm=)" != "bash" ]]; then
clear
msg_error "Your default shell is currently not set to Bash. To use these scripts, please switch to the Bash shell."
msg_error "Your default shell is not bash. Please report this to our github issues or discord."
echo -e "\nExiting..."
sleep 2
exit
@@ -439,14 +439,17 @@ advanced_settings() {
exit_script
fi
done
if CT_ID=$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox "Set Container ID" 8 58 "$NEXTID" --title "CONTAINER ID" 3>&1 1>&2 2>&3); then
if [ -z "$CT_ID" ]; then
CT_ID="$NEXTID"
echo -e "${CONTAINERID}${BOLD}${DGN}Container ID: ${BGN}$CT_ID${CL}"
else
echo -e "${CONTAINERID}${BOLD}${DGN}Container ID: ${BGN}$CT_ID${CL}"
fi
else
exit_script
fi
echo -e "${CONTAINERID}${BOLD}${DGN}Container ID: ${BGN}$CT_ID${CL}"
while true; do
if CT_NAME=$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox "Set Hostname" 8 58 "$NSAPP" --title "HOSTNAME" 3>&1 1>&2 2>&3); then
@@ -911,6 +914,7 @@ install_script() {
shell_check
root_check
arch_check
#ssh_check
maxkeys_check
diagnostics_check
@@ -1045,7 +1049,6 @@ start() {
source "$CORE_DIR/tools.func"
if command -v pveversion >/dev/null 2>&1; then
install_script
else
CHOICE=$(whiptail --backtitle "Proxmox VE Helper Scripts" --title "${APP} LXC Update/Setting" --menu \
"Support/Update functions for ${APP} LXC. Choose an option:" \
@@ -1075,7 +1078,6 @@ start() {
# This function collects user settings and integrates all the collected information.
build_container() {
# if [ "$VERBOSE" == "yes" ]; then set -x; fi
NET_STRING="-net0 name=eth0,bridge=$BRG$MAC,ip=$NET$GATE$VLAN$MTU"
@@ -1099,18 +1101,12 @@ build_container() {
fi
if [[ $DIAGNOSTICS == "yes" ]]; then
echo "Diagnostics enabled (post_to_api function not available)"
post_to_api
fi
TEMP_DIR=$(mktemp -d)
pushd "$TEMP_DIR" >/dev/null
# CORE_DIR is already defined at the top of the file
if [ "$var_os" == "alpine" ]; then
export FUNCTIONS_FILE_PATH="$(cat "$CORE_DIR/core.func" && echo && cat "$CORE_DIR/tools.func" && echo && cat "$CORE_DIR/alpine-install.func")"
else
export FUNCTIONS_FILE_PATH="$(cat "$CORE_DIR/core.func" && echo && cat "$CORE_DIR/tools.func" && echo && cat "$CORE_DIR/install.func")"
fi
export DIAGNOSTICS="$DIAGNOSTICS"
export RANDOM_UUID="$RANDOM_UUID"
@@ -1130,21 +1126,10 @@ build_container() {
export PCT_OSTYPE="$var_os"
export PCT_OSVERSION="$var_version"
export PCT_DISK_SIZE="$DISK_SIZE"
export PCT_OPTIONS="
-features $FEATURES
-hostname $HN
-tags $TAGS
$SD
$NS
$NET_STRING
-onboot 1
-cores $CORE_COUNT
-memory $RAM_SIZE
-unprivileged $CT_TYPE
$PW
"
export PCT_OPTIONS="-features $FEATURES -hostname $HN -tags $TAGS $SD $NS $NET_STRING -onboot 1 -cores $CORE_COUNT -memory $RAM_SIZE -unprivileged $CT_TYPE $PW"
# This executes create_lxc.sh and creates the container and .conf file
bash "$CORE_DIR/create_lxc.sh" $?
bash "$CORE_DIR/create_lxc.sh"
LXC_CONFIG="/etc/pve/lxc/${CTID}.conf"
@@ -1337,8 +1322,64 @@ EOF'
fi
msg_ok "Customized LXC Container"
if [ "$var_os" == "alpine" ]; then
FUNCTIONS_FILE_PATH="$(cat "$CORE_DIR/core.func" && echo && cat "$CORE_DIR/tools.func" && echo && cat "$CORE_DIR/alpine-install.func")"
else
FUNCTIONS_FILE_PATH="$(cat "$CORE_DIR/core.func" && echo && cat "$CORE_DIR/tools.func" && echo && cat "$CORE_DIR/install.func")"
fi
lxc-attach -n "$CTID" -- bash -c "$(cat "$(dirname "$CORE_DIR")/install/${var_install}.sh")"
FUNCTIONS_FILE="/tmp/functions.sh"
echo "$FUNCTIONS_FILE_PATH" | pct exec "$CTID" -- bash -c "cat > $FUNCTIONS_FILE"
pct exec "$CTID" -- test -f "$FUNCTIONS_FILE" || {
msg_error "Failed to write functions file to container"
exit 1
}
INSTALL_SCRIPT_PATH="$(dirname "$CORE_DIR")/install/${var_install}.sh"
INSTALL_SCRIPT_CONTENT=$(cat "$INSTALL_SCRIPT_PATH")
# Replace the old pattern: source /dev/stdin <<<"$FUNCTIONS_FILE_PATH"
# with direct file source: source "/tmp/functions.sh"
# Use sed with a simpler pattern that matches the exact line
MODIFIED_INSTALL_SCRIPT=$(echo "$INSTALL_SCRIPT_CONTENT" | \
sed "s|source /dev/stdin <<<\"\$FUNCTIONS_FILE_PATH\"|source \"$FUNCTIONS_FILE\"|")
# Verify replacement worked - if not, force add the source line
if ! echo "$MODIFIED_INSTALL_SCRIPT" | grep -q "source \"$FUNCTIONS_FILE\""; then
# If replacement didn't work, add source line after the last comment
MODIFIED_INSTALL_SCRIPT=$(echo "$MODIFIED_INSTALL_SCRIPT" | \
awk -v func_file="$FUNCTIONS_FILE" '
BEGIN { replaced = 0 }
{
if (/source.*dev\/stdin/ || /source.*FUNCTIONS_FILE_PATH/) {
print "source \"" func_file "\""
replaced = 1
next
}
if (!replaced && /^#/ && !/^#!/) {
print
next
}
if (!replaced && !/^#!/ && !/^#/) {
print "source \"" func_file "\""
replaced = 1
}
print
}
END {
if (!replaced) {
print "source \"" func_file "\""
}
}')
fi
# Write the modified script to a file in the container and execute it
INSTALL_SCRIPT_FILE="/tmp/install_script.sh"
echo "$MODIFIED_INSTALL_SCRIPT" | pct exec "$CTID" -- bash -c "cat > $INSTALL_SCRIPT_FILE"
pct exec "$CTID" -- chmod +x "$INSTALL_SCRIPT_FILE"
lxc-attach -n "$CTID" -- bash "$INSTALL_SCRIPT_FILE"
}
# This function sets the description of the container.
@@ -1383,6 +1424,37 @@ EOF
if [[ -f /etc/systemd/system/ping-instances.service ]]; then
systemctl start ping-instances.service
fi
post_update_to_api "done" "none"
}
api_exit_script() {
exit_code=$? # Capture the exit status of the last executed command
#200 exit codes indicate error in create_lxc.sh
#100 exit codes indicate error in install.func
if [ $exit_code -ne 0 ]; then
case $exit_code in
100) post_update_to_api "failed" "100: Unexpected error in create_lxc.sh" ;;
101) post_update_to_api "failed" "101: No network connection detected in create_lxc.sh" ;;
200) post_update_to_api "failed" "200: LXC creation failed in create_lxc.sh" ;;
201) post_update_to_api "failed" "201: Invalid Storage class in create_lxc.sh" ;;
202) post_update_to_api "failed" "202: User aborted menu in create_lxc.sh" ;;
203) post_update_to_api "failed" "203: CTID not set in create_lxc.sh" ;;
204) post_update_to_api "failed" "204: PCT_OSTYPE not set in create_lxc.sh" ;;
205) post_update_to_api "failed" "205: CTID cannot be less than 100 in create_lxc.sh" ;;
206) post_update_to_api "failed" "206: CTID already in use in create_lxc.sh" ;;
207) post_update_to_api "failed" "207: Template not found in create_lxc.sh" ;;
208) post_update_to_api "failed" "208: Error downloading template in create_lxc.sh" ;;
209) post_update_to_api "failed" "209: Container creation failed, but template is intact in create_lxc.sh" ;;
*) post_update_to_api "failed" "Unknown error, exit code: $exit_code in create_lxc.sh" ;;
esac
fi
}
if command -v pveversion >/dev/null 2>&1; then
trap 'api_exit_script' EXIT
fi
trap 'post_update_to_api "failed" "$BASH_COMMAND"' ERR
trap 'post_update_to_api "failed" "INTERRUPTED"' SIGINT
trap 'post_update_to_api "failed" "TERMINATED"' SIGTERM

View File

@@ -368,6 +368,54 @@ run_container_safe() {
" || __handle_general_error "lxc-attach to CT $ct"
}
cleanup_lxc() {
msg_info "Cleaning up"
if is_alpine; then
$STD apk cache clean || true
rm -rf /var/cache/apk/*
else
$STD apt -y autoremove || true
$STD apt -y autoclean || true
$STD apt -y clean || true
fi
# Clear temp artifacts (keep sockets/FIFOs; ignore errors)
find /tmp /var/tmp -type f -name 'tmp*' -delete 2>/dev/null || true
find /tmp /var/tmp -type f -name 'tempfile*' -delete 2>/dev/null || true
# Truncate writable log files silently (permission errors ignored)
if command -v truncate >/dev/null 2>&1; then
find /var/log -type f -writable -print0 2>/dev/null |
xargs -0 -n1 truncate -s 0 2>/dev/null || true
fi
# Python pip
if command -v pip &>/dev/null; then $STD pip cache purge || true; fi
# Python uv
if command -v uv &>/dev/null; then $STD uv cache clear || true; fi
# Node.js npm
if command -v npm &>/dev/null; then $STD npm cache clean --force || true; fi
# Node.js yarn
if command -v yarn &>/dev/null; then $STD yarn cache clean || true; fi
# Node.js pnpm
if command -v pnpm &>/dev/null; then $STD pnpm store prune || true; fi
# Go
if command -v go &>/dev/null; then $STD go clean -cache -modcache || true; fi
# Rust cargo
if command -v cargo &>/dev/null; then $STD cargo clean || true; fi
# Ruby gem
if command -v gem &>/dev/null; then $STD gem cleanup || true; fi
# Composer (PHP)
if command -v composer &>/dev/null; then $STD composer clear-cache || true; fi
if command -v journalctl &>/dev/null; then
$STD journalctl --rotate || true
$STD journalctl --vacuum-time=10m || true
fi
msg_ok "Cleaned"
}
check_or_create_swap() {
msg_info "Checking for active swap"
@@ -409,4 +457,4 @@ check_or_create_swap() {
trap 'stop_spinner' EXIT INT TERM
# Initialize functions when core.func is sourced
load_functions
load_functions

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,12 @@
'use client';
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
import { createContext, useContext, useEffect, useState, useCallback, type ReactNode } from 'react';
interface AuthContextType {
isAuthenticated: boolean;
username: string | null;
isLoading: boolean;
expirationTime: number | null;
login: (username: string, password: string) => Promise<boolean>;
logout: () => void;
checkAuth: () => Promise<void>;
@@ -21,8 +22,9 @@ export function AuthProvider({ children }: AuthProviderProps) {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [username, setUsername] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [expirationTime, setExpirationTime] = useState<number | null>(null);
const checkAuth = async () => {
const checkAuthInternal = async (retryCount = 0) => {
try {
// First check if setup is completed
const setupResponse = await fetch('/api/settings/auth-credentials');
@@ -33,30 +35,60 @@ export function AuthProvider({ children }: AuthProviderProps) {
if (!setupData.setupCompleted || !setupData.enabled) {
setIsAuthenticated(false);
setUsername(null);
setExpirationTime(null);
setIsLoading(false);
return;
}
}
// Only verify authentication if setup is completed and auth is enabled
const response = await fetch('/api/auth/verify');
const response = await fetch('/api/auth/verify', {
credentials: 'include', // Ensure cookies are sent
});
if (response.ok) {
const data = await response.json() as { username: string };
const data = await response.json() as {
username: string;
expirationTime?: number | null;
timeUntilExpiration?: number | null;
};
setIsAuthenticated(true);
setUsername(data.username);
setExpirationTime(data.expirationTime ?? null);
} else {
setIsAuthenticated(false);
setUsername(null);
setExpirationTime(null);
// Retry logic for failed auth checks (max 2 retries)
if (retryCount < 2) {
setTimeout(() => {
void checkAuthInternal(retryCount + 1);
}, 500);
return;
}
}
} catch (error) {
console.error('Error checking auth:', error);
setIsAuthenticated(false);
setUsername(null);
setExpirationTime(null);
// Retry logic for network errors (max 2 retries)
if (retryCount < 2) {
setTimeout(() => {
void checkAuthInternal(retryCount + 1);
}, 500);
return;
}
} finally {
setIsLoading(false);
}
};
const checkAuth = useCallback(() => {
return checkAuthInternal(0);
}, []);
const login = async (username: string, password: string): Promise<boolean> => {
try {
const response = await fetch('/api/auth/login', {
@@ -65,12 +97,21 @@ export function AuthProvider({ children }: AuthProviderProps) {
'Content-Type': 'application/json',
},
body: JSON.stringify({ username, password }),
credentials: 'include', // Ensure cookies are received
});
if (response.ok) {
const data = await response.json() as { username: string };
setIsAuthenticated(true);
setUsername(data.username);
// Check auth again to get expiration time
// Add a small delay to ensure the httpOnly cookie is available
await new Promise<void>((resolve) => {
setTimeout(() => {
void checkAuth().then(() => resolve());
}, 150);
});
return true;
} else {
const errorData = await response.json();
@@ -88,11 +129,12 @@ export function AuthProvider({ children }: AuthProviderProps) {
document.cookie = 'auth-token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
setIsAuthenticated(false);
setUsername(null);
setExpirationTime(null);
};
useEffect(() => {
void checkAuth();
}, []);
}, [checkAuth]);
return (
<AuthContext.Provider
@@ -100,6 +142,7 @@ export function AuthProvider({ children }: AuthProviderProps) {
isAuthenticated,
username,
isLoading,
expirationTime,
login,
logout,
checkAuth,

View File

@@ -185,13 +185,19 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
// Check if there's a corresponding local script
const hasLocalVersion = localScriptsData?.scripts?.some(local => {
if (!local?.name) return false;
// Primary: Exact slug-to-slug matching (most reliable, prevents false positives)
if (local.slug && script.slug) {
if (local.slug.toLowerCase() === script.slug.toLowerCase()) {
return true;
}
}
// Secondary: Check install basenames (for edge cases where install script names differ from slugs)
// Only use normalized matching for install basenames, not for slug/name matching
const normalizedLocal = normalizeId(local.name);
const matchesNameOrSlug = (
normalizedLocal === normalizeId(script.name) ||
normalizedLocal === normalizeId(script.slug)
);
const matchesInstallBasename = (script as any)?.install_basenames?.some((base: string) => normalizeId(base) === normalizedLocal) ?? false;
return matchesNameOrSlug || matchesInstallBasename;
return matchesInstallBasename;
}) ?? false;
return {

View File

@@ -41,6 +41,7 @@ export function FilterBar({
}: FilterBarProps) {
const [isTypeDropdownOpen, setIsTypeDropdownOpen] = useState(false);
const [isSortDropdownOpen, setIsSortDropdownOpen] = useState(false);
const [isMinimized, setIsMinimized] = useState(false);
const updateFilters = (updates: Partial<FilterState>) => {
onFiltersChange({ ...filters, ...updates });
@@ -98,44 +99,17 @@ export function FilterBar({
{!isLoadingFilters && (
<div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-medium text-foreground">Filter Scripts</h3>
<ContextualHelpIcon section="available-scripts" tooltip="Help with filtering and searching" />
</div>
)}
{/* Search Bar */}
<div className="mb-4">
<div className="relative max-w-md w-full">
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<svg
className="h-5 w-5 text-muted-foreground"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
</div>
<input
type="text"
placeholder="Search scripts..."
value={filters.searchQuery}
onChange={(e) => updateFilters({ searchQuery: e.target.value })}
className="block w-full rounded-lg border border-input bg-background py-3 pr-10 pl-10 text-sm leading-5 text-foreground placeholder-muted-foreground focus:border-primary focus:placeholder-muted-foreground focus:ring-2 focus:ring-primary focus:outline-none"
/>
{filters.searchQuery && (
<div className="flex items-center gap-2">
<ContextualHelpIcon section="available-scripts" tooltip="Help with filtering and searching" />
<Button
onClick={() => updateFilters({ searchQuery: "" })}
onClick={() => setIsMinimized(!isMinimized)}
variant="ghost"
size="icon"
className="absolute inset-y-0 right-0 pr-3 text-muted-foreground hover:text-foreground"
className="h-8 w-8 text-muted-foreground hover:text-foreground"
title={isMinimized ? "Expand filters" : "Minimize filters"}
>
<svg
className="h-5 w-5"
className={`h-4 w-4 transition-transform ${isMinimized ? "" : "rotate-180"}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@@ -144,16 +118,68 @@ export function FilterBar({
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
d="M5 15l7-7 7 7"
/>
</svg>
</Button>
)}
</div>
</div>
</div>
)}
{/* Filter Buttons */}
<div className="mb-4 flex flex-col sm:flex-row flex-wrap gap-2 sm:gap-3">
{/* Filter Content - Conditionally rendered based on minimized state */}
{!isMinimized && !isLoadingFilters && (
<>
{/* Search Bar */}
<div className="mb-4">
<div className="relative max-w-md w-full">
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<svg
className="h-5 w-5 text-muted-foreground"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
</div>
<input
type="text"
placeholder="Search scripts..."
value={filters.searchQuery}
onChange={(e) => updateFilters({ searchQuery: e.target.value })}
className="block w-full rounded-lg border border-input bg-background py-3 pr-10 pl-10 text-sm leading-5 text-foreground placeholder-muted-foreground focus:border-primary focus:placeholder-muted-foreground focus:ring-2 focus:ring-primary focus:outline-none"
/>
{filters.searchQuery && (
<Button
onClick={() => updateFilters({ searchQuery: "" })}
variant="ghost"
className="absolute inset-y-0 right-0 flex items-center justify-center pr-3 h-full text-muted-foreground hover:text-foreground"
>
<svg
className="h-5 w-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</Button>
)}
</div>
</div>
{/* Filter Buttons */}
<div className="mb-4 flex flex-col sm:flex-row flex-wrap gap-2 sm:gap-3">
{/* Updateable Filter */}
<Button
onClick={() => {
@@ -431,6 +457,8 @@ export function FilterBar({
</Button>
)}
</div>
</>
)}
{/* Click outside to close dropdowns */}
{(isTypeDropdownOpen || isSortDropdownOpen) && (

View File

@@ -8,6 +8,7 @@ import { ContextualHelpIcon } from './ContextualHelpIcon';
import { useTheme } from './ThemeProvider';
import { useRegisterModal } from './modal/ModalStackProvider';
import { api } from '~/trpc/react';
import { useAuth } from './AuthProvider';
interface GeneralSettingsModalProps {
isOpen: boolean;
@@ -17,7 +18,9 @@ interface GeneralSettingsModalProps {
export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalProps) {
useRegisterModal(isOpen, { id: 'general-settings-modal', allowEscape: true, onClose });
const { theme, setTheme } = useTheme();
const { isAuthenticated, expirationTime, checkAuth } = useAuth();
const [activeTab, setActiveTab] = useState<'general' | 'github' | 'auth' | 'auto-sync'>('general');
const [sessionExpirationDisplay, setSessionExpirationDisplay] = useState<string>('');
const [githubToken, setGithubToken] = useState('');
const [saveFilter, setSaveFilter] = useState(false);
const [savedFilters, setSavedFilters] = useState<any>(null);
@@ -34,6 +37,7 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
const [authHasCredentials, setAuthHasCredentials] = useState(false);
const [authSetupCompleted, setAuthSetupCompleted] = useState(false);
const [authLoading, setAuthLoading] = useState(false);
const [sessionDurationDays, setSessionDurationDays] = useState(7);
// Auto-sync state
const [autoSyncEnabled, setAutoSyncEnabled] = useState(false);
@@ -214,11 +218,12 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
try {
const response = await fetch('/api/settings/auth-credentials');
if (response.ok) {
const data = await response.json() as { username: string; enabled: boolean; hasCredentials: boolean; setupCompleted: boolean };
const data = await response.json() as { username: string; enabled: boolean; hasCredentials: boolean; setupCompleted: boolean; sessionDurationDays?: number };
setAuthUsername(data.username ?? '');
setAuthEnabled(data.enabled ?? false);
setAuthHasCredentials(data.hasCredentials ?? false);
setAuthSetupCompleted(data.setupCompleted ?? false);
setSessionDurationDays(data.sessionDurationDays ?? 7);
}
} catch (error) {
console.error('Error loading auth credentials:', error);
@@ -227,6 +232,64 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
}
};
// Format expiration time display
const formatExpirationTime = (expTime: number | null): string => {
if (!expTime) return 'No active session';
const now = Date.now();
const timeUntilExpiration = expTime - now;
if (timeUntilExpiration <= 0) {
return 'Session expired';
}
const days = Math.floor(timeUntilExpiration / (1000 * 60 * 60 * 24));
const hours = Math.floor((timeUntilExpiration % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
const minutes = Math.floor((timeUntilExpiration % (1000 * 60 * 60)) / (1000 * 60));
const parts: string[] = [];
if (days > 0) {
parts.push(`${days} ${days === 1 ? 'day' : 'days'}`);
}
if (hours > 0) {
parts.push(`${hours} ${hours === 1 ? 'hour' : 'hours'}`);
}
if (minutes > 0 && days === 0) {
parts.push(`${minutes} ${minutes === 1 ? 'minute' : 'minutes'}`);
}
if (parts.length === 0) {
return 'Less than a minute';
}
return parts.join(', ');
};
// Update expiration display periodically
useEffect(() => {
const updateExpirationDisplay = () => {
if (expirationTime) {
setSessionExpirationDisplay(formatExpirationTime(expirationTime));
} else {
setSessionExpirationDisplay('');
}
};
updateExpirationDisplay();
// Update every minute
const interval = setInterval(updateExpirationDisplay, 60000);
return () => clearInterval(interval);
}, [expirationTime]);
// Refresh auth when tab changes to auth tab
useEffect(() => {
if (activeTab === 'auth' && isOpen) {
void checkAuth();
}
}, [activeTab, isOpen, checkAuth]);
const saveAuthCredentials = async () => {
if (authPassword !== authConfirmPassword) {
setMessage({ type: 'error', text: 'Passwords do not match' });
@@ -265,6 +328,41 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
}
};
const saveSessionDuration = async (days: number) => {
if (days < 1 || days > 365) {
setMessage({ type: 'error', text: 'Session duration must be between 1 and 365 days' });
return;
}
setAuthLoading(true);
setMessage(null);
try {
const response = await fetch('/api/settings/auth-credentials', {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ sessionDurationDays: days }),
});
if (response.ok) {
setMessage({ type: 'success', text: `Session duration updated to ${days} days` });
setSessionDurationDays(days);
setTimeout(() => setMessage(null), 3000);
} else {
const errorData = await response.json();
setMessage({ type: 'error', text: errorData.error ?? 'Failed to update session duration' });
setTimeout(() => setMessage(null), 3000);
}
} catch {
setMessage({ type: 'error', text: 'Failed to update session duration' });
setTimeout(() => setMessage(null), 3000);
} finally {
setAuthLoading(false);
}
};
const toggleAuthEnabled = async (enabled: boolean) => {
setAuthLoading(true);
setMessage(null);
@@ -662,7 +760,10 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
{activeTab === 'auth' && (
<div className="space-y-4 sm:space-y-6">
<div>
<h3 className="text-base sm:text-lg font-medium text-foreground mb-3 sm:mb-4">Authentication Settings</h3>
<div className="flex items-center gap-2 mb-3 sm:mb-4">
<h3 className="text-base sm:text-lg font-medium text-foreground">Authentication Settings</h3>
<ContextualHelpIcon section="auth-settings" tooltip="Help with Authentication Settings" />
</div>
<p className="text-sm sm:text-base text-muted-foreground mb-4">
Configure authentication to secure access to your application.
</p>
@@ -699,6 +800,68 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
</div>
</div>
{isAuthenticated && expirationTime && (
<div className="p-4 border border-border rounded-lg">
<h4 className="font-medium text-foreground mb-2">Session Information</h4>
<div className="space-y-2">
<div>
<p className="text-sm text-muted-foreground">Session expires in:</p>
<p className="text-sm font-medium text-foreground">{sessionExpirationDisplay}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Expiration date:</p>
<p className="text-sm font-medium text-foreground">
{new Date(expirationTime).toLocaleString()}
</p>
</div>
</div>
</div>
)}
<div className="p-4 border border-border rounded-lg">
<h4 className="font-medium text-foreground mb-2">Session Duration</h4>
<p className="text-sm text-muted-foreground mb-4">
Configure how long user sessions should last before requiring re-authentication.
</p>
<div className="space-y-3">
<div>
<label htmlFor="session-duration" className="block text-sm font-medium text-foreground mb-1">
Session Duration (days)
</label>
<div className="flex items-center gap-3">
<Input
id="session-duration"
type="number"
min="1"
max="365"
placeholder="Enter days"
value={sessionDurationDays}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
const value = parseInt(e.target.value, 10);
if (!isNaN(value)) {
setSessionDurationDays(value);
}
}}
disabled={authLoading || !authSetupCompleted}
className="w-32"
/>
<span className="text-sm text-muted-foreground">days (1-365)</span>
<Button
onClick={() => saveSessionDuration(sessionDurationDays)}
disabled={authLoading || !authSetupCompleted}
size="sm"
>
Save
</Button>
</div>
<p className="text-xs text-muted-foreground mt-2">
Note: This setting applies to new logins. Current sessions will not be affected.
</p>
</div>
</div>
</div>
<div className="p-4 border border-border rounded-lg">
<h4 className="font-medium text-foreground mb-2">Update Credentials</h4>
<p className="text-sm text-muted-foreground mb-4">

View File

@@ -2,7 +2,7 @@
import { useState } from 'react';
import { Button } from './ui/button';
import { HelpCircle, Server, Settings, RefreshCw, Clock, Package, HardDrive, FolderOpen, Search, Download } from 'lucide-react';
import { HelpCircle, Server, Settings, RefreshCw, Clock, Package, HardDrive, FolderOpen, Search, Download, Lock } from 'lucide-react';
import { useRegisterModal } from './modal/ModalStackProvider';
interface HelpModalProps {
@@ -11,7 +11,7 @@ interface HelpModalProps {
initialSection?: string;
}
type HelpSection = 'server-settings' | 'general-settings' | 'sync-button' | 'auto-sync' | 'available-scripts' | 'downloaded-scripts' | 'installed-scripts' | 'lxc-settings' | 'update-system';
type HelpSection = 'server-settings' | 'general-settings' | 'auth-settings' | 'sync-button' | 'auto-sync' | 'available-scripts' | 'downloaded-scripts' | 'installed-scripts' | 'lxc-settings' | 'update-system';
export function HelpModal({ isOpen, onClose, initialSection = 'server-settings' }: HelpModalProps) {
useRegisterModal(isOpen, { id: 'help-modal', allowEscape: true, onClose });
@@ -22,6 +22,7 @@ export function HelpModal({ isOpen, onClose, initialSection = 'server-settings'
const sections = [
{ id: 'server-settings' as HelpSection, label: 'Server Settings', icon: Server },
{ id: 'general-settings' as HelpSection, label: 'General Settings', icon: Settings },
{ id: 'auth-settings' as HelpSection, label: 'Authentication Settings', icon: Lock },
{ id: 'sync-button' as HelpSection, label: 'Sync Button', icon: RefreshCw },
{ id: 'auto-sync' as HelpSection, label: 'Auto-Sync', icon: Clock },
{ id: 'available-scripts' as HelpSection, label: 'Available Scripts', icon: Package },
@@ -126,16 +127,113 @@ export function HelpModal({ isOpen, onClose, initialSection = 'server-settings'
<li> Token is stored securely and only used for API calls</li>
</ul>
</div>
</div>
</div>
);
case 'auth-settings':
return (
<div className="space-y-6">
<div>
<h3 className="text-xl font-semibold text-foreground mb-4">Authentication Settings</h3>
<p className="text-muted-foreground mb-6">
Secure your application with username and password authentication and configure session management.
</p>
</div>
<div className="space-y-4">
<div className="p-4 border border-border rounded-lg">
<h4 className="font-medium text-foreground mb-2">Authentication</h4>
<h4 className="font-medium text-foreground mb-2">Overview</h4>
<p className="text-sm text-muted-foreground mb-2">
Secure your application with username and password authentication.
Authentication settings allow you to secure access to your application with username and password protection.
Sessions persist across page refreshes, so users don&apos;t need to log in repeatedly.
</p>
<ul className="text-sm text-muted-foreground space-y-1">
<li> Set up username and password for app access</li>
<li> Enable/disable authentication as needed</li>
<li> Credentials are stored securely</li>
<li> Credentials are stored securely using bcrypt hashing</li>
<li> Sessions use secure httpOnly cookies</li>
</ul>
</div>
<div className="p-4 border border-border rounded-lg">
<h4 className="font-medium text-foreground mb-2">Setting Up Authentication</h4>
<ol className="text-sm text-muted-foreground space-y-2 list-decimal list-inside">
<li>Navigate to General Settings Authentication tab</li>
<li>Enter a username (minimum 3 characters)</li>
<li>Enter a password (minimum 6 characters)</li>
<li>Confirm your password</li>
<li>Click &quot;Save Credentials&quot; to save your authentication settings</li>
<li>Toggle &quot;Enable Authentication&quot; to activate authentication</li>
</ol>
</div>
<div className="p-4 border border-border rounded-lg">
<h4 className="font-medium text-foreground mb-2">Session Duration</h4>
<p className="text-sm text-muted-foreground mb-2">
Configure how long user sessions should last before requiring re-authentication.
</p>
<ul className="text-sm text-muted-foreground space-y-2">
<li> <strong>Configurable Duration:</strong> Set session duration from 1 to 365 days</li>
<li> <strong>Default Duration:</strong> Sessions default to 7 days if not configured</li>
<li> <strong>Session Persistence:</strong> Sessions persist across page refreshes and browser restarts</li>
<li> <strong>New Logins Only:</strong> Duration changes apply to new logins, not existing sessions</li>
</ul>
<div className="mt-3 p-3 bg-info/10 rounded-md">
<h5 className="font-medium text-info-foreground mb-2">How to Configure:</h5>
<ol className="text-xs text-info/80 space-y-1 list-decimal list-inside">
<li>Go to General Settings Authentication tab</li>
<li>Find the &quot;Session Duration&quot; section</li>
<li>Enter the number of days (1-365)</li>
<li>Click &quot;Save&quot; to apply the setting</li>
</ol>
</div>
</div>
<div className="p-4 border border-border rounded-lg">
<h4 className="font-medium text-foreground mb-2">Session Information</h4>
<p className="text-sm text-muted-foreground mb-2">
When authenticated, you can view your current session information in the Authentication tab.
</p>
<ul className="text-sm text-muted-foreground space-y-1">
<li> <strong>Time Until Expiration:</strong> See how much time remains before your session expires</li>
<li> <strong>Expiration Date:</strong> View the exact date and time your session will expire</li>
<li> <strong>Auto-Update:</strong> The expiration display updates every minute</li>
</ul>
</div>
<div className="p-4 border border-border rounded-lg">
<h4 className="font-medium text-foreground mb-2">Updating Credentials</h4>
<p className="text-sm text-muted-foreground mb-2">
You can change your username and password at any time from the Authentication tab.
</p>
<ul className="text-sm text-muted-foreground space-y-1">
<li> Update username without changing password (leave password fields empty)</li>
<li> Change password by entering a new password and confirmation</li>
<li> Both username and password can be updated together</li>
<li> Changes take effect immediately after saving</li>
</ul>
</div>
<div className="p-4 border border-border rounded-lg bg-muted/50">
<h4 className="font-medium text-foreground mb-2">Security Features</h4>
<ul className="text-sm text-muted-foreground space-y-2">
<li> <strong>Password Hashing:</strong> Passwords are hashed using bcrypt before storage</li>
<li> <strong>Secure Cookies:</strong> Authentication tokens stored in httpOnly cookies</li>
<li> <strong>HTTPS in Production:</strong> Cookies are secure (HTTPS-only) in production mode</li>
<li> <strong>SameSite Protection:</strong> Cookies use strict SameSite policy to prevent CSRF attacks</li>
<li> <strong>JWT Tokens:</strong> Sessions use JSON Web Tokens with expiration</li>
</ul>
</div>
<div className="p-4 border border-border rounded-lg bg-warning/10 border-warning/20">
<h4 className="font-medium text-warning-foreground mb-2"> Important Notes</h4>
<ul className="text-sm text-warning/80 space-y-2">
<li> <strong>First-Time Setup:</strong> You must complete the initial setup before enabling authentication</li>
<li> <strong>Session Duration:</strong> Changes to session duration only affect new logins</li>
<li> <strong>Logout:</strong> You can log out manually, which immediately invalidates your session</li>
<li> <strong>Lost Credentials:</strong> If you forget your password, you&apos;ll need to reset it manually in the .env file</li>
<li> <strong>Disabling Auth:</strong> Disabling authentication clears all credentials and allows unrestricted access</li>
</ul>
</div>
</div>

View File

@@ -517,9 +517,26 @@ export function InstalledScriptsTab() {
}
}
const handleDeleteScript = (id: number) => {
if (confirm('Are you sure you want to delete this installation record?')) {
void deleteScriptMutation.mutate({ id });
const handleDeleteScript = (id: number, script?: InstalledScript) => {
const scriptToDelete = script ?? scripts.find(s => s.id === id);
if (scriptToDelete && scriptToDelete.container_id && scriptToDelete.execution_mode === 'ssh') {
// For SSH scripts with container_id, use confirmation modal
setConfirmationModal({
isOpen: true,
variant: 'simple',
title: 'Delete Database Record Only',
message: `This will only delete the database record for "${scriptToDelete.script_name}" (Container ID: ${scriptToDelete.container_id}).\n\nThe container will remain intact and can be re-detected later via auto-detect.`,
onConfirm: () => {
void deleteScriptMutation.mutate({ id });
setConfirmationModal(null);
}
});
} else {
// For non-SSH scripts or scripts without container_id, use simple confirm
if (confirm('Are you sure you want to delete this installation record?')) {
void deleteScriptMutation.mutate({ id });
}
}
};
@@ -935,6 +952,18 @@ export function InstalledScriptsTab() {
>
{showAutoDetectForm ? 'Cancel Auto-Detect' : '🔍 Auto-Detect LXC Containers (Must contain a tag with "community-script")'}
</Button>
<Button
onClick={() => {
cleanupRunRef.current = false; // Allow cleanup to run again
void cleanupMutation.mutate();
}}
disabled={cleanupMutation.isPending}
variant="outline"
size="default"
className="border-warning/30 text-warning hover:bg-warning/10"
>
{cleanupMutation.isPending ? '🧹 Cleaning up...' : '🧹 Cleanup Orphaned Scripts'}
</Button>
<Button
onClick={() => {
// Trigger status check by calling the mutation directly
@@ -1556,6 +1585,14 @@ export function InstalledScriptsTab() {
>
{controllingScriptId === script.id ? 'Working...' : 'Destroy'}
</DropdownMenuItem>
<DropdownMenuSeparator className="bg-border" />
<DropdownMenuItem
onClick={() => handleDeleteScript(script.id, script)}
disabled={deleteScriptMutation.isPending}
className="text-muted-foreground hover:text-foreground hover:bg-muted/20 focus:bg-muted/20"
>
{deleteScriptMutation.isPending ? 'Deleting...' : 'Delete only from DB'}
</DropdownMenuItem>
</>
)}
{(!script.container_id || script.execution_mode !== 'ssh') && (

View File

@@ -7,6 +7,8 @@ import type { Script } from "~/types/script";
import { DiffViewer } from "./DiffViewer";
import { TextViewer } from "./TextViewer";
import { ExecutionModeModal } from "./ExecutionModeModal";
import { ConfirmationModal } from "./ConfirmationModal";
import { ScriptVersionModal } from "./ScriptVersionModal";
import { TypeBadge, UpdateableBadge, PrivilegedBadge, NoteBadge } from "./Badge";
import { Button } from "./ui/button";
import { useRegisterModal } from './modal/ModalStackProvider';
@@ -37,6 +39,10 @@ export function ScriptDetailModal({
const [selectedDiffFile, setSelectedDiffFile] = useState<string | null>(null);
const [textViewerOpen, setTextViewerOpen] = useState(false);
const [executionModeOpen, setExecutionModeOpen] = useState(false);
const [versionModalOpen, setVersionModalOpen] = useState(false);
const [selectedVersionType, setSelectedVersionType] = useState<string | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
// Check if script files exist locally
const {
@@ -83,6 +89,31 @@ export function ScriptDetailModal({
},
});
// Delete script mutation
const deleteScriptMutation = api.scripts.deleteScript.useMutation({
onSuccess: (data) => {
setIsDeleting(false);
if (data.success) {
const message =
"message" in data ? data.message : "Script deleted successfully";
setLoadMessage(`[SUCCESS] ${message}`);
// Refetch script files status and comparison data to update the UI
void refetchScriptFiles();
void refetchComparison();
} else {
const error = "error" in data ? data.error : "Failed to delete script";
setLoadMessage(`[ERROR] ${error}`);
}
// Clear message after 5 seconds
setTimeout(() => setLoadMessage(null), 5000);
},
onError: (error) => {
setIsDeleting(false);
setLoadMessage(`[ERROR] ${error.message}`);
setTimeout(() => setLoadMessage(null), 5000);
},
});
if (!isOpen || !script) return null;
const handleImageError = () => {
@@ -105,16 +136,43 @@ export function ScriptDetailModal({
const handleInstallScript = () => {
if (!script) return;
// Check if script has multiple variants (default and alpine)
const installMethods = script.install_methods || [];
const hasMultipleVariants = installMethods.filter(method =>
method.type === 'default' || method.type === 'alpine'
).length > 1;
if (hasMultipleVariants) {
// Show version selection modal first
setVersionModalOpen(true);
} else {
// Only one variant, proceed directly to execution mode
// Use the first available method or default to 'default' type
const defaultMethod = installMethods.find(method => method.type === 'default');
const firstMethod = installMethods[0];
setSelectedVersionType(defaultMethod?.type || firstMethod?.type || 'default');
setExecutionModeOpen(true);
}
};
const handleVersionSelect = (versionType: string) => {
setSelectedVersionType(versionType);
setVersionModalOpen(false);
setExecutionModeOpen(true);
};
const handleExecuteScript = (mode: "local" | "ssh", server?: any) => {
if (!script || !onInstallScript) return;
// Find the script path (CT or tools)
// Find the script path based on selected version type
const versionType = selectedVersionType || 'default';
const scriptMethod = script.install_methods?.find(
(method) => method.type === versionType && method.script,
) || script.install_methods?.find(
(method) => method.script,
);
if (scriptMethod?.script) {
const scriptPath = `scripts/${scriptMethod.script}`;
const scriptName = script.name;
@@ -130,6 +188,19 @@ export function ScriptDetailModal({
setTextViewerOpen(true);
};
const handleDeleteScript = () => {
if (!script) return;
setDeleteConfirmOpen(true);
};
const handleConfirmDelete = () => {
if (!script) return;
setDeleteConfirmOpen(false);
setIsDeleting(true);
setLoadMessage(null);
deleteScriptMutation.mutate({ slug: script.slug });
};
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4 backdrop-blur-sm bg-black/50"
@@ -165,6 +236,20 @@ export function ScriptDetailModal({
{script.privileged && <PrivilegedBadge />}
</div>
</div>
{/* Interface Port*/}
{script.interface_port && (
<div className="ml-3 sm:ml-4 flex-shrink-0">
<div className="bg-primary/10 border border-primary/30 rounded-lg px-3 py-1.5 sm:px-4 sm:py-2">
<span className="text-xs sm:text-sm font-medium text-muted-foreground mr-2">
Port:
</span>
<span className="text-sm sm:text-base font-semibold text-foreground font-mono">
{script.interface_port}
</span>
</div>
</div>
)}
</div>
{/* Close Button */}
@@ -359,6 +444,42 @@ export function ScriptDetailModal({
);
}
})()}
{/* Delete Button - only show if script files exist */}
{scriptFilesData?.success &&
(scriptFilesData.ctExists || scriptFilesData.installExists) && (
<Button
onClick={handleDeleteScript}
disabled={isDeleting}
variant="destructive"
size="default"
className="w-full sm:w-auto flex items-center justify-center space-x-2"
>
{isDeleting ? (
<>
<div className="h-4 w-4 animate-spin rounded-full border-b-2 border-white"></div>
<span>Deleting...</span>
</>
) : (
<>
<svg
className="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
<span>Delete Script</span>
</>
)}
</Button>
)}
</div>
{/* Content */}
@@ -708,11 +829,22 @@ export function ScriptDetailModal({
?.script?.split("/")
.pop() ?? `${script.slug}.sh`
}
script={script}
isOpen={textViewerOpen}
onClose={() => setTextViewerOpen(false)}
/>
)}
{/* Version Selection Modal */}
{script && (
<ScriptVersionModal
script={script}
isOpen={versionModalOpen}
onClose={() => setVersionModalOpen(false)}
onSelectVersion={handleVersionSelect}
/>
)}
{/* Execution Mode Modal */}
{script && (
<ExecutionModeModal
@@ -722,6 +854,20 @@ export function ScriptDetailModal({
onExecute={handleExecuteScript}
/>
)}
{/* Delete Confirmation Modal */}
{script && (
<ConfirmationModal
isOpen={deleteConfirmOpen}
onClose={() => setDeleteConfirmOpen(false)}
onConfirm={handleConfirmDelete}
title="Delete Script"
message={`Are you sure you want to delete all downloaded files for "${script.name}"? This action cannot be undone.`}
variant="simple"
confirmButtonText="Delete"
cancelButtonText="Cancel"
/>
)}
</div>
);
}

View File

@@ -0,0 +1,210 @@
'use client';
import { useState } from 'react';
import type { Script, ScriptInstallMethod } from '../../types/script';
import { Button } from './ui/button';
import { useRegisterModal } from './modal/ModalStackProvider';
interface ScriptVersionModalProps {
isOpen: boolean;
onClose: () => void;
onSelectVersion: (versionType: string) => void;
script: Script | null;
}
export function ScriptVersionModal({ isOpen, onClose, onSelectVersion, script }: ScriptVersionModalProps) {
useRegisterModal(isOpen, { id: 'script-version-modal', allowEscape: true, onClose });
const [selectedVersion, setSelectedVersion] = useState<string | null>(null);
if (!isOpen || !script) return null;
// Get available install methods
const installMethods = script.install_methods || [];
const defaultMethod = installMethods.find(method => method.type === 'default');
const alpineMethod = installMethods.find(method => method.type === 'alpine');
const handleConfirm = () => {
if (selectedVersion) {
onSelectVersion(selectedVersion);
onClose();
}
};
const handleVersionSelect = (versionType: string) => {
setSelectedVersion(versionType);
};
return (
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-card rounded-lg shadow-xl max-w-2xl w-full border border-border">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-border">
<h2 className="text-xl font-bold text-foreground">Select Version</h2>
<Button
onClick={onClose}
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-foreground"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</Button>
</div>
{/* Content */}
<div className="p-6">
<div className="mb-6">
<h3 className="text-lg font-medium text-foreground mb-2">
Choose a version for &quot;{script.name}&quot;
</h3>
<p className="text-sm text-muted-foreground">
Select the version you want to install. Each version has different resource requirements.
</p>
</div>
<div className="space-y-4">
{/* Default Version */}
{defaultMethod && (
<div
onClick={() => handleVersionSelect('default')}
className={`cursor-pointer rounded-lg border-2 p-4 transition-all ${
selectedVersion === 'default'
? 'border-primary bg-primary/10'
: 'border-border bg-card hover:border-primary/50'
}`}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center space-x-3 mb-3">
<div
className={`w-5 h-5 rounded-full border-2 flex items-center justify-center ${
selectedVersion === 'default'
? 'border-primary bg-primary'
: 'border-border'
}`}
>
{selectedVersion === 'default' && (
<svg className="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
)}
</div>
<h4 className="text-base font-semibold text-foreground capitalize">
{defaultMethod.type}
</h4>
</div>
<div className="grid grid-cols-2 gap-3 text-sm ml-8">
<div>
<span className="text-muted-foreground">CPU: </span>
<span className="text-foreground font-medium">{defaultMethod.resources.cpu} cores</span>
</div>
<div>
<span className="text-muted-foreground">RAM: </span>
<span className="text-foreground font-medium">{defaultMethod.resources.ram} MB</span>
</div>
<div>
<span className="text-muted-foreground">HDD: </span>
<span className="text-foreground font-medium">{defaultMethod.resources.hdd} GB</span>
</div>
<div>
<span className="text-muted-foreground">OS: </span>
<span className="text-foreground font-medium">
{defaultMethod.resources.os} {defaultMethod.resources.version}
</span>
</div>
</div>
</div>
</div>
</div>
)}
{/* Alpine Version */}
{alpineMethod && (
<div
onClick={() => handleVersionSelect('alpine')}
className={`cursor-pointer rounded-lg border-2 p-4 transition-all ${
selectedVersion === 'alpine'
? 'border-primary bg-primary/10'
: 'border-border bg-card hover:border-primary/50'
}`}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center space-x-3 mb-3">
<div
className={`w-5 h-5 rounded-full border-2 flex items-center justify-center ${
selectedVersion === 'alpine'
? 'border-primary bg-primary'
: 'border-border'
}`}
>
{selectedVersion === 'alpine' && (
<svg className="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
)}
</div>
<h4 className="text-base font-semibold text-foreground capitalize">
{alpineMethod.type}
</h4>
</div>
<div className="grid grid-cols-2 gap-3 text-sm ml-8">
<div>
<span className="text-muted-foreground">CPU: </span>
<span className="text-foreground font-medium">{alpineMethod.resources.cpu} cores</span>
</div>
<div>
<span className="text-muted-foreground">RAM: </span>
<span className="text-foreground font-medium">{alpineMethod.resources.ram} MB</span>
</div>
<div>
<span className="text-muted-foreground">HDD: </span>
<span className="text-foreground font-medium">{alpineMethod.resources.hdd} GB</span>
</div>
<div>
<span className="text-muted-foreground">OS: </span>
<span className="text-foreground font-medium">
{alpineMethod.resources.os} {alpineMethod.resources.version}
</span>
</div>
</div>
</div>
</div>
</div>
)}
</div>
{/* Action Buttons */}
<div className="flex justify-end space-x-3 mt-6">
<Button
onClick={onClose}
variant="outline"
size="default"
>
Cancel
</Button>
<Button
onClick={handleConfirm}
disabled={!selectedVersion}
variant="default"
size="default"
className={!selectedVersion ? 'bg-muted-foreground cursor-not-allowed' : ''}
>
Continue
</Button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -34,6 +34,7 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
});
const [saveFiltersEnabled, setSaveFiltersEnabled] = useState(false);
const [isLoadingFilters, setIsLoadingFilters] = useState(true);
const [isNewestMinimized, setIsNewestMinimized] = useState(false);
const gridRef = useRef<HTMLDivElement>(null);
const { data: scriptCardsData, isLoading: githubLoading, error: githubError, refetch } = api.scripts.getScriptCardsWithCategories.useQuery();
@@ -215,13 +216,19 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
// Check if there's a corresponding local script
const hasLocalVersion = localScriptsData?.scripts?.some(local => {
if (!local?.name) return false;
// Primary: Exact slug-to-slug matching (most reliable, prevents false positives)
if (local.slug && script.slug) {
if (local.slug.toLowerCase() === script.slug.toLowerCase()) {
return true;
}
}
// Secondary: Check install basenames (for edge cases where install script names differ from slugs)
// Only use normalized matching for install basenames, not for slug/name matching
const normalizedLocal = normalizeId(local.name);
const matchesNameOrSlug = (
normalizedLocal === normalizeId(script.name) ||
normalizedLocal === normalizeId(script.slug)
);
const matchesInstallBasename = (script as any)?.install_basenames?.some((base: string) => normalizeId(base) === normalizedLocal) ?? false;
return matchesNameOrSlug || matchesInstallBasename;
return matchesInstallBasename;
}) ?? false;
return {
@@ -535,7 +542,30 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
};
const handleDownloadAllFiltered = () => {
const slugsToDownload = filteredScripts.map(script => script.slug).filter(Boolean);
let scriptsToDownload: ScriptCardType[] = filteredScripts;
if (!hasActiveFilters) {
const scriptMap = new Map<string, ScriptCardType>();
filteredScripts.forEach(script => {
if (script?.slug) {
scriptMap.set(script.slug, script);
}
});
newestScripts.forEach(script => {
if (script?.slug && !scriptMap.has(script.slug)) {
scriptMap.set(script.slug, script);
}
});
scriptsToDownload = Array.from(scriptMap.values());
}
const slugsToDownload = scriptsToDownload.map(script => script.slug).filter(Boolean);
if (slugsToDownload.length > 0) {
void downloadScriptsIndividually(slugsToDownload);
}
@@ -666,8 +696,8 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
onViewModeChange={setViewMode}
/>
{/* Newest Scripts Carousel - Always show when there are newest scripts */}
{newestScripts.length > 0 && (
{/* Newest Scripts Carousel - Only show when no search, filters, or category is active */}
{newestScripts.length > 0 && !hasActiveFilters && !selectedCategory && (
<div className="mb-8">
<div className="bg-card border-l-4 border-l-primary border border-border rounded-lg p-6 shadow-lg">
<div className="flex items-center justify-between mb-4">
@@ -675,39 +705,64 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
<Clock className="h-6 w-6 text-primary" />
Newest Scripts
</h2>
<span className="text-sm text-muted-foreground">
{newestScripts.length} recently added
</span>
</div>
<div className="overflow-x-auto scrollbar-thin scrollbar-thumb-gray-300 dark:scrollbar-thumb-gray-600 scrollbar-track-transparent">
<div className="flex gap-4 pb-2" style={{ minWidth: 'max-content' }}>
{newestScripts.map((script, index) => {
if (!script || typeof script !== 'object') {
return null;
}
const uniqueKey = `newest-${script.slug ?? 'unknown'}-${script.name ?? 'unnamed'}-${index}`;
return (
<div key={uniqueKey} className="flex-shrink-0 w-64 sm:w-72 md:w-80">
<div className="relative">
<ScriptCard
script={script}
onClick={handleCardClick}
isSelected={selectedSlugs.has(script.slug ?? '')}
onToggleSelect={toggleScriptSelection}
/>
{/* NEW badge */}
<div className="absolute top-2 right-2 bg-success text-success-foreground text-xs font-semibold px-2 py-1 rounded-md shadow-md z-10">
NEW
</div>
</div>
</div>
);
})}
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">
{newestScripts.length} recently added
</span>
<Button
onClick={() => setIsNewestMinimized(!isNewestMinimized)}
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-foreground"
title={isNewestMinimized ? "Expand newest scripts" : "Minimize newest scripts"}
>
<svg
className={`h-4 w-4 transition-transform ${isNewestMinimized ? "" : "rotate-180"}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 15l7-7 7 7"
/>
</svg>
</Button>
</div>
</div>
{!isNewestMinimized && (
<div className="overflow-x-auto scrollbar-thin scrollbar-thumb-gray-300 dark:scrollbar-thumb-gray-600 scrollbar-track-transparent">
<div className="flex gap-4 pb-2" style={{ minWidth: 'max-content' }}>
{newestScripts.map((script, index) => {
if (!script || typeof script !== 'object') {
return null;
}
const uniqueKey = `newest-${script.slug ?? 'unknown'}-${script.name ?? 'unnamed'}-${index}`;
return (
<div key={uniqueKey} className="flex-shrink-0 w-64 sm:w-72 md:w-80">
<div className="relative">
<ScriptCard
script={script}
onClick={handleCardClick}
isSelected={selectedSlugs.has(script.slug ?? '')}
onToggleSelect={toggleScriptSelection}
/>
{/* NEW badge */}
<div className="absolute top-2 right-2 bg-success text-success-foreground text-xs font-semibold px-2 py-1 rounded-md shadow-md z-10">
NEW
</div>
</div>
</div>
);
})}
</div>
</div>
)}
</div>
</div>
)}

View File

@@ -53,6 +53,62 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
void loadColorCodingSetting();
}, []);
const validateServerAddress = (address: string): boolean => {
const trimmed = address.trim();
if (!trimmed) return false;
// IPv4 validation
const ipv4Regex = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
if (ipv4Regex.test(trimmed)) {
return true;
}
// Check for IPv6 with zone identifier (link-local addresses like fe80::...%eth0)
let ipv6Address = trimmed;
const zoneIdMatch = trimmed.match(/^(.+)%([a-zA-Z0-9_\-]+)$/);
if (zoneIdMatch) {
ipv6Address = zoneIdMatch[1];
// Zone identifier should be a valid interface name (alphanumeric, underscore, hyphen)
const zoneId = zoneIdMatch[2];
if (!/^[a-zA-Z0-9_\-]+$/.test(zoneId)) {
return false;
}
}
// IPv6 validation (supports compressed format like ::1 and full format)
// Matches: 2001:0db8:85a3:0000:0000:8a2e:0370:7334, ::1, 2001:db8::1, etc.
// Also supports IPv4-mapped IPv6 addresses like ::ffff:192.168.1.1
// Simplified validation: check for valid hex segments separated by colons
const ipv6Pattern = /^(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$|^::1$|^::$|^(?:[0-9a-fA-F]{1,4}:)*::(?:[0-9a-fA-F]{1,4}:)*[0-9a-fA-F]{1,4}$|^(?:[0-9a-fA-F]{1,4}:)*::[0-9a-fA-F]{1,4}$|^::(?:[0-9a-fA-F]{1,4}:)+[0-9a-fA-F]{1,4}$|^::ffff:(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$|^(?:[0-9a-fA-F]{1,4}:){1,4}:(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
if (ipv6Pattern.test(ipv6Address)) {
// Additional validation: ensure only one :: compression exists
const compressionCount = (ipv6Address.match(/::/g) || []).length;
if (compressionCount <= 1) {
return true;
}
}
// FQDN/hostname validation (RFC 1123 compliant)
// Allows letters, numbers, hyphens, dots; must start and end with alphanumeric
// Max length 253 characters, each label max 63 characters
const hostnameRegex = /^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?$/;
if (hostnameRegex.test(trimmed) && trimmed.length <= 253) {
// Additional check: each label (between dots) must be max 63 chars
const labels = trimmed.split('.');
if (labels.every(label => label.length > 0 && label.length <= 63)) {
return true;
}
}
// Also allow simple hostnames without dots (like 'localhost')
const simpleHostnameRegex = /^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?$/;
if (simpleHostnameRegex.test(trimmed) && trimmed.length <= 63) {
return true;
}
return false;
};
const validateForm = (): boolean => {
const newErrors: Partial<Record<keyof CreateServerData, string>> = {};
@@ -61,12 +117,10 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
}
if (!formData.ip.trim()) {
newErrors.ip = 'IP address is required';
newErrors.ip = 'Server address is required';
} else {
// Basic IP validation
const ipRegex = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
if (!ipRegex.test(formData.ip)) {
newErrors.ip = 'Please enter a valid IP address';
if (!validateServerAddress(formData.ip)) {
newErrors.ip = 'Please enter a valid IP address (IPv4/IPv6) or hostname';
}
}
@@ -221,7 +275,7 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
<div>
<label htmlFor="ip" className="block text-sm font-medium text-muted-foreground mb-1">
IP Address *
Host/IP Address *
</label>
<input
type="text"
@@ -231,7 +285,7 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
className={`w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring ${
errors.ip ? 'border-destructive' : 'border-border'
}`}
placeholder="e.g., 192.168.1.100"
placeholder="e.g., 192.168.1.100, server.example.com, 2001:db8::1, or fe80::...%eth0"
/>
{errors.ip && <p className="mt-1 text-sm text-destructive">{errors.ip}</p>}
</div>

View File

@@ -4,77 +4,156 @@ import { useState, useEffect, useCallback } from 'react';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { tomorrow } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { Button } from './ui/button';
import type { Script } from '../../types/script';
interface TextViewerProps {
scriptName: string;
isOpen: boolean;
onClose: () => void;
script?: Script | null;
}
interface ScriptContent {
ctScript?: string;
installScript?: string;
alpineCtScript?: string;
alpineInstallScript?: string;
}
export function TextViewer({ scriptName, isOpen, onClose }: TextViewerProps) {
export function TextViewer({ scriptName, isOpen, onClose, script }: TextViewerProps) {
const [scriptContent, setScriptContent] = useState<ScriptContent>({});
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<'ct' | 'install'>('ct');
const [selectedVersion, setSelectedVersion] = useState<'default' | 'alpine'>('default');
// Extract slug from script name (remove .sh extension)
const slug = scriptName.replace(/\.sh$/, '');
const slug = scriptName.replace(/\.sh$/, '').replace(/^alpine-/, '');
// Check if alpine variant exists
const hasAlpineVariant = script?.install_methods?.some(
method => method.type === 'alpine' && method.script?.startsWith('ct/')
);
// Get script names for default and alpine versions
const defaultScriptName = scriptName.replace(/^alpine-/, '');
const alpineScriptName = scriptName.startsWith('alpine-') ? scriptName : `alpine-${scriptName}`;
const loadScriptContent = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
// Try to load from different possible locations
const [ctResponse, toolsResponse, vmResponse, vwResponse, installResponse] = await Promise.allSettled([
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `ct/${scriptName}` } }))}`),
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `tools/pve/${scriptName}` } }))}`),
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `vm/${scriptName}` } }))}`),
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `vw/${scriptName}` } }))}`),
// Build fetch requests for default version
const requests: Promise<Response>[] = [];
// Default CT script
requests.push(
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `ct/${defaultScriptName}` } }))}`)
);
// Tools, VM, VW scripts
requests.push(
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `tools/pve/${defaultScriptName}` } }))}`)
);
requests.push(
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `vm/${defaultScriptName}` } }))}`)
);
requests.push(
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `vw/${defaultScriptName}` } }))}`)
);
// Default install script
requests.push(
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `install/${slug}-install.sh` } }))}`)
]);
);
// Alpine versions if variant exists
if (hasAlpineVariant) {
requests.push(
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `ct/${alpineScriptName}` } }))}`)
);
requests.push(
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `install/alpine-${slug}-install.sh` } }))}`)
);
}
const responses = await Promise.allSettled(requests);
const content: ScriptContent = {};
let responseIndex = 0;
if (ctResponse.status === 'fulfilled' && ctResponse.value.ok) {
// Default CT script
const ctResponse = responses[responseIndex];
if (ctResponse?.status === 'fulfilled' && ctResponse.value.ok) {
const ctData = await ctResponse.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } };
if (ctData.result?.data?.json?.success) {
content.ctScript = ctData.result.data.json.content;
}
}
if (toolsResponse.status === 'fulfilled' && toolsResponse.value.ok) {
responseIndex++;
// Tools script
const toolsResponse = responses[responseIndex];
if (toolsResponse?.status === 'fulfilled' && toolsResponse.value.ok) {
const toolsData = await toolsResponse.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } };
if (toolsData.result?.data?.json?.success) {
content.ctScript = toolsData.result.data.json.content; // Use ctScript field for tools scripts too
}
}
if (vmResponse.status === 'fulfilled' && vmResponse.value.ok) {
responseIndex++;
// VM script
const vmResponse = responses[responseIndex];
if (vmResponse?.status === 'fulfilled' && vmResponse.value.ok) {
const vmData = await vmResponse.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } };
if (vmData.result?.data?.json?.success) {
content.ctScript = vmData.result.data.json.content; // Use ctScript field for VM scripts too
}
}
if (vwResponse.status === 'fulfilled' && vwResponse.value.ok) {
responseIndex++;
// VW script
const vwResponse = responses[responseIndex];
if (vwResponse?.status === 'fulfilled' && vwResponse.value.ok) {
const vwData = await vwResponse.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } };
if (vwData.result?.data?.json?.success) {
content.ctScript = vwData.result.data.json.content; // Use ctScript field for VW scripts too
}
}
if (installResponse.status === 'fulfilled' && installResponse.value.ok) {
responseIndex++;
// Default install script
const installResponse = responses[responseIndex];
if (installResponse?.status === 'fulfilled' && installResponse.value.ok) {
const installData = await installResponse.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } };
if (installData.result?.data?.json?.success) {
content.installScript = installData.result.data.json.content;
}
}
responseIndex++;
// Alpine CT script
if (hasAlpineVariant) {
const alpineCtResponse = responses[responseIndex];
if (alpineCtResponse?.status === 'fulfilled' && alpineCtResponse.value.ok) {
const alpineCtData = await alpineCtResponse.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } };
if (alpineCtData.result?.data?.json?.success) {
content.alpineCtScript = alpineCtData.result.data.json.content;
}
}
responseIndex++;
}
// Alpine install script
if (hasAlpineVariant) {
const alpineInstallResponse = responses[responseIndex];
if (alpineInstallResponse?.status === 'fulfilled' && alpineInstallResponse.value.ok) {
const alpineInstallData = await alpineInstallResponse.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } };
if (alpineInstallData.result?.data?.json?.success) {
content.alpineInstallScript = alpineInstallData.result.data.json.content;
}
}
}
setScriptContent(content);
} catch (err) {
@@ -82,7 +161,7 @@ export function TextViewer({ scriptName, isOpen, onClose }: TextViewerProps) {
} finally {
setIsLoading(false);
}
}, [scriptName, slug]);
}, [defaultScriptName, alpineScriptName, slug, hasAlpineVariant]);
useEffect(() => {
if (isOpen && scriptName) {
@@ -106,11 +185,30 @@ export function TextViewer({ scriptName, isOpen, onClose }: TextViewerProps) {
<div className="bg-card rounded-lg shadow-xl max-w-6xl w-full max-h-[90vh] flex flex-col border border-border mx-4 sm:mx-0">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-border">
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-4 flex-1">
<h2 className="text-2xl font-bold text-foreground">
Script Viewer: {scriptName}
Script Viewer: {defaultScriptName}
</h2>
{scriptContent.ctScript && scriptContent.installScript && (
{hasAlpineVariant && (
<div className="flex space-x-2">
<Button
variant={selectedVersion === 'default' ? 'default' : 'outline'}
onClick={() => setSelectedVersion('default')}
className="px-3 py-1 text-sm"
>
Default
</Button>
<Button
variant={selectedVersion === 'alpine' ? 'default' : 'outline'}
onClick={() => setSelectedVersion('alpine')}
className="px-3 py-1 text-sm"
>
Alpine
</Button>
</div>
)}
{((selectedVersion === 'default' && (scriptContent.ctScript || scriptContent.installScript)) ||
(selectedVersion === 'alpine' && (scriptContent.alpineCtScript || scriptContent.alpineInstallScript))) && (
<div className="flex space-x-2">
<Button
variant={activeTab === 'ct' ? 'outline' : 'ghost'}
@@ -151,44 +249,87 @@ export function TextViewer({ scriptName, isOpen, onClose }: TextViewerProps) {
</div>
) : (
<div className="flex-1 overflow-auto">
{activeTab === 'ct' && scriptContent.ctScript ? (
<SyntaxHighlighter
language="bash"
style={tomorrow}
customStyle={{
margin: 0,
padding: '1rem',
fontSize: '14px',
lineHeight: '1.5',
minHeight: '100%'
}}
showLineNumbers={true}
wrapLines={true}
>
{scriptContent.ctScript}
</SyntaxHighlighter>
) : activeTab === 'install' && scriptContent.installScript ? (
<SyntaxHighlighter
language="bash"
style={tomorrow}
customStyle={{
margin: 0,
padding: '1rem',
fontSize: '14px',
lineHeight: '1.5',
minHeight: '100%'
}}
showLineNumbers={true}
wrapLines={true}
>
{scriptContent.installScript}
</SyntaxHighlighter>
) : (
<div className="flex items-center justify-center h-full">
<div className="text-lg text-muted-foreground">
{activeTab === 'ct' ? 'CT script not found' : 'Install script not found'}
{activeTab === 'ct' && (
selectedVersion === 'default' && scriptContent.ctScript ? (
<SyntaxHighlighter
language="bash"
style={tomorrow}
customStyle={{
margin: 0,
padding: '1rem',
fontSize: '14px',
lineHeight: '1.5',
minHeight: '100%'
}}
showLineNumbers={true}
wrapLines={true}
>
{scriptContent.ctScript}
</SyntaxHighlighter>
) : selectedVersion === 'alpine' && scriptContent.alpineCtScript ? (
<SyntaxHighlighter
language="bash"
style={tomorrow}
customStyle={{
margin: 0,
padding: '1rem',
fontSize: '14px',
lineHeight: '1.5',
minHeight: '100%'
}}
showLineNumbers={true}
wrapLines={true}
>
{scriptContent.alpineCtScript}
</SyntaxHighlighter>
) : (
<div className="flex items-center justify-center h-full">
<div className="text-lg text-muted-foreground">
{selectedVersion === 'default' ? 'Default CT script not found' : 'Alpine CT script not found'}
</div>
</div>
</div>
)
)}
{activeTab === 'install' && (
selectedVersion === 'default' && scriptContent.installScript ? (
<SyntaxHighlighter
language="bash"
style={tomorrow}
customStyle={{
margin: 0,
padding: '1rem',
fontSize: '14px',
lineHeight: '1.5',
minHeight: '100%'
}}
showLineNumbers={true}
wrapLines={true}
>
{scriptContent.installScript}
</SyntaxHighlighter>
) : selectedVersion === 'alpine' && scriptContent.alpineInstallScript ? (
<SyntaxHighlighter
language="bash"
style={tomorrow}
customStyle={{
margin: 0,
padding: '1rem',
fontSize: '14px',
lineHeight: '1.5',
minHeight: '100%'
}}
showLineNumbers={true}
wrapLines={true}
>
{scriptContent.alpineInstallScript}
</SyntaxHighlighter>
) : (
<div className="flex items-center justify-center h-full">
<div className="text-lg text-muted-foreground">
{selectedVersion === 'default' ? 'Default install script not found' : 'Alpine install script not found'}
</div>
</div>
)
)}
</div>
)}

View File

@@ -38,7 +38,8 @@ export async function POST(request: NextRequest) {
);
}
const token = generateToken(username);
const sessionDurationDays = authConfig.sessionDurationDays;
const token = generateToken(username, sessionDurationDays);
const response = NextResponse.json({
success: true,
@@ -46,12 +47,15 @@ export async function POST(request: NextRequest) {
username
});
// Set httpOnly cookie
// Determine if request is over HTTPS
const isSecure = request.url.startsWith('https://');
// Set httpOnly cookie with configured duration
response.cookies.set('auth-token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
secure: isSecure, // Only secure if actually over HTTPS
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60, // 7 days
maxAge: sessionDurationDays * 24 * 60 * 60, // Use configured duration
path: '/',
});

View File

@@ -22,10 +22,17 @@ export async function GET(request: NextRequest) {
);
}
// Calculate expiration time in milliseconds
const expirationTime = decoded.exp ? decoded.exp * 1000 : null;
const currentTime = Date.now();
const timeUntilExpiration = expirationTime ? expirationTime - currentTime : null;
return NextResponse.json({
success: true,
username: decoded.username,
authenticated: true
authenticated: true,
expirationTime,
timeUntilExpiration
});
} catch (error) {
console.error('Error verifying token:', error);

View File

@@ -1,6 +1,6 @@
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { getAuthConfig, updateAuthCredentials, updateAuthEnabled } from '~/lib/auth';
import { getAuthConfig, updateAuthCredentials, updateAuthEnabled, updateSessionDuration } from '~/lib/auth';
import fs from 'fs';
import path from 'path';
import { withApiLogging } from '../../../../server/logging/withApiLogging';
@@ -14,6 +14,7 @@ export const GET = withApiLogging(async function GET() {
enabled: authConfig.enabled,
hasCredentials: authConfig.hasCredentials,
setupCompleted: authConfig.setupCompleted,
sessionDurationDays: authConfig.sessionDurationDays,
});
} catch {
// Error handled by withApiLogging
@@ -66,48 +67,75 @@ export const POST = withApiLogging(async function POST(request: NextRequest) {
export const PATCH = withApiLogging(async function PATCH(request: NextRequest) {
try {
const { enabled } = await request.json() as { enabled: boolean };
const body = await request.json() as { enabled?: boolean; sessionDurationDays?: number };
if (typeof enabled !== 'boolean') {
return NextResponse.json(
{ error: 'Enabled flag must be a boolean' },
{ status: 400 }
);
}
if (enabled) {
// When enabling, just update the flag
updateAuthEnabled(enabled);
} else {
// When disabling, clear all credentials and set flag to false
const envPath = path.join(process.cwd(), '.env');
let envContent = '';
if (fs.existsSync(envPath)) {
envContent = fs.readFileSync(envPath, 'utf8');
if (body.enabled !== undefined) {
const { enabled } = body;
if (typeof enabled !== 'boolean') {
return NextResponse.json(
{ error: 'Enabled flag must be a boolean' },
{ status: 400 }
);
}
// Remove AUTH_USERNAME and AUTH_PASSWORD_HASH
envContent = envContent.replace(/^AUTH_USERNAME=.*$/m, '');
envContent = envContent.replace(/^AUTH_PASSWORD_HASH=.*$/m, '');
// Update or add AUTH_ENABLED
const enabledRegex = /^AUTH_ENABLED=.*$/m;
if (enabledRegex.test(envContent)) {
envContent = envContent.replace(enabledRegex, 'AUTH_ENABLED=false');
if (enabled) {
// When enabling, just update the flag
updateAuthEnabled(enabled);
} else {
envContent += (envContent.endsWith('\n') ? '' : '\n') + 'AUTH_ENABLED=false\n';
// When disabling, clear all credentials and set flag to false
const envPath = path.join(process.cwd(), '.env');
let envContent = '';
if (fs.existsSync(envPath)) {
envContent = fs.readFileSync(envPath, 'utf8');
}
// Remove AUTH_USERNAME and AUTH_PASSWORD_HASH
envContent = envContent.replace(/^AUTH_USERNAME=.*$/m, '');
envContent = envContent.replace(/^AUTH_PASSWORD_HASH=.*$/m, '');
// Update or add AUTH_ENABLED
const enabledRegex = /^AUTH_ENABLED=.*$/m;
if (enabledRegex.test(envContent)) {
envContent = envContent.replace(enabledRegex, 'AUTH_ENABLED=false');
} else {
envContent += (envContent.endsWith('\n') ? '' : '\n') + 'AUTH_ENABLED=false\n';
}
// Clean up empty lines
envContent = envContent.replace(/\n\n+/g, '\n');
fs.writeFileSync(envPath, envContent);
}
// Clean up empty lines
envContent = envContent.replace(/\n\n+/g, '\n');
fs.writeFileSync(envPath, envContent);
return NextResponse.json({
success: true,
message: `Authentication ${enabled ? 'enabled' : 'disabled'} successfully`
});
}
return NextResponse.json({
success: true,
message: `Authentication ${enabled ? 'enabled' : 'disabled'} successfully`
});
if (body.sessionDurationDays !== undefined) {
const { sessionDurationDays } = body;
if (typeof sessionDurationDays !== 'number' || sessionDurationDays < 1 || sessionDurationDays > 365) {
return NextResponse.json(
{ error: 'Session duration must be a number between 1 and 365 days' },
{ status: 400 }
);
}
updateSessionDuration(sessionDurationDays);
return NextResponse.json({
success: true,
message: `Session duration updated to ${sessionDurationDays} days`
});
}
return NextResponse.json(
{ error: 'No valid field to update' },
{ status: 400 }
);
} catch {
// Error handled by withApiLogging
return NextResponse.json(

View File

@@ -16,10 +16,12 @@ import { Button } from './_components/ui/button';
import { ContextualHelpIcon } from './_components/ContextualHelpIcon';
import { ReleaseNotesModal, getLastSeenVersion } from './_components/ReleaseNotesModal';
import { Footer } from './_components/Footer';
import { Package, HardDrive, FolderOpen } from 'lucide-react';
import { Package, HardDrive, FolderOpen, LogOut } from 'lucide-react';
import { api } from '~/trpc/react';
import { useAuth } from './_components/AuthProvider';
export default function Home() {
const { isAuthenticated, logout } = useAuth();
const [runningScript, setRunningScript] = useState<{ path: string; name: string; mode?: 'local' | 'ssh'; server?: any } | null>(null);
const [activeTab, setActiveTab] = useState<'scripts' | 'downloaded' | 'installed'>(() => {
if (typeof window !== 'undefined') {
@@ -152,7 +154,19 @@ export default function Home() {
<h1 className="text-2xl sm:text-3xl lg:text-4xl font-bold text-foreground flex items-center justify-center gap-2 sm:gap-3 flex-1">
<span className="break-words">PVE Scripts Management</span>
</h1>
<div className="flex-1 flex justify-end">
<div className="flex-1 flex justify-end items-center gap-2">
{isAuthenticated && (
<Button
variant="ghost"
size="icon"
onClick={logout}
className="text-muted-foreground hover:text-foreground transition-colors"
aria-label="Logout"
title="Logout"
>
<LogOut className="h-4 w-4" />
</Button>
)}
<ThemeToggle />
</div>
</div>

View File

@@ -5,7 +5,7 @@ import fs from 'fs';
import path from 'path';
const SALT_ROUNDS = 10;
const JWT_EXPIRY = '7d'; // 7 days
const DEFAULT_JWT_EXPIRY_DAYS = 7; // Default 7 days
// Cache for JWT secret to avoid multiple file reads
let jwtSecretCache: string | null = null;
@@ -66,18 +66,31 @@ export async function comparePassword(password: string, hash: string): Promise<b
/**
* Generate a JWT token
*/
export function generateToken(username: string): string {
export function generateToken(username: string, durationDays?: number): string {
const secret = getJwtSecret();
return jwt.sign({ username }, secret, { expiresIn: JWT_EXPIRY });
const days = durationDays ?? DEFAULT_JWT_EXPIRY_DAYS;
return jwt.sign({ username }, secret, { expiresIn: `${days}d` });
}
/**
* Decode a JWT token without verification (for extracting expiration time)
*/
export function decodeToken(token: string): { username: string; exp?: number; iat?: number } | null {
try {
const decoded = jwt.decode(token) as { username: string; exp?: number; iat?: number } | null;
return decoded;
} catch {
return null;
}
}
/**
* Verify a JWT token
*/
export function verifyToken(token: string): { username: string } | null {
export function verifyToken(token: string): { username: string; exp?: number; iat?: number } | null {
try {
const secret = getJwtSecret();
const decoded = jwt.verify(token, secret) as { username: string };
const decoded = jwt.verify(token, secret) as { username: string; exp?: number; iat?: number };
return decoded;
} catch {
return null;
@@ -93,6 +106,7 @@ export function getAuthConfig(): {
enabled: boolean;
hasCredentials: boolean;
setupCompleted: boolean;
sessionDurationDays: number;
} {
const envPath = path.join(process.cwd(), '.env');
@@ -103,6 +117,7 @@ export function getAuthConfig(): {
enabled: false,
hasCredentials: false,
setupCompleted: false,
sessionDurationDays: DEFAULT_JWT_EXPIRY_DAYS,
};
}
@@ -128,6 +143,13 @@ export function getAuthConfig(): {
const setupCompletedMatch = setupCompletedRegex.exec(envContent);
const setupCompleted = setupCompletedMatch ? setupCompletedMatch[1]?.trim().toLowerCase() === 'true' : false;
// Extract AUTH_SESSION_DURATION_DAYS
const sessionDurationRegex = /^AUTH_SESSION_DURATION_DAYS=(.*)$/m;
const sessionDurationMatch = sessionDurationRegex.exec(envContent);
const sessionDurationDays = sessionDurationMatch
? parseInt(sessionDurationMatch[1]?.trim() || String(DEFAULT_JWT_EXPIRY_DAYS), 10) || DEFAULT_JWT_EXPIRY_DAYS
: DEFAULT_JWT_EXPIRY_DAYS;
const hasCredentials = !!(username && passwordHash);
return {
@@ -136,6 +158,7 @@ export function getAuthConfig(): {
enabled,
hasCredentials,
setupCompleted,
sessionDurationDays,
};
}
@@ -238,3 +261,30 @@ export function updateAuthEnabled(enabled: boolean): void {
fs.writeFileSync(envPath, envContent);
}
/**
* Update AUTH_SESSION_DURATION_DAYS in .env
*/
export function updateSessionDuration(days: number): void {
// Validate: between 1 and 365 days
const validDays = Math.max(1, Math.min(365, Math.floor(days)));
const envPath = path.join(process.cwd(), '.env');
// Read existing .env file
let envContent = '';
if (fs.existsSync(envPath)) {
envContent = fs.readFileSync(envPath, 'utf8');
}
// Update or add AUTH_SESSION_DURATION_DAYS
const sessionDurationRegex = /^AUTH_SESSION_DURATION_DAYS=.*$/m;
if (sessionDurationRegex.test(envContent)) {
envContent = envContent.replace(sessionDurationRegex, `AUTH_SESSION_DURATION_DAYS=${validDays}`);
} else {
envContent += (envContent.endsWith('\n') ? '' : '\n') + `AUTH_SESSION_DURATION_DAYS=${validDays}\n`;
}
// Write back to .env file
fs.writeFileSync(envPath, envContent);
}

View File

@@ -887,77 +887,142 @@ export const installedScriptsRouter = createTRPCRouter({
);
// Group scripts by server to batch check containers
const scriptsByServer = new Map<number, any[]>();
for (const script of scriptsToCheck) {
const scriptData = script as any;
if (!scriptData.server_id) continue;
if (!scriptsByServer.has(scriptData.server_id)) {
scriptsByServer.set(scriptData.server_id, []);
}
scriptsByServer.get(scriptData.server_id)!.push(scriptData);
}
// Process each server
for (const [serverId, serverScripts] of scriptsByServer.entries()) {
try {
const scriptData = script as any;
const server = allServers.find((s: any) => s.id === scriptData.server_id);
const server = allServers.find((s: any) => s.id === serverId);
if (!server) {
await db.deleteInstalledScript(Number(scriptData.id));
deletedScripts.push(String(scriptData.script_name));
// Server doesn't exist, delete all scripts for this server
for (const scriptData of serverScripts) {
await db.deleteInstalledScript(Number(scriptData.id));
deletedScripts.push(String(scriptData.script_name));
}
continue;
}
// Test SSH connection
const connectionTest = await sshService.testSSHConnection(server as Server);
if (!(connectionTest as any).success) {
console.warn(`cleanupOrphanedScripts: SSH connection failed for server ${String((server as any).name)}, skipping ${serverScripts.length} scripts`);
continue;
}
// Check if the container config file still exists
const checkCommand = `test -f "/etc/pve/lxc/${scriptData.container_id}.conf" && echo "exists" || echo "not_found"`;
// Get all existing containers from pct list (more reliable than checking config files)
const listCommand = 'pct list';
let listOutput = '';
// Await full command completion to avoid early false negatives
const containerExists = await new Promise<boolean>((resolve) => {
let combinedOutput = '';
let resolved = false;
const finish = () => {
if (resolved) return;
resolved = true;
const out = combinedOutput.trim();
if (out.includes('exists')) {
resolve(true);
} else if (out.includes('not_found')) {
resolve(false);
} else {
// Unknown output; treat as not found but log for diagnostics
console.warn(`cleanupOrphanedScripts: unexpected output for ${String(scriptData.script_name)} (${String(scriptData.container_id)}): ${out}`);
resolve(false);
}
};
// Add a guard timeout so we don't hang indefinitely
const timer = setTimeout(() => {
console.warn(`cleanupOrphanedScripts: timeout while checking ${String(scriptData.script_name)} on server ${String((server as any).name)}`);
finish();
}, 15000);
const existingContainerIds = await new Promise<Set<string>>((resolve, reject) => {
const timeout = setTimeout(() => {
console.warn(`cleanupOrphanedScripts: timeout while getting container list from server ${String((server as any).name)}`);
resolve(new Set()); // Treat timeout as no containers found
}, 20000);
void sshExecutionService.executeCommand(
server as Server,
checkCommand,
listCommand,
(data: string) => {
combinedOutput += data;
listOutput += data;
},
(error: string) => {
combinedOutput += error;
console.error(`cleanupOrphanedScripts: error getting container list from server ${String((server as any).name)}:`, error);
clearTimeout(timeout);
resolve(new Set()); // Treat error as no containers found
},
(_exitCode: number) => {
clearTimeout(timer);
finish();
clearTimeout(timeout);
// Parse pct list output to extract container IDs
const containerIds = new Set<string>();
const lines = listOutput.split('\n').filter(line => line.trim());
for (const line of lines) {
// pct list format: CTID Status Name
// Skip header line if present
if (line.includes('CTID') || line.includes('VMID')) continue;
const parts = line.trim().split(/\s+/);
if (parts.length > 0) {
const containerId = parts[0]?.trim();
if (containerId && /^\d{3,4}$/.test(containerId)) {
containerIds.add(containerId);
}
}
}
resolve(containerIds);
}
);
});
if (!containerExists) {
await db.deleteInstalledScript(Number(scriptData.id));
deletedScripts.push(String(scriptData.script_name));
} else {
}
// Check each script against the list of existing containers
for (const scriptData of serverScripts) {
try {
const containerId = String(scriptData.container_id).trim();
// Check if container exists in pct list
if (!existingContainerIds.has(containerId)) {
// Also verify config file doesn't exist as a double-check
const checkCommand = `test -f "/etc/pve/lxc/${containerId}.conf" && echo "exists" || echo "not_found"`;
const configExists = await new Promise<boolean>((resolve) => {
let combinedOutput = '';
let resolved = false;
const finish = () => {
if (resolved) return;
resolved = true;
const out = combinedOutput.trim();
resolve(out.includes('exists'));
};
const timer = setTimeout(() => {
finish();
}, 10000);
void sshExecutionService.executeCommand(
server as Server,
checkCommand,
(data: string) => {
combinedOutput += data;
},
(_error: string) => {
// Ignore errors, just check output
},
(_exitCode: number) => {
clearTimeout(timer);
finish();
}
);
});
// If container is not in pct list AND config file doesn't exist, it's orphaned
if (!configExists) {
console.log(`cleanupOrphanedScripts: Removing orphaned script ${String(scriptData.script_name)} (container ${containerId}) from server ${String((server as any).name)}`);
await db.deleteInstalledScript(Number(scriptData.id));
deletedScripts.push(String(scriptData.script_name));
} else {
// Config exists but not in pct list - might be in a transitional state, log but don't delete
console.warn(`cleanupOrphanedScripts: Container ${containerId} (${String(scriptData.script_name)}) config exists but not in pct list - may be in transitional state`);
}
}
} catch (error) {
console.error(`cleanupOrphanedScripts: Error checking script ${String((scriptData as any).script_name)}:`, error);
}
}
} catch (error) {
console.error(`Error checking script ${(script as any).script_name}:`, error);
console.error(`cleanupOrphanedScripts: Error processing server ${serverId}:`, error);
}
}

View File

@@ -363,6 +363,34 @@ export const scriptsRouter = createTRPCRouter({
}
}),
// Delete script files
deleteScript: publicProcedure
.input(z.object({ slug: z.string() }))
.mutation(async ({ input }) => {
try {
// Get the script details
const script = await localScriptsService.getScriptBySlug(input.slug);
if (!script) {
return {
success: false,
error: 'Script not found',
deletedFiles: []
};
}
// Delete the script files
const result = await scriptDownloaderService.deleteScript(script);
return result;
} catch (error) {
console.error('Error in deleteScript:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to delete script',
deletedFiles: []
};
}
}),
// Compare local and remote script content
compareScriptContent: publicProcedure
.input(z.object({ slug: z.string() }))

View File

@@ -1,6 +1,6 @@
// Real JavaScript implementation for script downloading
import { join } from 'path';
import { writeFile, mkdir, access } from 'fs/promises';
import { writeFile, mkdir, access, readFile, unlink } from 'fs/promises';
export class ScriptDownloaderService {
constructor() {
@@ -112,16 +112,6 @@ export class ScriptDownloaderService {
await this.ensureDirectoryExists(join(this.scriptsDirectory, finalTargetDir));
filePath = join(this.scriptsDirectory, finalTargetDir, fileName);
await writeFile(filePath, content, 'utf-8');
} else if (scriptPath.startsWith('vw/')) {
targetDir = 'vw';
// Preserve subdirectory structure for VW scripts
const subPath = scriptPath.replace('vw/', '');
const subDir = subPath.includes('/') ? subPath.substring(0, subPath.lastIndexOf('/')) : '';
finalTargetDir = subDir ? join(targetDir, subDir) : targetDir;
// Ensure the subdirectory exists
await this.ensureDirectoryExists(join(this.scriptsDirectory, finalTargetDir));
filePath = join(this.scriptsDirectory, finalTargetDir, fileName);
await writeFile(filePath, content, 'utf-8');
} else {
// Handle other script types (fallback to ct directory)
targetDir = 'ct';
@@ -155,6 +145,35 @@ export class ScriptDownloaderService {
}
}
// Download alpine install script if alpine variant exists (only for CT scripts)
const hasAlpineCtVariant = script.install_methods?.some(
method => method.type === 'alpine' && method.script?.startsWith('ct/')
);
console.log(`[${script.slug}] Checking for alpine variant:`, {
hasAlpineCtVariant,
installMethods: script.install_methods?.map(m => ({ type: m.type, script: m.script }))
});
if (hasAlpineCtVariant) {
const alpineInstallScriptName = `alpine-${script.slug}-install.sh`;
try {
console.log(`[${script.slug}] Downloading alpine install script: install/${alpineInstallScriptName}`);
const alpineInstallContent = await this.downloadFileFromGitHub(`install/${alpineInstallScriptName}`);
const localAlpineInstallPath = join(this.scriptsDirectory, 'install', alpineInstallScriptName);
await writeFile(localAlpineInstallPath, alpineInstallContent, 'utf-8');
files.push(`install/${alpineInstallScriptName}`);
console.log(`[${script.slug}] Successfully downloaded: install/${alpineInstallScriptName}`);
} catch (error) {
// Alpine install script might not exist, that's okay
console.error(`[${script.slug}] Alpine install script not found or error: install/${alpineInstallScriptName}`, error);
if (error instanceof Error) {
console.error(`[${script.slug}] Error details:`, error.message, error.stack);
}
}
} else {
console.log(`[${script.slug}] No alpine CT variant found, skipping alpine install script download`);
}
return {
success: true,
message: `Successfully loaded ${files.length} script(s) for ${script.name}`,
@@ -201,12 +220,6 @@ export class ScriptDownloaderService {
const subDir = subPath.includes('/') ? subPath.substring(0, subPath.lastIndexOf('/')) : '';
finalTargetDir = subDir ? join(targetDir, subDir) : targetDir;
filePath = join(this.scriptsDirectory, finalTargetDir, fileName);
} else if (scriptPath.startsWith('vw/')) {
targetDir = 'vw';
const subPath = scriptPath.replace('vw/', '');
const subDir = subPath.includes('/') ? subPath.substring(0, subPath.lastIndexOf('/')) : '';
finalTargetDir = subDir ? join(targetDir, subDir) : targetDir;
filePath = join(this.scriptsDirectory, finalTargetDir, fileName);
} else {
targetDir = 'ct';
finalTargetDir = targetDir;
@@ -244,23 +257,39 @@ export class ScriptDownloaderService {
if (fileName) {
let targetDir;
let finalTargetDir;
let filePath;
if (scriptPath.startsWith('ct/')) {
targetDir = 'ct';
finalTargetDir = targetDir;
filePath = join(this.scriptsDirectory, targetDir, fileName);
} else if (scriptPath.startsWith('tools/')) {
targetDir = 'tools';
// Preserve subdirectory structure for tools scripts
const subPath = scriptPath.replace('tools/', '');
const subDir = subPath.includes('/') ? subPath.substring(0, subPath.lastIndexOf('/')) : '';
finalTargetDir = subDir ? join(targetDir, subDir) : targetDir;
filePath = join(this.scriptsDirectory, finalTargetDir, fileName);
} else if (scriptPath.startsWith('vm/')) {
targetDir = 'vm';
// Preserve subdirectory structure for VM scripts
const subPath = scriptPath.replace('vm/', '');
const subDir = subPath.includes('/') ? subPath.substring(0, subPath.lastIndexOf('/')) : '';
finalTargetDir = subDir ? join(targetDir, subDir) : targetDir;
filePath = join(this.scriptsDirectory, finalTargetDir, fileName);
} else {
targetDir = 'ct'; // Default fallback
finalTargetDir = targetDir;
filePath = join(this.scriptsDirectory, targetDir, fileName);
}
const filePath = join(this.scriptsDirectory, targetDir, fileName);
try {
await access(filePath);
files.push(`${targetDir}/${fileName}`);
files.push(`${finalTargetDir}/${fileName}`);
if (scriptPath.startsWith('ct/')) {
// Set ctExists for all script types (CT, tools, vm) for UI consistency
if (scriptPath.startsWith('ct/') || scriptPath.startsWith('tools/') || scriptPath.startsWith('vm/')) {
ctExists = true;
}
} catch {
@@ -286,12 +315,230 @@ export class ScriptDownloaderService {
}
}
// Check alpine install script if alpine variant exists (only for CT scripts)
const hasAlpineCtVariant = script.install_methods?.some(
method => method.type === 'alpine' && method.script?.startsWith('ct/')
);
if (hasAlpineCtVariant) {
const alpineInstallScriptName = `alpine-${script.slug}-install.sh`;
const alpineInstallPath = join(this.scriptsDirectory, 'install', alpineInstallScriptName);
try {
await access(alpineInstallPath);
files.push(`install/${alpineInstallScriptName}`);
installExists = true; // Mark as exists if alpine install script exists
} catch {
// File doesn't exist
}
}
return { ctExists, installExists, files };
} catch (error) {
console.error('Error checking script existence:', error);
return { ctExists: false, installExists: false, files: [] };
}
}
async deleteScript(script) {
this.initializeConfig();
const deletedFiles = [];
try {
// Get the list of files that exist for this script
const fileCheck = await this.checkScriptExists(script);
if (fileCheck.files.length === 0) {
return {
success: false,
message: 'No script files found to delete',
deletedFiles: []
};
}
// Delete all files
for (const filePath of fileCheck.files) {
try {
const fullPath = join(this.scriptsDirectory, filePath);
await unlink(fullPath);
deletedFiles.push(filePath);
} catch (error) {
// Log error but continue deleting other files
console.error(`Error deleting file ${filePath}:`, error);
}
}
if (deletedFiles.length === 0) {
return {
success: false,
message: 'Failed to delete any script files',
deletedFiles: []
};
}
return {
success: true,
message: `Successfully deleted ${deletedFiles.length} file(s) for ${script.name}`,
deletedFiles
};
} catch (error) {
console.error('Error deleting script:', error);
return {
success: false,
message: error instanceof Error ? error.message : 'Failed to delete script',
deletedFiles
};
}
}
async compareScriptContent(script) {
this.initializeConfig();
const differences = [];
let hasDifferences = false;
try {
// First check if any local files exist
const localFilesExist = await this.checkScriptExists(script);
if (!localFilesExist.ctExists && !localFilesExist.installExists) {
// No local files exist, so no comparison needed
return { hasDifferences: false, differences: [] };
}
// If we have local files, proceed with comparison
// Use Promise.all to run comparisons in parallel
const comparisonPromises = [];
// Compare scripts only if they exist locally
if (localFilesExist.ctExists && script.install_methods?.length) {
for (const method of script.install_methods) {
if (method.script) {
const scriptPath = method.script;
const fileName = scriptPath.split('/').pop();
if (fileName) {
let targetDir;
let finalTargetDir;
if (scriptPath.startsWith('ct/')) {
targetDir = 'ct';
finalTargetDir = targetDir;
} else if (scriptPath.startsWith('tools/')) {
targetDir = 'tools';
// Preserve subdirectory structure for tools scripts
const subPath = scriptPath.replace('tools/', '');
const subDir = subPath.includes('/') ? subPath.substring(0, subPath.lastIndexOf('/')) : '';
finalTargetDir = subDir ? join(targetDir, subDir) : targetDir;
} else if (scriptPath.startsWith('vm/')) {
targetDir = 'vm';
// Preserve subdirectory structure for VM scripts
const subPath = scriptPath.replace('vm/', '');
const subDir = subPath.includes('/') ? subPath.substring(0, subPath.lastIndexOf('/')) : '';
finalTargetDir = subDir ? join(targetDir, subDir) : targetDir;
} else {
continue; // Skip unknown script types
}
comparisonPromises.push(
this.compareSingleFile(scriptPath, `${finalTargetDir}/${fileName}`)
.then(result => {
if (result.hasDifferences) {
hasDifferences = true;
differences.push(result.filePath);
}
})
.catch(() => {
// Don't add to differences if there's an error reading files
})
);
}
}
}
}
// Compare install script only if it exists locally
if (localFilesExist.installExists) {
const installScriptName = `${script.slug}-install.sh`;
const installScriptPath = `install/${installScriptName}`;
comparisonPromises.push(
this.compareSingleFile(installScriptPath, installScriptPath)
.then(result => {
if (result.hasDifferences) {
hasDifferences = true;
differences.push(result.filePath);
}
})
.catch(() => {
// Don't add to differences if there's an error reading files
})
);
}
// Compare alpine install script if alpine variant exists (only for CT scripts)
const hasAlpineCtVariant = script.install_methods?.some(
method => method.type === 'alpine' && method.script?.startsWith('ct/')
);
if (hasAlpineCtVariant) {
const alpineInstallScriptName = `alpine-${script.slug}-install.sh`;
const alpineInstallScriptPath = `install/${alpineInstallScriptName}`;
const localAlpineInstallPath = join(this.scriptsDirectory, alpineInstallScriptPath);
// Check if alpine install script exists locally
try {
await access(localAlpineInstallPath);
comparisonPromises.push(
this.compareSingleFile(alpineInstallScriptPath, alpineInstallScriptPath)
.then(result => {
if (result.hasDifferences) {
hasDifferences = true;
differences.push(result.filePath);
}
})
.catch(() => {
// Don't add to differences if there's an error reading files
})
);
} catch {
// Alpine install script doesn't exist locally, skip comparison
}
}
// Wait for all comparisons to complete
await Promise.all(comparisonPromises);
return { hasDifferences, differences };
} catch (error) {
console.error('Error comparing script content:', error);
return { hasDifferences: false, differences: [] };
}
}
async compareSingleFile(remotePath, filePath) {
try {
const localPath = join(this.scriptsDirectory, filePath);
// Read local content
const localContent = await readFile(localPath, 'utf-8');
// Download remote content
const remoteContent = await this.downloadFileFromGitHub(remotePath);
// Apply modification only for CT scripts, not for other script types
let modifiedRemoteContent;
if (remotePath.startsWith('ct/')) {
modifiedRemoteContent = this.modifyScriptContent(remoteContent);
} else {
modifiedRemoteContent = remoteContent; // Don't modify tools or vm scripts
}
// Compare content
const hasDifferences = localContent !== modifiedRemoteContent;
return { hasDifferences, filePath };
} catch (error) {
console.error(`Error comparing file ${filePath}:`, error);
return { hasDifferences: false, filePath };
}
}
}
export const scriptDownloaderService = new ScriptDownloaderService();

View File

@@ -1,4 +1,4 @@
import { readFile, writeFile, mkdir } from 'fs/promises';
import { readFile, writeFile, mkdir, unlink } from 'fs/promises';
import { join } from 'path';
import { env } from '~/env.js';
import type { Script } from '~/types/script';
@@ -461,6 +461,57 @@ export class ScriptDownloaderService {
}
}
async deleteScript(script: Script): Promise<{ success: boolean; message: string; deletedFiles: string[] }> {
this.initializeConfig();
const deletedFiles: string[] = [];
try {
// Get the list of files that exist for this script
const fileCheck = await this.checkScriptExists(script);
if (fileCheck.files.length === 0) {
return {
success: false,
message: 'No script files found to delete',
deletedFiles: []
};
}
// Delete all files
for (const filePath of fileCheck.files) {
try {
const fullPath = join(this.scriptsDirectory!, filePath);
await unlink(fullPath);
deletedFiles.push(filePath);
} catch (error) {
// Log error but continue deleting other files
console.error(`Error deleting file ${filePath}:`, error);
}
}
if (deletedFiles.length === 0) {
return {
success: false,
message: 'Failed to delete any script files',
deletedFiles: []
};
}
return {
success: true,
message: `Successfully deleted ${deletedFiles.length} file(s) for ${script.name}`,
deletedFiles
};
} catch (error) {
console.error('Error deleting script:', error);
return {
success: false,
message: error instanceof Error ? error.message : 'Failed to delete script',
deletedFiles
};
}
}
async compareScriptContent(script: Script): Promise<{ hasDifferences: boolean; differences: string[] }> {
this.initializeConfig();
const differences: string[] = [];

View File

@@ -9,26 +9,35 @@
"moduleDetection": "force",
"isolatedModules": true,
"verbatimModuleSyntax": true,
/* Strictness */
"strict": true,
"noUncheckedIndexedAccess": true,
"checkJs": true,
/* Bundled projects */
"lib": ["dom", "dom.iterable", "ES2022"],
"lib": [
"dom",
"dom.iterable",
"ES2022"
],
"noEmit": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "preserve",
"plugins": [{ "name": "next" }],
"plugins": [
{
"name": "next"
}
],
"incremental": true,
/* Path Aliases */
"baseUrl": ".",
"paths": {
"~/*": ["./src/*"],
"@/*": ["./src/*"]
"~/*": [
"./src/*"
],
"@/*": [
"./src/*"
]
}
},
"include": [
@@ -37,7 +46,10 @@
"**/*.tsx",
"**/*.cjs",
"**/*.js",
".next/types/**/*.ts"
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": ["node_modules"]
"exclude": [
"node_modules"
]
}

View File

@@ -550,7 +550,10 @@ update_files() {
"update.log"
"*.backup"
"*.bak"
"scripts"
"scripts/ct"
"scripts/install"
"scripts/tools"
"scripts/vm"
)
# Find the actual source directory (strip the top-level directory)