Compare commits

...
267 Commits
Author SHA1 Message Date
github-actions[bot] 141abbae9d chore: release 1.22.2 2026-07-06 06:18:46 +00:00
Charles GTEandGitHub 278ac6a17e Merge pull request #362 from Portabase/fix/dark-light-toggle
fix: theme toggle
2026-07-06 08:18:04 +02:00
charles-gauthereau 4c63408094 fix: theme toggle 2026-07-06 08:01:53 +02:00
github-actions[bot] adda02e91f chore: release 1.22.1 2026-07-05 17:36:01 +00:00
Charles GTEandGitHub 4fd3125bf9 Merge pull request #361 from Portabase/fix/delete-org
fix: org deletion
2026-07-05 19:35:14 +02:00
charles-gauthereau 2ef7f7929d fix: org deletion 2026-07-05 19:15:01 +02:00
github-actions[bot] 666d677349 chore: release 1.22.0 2026-07-05 16:50:54 +00:00
Charles GTEandGitHub 1afc6b8c62 Merge pull request #359 from Portabase/dev
Dev
2026-07-05 18:50:16 +02:00
Charles GTEandGitHub 9f15b49345 Merge pull request #360 from Portabase/fix/remove-database-link-to-agent
fix: remove-database-link-to-agent
2026-07-05 18:32:58 +02:00
charles-gauthereau 6d19fb8833 fix: database-delete.action.ts 2026-07-05 18:31:52 +02:00
charles-gauthereau df692c4729 fix(database): restrict db delete to agent-owning org (system agents admin-only)
- UI: delete button gated by canDeleteDatabases prop threaded from each page
  (org page: only org-created agents; admin page: system agents)
- authz: org path requires agent.organizationId === active org; drop join-based
  attribution so a system agent merely linked to an org is admin-delete-only
2026-07-05 18:31:52 +02:00
charles-gauthereau cd72cfbff4 fix(database): authorize soft-delete by agent org + add onError + guard bulk-restore lists 2026-07-05 18:31:52 +02:00
charles-gauthereau 20636bf6f2 feat(database): reject soft-deleted databases in single-row lookups 2026-07-05 18:31:52 +02:00
charles-gauthereau 8f2d6f0b01 feat(database): exclude soft-deleted databases from all list queries 2026-07-05 18:31:52 +02:00
charles-gauthereau 64b3bf1b16 feat(agents): show database delete button in card footer on agent page 2026-07-05 18:31:52 +02:00
charles-gauthereau dc4190aa1b fix(agents): add database delete button with confirm popover 2026-07-05 18:31:52 +02:00
charles-gauthereau 6efcb55638 fix(database): add soft-delete server action clearing links and policies 2026-07-05 18:31:45 +02:00
charles-gauthereau 6b89cdfa1d fix(db): make databases.agent_id and agent_database_id nullable for soft-delete 2026-07-05 18:11:32 +02:00
charles-gauthereau 693ef6ec3e chore: remove unused Link import and stray whitespace 2026-07-05 17:06:42 +02:00
Charles GTEandGitHub de33dea50d Merge pull request #358 from Portabase/fix/mass-backup-restore-button-on-project-page
fix: mass-backup-restore-button-on-project-page
2026-07-05 16:34:23 +02:00
charles-gauthereau d5e453c390 fix: docker volume upload 2026-07-05 16:33:28 +02:00
Charles GTE ff65590c97 fix: block if already ongoing or waiting 2026-07-05 14:06:40 +02:00
Charles GTE 503d5469d4 fix: bulk restore/backup 2026-07-05 13:50:33 +02:00
Charles GTE 96c51a08c6 fix(projects): enable bulk backup/restore selection on project page 2026-07-05 13:50:33 +02:00
Charles GTE fe1d077754 fix(projects): selection wrapper wiring bulk backup/restore with toasts 2026-07-05 13:50:33 +02:00
Charles GTE f2acc103a7 fix(projects): bulk restore preview + type-to-confirm modal 2026-07-05 13:50:33 +02:00
Charles GTE c073a30d20 fix(projects): floating bulk action bar 2026-07-05 13:50:33 +02:00
Charles GTE bdc863de1f fix(backup): bulkBackupAction to queue backups for many databases 2026-07-05 13:50:33 +02:00
Charles GTE 97e2c21d52 fix(restore): bulk restore preview + queue actions with latest-backup resolver 2026-07-05 13:50:33 +02:00
Charles GTE 60f57a734d fix(db-card): optional selection checkbox and selected state 2026-07-05 13:50:33 +02:00
github-actions[bot] 6952ac5f48 chore: release 1.21.3 2026-07-04 12:44:37 +00:00
45acc781f2 fix: onboarding (#354)
* fix

* fix: README.md

---------

Co-authored-by: Théo LAGACHE <theo.lagache@soluce-technologies.com>
Co-authored-by: Charles GTE <charlesgte31@gmail.com>
2026-07-04 14:43:58 +02:00
github-actions[bot] 9c2359e87b chore: release 1.21.2 2026-07-04 10:25:02 +00:00
Charles GTEandGitHub 8dcb406790 fix: security (#355) 2026-07-04 12:24:23 +02:00
Charles GTEandGitHub a3d0a6e1df fix: update README.md (#353) 2026-07-03 11:42:02 +02:00
github-actions[bot] 8edf0ca742 chore: release 1.21.1 2026-07-03 09:16:44 +00:00
568d9612c5 fix: stale-ongoing-backups (#352)
* fix: add STALE_BACKUP_THRESHOLD_HOURS env (#351)

* fix: fail stale ongoing backups and notify (#351)

* fix: prefer waiting backup over stale ongoing in dispatch (#351)

* fix: prefer waiting over ongoing by status priority in dispatch (#351)

---------

Co-authored-by: charles-gauthereau <charles.gauthereau@soluce-technologies.com>
2026-07-03 11:15:57 +02:00
github-actions[bot] 6548945b2f chore: release 1.21.0 2026-07-03 06:15:32 +00:00
Charles GTEandGitHub 4b1d0aba74 Merge pull request #350 from Portabase/feat/backup-restore-docker-volume
feat: docker-volume provider
2026-07-03 08:14:51 +02:00
charles-gauthereau c726346258 feat: docker-volume provider 2026-07-02 21:55:19 +02:00
github-actions[bot] 0677f14ac5 chore: release 1.20.7 2026-07-01 06:11:01 +00:00
Charles GTEandGitHub d346f5cdc4 Merge pull request #349 from Portabase/fix/docker-image-weight
fix: docker-image-weight
2026-07-01 08:10:22 +02:00
charles-gauthereau 1ce08448ec fix: Dockerfile 2026-06-30 16:56:28 +02:00
charles-gauthereau 77fb1095f1 fix: removed unused packages 2026-06-30 16:24:06 +02:00
29dd597c7a fix: s3-ssl-error [skip-release] (#348)
* fix: rustfs test instance

* fix: s3.form.tsx

---------

Co-authored-by: charles-gauthereau <charles.gauthereau@soluce-technologies.com>
2026-06-30 15:32:06 +02:00
github-actions[bot] 7943f3fd4b chore: release 1.20.6 2026-06-30 07:33:58 +00:00
a63c29b943 fix: docker.yml (#346)
Co-authored-by: charles-gauthereau <charles.gauthereau@soluce-technologies.com>
2026-06-30 09:32:58 +02:00
github-actions[bot] f4d72090a3 chore: release 1.20.5 2026-06-30 07:14:59 +00:00
8b379067a1 fix: readme (#345)
Co-authored-by: charles-gauthereau <charles.gauthereau@soluce-technologies.com>
2026-06-30 09:13:56 +02:00
github-actions[bot] 896b65f2a5 chore: release 1.20.4 2026-06-30 06:18:38 +00:00
79d725cd12 fix: uuid v7 (#344)
Co-authored-by: charles-gauthereau <charles.gauthereau@soluce-technologies.com>
2026-06-30 08:17:46 +02:00
github-actions[bot] dfa5b42467 chore: release 1.20.3 2026-06-27 19:34:36 +00:00
Charles GTEandGitHub e6b63a4570 Merge pull request #340 from Portabase/dev
Dev
2026-06-27 21:14:47 +02:00
Charles GTE eb13fce8e9 chore: pnpm 2026-06-27 20:54:54 +02:00
Charles GTEandGitHub f70d86f394 fix: azure form (#341) 2026-06-27 20:52:43 +02:00
Hayzie ChuGitHubcoderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>Charles GTE
8f44071069 Feature/google cloud storage (#316)
* environment setup and reproduce

* rmv my dashboard

* Stop tracking my-dashboard

* gitignore: ignore all .env files

* working UI, not yet tested

* Update credential validation

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* tested with fake-gcs-server

* fix: migration

* fix: migration, init for gcs and refactoring

* fix: get object gcs

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Charles GTE <charlesgte31@gmail.com>
2026-06-27 20:35:59 +02:00
79832f0d0b Agent count in dashboard bug (#336)
* potential issue source

* filtered agents

---------

Co-authored-by: Charles GTE <charlesgte31@gmail.com>
2026-06-27 17:19:26 +02:00
Charles GTEandGitHub f89b8d3ac3 fix: postgres-cluster (#339)
* fix: postgres cluster

* fix: migration

* fix: migration
2026-06-27 17:14:39 +02:00
40247efddd feat/onboarding (#337)
* feat(onboarding): add cookie helper, types and mock data

* feat(onboarding): add stepper, contextual preview and shell

* feat(onboarding): add sso-gate step

* feat(onboarding): add account-info step with real signup

* feat(onboarding): add security step

* feat(onboarding): add preferences step

* fix(onboarding): validate avatar upload size/type and handle FileReader errors

* feat(onboarding): add org-create step

* feat(onboarding): add invite-members step

* feat(onboarding): add notifier step reusing real provider list

* feat(onboarding): add storage step reusing real provider list

* feat(onboarding): add defaults step

* feat(onboarding): add agent-create step

* feat(onboarding): add agent-waiting step with simulated ping

* feat(onboarding): add project-create step

* feat(onboarding): add db-settings step with per-db repeat

* feat(onboarding): add finish step with confetti

* feat(onboarding): wire full step graph

* feat(onboarding): add /welcome route

* feat(onboarding): gate landing, auth and dashboard routes behind onboarding cookie

* fix(onboarding): call next() in finish step so onFlowComplete fires and cookie is written

* docs: add onboarding channel config + style refactor spec

* docs: add conditional step skip when no agents to spec

* docs(onboarding): add implementation plan for channel config flow and style refactor

* feat(onboarding): extend OnboardingChannel with name and config fields

* feat(onboarding): add two-phase config flow and org-combobox style to notifier step

* fix(onboarding): prevent duplicate notifier providers and guard Continue button

* feat(onboarding): add two-phase config flow and org-combobox style to storage step

* style(onboarding): restyle sso-gate provider buttons to org-combobox card style

* style(onboarding): restyle security method choice to org-combobox card style

* style(onboarding): restyle theme toggles in preferences step to org-combobox card style

* style(onboarding): restyle DB toggle buttons in project-create step to org-combobox card style

* feat(onboarding): skip agent-waiting/project-create/db-settings when no agents created

* feat(onboarding): update type system — meta, firstName/lastName, org.id, databases

* feat(onboarding): add settings.onboarding column + markOnboardingDoneAction

* feat(onboarding): add resolveOnboardingState server function

* fix(onboarding): remove unused imports from onboarding-state.ts

* style(onboarding): add branding to shell, fix progress bar full width

* feat(onboarding): replace preview panel with stepper checklist

* feat(onboarding): add step-login replacing sso-gate

* fix(onboarding): step-login — conform to global mutation pattern, handle SSO errors

* feat(onboarding): step-account-info — firstName/lastName, conditional password, findOrCreate

* feat(onboarding): avatar API route (next/og) + AvatarPicker in preferences step

* feat(onboarding): step-security reads passkeyEnabled from flowData.meta

* feat(onboarding): step-org-create calls real createOrganizationAction

* fix(onboarding): step-org-create — simplify error handling, throw on empty name

* feat(onboarding): notifier + storage steps call real actions + findOrCreate on back

* feat(onboarding): step-agent-create calls real createAgentAction + findOrCreate

* feat(onboarding): step-agent-waiting polls real agent lastContact via useQuery

* feat(onboarding): add step-agent-key with edgeKey generation via server action

* feat(onboarding): step-project-create uses real action + real DBs from flowData

* feat(onboarding): wire steps — add login + agent-key, remove sso-gate

* feat(onboarding): page.tsx uses resolveOnboardingState — server-driven resume

* chore(onboarding): delete mock/cookie/preview/sso-gate files — replaced by real implementation

* feat(onboarding): add emailPasswordEnabled to meta, remove logoDataUrl from org

* feat(onboarding): DB-driven resume — query notifiers/storages, check agent lastContact

* feat(onboarding): agent-waiting step is now skippable

* feat(onboarding): login — auto-advance if session, callbackURL on SSO, hide email/pw when disabled, empty state

* fix(onboarding): step-login — add next to useEffect deps, default emailPasswordEnabled to false

* fix(onboarding): verify session after passkey registration, fallback to signIn.passkey()

* feat(onboarding): theme applied dynamically via setTheme, avatar saved to user.image

* feat(onboarding): remove org logo upload — orgs have no logo

* feat(onboarding): show Next button in shell when navigating backwards

* docs: add onboarding refactor design spec

* docs: add onboarding refactor implementation plan

* refactor(onboarding): extract useCreateAgent and useDeleteAgent hooks

* refactor(onboarding): extract useCreateOrg and useCreateProject hooks

* fix(onboarding): trim project name, standardize error narrowing in org/project hooks

* fix(onboarding): use narrowed createData.value in use-create-org

* refactor(onboarding): extract useUpdateAccount hook

* refactor(onboarding): extract useAddNotifier and useRemoveNotifier hooks

* refactor(onboarding): extract useAddStorage and useRemoveStorage hooks

* refactor(onboarding): extract useAgentStatus and useGenerateEdgeKey hooks

* refactor(onboarding): extract useMarkOnboardingDone hook, remove .gitkeep files

* docs: add onboarding theme persistence & progress bar fix spec

* docs: add onboarding theme & progress bar implementation plan

* fix(onboarding): persist theme to db on selection in StepPreferences

* fix(onboarding): recalculate progress bar from full STEP_ORDER position

* fix(theme): sync user theme from DB on all routes, not only dashboard

* fix(theme): use current theme as fallback in preferences, prevent session override of localStorage

* fix(onboarding): correct resume steps and skip agent-waiting flash when agent is connected

* fix(onboarding): properly resolve onboarding state, remove redirect trap in step-defaults, block waiting flash

* fix(onboarding): resume at db-settings instead of finish if no database exists for the project

* fix(onboarding): redirect to agent-create if project exists but no agent is found

* fix(onboarding): allow going from agent-create to project-create if a project already exists

* fix(onboarding): skip intermediate agent steps when going back from project-create to agent-create

* fix(onboarding): skip default step when going back from agent-create if no notifiers and storages exist

* feat(onboarding): allow user to choose between passkey and password during registration if both are enabled

* feat(onboarding): replace tabs with single form and multiple buttons for auth choice

* style(onboarding): improve empty auth state UI with lucide icon and better layout

* fix(onboarding): completely hide back button on the first step instead of disabling it

* fix(onboarding): skip default step if no notifiers and storages exist when advancing from storage step

* fix(onboarding): correctly show select placeholder when default value does not exist in the list

* refactor(onboarding): remove duplicate generate-edge-key action and use core utility directly

* feat(onboarding): compute edgeKey directly on the server side to avoid lazy loading

* fix(onboarding): skip db-settings step if no database is found on the agent

* fix(onboarding): do not skip db settings if agent is connected but no databases found

* refactor(onboarding): skip db settings entirely if no database is selected in project create

* feat(onboarding): auto-save database selections on project create instantly

* style(onboarding): replace button opacity flash with specific spinner animation on db select

* feat(onboarding): extend OnboardingDbSettings with retention, schedule, and policy fields

* feat(onboarding): add applyOnboardingDbSettingsAction server action

* feat(onboarding): add BackupScheduleSelector component

* feat(onboarding): rewrite step-db-settings with three-level phase navigation and full policy persistence

* fix(onboarding): wire applyMutation.mutateAsync everywhere and stabilize list keys

* docs(sdd): append hotfix summary for applyMutation and list keys

* fix(onboarding): address final review findings - atomicity, scheduling guard, apply-all recovery, client validation

* add: onboarding

* fix(migration): merge

* fix(migration): apply for onboarding

* fix(settings): make avatar selector, dicebear picker and tab bar responsive

* chore: checkpoint before features reorganization

* Task 2: Add migration script for features directory reorganization

* refactor(agents): reorganize into actions/components/schemas/hooks

* refactor(auth): reorganize into actions/components/schemas

* refactor(channel): reorganize into components/schemas, nest notifications+storages under components

* refactor(database): reorganize into actions/components/schemas

* refactor(layout): move all components to components/

* refactor(logs): reorganize into components/types

* refactor(migration): reorganize into actions/components

* refactor(notifications): reorganize into components/types/utils

* refactor(organizations): reorganize into actions/components/schemas/hooks/utils

* refactor(profile): reorganize into actions/components/schemas

* refactor(projects): reorganize into actions/components/schemas

* refactor(settings): reorganize into actions/components/schemas/hooks

* refactor(statistics): reorganize into components/utils

* refactor(storages): reorganize into types/utils

* refactor(theme): move all components to components/

* refactor(updates): reorganize into components/hooks

* refactor(upload): move action to actions/

* refactor(users): reorganize into actions/components/schemas

* fix: update broken imports after features reorganization (double-quote relative imports, channel subdir absolute paths)

* fix: .gitignore

* fix: onboarding

* fix: removed dead files

* feat: backfill onboarding flag for instances with existing data

* fix: persist onboarding=true on boot when SKIP_ONBOARDING is set

* feat: remove avatarMode/dicebearStyle from DB schema and types

* feat: simplify resolveAvatarUrl — custom image first, gravatar fallback

* feat: allow avatar upload regardless of mode

* feat: remove avatarMode from layout and profile prop chains

* feat: remove avatar mode admin tab and supporting files

* feat: remove avatar mode selector from onboarding defaults step

* fix: remove preferences onboarding step and fix routing

* fix: remove stale preferences entry from onboarding checklist

* feat: add avatar reset button and explanatory tooltip on profile page

* fix: move avatar tooltip to top-right badge overlay on avatar

* fix: use small destructive button for avatar removal

* fix

* Update step-defaults.tsx

* fix: default dev password for keycloak refer to env

* fix

* fix(migration): refactoring

* fix: api agent helpers.ts

* Update .env.example

---------

Co-authored-by: Théo LAGACHE <theo.lagache@soluce-technologies.com>
Co-authored-by: charles-gauthereau <charles.gauthereau@soluce-technologies.com>
2026-06-27 17:08:38 +02:00
github-actions[bot] 40d3228db6 chore: release 1.20.2 2026-06-25 12:57:03 +00:00
Charles GTEandGitHub 383564f988 Merge pull request #335 from Portabase/fix/backup-size-restoration
fix: size in restoration agent helper
2026-06-25 14:56:06 +02:00
Charles GTE fb2f7791fd fix: size in restoration agent helper 2026-06-25 14:42:42 +02:00
github-actions[bot] 80a3f8642a chore: release 1.20.1 2026-06-24 06:47:12 +00:00
Charles GTEandGitHub 18bdbafa75 Merge pull request #333 from Portabase/fix/retention-failed-backups
fix: clean up failed and ongoing backups
2026-06-24 08:46:22 +02:00
charles-gauthereau e31245b492 fix: clean up failed and ongoing backups 2026-06-24 08:32:40 +02:00
github-actions[bot] c7d10efefe chore: release 1.20.0 2026-06-21 14:40:11 +00:00
Charles GTEandGitHub 408d6d76ef Merge pull request #327 from Portabase/dev
Dev
2026-06-21 16:39:06 +02:00
Charles GTEandGitHub bf0ed69b60 Merge pull request #328 from jjingi/feature/Integrate-Azure-Blob
Feature/integrate azure blob
2026-06-21 15:49:52 +02:00
Charles GTE afd9a8eff2 chore: refactoring 2026-06-21 15:47:23 +02:00
Charles GTE baab060628 fix: azurite and refactoring 2026-06-20 19:35:40 +02:00
Charles GTE 3018515abf Merge branch 'dev' into feature/Integrate-Azure-Blob
# Conflicts:
#	src/db/migrations/meta/0063_snapshot.json
#	src/db/migrations/meta/_journal.json
2026-06-20 14:56:41 +02:00
Charles GTE 402d3f3d9d fix: README.md 2026-06-20 09:59:30 +02:00
Charles GTE e193a300b1 fix: README.md 2026-06-20 09:45:02 +02:00
Charles GTE 0ed3be16f5 fix: README.md 2026-06-20 09:23:31 +02:00
jingi c34d8c5532 fix: coderabbit review fixed(typo, url validation, and unique ping key 2026-06-19 14:45:24 -07:00
Charles GTE aaa0c1d9a3 fix: README.md 2026-06-19 23:13:34 +02:00
jingi 1dfd0c2e56 fix: changed lowercase blob provder to uppercase to match StoragePrivderKind 2026-06-19 14:06:15 -07:00
jingi 63a94143e2 chore: regenrate pnpm-lock.yaml after rebase 2026-06-19 13:54:44 -07:00
jingi 243d724fb2 feat: add blob to db migration and register in channel helpers 2026-06-19 13:53:58 -07:00
jingi d6235adccd fix: add blob to db schema 2026-06-19 13:51:12 -07:00
jingi ae0f9a770d fix: add form for azure blob 2026-06-19 13:51:12 -07:00
jingi 305d55d972 feat: add Blobl.form.tsx file 2026-06-19 13:51:12 -07:00
jingi aa14367f65 feat: add blob variant to SotrageChannelFormSchema 2026-06-19 13:51:12 -07:00
jingi 8f797df033 feat: add auzre blob on index.ts handler 2026-06-19 13:51:12 -07:00
jingi d680eeb40e fix: rename blob.schema.ts to Blob.schema.ts 2026-06-19 13:51:12 -07:00
jingi 6ede27667f feat: implement Azure Blob storage (getBlobClient, upload, get, delete, ping, copy) 2026-06-19 13:51:12 -07:00
jingi 187d100230 feat: add Zod schema for Azure Blob storage config 2026-06-19 13:51:12 -07:00
jingi 4f8df0cef6 feat: add azure storage dependency 2026-06-19 13:50:19 -07:00
jingi 76a7c2ce43 feat: add blob to storage types 2026-06-19 13:48:42 -07:00
Charles GTE 4911e49c6b fix: README.md 2026-06-19 22:45:04 +02:00
Charles GTE f2f5f767b4 fix: README.md 2026-06-19 22:22:20 +02:00
Charles GTEandGitHub f209989f11 Merge pull request #325 from pcrosthwaite/feature/pushover-notifications
Feature/pushover notifications
2026-06-19 19:22:33 +02:00
charles-gauthereau d651568921 fix: schema and form select 2026-06-19 19:20:37 +02:00
Peter Crosthwaite 27348a3341 Updated to use AbortController pattern 2026-06-19 22:04:03 +10:00
Peter Crosthwaite ceba848bdd pushover request fixes
Implemented truncation on data elements to comply with pushover restrictions.
Added a timeout for a request to the pushover api endpoint
2026-06-19 21:22:07 +10:00
Peter Crosthwaite 1a36b8b6ef Merge main into feature/pushover-notifications, add Microsoft Teams provider
Both branches added a migration at index 0062. Resolved by keeping
upstream's teams migration at 0062 and promoting pushover to 0063.
Both providers are now included in the enum, types, handlers, and forms.
2026-06-19 18:27:45 +10:00
github-actions[bot] 61bc4a286d chore: release 1.19.0 2026-06-19 08:01:10 +00:00
Charles GTEandGitHub f5b3ad7381 Merge pull request #324 from Portabase/dev
Dev
2026-06-19 10:00:13 +02:00
Peter Crosthwaite a96c739d90 Replace manual pushover migration with drizzle-generated version 2026-06-19 17:49:50 +10:00
Peter Crosthwaite aaeaeabc81 Removed duplicated migration 2026-06-19 17:44:42 +10:00
charles-gauthereau 0cf7c45488 fix: README.md and removed the e2E folder as it is in a dedicated project now 2026-06-19 09:44:41 +02:00
Peter Crosthwaite db6eb221d8 Updated failed response to display in activity log 2026-06-19 17:24:44 +10:00
MalilavGitHubkillian-larcherCharles GTEgithub-actions[bot] <github-actions[bot]@users.noreply.github.com>github-actions[bot] <github-actions[bot]@users.noreply.github.com>Michael KutýKillian Larchergithub-actions[bot] <github-actions[bot]@users.noreply.github.com>Shawn M
130c23dd5b feat: Add Microsoft Teams as notification provider (issue 260) (#313)
* feat: add Microsoft Teams notification provider

* feat: Add Microsoft Teams as notification provider

* fix: Update database migration

* fix: Remove duplicate file 0058 db migration

* add Microsoft Teams notification provider E2E tests

* fix: externalize e2e testings and profile name trim (#318)

* fix: externalize e2e testings

* fix: externalize e2e tests.

* refactor: improve e2e workflow.

* chore: upgrade workflow action version.

* fix: createEnv by adding emptyStringAsUndefined (#317)

* chore: release 1.18.2

---------

Co-authored-by: killian-larcher <killian.larcher@soluce-technologies.com>
Co-authored-by: Charles GTE <charles.gauthereau@soluce-technologies.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

* fix(layout): guard undefined user.name in avatar fallbacks (#320)

* fix(layout): guard undefined user.name in avatar fallbacks

* fix: AUTH_DEFAULT_USER as zod email

---------

Co-authored-by: charles-gauthereau <charles.gauthereau@soluce-technologies.com>

* fix: profile name trim

---------

Co-authored-by: killian-larcher <killian.larcher@soluce-technologies.com>
Co-authored-by: Charles GTE <charles.gauthereau@soluce-technologies.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Michael Kutý <6du1ro.n@gmail.com>
Co-authored-by: charles-gauthereau <charles.gauthereau@soluce-technologies.com>

* chore: release 1.18.3

* chore: Update README.md (#323)

* chore: Update README.md

added unraid link

* chore: Update README.md

fix markdown

* chore: release 1.18.4

* fix: teams.ts with timeout

---------

Co-authored-by: Killian Larcher <98161034+KillianLarcher@users.noreply.github.com>
Co-authored-by: killian-larcher <killian.larcher@soluce-technologies.com>
Co-authored-by: Charles GTE <charles.gauthereau@soluce-technologies.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Michael Kutý <6du1ro.n@gmail.com>
Co-authored-by: charles-gauthereau <charles.gauthereau@soluce-technologies.com>
Co-authored-by: Shawn M <84647313+hyperion02@users.noreply.github.com>
2026-06-19 07:42:50 +02:00
Peter Crosthwaite 6075872881 Added support for pushover notifications 2026-06-19 15:37:05 +10:00
github-actions[bot] 7b65d5648f chore: release 1.18.4 2026-06-16 19:12:17 +00:00
Shawn MandGitHub eb4a31e5f4 chore: Update README.md (#323)
* chore: Update README.md

added unraid link

* chore: Update README.md

fix markdown
2026-06-16 21:11:25 +02:00
github-actions[bot] 0d2f2957b3 chore: release 1.18.3 2026-06-11 13:53:57 +00:00
Killian LarcherGitHubkillian-larcherCharles GTEgithub-actions[bot] <github-actions[bot]@users.noreply.github.com>charles-gauthereaugithub-actions[bot] <github-actions[bot]@users.noreply.github.com>Michael Kutý
51bf9cf679 fix: externalize e2e testings and profile name trim (#318)
* fix: externalize e2e testings

* fix: externalize e2e tests.

* refactor: improve e2e workflow.

* chore: upgrade workflow action version.

* fix: createEnv by adding emptyStringAsUndefined (#317)

* chore: release 1.18.2

---------

Co-authored-by: killian-larcher <killian.larcher@soluce-technologies.com>
Co-authored-by: Charles GTE <charlesgte31@gmail.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

* fix(layout): guard undefined user.name in avatar fallbacks (#320)

* fix(layout): guard undefined user.name in avatar fallbacks

* fix: AUTH_DEFAULT_USER as zod email

---------

Co-authored-by: charles-gauthereau <charles.gauthereau@soluce-technologies.com>

* fix: profile name trim

---------

Co-authored-by: killian-larcher <killian.larcher@soluce-technologies.com>
Co-authored-by: Charles GTE <charlesgte31@gmail.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Michael Kutý <6du1ro.n@gmail.com>
Co-authored-by: charles-gauthereau <charles.gauthereau@soluce-technologies.com>
2026-06-11 15:53:07 +02:00
charles-gauthereau 777a8bba3c fix: profile name trim 2026-06-11 15:28:48 +02:00
09eba5c0e8 fix(layout): guard undefined user.name in avatar fallbacks (#320)
* fix(layout): guard undefined user.name in avatar fallbacks

* fix: AUTH_DEFAULT_USER as zod email

---------

Co-authored-by: charles-gauthereau <charles.gauthereau@soluce-technologies.com>
2026-06-11 15:20:06 +02:00
killian-larcher b9924f511f Merge branch 'main' into dev 2026-06-10 18:21:21 +02:00
Killian LarcherGitHubkillian-larcherCharles GTEgithub-actions[bot] <github-actions[bot]@users.noreply.github.com>
32da8b5fd0 feat: externalize e2e testings [skip-release] (#312)
* feat: externalize e2e tests.

* refactor: improve e2e workflow.

* chore: upgrade workflow action version.

* fix: createEnv by adding emptyStringAsUndefined (#317)

* chore: release 1.18.2

---------

Co-authored-by: killian-larcher <killian.larcher@optimusai.fr>
Co-authored-by: Charles GTE <charlesgte31@gmail.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-06-10 18:08:58 +02:00
github-actions[bot] 87f8a50326 chore: release 1.18.2 2026-06-10 15:50:59 +00:00
Charles GTEandGitHub b70a8c8a16 fix: createEnv by adding emptyStringAsUndefined (#317) 2026-06-10 17:50:04 +02:00
github-actions[bot] ebdef2d302 chore: release 1.18.1 2026-06-09 18:07:45 +00:00
Charles GTEandGitHub a6bb50c5e2 Merge pull request #314 from Portabase/dev
Dev
2026-06-09 19:55:38 +02:00
Charles GTEandGitHub 50eb48fcf9 Merge pull request #311 from GaboritJunior/fix/tusd-behind-proxy
fix: Enable --behind-proxy for tusd
2026-06-09 19:44:03 +02:00
Charles GTE 6622565b14 fix: echo log in app-prod-entrypoint.sh and TUSD_BEHIND_PROXY in .env.example 2026-06-09 19:42:24 +02:00
Junior GABORITGitHubcoderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
f001de1e97 Update docker/nginx/nginx.conf
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-06-08 11:38:04 +02:00
Junior GABORIT 6711fab413 [docker/nginx/nginx.conf] Apply coderabbitai change 2026-06-08 11:22:20 +02:00
Junior GABORIT e2f28a84bc [K8S] Add TUSD_BEHIND_PROXY env var 2026-06-08 10:08:41 +02:00
Junior GABORIT d1941c2f3d [docker/entrypoints] Update tusd lauch command 2026-06-08 10:07:14 +02:00
Junior GABORIT f35e6abb05 [docker/Dockerfile] Add TUSD_BEHIND_PROXY env var 2026-06-08 10:05:48 +02:00
Junior GABORIT 0598f78a34 [docker/nginx/nginx.conf] Update X-Forwarded-Proto value 2026-06-08 10:05:18 +02:00
github-actions[bot] f30b55ba47 chore: release 1.18.0 2026-06-07 15:42:38 +00:00
Charles GTEandGitHub 751f29e340 Merge pull request #309 from Portabase/feat/backup-restore-logs
feat: backup-restore-logs
2026-06-07 17:41:43 +02:00
charles-gauthereau 2e629f8423 fix: refactoring and ordering data on agent and project page 2026-06-07 17:33:47 +02:00
Charles GTE ae431f5b85 fix: pnpm 2026-06-07 11:22:22 +02:00
Charles GTE f5f697ceb0 fix: refactoring 2026-06-07 11:18:45 +02:00
Charles GTE 02a8f0baf9 fix: also add for restoration 2026-06-07 11:15:21 +02:00
Charles GTE 2932149b92 feat: backup and restore logs modal view 2026-06-06 22:52:21 +02:00
Charles GTE e1e4d67114 feat: backup and restore logs modal view 2026-06-06 22:46:36 +02:00
Charles GTE 1e3f112d9c Merge branch 'main' into dev 2026-06-05 21:32:28 +02:00
github-actions[bot] e3600949b0 chore: release 1.17.4 2026-06-04 07:09:53 +00:00
Charles GTEandGitHub 856c80cc20 fix: release.yml (#307) 2026-06-04 09:09:06 +02:00
Charles GTE 8c77796f91 Merge branch 'main' into dev 2026-06-04 08:58:07 +02:00
github-actions[bot] dbeaaa4bac chore: release 1.17.3 2026-06-04 06:44:52 +00:00
charles-gauthereau f9986e1b7f Merge branch 'main' into dev 2026-06-02 21:21:06 +02:00
github-actions[bot] 895008ba1d chore: release 1.17.2 2026-06-02 16:37:29 +00:00
Charles GTEandGitHub 09791ae86e fix: demo-banner (#305) 2026-06-02 18:36:35 +02:00
github-actions[bot] 564dee512f chore: release 1.17.1 2026-06-02 07:18:07 +00:00
Charles GTEandGitHub 56a9922057 fix: refactoring the api keys section and add documentation link. (#304) 2026-06-02 09:17:21 +02:00
github-actions[bot] 44f0112620 chore: release 1.17.0 2026-06-01 21:07:33 +00:00
Charles GTEandGitHub 2d7a5ce264 feat: add mcp server (#303)
* feat: added the mcp server

* feat: added the mcp server

* fix: api for database not linked to any projects

* fix: added variable to disable MCP

* chore: refactoring
2026-06-01 23:06:21 +02:00
charles-gauthereau 48f8d00d2e Merge branch 'main' into dev 2026-05-29 18:29:03 +02:00
github-actions[bot] 7afcf0f76b chore: release 1.16.2 2026-05-29 06:23:23 +00:00
a7b28ce84d fix: Discord Notification (#302)
Co-authored-by: charles-gauthereau <charles.gauthereau@soluce-technologies.com>
2026-05-29 08:22:41 +02:00
github-actions[bot] 4ef82ac3a8 chore: release 1.16.1 2026-05-29 06:07:05 +00:00
f86c826d1b chore: env variables (#301)
Co-authored-by: charles-gauthereau <charles.gauthereau@soluce-technologies.com>
2026-05-29 08:06:17 +02:00
github-actions[bot] 3de7ef1a26 chore: release 1.16.0 2026-05-29 05:39:20 +00:00
Charles GTEandGitHub 1713780528 fieat: Merge pull request #300 from Portabase/feat/rest-api
feat: rest-api
2026-05-29 07:38:18 +02:00
charles-gauthereau 6db39b829e fix: refactoring 2026-05-28 22:35:09 +02:00
charles-gauthereau 174ff0886c fix: added OPENAPI_ENABLED & API_ENABLED variables 2026-05-28 22:08:49 +02:00
charles-gauthereau d49d8f52c4 fix: hydration errors 2026-05-28 20:50:05 +02:00
charles-gauthereau c9552e448a fix: refactoring 2026-05-28 20:44:27 +02:00
charles-gauthereau 47d3059d78 fix: some bugs 2026-05-28 20:12:59 +02:00
charles-gauthereau c3bafef6d8 fix: openapi schemas 2026-05-28 19:05:57 +02:00
charles-gauthereau 1fbbc935c1 fix: add cdn.jsdelivr.net to style-src CSP for Swagger UI 2026-05-28 11:20:04 +02:00
charles-gauthereau ad0e6be10d fix: pin swagger-ui CDN assets with SRI and add document-level security default 2026-05-28 11:15:04 +02:00
charles-gauthereau 5735d47889 feat: serve Swagger UI at GET /api/v1/docs 2026-05-28 11:00:47 +02:00
charles-gauthereau 294bdfa296 feat: serve OpenAPI spec JSON at GET /api/v1/openapi 2026-05-28 11:00:04 +02:00
charles-gauthereau 83eb012cb5 feat: add buildSpec() to assemble /api/v1 OpenAPI document 2026-05-28 10:59:25 +02:00
charles-gauthereau 51d165fe84 feat: register database routes in OpenAPI spec 2026-05-28 10:58:26 +02:00
charles-gauthereau 3b7eb6c606 feat: register agent routes in OpenAPI spec 2026-05-28 10:56:29 +02:00
charles-gauthereau 333146c590 feat: register apiKeyAuth security scheme for OpenAPI spec 2026-05-28 10:55:17 +02:00
charles-gauthereau 6adb310f0c feat: add zod-to-openapi bootstrap for /api/v1 OpenAPI spec 2026-05-28 10:53:39 +02:00
charles-gauthereau a64fe1c08c chore: add @asteasolutions/zod-to-openapi for OpenAPI spec generation 2026-05-28 10:51:58 +02:00
charles-gauthereau 40cdb3d0ab fix: some security issues 2026-05-28 10:18:28 +02:00
charles-gauthereau 7a97721abf fix: agent route to add json parser 2026-05-28 08:16:34 +02:00
charles-gauthereau c1b71e7772 fix: endpoints 2026-05-27 22:02:22 +02:00
charles-gauthereau 56517226b6 fix: endpoints 2026-05-25 18:31:00 +02:00
charles-gauthereau 337d270ba6 fix: correct rate-limit response shape in withApiKey middleware 2026-05-25 16:17:18 +02:00
charles-gauthereau 373ab315a3 feat: register api/v1 routes in proxy route guard 2026-05-25 15:36:33 +02:00
charles-gauthereau 685e1fc300 fix: apply soft-delete filters consistently across api/v1 routes
Four missed cases where deleted/archived resources could leak through:
- acl.ts: exclude deleted databases from getAccessibleDatabaseIds
- databases/route.ts: exclude deleted databases in list endpoint
- agents/route.ts: re-apply isArchived guard on final fetch (ACL ids stale)
- databases/[id]/status/route.ts: exclude deleted restorations
2026-05-25 15:29:33 +02:00
charles-gauthereau 274aded8fd fix(api-v1): add soft-delete filters and remove any types in database operation routes 2026-05-25 15:18:55 +02:00
charles-gauthereau 39b6b5e116 feat(api-v1): add GET /databases/:id/status 2026-05-25 15:14:41 +02:00
charles-gauthereau 3f52d8c76e feat(api-v1): add POST /databases/:id/restore 2026-05-25 15:14:39 +02:00
charles-gauthereau 11f814777b feat(api-v1): add GET /databases/:id/backup/:backupId 2026-05-25 15:14:38 +02:00
charles-gauthereau dd66ecdb78 feat(api-v1): add GET and POST /databases/:id/backup 2026-05-25 15:14:36 +02:00
charles-gauthereau ec90a34262 fix(api-v1): return 404 for archived agents in resolveAgentAccess 2026-05-25 15:12:45 +02:00
charles-gauthereau 5a2ce4968d feat(api-v1): add GET /databases/:id 2026-05-25 15:09:44 +02:00
charles-gauthereau 03659b4a15 feat(api-v1): add GET /databases 2026-05-25 15:09:32 +02:00
charles-gauthereau b032c96a7c feat(api-v1): add GET /agents/:id and DELETE /agents/:id 2026-05-25 15:09:26 +02:00
charles-gauthereau d887d44bd4 fix(api-v1): transaction safety and isArchived consistency in agents route 2026-05-25 15:08:11 +02:00
charles-gauthereau 7115ce8484 feat(api-v1): add GET /agents and POST /agents 2026-05-25 15:03:21 +02:00
charles-gauthereau 96cc9822d0 fix(api-v1): handle agent.organizationId FK path and nullable isArchived in ACL helpers 2026-05-25 15:01:25 +02:00
charles-gauthereau 68fb74f441 feat(api-v1): add ACL helpers 2026-05-25 14:57:20 +02:00
charles-gauthereau 06ed4f3517 fix(api-v1): strengthen middleware type safety and remove redundant import 2026-05-25 14:55:53 +02:00
charles-gauthereau 3a4dd10b4c feat(api-v1): add withApiKey middleware 2026-05-25 14:46:20 +02:00
charles-gauthereau 0316fdc0a3 feat: added the api key list, delete, create in profile modal. 2026-05-25 13:04:42 +02:00
charles-gauthereau 72ecd7f054 feat: added the api key list, delete, create in profile modal. 2026-05-25 11:39:30 +02:00
Charles GTE 8db995010f Merge branch 'main' into dev 2026-05-24 17:13:16 +02:00
github-actions[bot] 539fec4dbc chore: release 1.15.5 2026-05-24 15:09:58 +00:00
Charles GTEandGitHub 3fa0f9b76d fix: feature-base-refactoring (#297)
* docs: add AGENTS.md with feature-based architecture conventions

Defines the target src/ structure, feature list, naming conventions
(kebab-case, file type patterns), import direction rules, and
feature boundary guidelines for the planned big-bang refactor.

* docs: add features/channel/ to architecture conventions

Generic channel UI (card, modal, form) goes to features/channel/.
Provider-specific sub-folders: channel/notifications/ and channel/storages/.

* refactor: move event emitter to lib/, project service to db/services/, dedup use-mobile hook

* fix: remove unused auth import in db/services/project.ts

* refactor: migrate wrappers/common to components/common (flat)

* fix: remove dead commented-out code from button-with-loading.tsx

* refactor: merge features/browser into features/theme, flatten upload/updates, migrate statistics

* refactor: migrate wrappers/auth to features/auth

Move all auth components and forms from src/components/wrappers/auth/
into src/features/auth/ with flat kebab-case naming. Handle extra
reset-password-form.tsx and differing reset-password-schema.ts by
copying them as reset-password-page-form.tsx and reset-password-page.schema.ts.

* refactor: remove dead auth files and fix zPassword schema

Delete reset-password-section.tsx and reset-password-page-form.tsx (zero consumers).
Fix reset-password-page.schema.ts to import zPassword from @/lib/zod.

* refactor: migrate wrappers/dashboard/common to features/layout, rename Header.tsx to header.tsx

* fix: decouple layout from theme/updates cross-feature imports

Use slot props (actions, updateNotification) so app/ composes
ModeToggle and UpdateNotification into Header and AppSidebar.
Delete dead side-bar-logo.tsx (zero consumers).

* refactor: merge features/keys into features/agents, flatten sub-dirs, migrate agent wrappers

- Merge src/features/keys/keys.action.ts into src/features/agents/
- Flatten src/features/agents/components/ and hooks/ into feature root
- Migrate all src/components/wrappers/dashboard/agent/ files into src/features/agents/
- Update all import paths across app/ and src/ accordingly
- Fix broken code-snippet import in agent-key-modal.tsx (was pointing to non-existent wrappers path)

* fix: move github-releases to lib/, remove dead code and unused imports

Move GitHub release fetch logic from features/updates/github.ts to
lib/github-releases.ts so both features/updates and features/agents
can import it without cross-feature imports.
Remove commented-out impersonation block from agent-database-card.tsx.
Remove unused imports (CopyButton, Terminal, Key, Info).

* refactor: flatten features/projects/components, migrate project wrappers

Move project.dialog/form out of sub-dir, migrate project-card, project-database-card, and delete-project button/action from wrappers into features/projects/. Update all import paths across src/ and app/. Leave wrappers/dashboard/projects/database/ untouched for Task 10.

* fix: extract DatabaseCard to components/common, remove cross-feature import

DatabaseCard was imported by both features/projects and features/agents,
violating the no-cross-feature-import rule. Extract to components/common/
database-card.tsx so both features import from the shared layer.
Also tighten extendedProps type and remove commented-out maxCount prop.

* refactor: migrate all database/backup/restore/health UI to features/database

Consolidates ~40 files from 5 source locations (features/dashboard/backup,
features/dashboard/restore, wrappers/dashboard/backup, wrappers/dashboard/database,
wrappers/dashboard/health, wrappers/dashboard/projects/database) into the new
flat features/database directory with kebab-case filenames and updated @/ imports.

* fix: remove dead restore-form files and console.log from health-grid

Delete restore-form.tsx (186 lines all commented-out, zero consumers)
and restore-form.schema.ts (only a commented import).
Remove console.log(logs) from getOldestLog helper in health-grid.tsx.

Cross-feature imports (database→storages for dispatchStorage/storeBackupFiles)
are accepted as known architectural coupling — storage dispatch is called from
database server actions at runtime and cannot route through app/ props.

* refactor: migrate wrappers/admin/users to features/users, fix table-colums typo

* fix: fix relative imports, string interpolation, and console.error in users feature

Replace relative imports with @/ aliases in user-actions-cell.tsx and
admin-user-edit-modal.tsx. Fix string interpolation bug in dialog title
(was regular string, now template literal). Remove console.error from
admin-user-change-role-modal.tsx.

* refactor: migrate wrappers/profile to features/profile, fix profile-apperance typo

* fix: remove console.error calls and ts-ignore in profile feature

Remove console.error from profile-account.tsx, 2fa-form.tsx (x2).
Replace @ts-ignore on fileInput.onchange with typed cast in avatar-with-upload.tsx.

* refactor: rename features/organization to features/organizations, migrate all org wrappers

Moves 37 org-related files from features/organization/, features/dashboard/,
hooks/, wrappers/admin/organizations/, wrappers/admin/channels/organization/,
and wrappers/dashboard/organization/ into a flat features/organizations/ feature
directory. Updates all import paths codebase-wide. Fixes @ts-ignore and removes
unused @better-fetch/fetch import in admin-organization-form.

* fix: remove console.error, @ts-ignore, and unused destructures in features/organizations

* refactor: migrate wrappers/organization/migration to features/migration

Move all 5 migration files to src/features/migration/, update internal
imports to @/features/migration/ absolute paths, remove console.log,
and delete src/components/wrappers/dashboard/organization/ entirely.

* refactor: migrate wrappers/admin/settings to features/settings

Move all 14 settings files (email, notification, storage, s3) from
wrappers/dashboard/admin/settings/ into features/settings/, rewriting
imports, replacing @ts-ignore with @ts-expect-error, and renaming error
params to _error where applicable.

* refactor: rename notifications files to kebab convention, migrate notification logs

- Rename dispatch.ts/helpers.ts/types.ts to notifications.*.ts
- Migrate notification logs components from wrappers/dashboard/admin/notifications/logs/ to features/notifications/
- Fix all import paths across codebase and in providers/
- Remove unused useRouter import, replace @ts-ignore with @ts-expect-error
- Delete src/components/wrappers/dashboard/admin/notifications/

* refactor: rename storages dispatch/helpers/types to kebab convention

- Rename dispatch.ts/helpers.ts/types.ts to storages.*.ts
- Fix all import paths across codebase and in providers/
- Fix relative ../types and ../../types imports in all storage providers to absolute @/ paths
- Replace @ts-ignore with @ts-expect-error, remove console.debug/console.error

* refactor: create features/channel with generic UI and notifications/storages provider sub-folders

Consolidates 4 source locations into src/features/channel/:
- components/wrappers/dashboard/admin/channels/ → features/channel/ (11 generic UI files)
- features/notifications/providers/ → features/channel/notifications/ (9 backend + 18 form files)
- features/storages/providers/ → features/channel/storages/ (backend + forms)

The two conflicting google-drive/helpers.ts files (client helpers vs OAuth action)
are kept separate: helpers.ts and google-drive-refresh.action.ts.

Rewrites all imports codebase-wide to point to new locations.
Removes console.log/console.error calls, upgrades @ts-ignore to @ts-expect-error.

* fix: remove console.error from local storage provider

* fix: multi-select.tsx scroll issue

* fix: multi-select.tsx scroll issue
2026-05-24 17:09:12 +02:00
Charles GTE 41d8b2c375 Merge branch 'main' into dev 2026-05-23 18:42:07 +02:00
github-actions[bot] 4fadfcfaf2 chore: release 1.15.4 2026-05-21 17:46:00 +00:00
af3df7f72a fix: multiple-webhook-headers and nextcloud talk provider (#296)
* feat(webhook): replace single secret header pair with webhookHeaders array schema

* feat(webhook): iterate webhookHeaders array; keep backward compat for legacy secret fields

* feat(webhook): replace single header pair with dynamic useFieldArray header list

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(webhook): add field labels to header key/value inputs

* fix(webhook): guard reserved headers; align delete button with inputs

* feat(nextcloud): add nextcloud to provider_kind enum

* feat(nextcloud): add ProviderKind entry and HMAC-signed provider

* feat(nextcloud): add Zod schema and form component

* feat(nextcloud): wire nextcloud into form registry and UI provider list

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(nextcloud): register sendNextcloud in provider dispatch map

* fix: multiple webhook headers and nextcloud talk notification provider

* test(webhook): update e2e to use Add Header button and Header Value label

* fix: webhook.ts

---------

Co-authored-by: charles-gauthereau <charles.gauthereau@soluce-technologies.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 19:45:08 +02:00
Charles GTE 3b8fb93fb2 Merge branch 'main' into dev 2026-05-21 19:28:57 +02:00
github-actions[bot] 2cd5bda416 chore: release 1.15.3 2026-05-21 05:51:50 +00:00
965b215224 fix: replace integer column with bigint for size (#295)
Co-authored-by: charles-gauthereau <charles.gauthereau@soluce-technologies.com>
2026-05-21 07:51:08 +02:00
github-actions[bot] 27f6b746e0 chore: release 1.15.2 2026-05-21 05:28:56 +00:00
7d463d7da8 fix: default user from init .env variables (#293)
* fix: default user from init en .env variables

* fix: .env.example

---------

Co-authored-by: charles-gauthereau <charles.gauthereau@soluce-technologies.com>
2026-05-21 07:28:05 +02:00
killian-larcher 55be9374e1 Revert "docs: add OIDC E2E design spec"
This reverts commit b28bb44f97.
2026-05-20 21:45:46 +02:00
killian-larcher ce1bade773 Merge remote-tracking branch 'origin/dev' into dev 2026-05-17 10:46:53 +02:00
github-actions[bot] 332540488d chore: release 1.15.1 2026-05-17 08:42:07 +00:00
ab52ef6c27 fix: logger.ts (#288)
* fix: release.yml

* fix: S3ChannelConfigSchema type port mismatch

* chore: package.json

* fix: refactoring logs and adding pino logger for production and dev.

* fix: trusted url error due to better auth update.

* fix: logger.ts

---------

Co-authored-by: charlesgauthereau <charles.gauthereau@soluce-technologies.com>
Co-authored-by: killianlarcher <killian.larcher@soluce-technologies.com>
2026-05-17 10:41:36 +02:00
Charles GTE 9fe046617c fix: logger.ts 2026-05-17 10:34:54 +02:00
killian-larcher 01763a6b36 chore: ignore local worktrees 2026-05-17 10:31:15 +02:00
killian-larcher b28bb44f97 docs: add OIDC E2E design spec 2026-05-17 10:23:41 +02:00
Charles GTE 411d0b4565 Merge branch 'main' into dev 2026-05-17 10:04:10 +02:00
github-actions[bot] 2d55618898 chore: release 1.15.0 2026-05-17 08:00:16 +00:00
Charles GTEandGitHub 6b3df405eb fix: add-healthcheck (#287)
fix: add-healthcheck
2026-05-17 09:59:38 +02:00
Charles GTE e91ef4c142 fix: docker-compose.prod.yml 2026-05-17 09:52:58 +02:00
Charles GTE 9b504ccd68 fix: proxy.ts 2026-05-17 09:46:03 +02:00
Charles GTE dd3f4411cf chore: add startupProbe and fix probe timing for cold-boot safety 2026-05-17 09:39:11 +02:00
Charles GTE 0ab94b6221 chore: add liveness and readiness probes to Helm deployment 2026-05-17 09:37:28 +02:00
Charles GTE 8477889cea chore: fix healthcheck start_period and clean up curl command 2026-05-17 09:36:49 +02:00
Charles GTE 2eb4a7a0fe chore: add healthcheck probe to prod docker-compose app service 2026-05-17 09:35:01 +02:00
Charles GTE 1f68adda90 feat: add GET /api/health liveness endpoint 2026-05-17 09:33:53 +02:00
Charles GTE 16697c442c Merge branch 'main' into dev 2026-05-15 23:03:57 +02:00
Charles GTEandGitHub ffba2c56a3 fix: env
fix: env.mjs [skip-release]
2026-05-15 23:03:19 +02:00
github-actions[bot] 02be78a26e chore: release 1.14.1 2026-05-15 20:42:10 +00:00
Charles GTEandGitHub 711d4613ed fix: log noise #284
fix: logs noise
2026-05-15 22:41:21 +02:00
Charles GTE a5fe5225e1 fix: pin @better-auth/sso and @better-auth/passkey to 1.6.2 to fix build 2026-05-15 22:28:38 +02:00
Charles GTE b752d9437c chore: pnpm 2026-05-15 22:22:21 +02:00
Charles GTE 18df92e048 chore: update packages 2026-05-15 22:18:55 +02:00
Charles GTE 4e66b8ed04 fix: logs 2026-05-15 22:08:14 +02:00
Charles GTEandClaude Sonnet 4.6 4f14d16452 fix: replace console.log with Pino logger in server-side files
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 22:05:13 +02:00
Charles GTE 592588ef76 docs: set LOG_LEVEL=info as dev default in .env.example 2026-05-15 22:01:19 +02:00
Charles GTE 3fec7c3392 docs: document LOG_LEVEL env var in .env.example 2026-05-15 21:59:00 +02:00
Charles GTE 2335caefd7 fix: downgrade cleaningJob logs to debug; fix catch log level 2026-05-15 21:57:56 +02:00
Charles GTE 194eedeaa8 fix: downgrade agent status ping log to debug 2026-05-15 21:57:21 +02:00
Charles GTE ecd09aedb9 fix: cache log level index at module init in loggingMiddleware 2026-05-15 21:56:37 +02:00
Charles GTE b5350b5fe3 fix: .gitignore 2026-05-15 21:55:19 +02:00
Charles GTE ceaafd5531 fix: suppress agent status ping logs below debug level 2026-05-15 21:55:01 +02:00
Charles GTE 654accad4a fix: use nullish coalescing for LOG_LEVEL fallback 2026-05-15 21:53:54 +02:00
Charles GTE 173c7894b4 fix: respect LOG_LEVEL env var in Pino logger 2026-05-15 21:52:51 +02:00
Charles GTE 1ab51d15f9 fix: added mailpit 2026-05-15 21:24:11 +02:00
Charles GTEandGitHub ddce51d2d3 fix: README.md [skip-release] (#280) 2026-05-12 20:03:49 +02:00
Charles GTEandGitHub fc9fbf4357 chore: update the README.md with mssql (#278) [skip-release] 2026-05-11 22:27:19 +02:00
github-actions[bot] 5e8cd7e0e6 chore: release 1.14.0 2026-05-11 20:09:21 +00:00
Charles GTEandGitHub 1eb5fbb983 feat: add support for mssql (#277)
* feat: add support for mssql

* fix: common.ts
2026-05-11 22:08:36 +02:00
github-actions[bot] d9ccb389f0 chore: release 1.13.1 2026-05-11 17:34:22 +00:00
Charles GTE a3b7af3ba5 Merge branch 'main' into dev 2026-04-29 22:41:02 +02:00
github-actions[bot] dc80d1d752 chore: release 1.13.0 2026-04-29 20:40:27 +00:00
ff28a80d7c feat: migration-tool (#269)
* feat: working on migration backup tool

* feat: add migration sequences

* fix: responsive for migration tool

* fix: truncate database name in migration

* fix: filter on databases success for migration tool

---------

Co-authored-by: charles-gauthereau <charles.gauthereau@soluce-technologies.com>
2026-04-29 22:39:37 +02:00
github-actions[bot] ab55ab349a chore: release 1.12.1 2026-04-24 19:43:11 +00:00
Charles GTEandGitHub a1ec7dbec2 fix: migration error on 2fa (#268) 2026-04-24 21:42:20 +02:00
github-actions[bot] e29846ae25 chore: release 1.12.0 2026-04-10 16:38:28 +00:00
e38519aec2 feat: org agent management (#259)
* feat: org agent management

* feat: org agent management

* fix: adding migrations for legacy and refactoring.

* fix: refactoring

* fix: delete agent

* fix: refactoring

---------

Co-authored-by: charles-gauthereau <charles.gauthereau@soluce-technologies.com>
2026-04-10 18:37:35 +02:00
Charles GTE 33492b8f3a Merge branch 'main' into dev 2026-04-07 18:40:51 +02:00
github-actions[bot] a620d7a9f7 chore: release 1.11.5 2026-04-06 19:24:12 +00:00
Charles GTEandGitHub 88ebd79681 fix: username capitalization in profile modal (#257) 2026-04-06 21:23:36 +02:00
github-actions[bot] e08efa12fa chore: release 1.11.4 2026-04-06 19:09:07 +00:00
Charles GTEandGitHub 7a685c0518 fix: home project counter (#256)
* fix: home project counter

* fix: home project counter
2026-04-06 21:08:29 +02:00
Charles GTE 150ab83f73 Merge branch 'main' into dev 2026-03-30 17:51:28 +02:00
Charles GTE 35db7a4416 fix: trusted url error due to better auth update. 2026-03-29 14:08:20 +02:00
Charles GTE 1ce93af144 fix: refactoring logs and adding pino logger for production and dev. 2026-03-29 11:41:39 +02:00
Charles GTE f410a8fdd5 Merge branch 'main' into dev 2026-03-29 11:23:38 +02:00
Charles GTE 88914169bc Merge branch 'main' into dev 2026-03-29 09:25:00 +02:00
Charles GTE 8d51bcb74d Merge branch 'main' into dev 2026-03-28 19:01:31 +01:00
Charles GTE 5a87529934 fix: refactoring 2026-03-28 16:55:31 +01:00
Charles GTE 0d7a41996e chore: package.json 2026-03-21 08:39:29 +01:00
killianlarcher dc97de03d0 Merge branch 'main' into dev 2026-03-18 19:34:44 +01:00
charlesgauthereau 5d4a1248e4 fix: S3ChannelConfigSchema type port mismatch 2026-03-16 08:54:13 +01:00
charlesgauthereau f876d8eb09 Merge branch 'main' into dev 2026-03-16 07:59:39 +01:00
charlesgauthereau 1d1d92a5cf fix: release.yml 2026-03-08 21:57:13 +01:00
656 changed files with 86181 additions and 13132 deletions
+24 -1
View File
@@ -1,6 +1,12 @@
# Environment
NODE_ENV=development
# Logging
# Controls minimum log level. Options: debug | info | warn | error
# Set to "warn" or "error" in production to suppress health-check ping noise.
# Use "debug" to see all request logs including agent status pings.
LOG_LEVEL=info
# Database
DATABASE_URL=postgresql://devuser:changeme@localhost:5433/devdb?schema=public
@@ -9,6 +15,15 @@ PROJECT_NAME="Portabase"
PROJECT_URL=http://localhost:8887
PROJECT_SECRET=
AUTH_DEFAULT_USER_NAME="Portabase Admin"
AUTH_DEFAULT_USER=user@example.com
# Password must contain at least 8 characters
# Password must contain at least 1 number
# Password must contain at least 1 lowercase letter
# Password must contain at least 1 uppercase letter
# Password must contain at least 1 special character
AUTH_DEFAULT_PASSWORD=testPASS123456!
# SMTP (email)
SMTP_HOST=
SMTP_PORT=
@@ -39,7 +54,6 @@ AUTH_OIDC_ROLE_MAP=""
AUTH_ALLOW_LINKING=true
AUTH_ALLOW_UNLINKING=true
# Social OAuth2 Authentification
AUTH_SOCIAL_ID=""
AUTH_SOCIAL_TITLE=""
@@ -61,3 +75,12 @@ RETENTION_CRON="* * * * *"
TRUSTED_DOMAINS="http://localhost:8887, http://localhost:3055, http://localhost:3056"
#OPENAPI_ENABLED=true
#API_ENABLED=true
#MCP_ENABLED=true
# Default to false
#TUSD_BEHIND_PROXY=true
#Skip the onboarding on fresh instance
#SKIP_ONBOARDING=true
+1 -5
View File
@@ -1,11 +1,7 @@
{
"extends": [
"next/core-web-vitals",
"plugin:tailwindcss/recommended"
],
"extends": ["next/core-web-vitals", "plugin:tailwindcss/recommended"],
"rules": {
"react/no-unescaped-entities": "off",
"react-hooks/rules-of-hooks": "off"
}
}
+11 -2
View File
@@ -14,8 +14,9 @@ Please take a moment to review this guide. It will help you understand how to co
2. [Reporting Issues](#reporting-issues)
3. [Submitting Changes](#submitting-changes)
4. [Code Style Guidelines](#code-style-guidelines)
5. [Pull Request Process](#pull-request-process)
6. [Community Guidelines](#community-guidelines)
5. [E2E Tests](#e2e-tests)
6. [Pull Request Process](#pull-request-process)
7. [Community Guidelines](#community-guidelines)
---
@@ -88,6 +89,14 @@ If you encounter a bug or have a suggestion for improvement, follow these steps:
---
## E2E Tests
E2E tests now live in a dedicated repository: [Portabase/e2e-tests](https://github.com/Portabase/e2e-tests).
If your contribution needs corresponding end-to-end test coverage, add it there instead of this repo.
---
## Pull Request Process
1. Ensure your code passes all tests and linters.
+55 -19
View File
@@ -24,50 +24,86 @@ on:
jobs:
notify-discord:
runs-on: ubuntu-latest
steps:
- name: Send Discord Notification
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
GH_TOKEN: ${{ secrets.GH_TOKEN }}
RELEASE_TAG: ${{ inputs.release_tag }}
DISCORD_TITLE: ${{ inputs.discord_title }}
DISCORD_COLOR: ${{ inputs.discord_color }}
DISCORD_FOOTER: ${{ inputs.discord_footer }}
run: |
RELEASE_INFO=$(gh release view "${{ inputs.release_tag }}" -R ${{ github.repository }} --json name,url,body,author)
set -euo pipefail
RELEASE_TITLE=$(echo "$RELEASE_INFO" | jq -r .name)
if [ -z "$RELEASE_TITLE" ] || [ "$RELEASE_TITLE" = "null" ]; then RELEASE_TITLE="${{ inputs.release_tag }}"; fi
RELEASE_INFO=$(gh release view "$RELEASE_TAG" -R "${{ github.repository }}" --json name,url,body,author)
RELEASE_URL=$(echo "$RELEASE_INFO" | jq -r .url)
RELEASE_BODY=$(echo "$RELEASE_INFO" | jq -r .body)
RELEASE_TITLE=$(echo "$RELEASE_INFO" | jq -r '.name // empty')
if [ -z "$RELEASE_TITLE" ]; then
RELEASE_TITLE="$RELEASE_TAG"
fi
RELEASE_URL=$(echo "$RELEASE_INFO" | jq -r '.url // empty')
RELEASE_BODY=$(echo "$RELEASE_INFO" | jq -r '.body // ""')
AUTHOR_NAME="Portabase"
AUTHOR_ICON="https://github.com/Portabase.png"
PAYLOAD=$(jq -n \
jq -n \
--arg title "$RELEASE_TITLE" \
--arg description "$RELEASE_BODY" \
--arg url "$RELEASE_URL" \
--arg author "$AUTHOR_NAME" \
--arg icon "$AUTHOR_ICON" \
--arg discord_title "${{ inputs.discord_title }}" \
--arg discord_footer "${{ inputs.discord_footer }}" \
--argjson discord_color ${{ inputs.discord_color }} \
--arg discord_title "$DISCORD_TITLE" \
--arg discord_footer "$DISCORD_FOOTER" \
--argjson discord_color "$DISCORD_COLOR" \
'{
content: $discord_title,
content: (
if ($discord_title | length) > 1900
then ($discord_title[0:1900] + "...")
else $discord_title
end
),
embeds: [{
title: $title,
title: (
if ($title | length) > 256
then ($title[0:253] + "...")
else $title
end
),
url: $url,
description: $description,
description: (
if ($description | length) > 3800
then ($description[0:3800] + "\n\n...")
else $description
end
),
color: $discord_color,
author: {
name: $author,
name: (
if ($author | length) > 256
then ($author[0:253] + "...")
else $author
end
),
icon_url: $icon
},
footer: {
text: $discord_footer
text: (
if ($discord_footer | length) > 512
then ($discord_footer[0:509] + "...")
else $discord_footer
end
)
}
}]
}'
)
}' > payload.json
curl -H "Content-Type: application/json" \
-d "$PAYLOAD" \
"$DISCORD_WEBHOOK"
cat payload.json | jq .
curl --fail-with-body -sS \
-H "Content-Type: application/json" \
-d @payload.json \
"$DISCORD_WEBHOOK?wait=true"
+1 -1
View File
@@ -73,7 +73,7 @@ jobs:
tags: ${{ steps.prep.outputs.image }}
target: ${{ inputs.target }}
cache-from: type=gha,scope=build-${{ matrix.platform }}
cache-to: type=gha,mode=max,scope=build-${{ matrix.platform }}
cache-to: type=gha,mode=max,scope=build-${{ matrix.platform }},ignore-error=true
- name: Save image name for manifest
run: echo "${{ steps.prep.outputs.image }}" > image.txt
+66 -97
View File
@@ -4,114 +4,83 @@ on:
pull_request:
branches: [main]
permissions:
contents: read
packages: write
jobs:
build-and-test:
build-image:
runs-on: ubuntu-latest
if: github.event.pull_request.head.repo.full_name == github.repository
outputs:
image: ${{ steps.image.outputs.ref }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Compute image reference
id: image
run: echo "ref=ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/portabase:${GITHUB_SHA}" >> "$GITHUB_OUTPUT"
- name: Setup Node.js
uses: actions/setup-node@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
node-version: 22
cache: 'pnpm'
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build Docker image
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
file: ./docker/dockerfile/Dockerfile
push: false
load: true
tags: portabase/portabase:test
push: true
tags: ${{ steps.image.outputs.ref }}
target: prod
- name: Run app container
run: |
docker run -d --name myapp \
-p 8887:80 \
-e NODE_ENV=production \
-e PROJECT_URL=http://localhost:8887 \
-e PROJECT_SECRET=80af5e4c875dfded3a6ba511c6ed8b0160cad723f848ee0851403ed6141f6ba1 \
portabase/portabase:test
- name: Wait for app port to be listening
run: |
for i in {1..40}; do
if curl -fsS http://localhost:8887/ >/dev/null; then
echo "App is responding on port 8887"
exit 0
fi
echo "Waiting for app readiness... ($i/40)"
sleep 2
done
echo "Timeout: app never became ready"
docker logs myapp
exit 1
- name: Install Playwright browsers
run: pnpm exec playwright install --with-deps chromium
- name: Run Playwright tests
run: pnpm exec playwright test
env:
PROJECT_URL: http://localhost:8887
# E2E Notification
E2E_NOTIFICATION_SMTP_HOST: ${{ secrets.E2E_NOTIFICATION_SMTP_HOST }}
E2E_NOTIFICATION_SMTP_PORT: ${{ secrets.E2E_NOTIFICATION_SMTP_PORT }}
E2E_NOTIFICATION_SMTP_USER: ${{ secrets.E2E_NOTIFICATION_SMTP_USER }}
E2E_NOTIFICATION_SMTP_PASSWORD: ${{ secrets.E2E_NOTIFICATION_SMTP_PASSWORD }}
E2E_NOTIFICATION_SMTP_FROM: ${{ secrets.E2E_NOTIFICATION_SMTP_FROM }}
E2E_NOTIFICATION_SMTP_TO: ${{ secrets.E2E_NOTIFICATION_SMTP_TO }}
E2E_NOTIFICATION_SLACK_WEBHOOK: ${{ secrets.E2E_NOTIFICATION_SLACK_WEBHOOK }}
E2E_NOTIFICATION_DISCORD_WEBHOOK: ${{ secrets.E2E_NOTIFICATION_DISCORD_WEBHOOK }}
E2E_NOTIFICATION_TELEGRAM_BOT_TOKEN: ${{ secrets.E2E_NOTIFICATION_TELEGRAM_BOT_TOKEN }}
E2E_NOTIFICATION_TELEGRAM_CHAT_ID: ${{ secrets.E2E_NOTIFICATION_TELEGRAM_CHAT_ID }}
E2E_NOTIFICATION_TELEGRAM_TOPIC_ID: ${{ secrets.E2E_NOTIFICATION_TELEGRAM_TOPIC_ID }}
E2E_NOTIFICATION_GOTIFY_SERVER_URL: ${{ secrets.E2E_NOTIFICATION_GOTIFY_SERVER_URL }}
E2E_NOTIFICATION_GOTIFY_APP_TOKEN: ${{ secrets.E2E_NOTIFICATION_GOTIFY_APP_TOKEN }}
E2E_NOTIFICATION_NTFY_TOPIC: ${{ secrets.E2E_NOTIFICATION_NTFY_TOPIC }}
E2E_NOTIFICATION_NTFY_SERVER_URL: ${{ secrets.E2E_NOTIFICATION_NTFY_SERVER_URL }}
E2E_NOTIFICATION_NTFY_TOKEN: ${{ secrets.E2E_NOTIFICATION_NTFY_TOKEN }}
E2E_NOTIFICATION_NTFY_USERNAME: ${{ secrets.E2E_NOTIFICATION_NTFY_USERNAME }}
E2E_NOTIFICATION_NTFY_PASSWORD: ${{ secrets.E2E_NOTIFICATION_NTFY_PASSWORD }}
E2E_NOTIFICATION_WEBHOOK_URL: ${{ secrets.E2E_NOTIFICATION_WEBHOOK_URL }}
E2E_NOTIFICATION_WEBHOOK_SECRET_HEADER: ${{ secrets.E2E_NOTIFICATION_WEBHOOK_SECRET_HEADER }}
E2E_NOTIFICATION_WEBHOOK_SECRET: ${{ secrets.E2E_NOTIFICATION_WEBHOOK_SECRET }}
# E2E Storage
E2E_STORAGE_AWS_S3_ENDPOINT_URL: ${{ secrets.E2E_STORAGE_AWS_S3_ENDPOINT_URL }}
E2E_STORAGE_AWS_S3_REGION: ${{ secrets.E2E_STORAGE_AWS_S3_REGION }}
E2E_STORAGE_AWS_S3_ACCESS_KEY: ${{ secrets.E2E_STORAGE_AWS_S3_ACCESS_KEY }}
E2E_STORAGE_AWS_S3_SECRET_KEY: ${{ secrets.E2E_STORAGE_AWS_S3_SECRET_KEY }}
E2E_STORAGE_AWS_S3_BUCKET_NAME: ${{ secrets.E2E_STORAGE_AWS_S3_BUCKET_NAME }}
E2E_STORAGE_AWS_S3_PORT: ${{ secrets.E2E_STORAGE_AWS_S3_PORT }}
E2E_STORAGE_R2_ENDPOINT_URL: ${{ secrets.E2E_STORAGE_R2_ENDPOINT_URL }}
E2E_STORAGE_R2_REGION: ${{ secrets.E2E_STORAGE_R2_REGION }}
E2E_STORAGE_R2_ACCESS_KEY: ${{ secrets.E2E_STORAGE_R2_ACCESS_KEY }}
E2E_STORAGE_R2_SECRET_KEY: ${{ secrets.E2E_STORAGE_R2_SECRET_KEY }}
E2E_STORAGE_R2_BUCKET_NAME: ${{ secrets.E2E_STORAGE_R2_BUCKET_NAME }}
E2E_STORAGE_R2_PORT: ${{ secrets.E2E_STORAGE_R2_PORT }}
E2E_STORAGE_GOOGLE_DRIVE_CLIENT_ID: ${{ secrets.E2E_STORAGE_GOOGLE_DRIVE_CLIENT_ID }}
E2E_STORAGE_GOOGLE_DRIVE_CLIENT_SECRET: ${{ secrets.E2E_STORAGE_GOOGLE_DRIVE_CLIENT_SECRET }}
E2E_STORAGE_GOOGLE_DRIVE_FOLDER_ID: ${{ secrets.E2E_STORAGE_GOOGLE_DRIVE_FOLDER_ID }}
- name: Upload report if failed
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
retention-days: 7
- name: Cleanup
if: always()
run: docker stop myapp && docker rm myapp || true
run-e2e:
if: github.event.pull_request.head.repo.full_name == github.repository
needs: build-image
uses: portabase/e2e-tests/.github/workflows/run-e2e.yml@main
with:
server_image: ${{ needs.build-image.outputs.image }}
secrets:
E2E_NOTIFICATION_SMTP_HOST: ${{ secrets.E2E_NOTIFICATION_SMTP_HOST }}
E2E_NOTIFICATION_SMTP_PORT: ${{ secrets.E2E_NOTIFICATION_SMTP_PORT }}
E2E_NOTIFICATION_SMTP_USER: ${{ secrets.E2E_NOTIFICATION_SMTP_USER }}
E2E_NOTIFICATION_SMTP_PASSWORD: ${{ secrets.E2E_NOTIFICATION_SMTP_PASSWORD }}
E2E_NOTIFICATION_SMTP_FROM: ${{ secrets.E2E_NOTIFICATION_SMTP_FROM }}
E2E_NOTIFICATION_SMTP_TO: ${{ secrets.E2E_NOTIFICATION_SMTP_TO }}
E2E_NOTIFICATION_SLACK_WEBHOOK: ${{ secrets.E2E_NOTIFICATION_SLACK_WEBHOOK }}
E2E_NOTIFICATION_DISCORD_WEBHOOK: ${{ secrets.E2E_NOTIFICATION_DISCORD_WEBHOOK }}
E2E_NOTIFICATION_TELEGRAM_BOT_TOKEN: ${{ secrets.E2E_NOTIFICATION_TELEGRAM_BOT_TOKEN }}
E2E_NOTIFICATION_TELEGRAM_CHAT_ID: ${{ secrets.E2E_NOTIFICATION_TELEGRAM_CHAT_ID }}
E2E_NOTIFICATION_TELEGRAM_TOPIC_ID: ${{ secrets.E2E_NOTIFICATION_TELEGRAM_TOPIC_ID }}
E2E_NOTIFICATION_GOTIFY_SERVER_URL: ${{ secrets.E2E_NOTIFICATION_GOTIFY_SERVER_URL }}
E2E_NOTIFICATION_GOTIFY_APP_TOKEN: ${{ secrets.E2E_NOTIFICATION_GOTIFY_APP_TOKEN }}
E2E_NOTIFICATION_NTFY_TOPIC: ${{ secrets.E2E_NOTIFICATION_NTFY_TOPIC }}
E2E_NOTIFICATION_NTFY_SERVER_URL: ${{ secrets.E2E_NOTIFICATION_NTFY_SERVER_URL }}
E2E_NOTIFICATION_NTFY_TOKEN: ${{ secrets.E2E_NOTIFICATION_NTFY_TOKEN }}
E2E_NOTIFICATION_NTFY_USERNAME: ${{ secrets.E2E_NOTIFICATION_NTFY_USERNAME }}
E2E_NOTIFICATION_NTFY_PASSWORD: ${{ secrets.E2E_NOTIFICATION_NTFY_PASSWORD }}
E2E_NOTIFICATION_WEBHOOK_URL: ${{ secrets.E2E_NOTIFICATION_WEBHOOK_URL }}
E2E_NOTIFICATION_WEBHOOK_SECRET_HEADER: ${{ secrets.E2E_NOTIFICATION_WEBHOOK_SECRET_HEADER }}
E2E_NOTIFICATION_WEBHOOK_SECRET: ${{ secrets.E2E_NOTIFICATION_WEBHOOK_SECRET }}
E2E_STORAGE_AWS_S3_ENDPOINT_URL: ${{ secrets.E2E_STORAGE_AWS_S3_ENDPOINT_URL }}
E2E_STORAGE_AWS_S3_REGION: ${{ secrets.E2E_STORAGE_AWS_S3_REGION }}
E2E_STORAGE_AWS_S3_ACCESS_KEY: ${{ secrets.E2E_STORAGE_AWS_S3_ACCESS_KEY }}
E2E_STORAGE_AWS_S3_SECRET_KEY: ${{ secrets.E2E_STORAGE_AWS_S3_SECRET_KEY }}
E2E_STORAGE_AWS_S3_BUCKET_NAME: ${{ secrets.E2E_STORAGE_AWS_S3_BUCKET_NAME }}
E2E_STORAGE_AWS_S3_PORT: ${{ secrets.E2E_STORAGE_AWS_S3_PORT }}
E2E_STORAGE_R2_ENDPOINT_URL: ${{ secrets.E2E_STORAGE_R2_ENDPOINT_URL }}
E2E_STORAGE_R2_REGION: ${{ secrets.E2E_STORAGE_R2_REGION }}
E2E_STORAGE_R2_ACCESS_KEY: ${{ secrets.E2E_STORAGE_R2_ACCESS_KEY }}
E2E_STORAGE_R2_SECRET_KEY: ${{ secrets.E2E_STORAGE_R2_SECRET_KEY }}
E2E_STORAGE_R2_BUCKET_NAME: ${{ secrets.E2E_STORAGE_R2_BUCKET_NAME }}
E2E_STORAGE_R2_PORT: ${{ secrets.E2E_STORAGE_R2_PORT }}
E2E_STORAGE_GOOGLE_DRIVE_CLIENT_ID: ${{ secrets.E2E_STORAGE_GOOGLE_DRIVE_CLIENT_ID }}
E2E_STORAGE_GOOGLE_DRIVE_CLIENT_SECRET: ${{ secrets.E2E_STORAGE_GOOGLE_DRIVE_CLIENT_SECRET }}
E2E_STORAGE_GOOGLE_DRIVE_FOLDER_ID: ${{ secrets.E2E_STORAGE_GOOGLE_DRIVE_FOLDER_ID }}
+1 -1
View File
@@ -32,7 +32,7 @@ jobs:
create-release:
needs: check-skip
if: ${{ needs.check-skip.outputs.skip == 'false' }}
if: ${{ needs.check-skip.outputs.skip == 'false' && github.event.pull_request.merged == true }}
runs-on: ubuntu-latest
outputs:
draft_tag: ${{ steps.release_step.outputs.draft_tag }}
+10
View File
@@ -9,6 +9,8 @@
# testing
/coverage
gcs-data/
gcs-storage/
# next.js
/.next/
@@ -28,6 +30,8 @@ yarn-error.log*
# local env files
.env*.local
.env
**/.env
# vercel
.vercel
@@ -55,3 +59,9 @@ certificates
/playwright/.cache/
/playwright/.auth/
/e2e/local-storage.json
.claude
.codex
/docs
.worktrees
.superpowers
+3 -1
View File
@@ -21,6 +21,8 @@ keywords:
- backups
- postgresql
- mysql
- firebird
- mssql
- mariadb
- devops
- database
@@ -31,5 +33,5 @@ keywords:
- web-ui
- agent
license: Apache-2.0
version: 1.11.3
version: 1.22.2
date-released: '2026-03-02'
+7 -13
View File
@@ -16,7 +16,7 @@ seed-pocket:
@docker compose -f docker-compose.func.yml stop pocket-id >/dev/null 2>&1 || true
@docker compose -f docker-compose.func.yml rm -f -s pocket-id >/dev/null 2>&1 || true
@docker volume rm portabase-dev-func_pocket-id-data >/dev/null 2>&1 || true
@docker compose -f docker-compose.func.yml run --rm -v ./seeds/pocket-id/portabase.zip:/tmp/portabase.zip pocket-id ./pocket-id import --yes --path /tmp/portabase.zip >/dev/null
@docker compose -f docker-compose.func.yml run --rm -v $$(pwd)/seeds/pocket-id/portabase.zip:/tmp/portabase.zip pocket-id ./pocket-id import --yes --path /tmp/portabase.zip >/dev/null
@docker compose -f docker-compose.func.yml up -d pocket-id
@sleep 2
@docker compose -f docker-compose.func.yml exec pocket-id ./pocket-id one-time-access-token admin
@@ -47,16 +47,10 @@ export-keycloak:
@docker compose -f docker-compose.func.yml start keycloak >/dev/null 2>&1
@echo "Keycloak configuration and users exported to seeds/keycloak/*.json"
end-to-end:
@echo "Starting E2E testing..."
@docker compose -f docker-compose.e2e.yml up -d
@CI=true PROJECT_URL=http://localhost:8887 pnpm playwright test --project=chromium || (docker compose -f docker-compose.e2e.yml down --volumes && exit 1)
@docker compose -f docker-compose.e2e.yml down --volumes
@echo "Finished E2E testing successfully."
seed-blob:
@chmod +x seeds/azurite/azurite-seed.sh
@bash seeds/azurite/azurite-seed.sh
e2e-manual:
@echo "Starting E2E testing..."
@docker compose -f docker-compose.e2e.yml up -d
@pnpm playwright test --ui || (docker compose -f docker-compose.e2e.yml down --volumes && exit 1)
@docker compose -f docker-compose.e2e.yml down --volumes
@echo "Finished E2E testing successfully."
list-blob:
@chmod +x seeds/azurite/azurite-list.sh
@bash seeds/azurite/azurite-list.sh
+16 -13
View File
@@ -25,6 +25,7 @@
[![MongoDB](https://img.shields.io/badge/-MongoDB-13aa52?logo=mongodb&logoColor=white)](https://www.mongodb.com/)
[![Valkey](https://img.shields.io/badge/Valkey-6284fc?style=flat&logo=Valkey&logoColor=white)](https://valkey.io/)
[![Firebird](https://img.shields.io/badge/Firebird-f55b14?style=flat&logo=Firebird&logoColor=white)](https://firebirdsql.org/)
[![Microsoft SQL Server](https://img.shields.io/badge/Microsoft%20SQL%20Server-CC2927?style=flat&logo=microsoftsqlserver&logoColor=white)](https://www.microsoft.com/en-us/sql-server)
[![Self Hosted](https://img.shields.io/badge/self--hosted-yes-brightgreen)](https://github.com/Portabase/portabase)
[![Open Source](https://img.shields.io/badge/open%20source-❤️-red)](https://github.com/Portabase/portabase)
@@ -53,29 +54,31 @@
## Installation
You have 4 ways to install Portabase:
You have 5 ways to install Portabase:
- Automated CLI (recommended) - [details](https://portabase.io/docs/dashboard/setup#cli)
- Docker Run - [details](https://portabase.io/docs/dashboard/setup#docker)
- Docker Compose setup - [details](https://portabase.io/docs/dashboard/setup#docker-compose)
- Kubernetes with Helm [details](https://portabase.io/docs/dashboard/setup#helm)
- Unraid Community Apps - [details](https://ca.unraid.net/apps/portabase-dashboard-1sdc97m05ufd7q) - [unraid support thread](https://forums.unraid.net/topic/199072-support-portabase-dashboard-agent-dockers/)
- Kubernetes with Helm - [details](https://portabase.io/docs/dashboard/setup#helm)
- Development setup - [details](https://portabase.io/docs/dashboard/setup#development)
**Ensure Docker is installed on your machine before getting started.**
## Supported databases
| Engine | Support | Supported Versions | Restore |
|:-------------------|:-----------|:------------------------------|:--------|
| **PostgreSQL** | ✅ Stable | 12, 13, 14, 15, 16, 17 and 18 | Yes |
| **MySQL** | ✅ Stable | 5.7, 8 and 9 | Yes |
| **MariaDB** | ✅ Stable | 10 and 11 | Yes |
| **MongoDB** | ✅ Stable | 4, 5, 6, 7 and 8 | Yes |
| **SQLite** | ✅ Stable | 3.x | Yes |
| **Redis** | ✅ Stable | 2.8+ | No |
| **Valkey** | ✅ Stable | 7.2+ | No |
| **Firebird** | ✅ Stable | 3.0, 4.0, 5.0 | Yes |
| **MSSQL Server** | ❌ Ongoing | - | Yes |
| Engine | Support | Supported Versions | Restore |
|:-------------------|:----------|:------------------------------|:--------|
| **PostgreSQL** | ✅ Stable | 12, 13, 14, 15, 16, 17 and 18 | Yes |
| **MySQL** | ✅ Stable | 5.7, 8 and 9 | Yes |
| **MariaDB** | ✅ Stable | 10 and 11 | Yes |
| **MongoDB** | ✅ Stable | 4, 5, 6, 7 and 8 | Yes |
| **SQLite** | ✅ Stable | 3.x | Yes |
| **Redis** | ✅ Stable | 2.8+ | No |
| **Valkey** | ✅ Stable | 7.2+ | No |
| **Firebird** | ✅ Stable | 3.0, 4.0, 5.0 | Yes |
| **MSSQL Server** | ✅ Stable | 2017, 2019, 2022, Azure SQL | Yes |
| **Docker Volume** | ✅ Stable | Docker Engine 20.10+ | Yes |
See the [Database Servers documentation](https://portabase.io/docs/agent/db) for version-specific backup and restore details.
+2 -2
View File
@@ -1,9 +1,9 @@
import { CardContent, CardHeader } from "@/components/ui/card";
import { TooltipProvider } from "@/components/ui/tooltip";
import { ForgotPasswordForm } from "@/components/wrappers/auth/login/forgot-password-form/forgot-password-form";
import { ForgotPasswordForm } from "@/features/auth/components/forgot-password-form";
import { env } from "@/env.mjs";
import { CardAuth } from "@/features/layout/card-auth";
import { CardAuth } from "@/features/layout/components/card-auth";
import { redirect } from "next/navigation";
export default async function RoutePage(props: { searchParams: Promise<{ callbackUrl: string | undefined }> }) {
+2 -2
View File
@@ -1,9 +1,9 @@
import {CardContent, CardHeader} from "@/components/ui/card";
import {TooltipProvider} from "@/components/ui/tooltip";
import {GuardForm} from "@/components/wrappers/auth/guard/guard-form";
import {GuardForm} from "@/features/auth/components/guard-form";
import {cookies} from "next/headers";
import {redirect} from "next/navigation";
import {CardAuth} from "@/features/layout/card-auth";
import {CardAuth} from "@/features/layout/components/card-auth";
export default async function GuardPage() {
+7 -1
View File
@@ -1,7 +1,9 @@
import React from "react";
import { redirect } from "next/navigation";
import { currentUser } from "@/lib/auth/current-user";
import { AuthLogoSection } from "@/components/wrappers/auth/auth-logo-section";
import { isOnboardingDone } from "@/db/services/setting";
import { AuthLogoSection } from "@/features/auth/components/auth-logo-section";
import { env } from "@/env.mjs";
import { Heart } from "lucide-react";
export default async function Layout({
@@ -9,6 +11,10 @@ export default async function Layout({
}: {
children: React.ReactNode;
}) {
if (env.SKIP_ONBOARDING !== "true" && !(await isOnboardingDone())) {
redirect("/welcome");
}
const user = await currentUser();
if (user && !user.banned && user.role !== "pending") {
+3 -3
View File
@@ -1,12 +1,12 @@
import { LoginForm } from "@/components/wrappers/auth/login/login-form/login-form";
import { LoginForm } from "@/features/auth/components/login-form";
import { Metadata } from "next";
import { SUPPORTED_PROVIDERS } from "@/lib/auth/config";
import { SocialAuthButtons } from "@/components/wrappers/auth/social-buttons";
import { SocialAuthButtons } from "@/features/auth/components/social-buttons";
import { TooltipProvider } from "@/components/ui/tooltip";
import { CardContent, CardHeader } from "@/components/ui/card";
import Link from "next/link";
import { Separator } from "@/components/ui/separator";
import { CardAuth } from "@/features/layout/card-auth";
import { CardAuth } from "@/features/layout/components/card-auth";
import { env } from "@/env.mjs";
export const metadata: Metadata = {
+1 -1
View File
@@ -1,5 +1,5 @@
import { PageParams } from "@/types/next";
import { RegisterForm } from "@/components/wrappers/auth/register/register-form/register-form";
import { RegisterForm } from "@/features/auth/components/register-form";
import { Metadata } from "next";
import { redirect } from "next/navigation";
import { env } from "@/env.mjs";
+2 -2
View File
@@ -1,10 +1,10 @@
import { CardContent, CardHeader } from "@/components/ui/card";
import { TooltipProvider } from "@/components/ui/tooltip";
import { ResetPasswordForm } from "@/components/wrappers/auth/login/reset-password-form/reset-password-form";
import { ResetPasswordForm } from "@/features/auth/components/reset-password-form";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth/auth";
import { Avatar, AvatarImage, AvatarFallback } from "@radix-ui/react-avatar";
import { CardAuth } from "@/features/layout/card-auth";
import { CardAuth } from "@/features/layout/components/card-auth";
import { env } from "@/env.mjs";
export default async function RoutePage(props: { searchParams: Promise<{ token: string | undefined }> }) {
@@ -3,8 +3,8 @@ import {eq} from "drizzle-orm";
import {db} from "@/db";
import * as drizzleDb from "@/db";
import {PageParams} from "@/types/next";
import {Page} from "@/features/layout/page";
import {OrganizationManagement} from "@/components/wrappers/dashboard/admin/organizations/organization/organization-management";
import {Page} from "@/features/layout/components/page";
import {OrganizationManagement} from "@/features/organizations/components/admin-organization-management";
import {buildOrganizationWithMembers} from "@/utils/common";
import {isUUID} from "@/utils/text";
import {user} from "@/db/schema/02_user";
@@ -1,10 +1,10 @@
import {PageParams} from "@/types/next";
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/components/page";
import {db} from "@/db";
import {
AdminOrganizationAddModal
} from "@/components/wrappers/dashboard/admin/organizations/organization/admin-organization-add-modal";
import {AdminOrganizationList} from "@/components/wrappers/dashboard/admin/organizations/organization/admin-orgnization-list";
} from "@/features/organizations/components/admin-organization-add-modal";
import {AdminOrganizationList} from "@/features/organizations/components/admin-organization-list";
import {isNull} from "drizzle-orm";
export default async function RoutePage(props: PageParams<{}>) {
@@ -1,20 +1,23 @@
import {PageParams} from "@/types/next";
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/components/page";
import {db} from "@/db";
import {logger} from "@/lib/logger";
import {notFound} from "next/navigation";
import {SettingsTabs} from "@/components/wrappers/dashboard/admin/settings/settings-tabs";
import {SettingsTabs} from "@/features/settings/components/settings-tabs";
import {desc, isNull} from "drizzle-orm";
import * as drizzleDb from "@/db";
import {StorageChannelWith} from "@/db/schema/12_storage-channel";
import {NotificationChannelWith} from "@/db/schema/09_notification-channel";
const log = logger.child({module: "dashboard/admin/settings"});
export default async function RoutePage(props: PageParams<{}>) {
const settings = await db.query.setting.findFirst({
where: (fields, {eq}) => eq(fields.name, "system"),
});
console.log(settings)
log.debug({settings}, "Settings loaded");
const storageChannels = await db.query.storageChannel.findMany({
with: {
@@ -33,7 +36,7 @@ export default async function RoutePage(props: PageParams<{}>) {
}) as NotificationChannelWith[]
if (!settings || !storageChannels || !notificationChannels ) {
if (!settings || !storageChannels || !notificationChannels) {
notFound()
}
@@ -45,7 +48,8 @@ export default async function RoutePage(props: PageParams<{}>) {
</div>
</PageHeader>
<PageContent className="flex flex-col gap-5">
<SettingsTabs storageChannels={storageChannels} notificationChannels={notificationChannels} settings={settings} />
<SettingsTabs storageChannels={storageChannels} notificationChannels={notificationChannels}
settings={settings}/>
</PageContent>
</Page>
);
@@ -1,21 +1,28 @@
import {PageParams} from "@/types/next";
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/components/page";
import {db} from "@/db";
import {desc, isNull} from "drizzle-orm";
import {AdminUserList} from "@/components/wrappers/dashboard/admin/users/admin-user-list";
import {AdminUserAddModal} from "@/components/wrappers/dashboard/admin/users/admin-user-add-modal";
import {AdminUserList} from "@/features/users/components/admin-user-list";
import {AdminUserAddModal} from "@/features/users/components/admin-user-add-modal";
import {SUPPORTED_PROVIDERS} from "@/lib/auth/config";
import {getSettings} from "@/db/services/setting";
import {resolveAvatarUrl} from "@/utils/resolve-avatar-url";
export default async function RoutePage(props: PageParams<{}>) {
const users = await db.query.user.findMany({
where: (fields) => isNull(fields.deletedAt),
with: {
accounts: true
},
orderBy: (fields) => desc(fields.createdAt),
const [settings, users] = await Promise.all([
getSettings(),
db.query.user.findMany({
where: (fields) => isNull(fields.deletedAt),
with: { accounts: true },
orderBy: (fields) => desc(fields.createdAt),
}),
]);
const avatarUrls = Object.fromEntries(
users.map((u) => [u.id, resolveAvatarUrl(u, settings)])
);
});
const organizations = await db.query.organization.findMany({
with: {
members: true,
@@ -30,13 +37,15 @@ export default async function RoutePage(props: PageParams<{}>) {
<PageHeader className="flex flex-col">
<div className="flex justify-between">
<PageTitle className="mb-3">Active users</PageTitle>
<PageActions>
<AdminUserAddModal organizations={organizations}/>
</PageActions>
{isPasswordAuthEnabled && (
<PageActions>
<AdminUserAddModal organizations={organizations}/>
</PageActions>
)}
</div>
</PageHeader>
<PageContent className="flex flex-col gap-5">
<AdminUserList users={users} isPasswordAuthEnabled={isPasswordAuthEnabled}/>
<AdminUserList users={users} isPasswordAuthEnabled={isPasswordAuthEnabled} avatarUrls={avatarUrls}/>
</PageContent>
</Page>
);
@@ -4,18 +4,18 @@ import {
PageContent,
PageDescription,
PageTitle,
} from "@/features/layout/page";
} from "@/features/layout/components/page";
import { db } from "@/db";
import * as drizzleDb from "@/db";
import { eq } from "drizzle-orm";
import {eq, isNull} from "drizzle-orm";
import { notFound } from "next/navigation";
import { ButtonDeleteAgent } from "@/components/wrappers/dashboard/agent/button-delete-agent/button-delete-agent";
import { ButtonDeleteAgent } from "@/features/agents/components/agent-delete-button";
import { capitalizeFirstLetter } from "@/utils/text";
import { generateEdgeKey } from "@/utils/edge_key";
import { getServerUrl } from "@/utils/get-server-url";
import { AgentContentPage } from "@/components/wrappers/dashboard/agent/agent-content";
import { AgentDialog } from "@/features/agents/components/agent.dialog";
import { AgentType } from "@/features/agents/agents.schema";
import { AgentContentPage } from "@/features/agents/components/agent-content";
import { AgentDialog } from "@/features/agents/components/agent-dialog";
export default async function RoutePage(
props: PageParams<{ agentId: string }>,
@@ -26,13 +26,30 @@ export default async function RoutePage(
where: eq(drizzleDb.schemas.agent.id, agentId),
with: {
databases: true,
organizations: true,
},
});
const organizations = await db.query.organization.findMany({
where: (fields) => isNull(fields.deletedAt),
with: {
members: true,
},
});
if (!agent) {
notFound();
}
const isOwnerByAnOrganization = agent.organizationId
if (isOwnerByAnOrganization){
notFound();
}
const organizationIds = agent.organizations.map(org => org.organizationId)
const edgeKey = await generateEdgeKey(getServerUrl(), agent.id);
return (
@@ -45,12 +62,14 @@ export default async function RoutePage(
<div className="flex items-center gap-2 md:justify-between w-full ">
<div className="flex items-center gap-2">
<AgentDialog
agent={agent as AgentType & { id: string }}
agent={agent}
typeTrigger={"edit"}
adminView={true}
organizations={organizations}
/>
</div>
<div className="flex items-center gap-2">
<ButtonDeleteAgent agentId={agentId} text={"Delete Agent"} />
<ButtonDeleteAgent organizationIds={organizationIds} agentId={agentId} text={"Delete Agent"} />
</div>
</div>
</PageTitle>
@@ -62,7 +81,7 @@ export default async function RoutePage(
</PageDescription>
)}
<PageContent className="flex flex-col w-full h-full justify-between gap-6">
<AgentContentPage agent={agent} edgeKey={edgeKey} />
<AgentContentPage agent={agent} edgeKey={edgeKey} canDeleteDatabases={true} />
</PageContent>
</Page>
);
@@ -1,13 +1,13 @@
import {PageParams} from "@/types/next";
import {AgentCard} from "@/components/wrappers/dashboard/agent/agent-card/agent-card";
import {CardsWithPagination} from "@/components/wrappers/common/cards-with-pagination";
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {AgentCard} from "@/features/agents/components/agent-card";
import {CardsWithPagination} from "@/components/common/cards-with-pagination";
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/components/page";
import {notFound} from "next/navigation";
import {db} from "@/db";
import * as drizzleDb from "@/db";
import {desc, eq, not} from "drizzle-orm";
import {and, desc, eq, isNull, not} from "drizzle-orm";
import {Metadata} from "next";
import {AgentDialog} from "@/features/agents/components/agent.dialog";
import {AgentDialog} from "@/features/agents/components/agent-dialog";
export const metadata: Metadata = {
title: "Agents",
@@ -16,13 +16,13 @@ export const metadata: Metadata = {
export default async function RoutePage(props: PageParams<{}>) {
const agents = await db.query.agent.findMany({
where: not(eq(drizzleDb.schemas.agent.isArchived, true)),
where: and(not(eq(drizzleDb.schemas.agent.isArchived, true)),isNull(drizzleDb.schemas.agent.organizationId)),
with: {
databases: true
},
orderBy: (fields) => desc(fields.lastContact),
});
if (!agents) {
notFound();
}
@@ -1,11 +1,11 @@
import {PageParams} from "@/types/next";
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/components/page";
import {Metadata} from "next";
import {db} from "@/db";
import {notificationChannel, NotificationChannelWith} from "@/db/schema/09_notification-channel";
import {desc, isNull} from "drizzle-orm";
import {ChannelsSection} from "@/components/wrappers/dashboard/admin/channels/channels-section";
import {ChannelAddEditModal} from "@/components/wrappers/dashboard/admin/channels/channel/channel-add-edit-modal";
import {ChannelsSection} from "@/features/channel/components/channels-section";
import {ChannelAddEditModal} from "@/features/channel/components/channel-add-edit-modal";
import * as drizzleDb from "@/db";
export const metadata: Metadata = {
@@ -1,7 +1,7 @@
import {PageParams} from "@/types/next";
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/components/page";
import {Metadata} from "next";
import {NotificationLogsList} from "@/components/wrappers/dashboard/admin/notifications/logs/notification-logs-list";
import {NotificationLogsList} from "@/features/notifications/components/notification-logs-list";
import {notFound} from "next/navigation";
import {getNotificationHistory} from "@/db/services/notification-log";
@@ -1,12 +1,12 @@
import {PageParams} from "@/types/next";
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/components/page";
import {Metadata} from "next";
import {ChannelsSection} from "@/components/wrappers/dashboard/admin/channels/channels-section";
import {ChannelsSection} from "@/features/channel/components/channels-section";
import {db} from "@/db";
import {desc, eq, isNull} from "drizzle-orm";
import * as drizzleDb from "@/db";
import {StorageChannelWith} from "@/db/schema/12_storage-channel";
import {ChannelAddEditModal} from "@/components/wrappers/dashboard/admin/channels/channel/channel-add-edit-modal";
import {ChannelAddEditModal} from "@/features/channel/components/channel-add-edit-modal";
export const metadata: Metadata = {
title: "Storage Channels",
@@ -0,0 +1,65 @@
import {PageParams} from "@/types/next";
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/components/page";
import {notFound} from "next/navigation";
import {getOrganization} from "@/lib/auth/auth";
import {Metadata} from "next";
import {db} from "@/db";
import {MigrationTool} from "@/features/migration/components/migration-tool";
export const metadata: Metadata = {
title: "Projects",
};
export default async function RoutePage(props: PageParams<{}>) {
const organization = await getOrganization({});
if (!organization) {
notFound();
}
const projects = await db.query.project.findMany({
where: (project, {eq, and, not}) =>
and(
eq(project.organizationId, organization.id),
not(eq(project.isArchived, true))
),
with: {
organization: true,
databases: {
where: (database, { isNull, not, inArray, and }) =>
and(
isNull(database.deletedAt),
not(inArray(database.dbms, ["valkey", "redis"]))
),
with: {
backups: {
where: (backup, { isNull, eq, and }) =>
and(
isNull(backup.deletedAt),
eq(backup.status, "success")
),
orderBy: (backup, {desc}) => [desc(backup.createdAt)],
limit: 15,
}
}
},
},
});
return (
<Page>
<PageHeader className="flex flex-col items-start justify-between mb-6">
<PageTitle className="mb-2">
Database Migration
</PageTitle>
<p className="text-sm text-muted-foreground">
Import backups from a source project into your target database
</p>
</PageHeader>
<PageContent>
<MigrationTool projects={projects}/>
</PageContent>
</Page>
);
}
@@ -1,115 +1,137 @@
import {PageParams} from "@/types/next";
import {notFound, redirect} from "next/navigation";
import {Page} from "@/features/layout/page";
import {db} from "@/db";
import {eq, and, inArray} from "drizzle-orm";
import { PageParams } from "@/types/next";
import { notFound, redirect } from "next/navigation";
import { Page } from "@/features/layout/components/page";
import { db } from "@/db";
import { eq, and, inArray } from "drizzle-orm";
import * as drizzleDb from "@/db";
import {getOrganizationProjectDatabases} from "@/lib/services";
import {getActiveMember, getOrganization} from "@/lib/auth/auth";
import {BackupModalProvider} from "@/components/wrappers/dashboard/database/backup/backup-modal-context";
import {DatabaseContent} from "@/components/wrappers/dashboard/projects/database/database-content";
import {getHealthLast12hLogs} from "@/db/services/healthcheck";
import { getOrganizationProjectDatabases } from "@/db/services/project";
import { getActiveMember, getOrganization } from "@/lib/auth/auth";
import { BackupModalProvider } from "@/features/database/components/backup-modal-context";
import { DatabaseContent } from "@/features/database/components/database-content";
import { getHealthLast12hLogs } from "@/db/services/healthcheck";
import { LogsModalProvider } from "@/features/logs/components/logs-modal-context";
export default async function RoutePage(props: PageParams<{
export default async function RoutePage(
props: PageParams<{
projectId: string;
databaseId: string
}>) {
const {projectId, databaseId} = await props.params;
databaseId: string;
}>,
) {
const { projectId, databaseId } = await props.params;
const organization = await getOrganization({});
const activeMember = await getActiveMember()
const organization = await getOrganization({});
const activeMember = await getActiveMember();
if (!organization || !activeMember) {
notFound();
}
if (!organization || !activeMember) {
notFound();
}
const databasesProject = await getOrganizationProjectDatabases({
organizationSlug: organization.slug,
projectId: projectId
})
const databasesProject = await getOrganizationProjectDatabases({
organizationSlug: organization.slug,
projectId: projectId,
});
const dbItem = await db.query.database.findFirst({
where: and(inArray(drizzleDb.schemas.backup.id, databasesProject.ids ?? []), eq(drizzleDb.schemas.database.id, databaseId), eq(drizzleDb.schemas.database.projectId, projectId)),
const dbItem = await db.query.database.findFirst({
where: and(
inArray(drizzleDb.schemas.backup.id, databasesProject.ids ?? []),
eq(drizzleDb.schemas.database.id, databaseId),
eq(drizzleDb.schemas.database.projectId, projectId),
),
with: {
project: true,
retentionPolicy: true,
alertPolicies: true,
storagePolicies: true,
},
});
if (!dbItem) {
redirect("/dashboard/projects");
}
const backups = await db.query.backup.findMany({
where: eq(drizzleDb.schemas.backup.databaseId, dbItem.id),
with: {
restorations: true,
storages: {
with: {
project: true,
retentionPolicy: true,
alertPolicies: true,
storagePolicies: true
}
});
if (!dbItem) {
redirect("/dashboard/projects");
}
const backups = await db.query.backup.findMany({
where: eq(drizzleDb.schemas.backup.databaseId, dbItem.id),
with: {
restorations: true,
storages: {
with: {
storageChannel: true
}
}
storageChannel: true,
},
orderBy: (b, {desc}) => [desc(b.createdAt)],
});
},
logs: true,
},
orderBy: (b, { desc }) => [desc(b.createdAt)],
});
const restorations = await db.query.restoration.findMany({
where: eq(drizzleDb.schemas.restoration.databaseId, dbItem.id),
orderBy: (r, {desc}) => [desc(r.createdAt)],
});
const restorations = await db.query.restoration.findMany({
where: eq(drizzleDb.schemas.restoration.databaseId, dbItem.id),
with: {
logs: true,
},
orderBy: (r, { desc }) => [desc(r.createdAt)],
});
const isAlreadyBackup = backups.some((b) => b.status === "waiting" || b.status === "ongoing");
const isAlreadyRestore = restorations.some((r) => r.status === "waiting");
//const isAlreadyBackup = backups.some((b) => b.status === "waiting" || b.status === "ongoing");
const isAlreadyRestore = restorations.some((r) => r.status === "waiting");
const totalBackups = await db.select({count: drizzleDb.schemas.backup.id})
.from(drizzleDb.schemas.backup)
.where(eq(drizzleDb.schemas.backup.databaseId, dbItem.id))
.then(rows => rows.length);
const totalBackups = await db
.select({ count: drizzleDb.schemas.backup.id })
.from(drizzleDb.schemas.backup)
.where(eq(drizzleDb.schemas.backup.databaseId, dbItem.id))
.then((rows) => rows.length);
const availableBackups = backups.filter(b => !b.deletedAt).length;
const availableBackups = backups.filter((b) => !b.deletedAt).length;
const successfulBackups = await db.select({count: drizzleDb.schemas.backup.id})
.from(drizzleDb.schemas.backup)
.where(and(
eq(drizzleDb.schemas.backup.databaseId, dbItem.id),
eq(drizzleDb.schemas.backup.status, "success")
))
.then(rows => rows.length);
const successfulBackups = await db
.select({ count: drizzleDb.schemas.backup.id })
.from(drizzleDb.schemas.backup)
.where(
and(
eq(drizzleDb.schemas.backup.databaseId, dbItem.id),
eq(drizzleDb.schemas.backup.status, "success"),
),
)
.then((rows) => rows.length);
const [settings] = await db
.select()
.from(drizzleDb.schemas.setting)
.where(eq(drizzleDb.schemas.setting.name, "system"))
.limit(1);
if (!settings) {
notFound();
}
const [settings] = await db.select().from(drizzleDb.schemas.setting).where(eq(drizzleDb.schemas.setting.name, "system")).limit(1);
if (!settings) {
notFound();
}
const databaseHealthLogs = dbItem
? await getHealthLast12hLogs({ id: dbItem.id })
: [];
const databaseHealthLogs = dbItem ? await getHealthLast12hLogs({ id: dbItem.id }) : []
const successRate =
totalBackups > 0 ? (successfulBackups / totalBackups) * 100 : null;
//const isMember = activeMember?.role === "member";
const successRate = totalBackups > 0 ? (successfulBackups / totalBackups) * 100 : null;
const isMember = activeMember?.role === "member";
return (
<Page>
<BackupModalProvider>
<DatabaseContent
activeMember={activeMember}
settings={settings}
database={dbItem}
databaseHealthLogs={databaseHealthLogs}
isAlreadyRestore={isAlreadyRestore}
restorations={restorations}
backups={backups}
totalBackups={totalBackups}
availableBackups={availableBackups}
successRate={successRate}
organizationId={organization.id}
activeOrganizationChannels={[]}
activeOrganizationStorageChannels={[]}
/>
</BackupModalProvider>
</Page>
);
}
return (
<Page>
<LogsModalProvider>
<BackupModalProvider>
<DatabaseContent
activeMember={activeMember}
settings={settings}
database={dbItem}
databaseHealthLogs={databaseHealthLogs}
isAlreadyRestore={isAlreadyRestore}
restorations={restorations}
backups={backups}
totalBackups={totalBackups}
availableBackups={availableBackups}
successRate={successRate}
organizationId={organization.id}
activeOrganizationChannels={[]}
activeOrganizationStorageChannels={[]}
/>
</BackupModalProvider>
</LogsModalProvider>
</Page>
);
}
@@ -1,119 +1,123 @@
import {PageParams} from "@/types/next";
import {Page, PageContent, PageTitle} from "@/features/layout/page";
import {
ButtonDeleteProject
} from "@/components/wrappers/dashboard/projects/button-delete-project/button-delete-project";
import {CardsWithPagination} from "@/components/wrappers/common/cards-with-pagination";
import {ProjectDatabaseCard} from "@/components/wrappers/dashboard/projects/project-card/project-database-card";
import {notFound, redirect} from "next/navigation";
import {db} from "@/db";
import {eq} from "drizzle-orm";
import {getActiveMember, getOrganization} from "@/lib/auth/auth";
import { PageParams } from "@/types/next";
import { Page, PageContent, PageTitle } from "@/features/layout/components/page";
import { ButtonDeleteProject } from "@/features/projects/components/project-delete-button";
import { CardsWithPagination } from "@/components/common/cards-with-pagination";
import { ProjectDatabaseCard } from "@/features/projects/components/project-database-card";
import { ProjectDatabaseSelection } from "@/features/projects/components/project-database-selection";
import { notFound, redirect } from "next/navigation";
import { db } from "@/db";
import { eq } from "drizzle-orm";
import { getActiveMember, getOrganization } from "@/lib/auth/auth";
import * as drizzleDb from "@/db";
import {capitalizeFirstLetter} from "@/utils/text";
import {ProjectDialog} from "@/features/projects/components/project.dialog";
import {DatabaseWith} from "@/db/schema/07_database";
import {ProjectWith} from "@/db/schema/06_project";
import {isUuidv4} from "@/utils/verify-uuid";
import { capitalizeFirstLetter, isUUID } from "@/utils/text";
import { ProjectDialog } from "@/features/projects/components/project-dialog";
import { ProjectWith } from "@/db/schema/06_project";
import { getOrganizationAvailableDatabases } from "@/db/services/database";
export default async function RoutePage(
props: PageParams<{
projectId: string;
}>,
) {
const { projectId } = await props.params;
export default async function RoutePage(props: PageParams<{
projectId: string
}>) {
const {
projectId
} = await props.params;
if (!isUUID(projectId)) {
notFound();
}
if (!isUuidv4(projectId)) {
notFound()
}
const organization = await getOrganization({});
const activeMember = await getActiveMember();
const organization = await getOrganization({});
const activeMember = await getActiveMember()
if (!organization) {
notFound();
}
const org = await db.query.organization.findFirst({
where: eq(drizzleDb.schemas.organization.slug, organization.slug),
});
if (!organization) {
notFound();
}
const org = await db.query.organization.findFirst({
where: eq(drizzleDb.schemas.organization.slug, organization.slug),
});
if (!org) notFound();
if (!org) notFound();
const proj = await db.query.project.findFirst({
where: (proj, { and, eq, not }) =>
and(
eq(proj.id, projectId),
eq(proj.organizationId, org.id),
not(eq(proj.isArchived, true)),
),
with: {
databases: true,
},
});
const proj = await db.query.project.findFirst({
where: (proj, {
and,
eq,
not
}) => and(eq(proj.id, projectId), eq(proj.organizationId, org.id), not(eq(proj.isArchived, true))),
with: {
databases: true,
},
});
if (!proj) {
redirect("/dashboard/projects");
}
if (!proj) {
redirect("/dashboard/projects");
}
const availableDatabases = await getOrganizationAvailableDatabases(
organization.id,
proj.id,
);
const isMember = activeMember?.role === "member";
const availableDatabases = (
await db.query.database.findMany({
where: (db, {or, eq, isNull}) => or(isNull(db.projectId), eq(db.projectId, proj.id)),
with: {
agent: true,
project: true,
backups: true,
restorations: true,
},
orderBy: (db, {desc}) => [desc(db.createdAt)],
})
) as DatabaseWith[];
const isMember = activeMember?.role === "member";
return (
<Page>
<div className="justify-between gap-2 sm:flex">
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full ">
<div className="min-w-full md:min-w-fit ">
{capitalizeFirstLetter(proj.name)}
</div>
{!isMember && (
<div className="flex items-center gap-2 md:justify-between w-full ">
<div className="flex items-center gap-2">
<ProjectDialog
databases={availableDatabases}
organization={org}
project={proj as ProjectWith}
isEdit={true}
/>
</div>
<div className="flex items-center gap-2">
<ButtonDeleteProject projectId={projectId} text={"Delete Project"}/>
</div>
</div>
)}
</PageTitle>
return (
<Page>
<div className="justify-between gap-2 sm:flex">
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full ">
<div className="min-w-full md:min-w-fit ">
{capitalizeFirstLetter(proj.name)}
</div>
{!isMember && (
<div className="flex items-center gap-2 md:justify-between w-full ">
<div className="flex items-center gap-2">
<ProjectDialog
databases={availableDatabases}
organization={org}
project={proj as ProjectWith}
isEdit={true}
/>
</div>
<div className="flex items-center gap-2">
<ButtonDeleteProject
projectId={projectId}
text={"Delete Project"}
/>
</div>
</div>
<PageContent className="flex flex-col w-full h-full">
{proj.databases.length > 0 ? (
<CardsWithPagination
data={proj.databases}
organizationSlug={organization.slug}
// @ts-ignore
cardItem={ProjectDatabaseCard}
cardsPerPage={20}
numberOfColumns={3}
pageSizeOptions={[10, 20, 50]}
extendedProps={proj}
/>
) : (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground py-20">
<p className="text-lg font-medium">No databases found</p>
<p className="text-sm mt-2">You havent added any databases to this project yet.</p>
</div>
)}
</PageContent>
</Page>
);
}
)}
</PageTitle>
</div>
<PageContent className="flex flex-col w-full h-full">
{proj.databases.length > 0 ? (
isMember ? (
<CardsWithPagination
data={[...proj.databases].sort(
(a, b) =>
new Date(b.createdAt).getTime() -
new Date(a.createdAt).getTime(),
)}
organizationSlug={organization.slug}
// @ts-ignore
cardItem={ProjectDatabaseCard}
cardsPerPage={20}
numberOfColumns={3}
pageSizeOptions={[10, 20, 50]}
extendedProps={proj}
/>
) : (
<ProjectDatabaseSelection
projectId={proj.id}
databases={proj.databases}
/>
)
) : (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground py-20">
<p className="text-lg font-medium">No databases found</p>
<p className="text-sm mt-2">
You havent added any databases to this project yet.
</p>
</div>
)}
</PageContent>
</Page>
);
}
@@ -1,14 +1,15 @@
import {PageParams} from "@/types/next";
import {CardsWithPagination} from "@/components/wrappers/common/cards-with-pagination";
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {ProjectCard} from "@/components/wrappers/dashboard/projects/project-card/project-card";
import {CardsWithPagination} from "@/components/common/cards-with-pagination";
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/components/page";
import {ProjectCard} from "@/features/projects/components/project-card";
import {db} from "@/db";
import {notFound} from "next/navigation";
import {getActiveMember, getOrganization} from "@/lib/auth/auth";
import {EmptyStatePlaceholder} from "@/components/wrappers/common/empty-state-placeholder";
import {EmptyStatePlaceholder} from "@/components/common/empty-state-placeholder";
import {Metadata} from "next";
import {ProjectDialog} from "@/features/projects/components/project.dialog";
import {ProjectDialog} from "@/features/projects/components/project-dialog";
import {DatabaseWith} from "@/db/schema/07_database";
import {getOrganizationAvailableDatabases} from "@/db/services/database";
export const metadata: Metadata = {
title: "Projects",
@@ -35,18 +36,7 @@ export default async function RoutePage(props: PageParams<{}>) {
});
const isMember = activeMember?.role === "member";
const availableDatabases = (
await db.query.database.findMany({
where: (db, {isNull}) => isNull(db.projectId),
with: {
agent: true,
project: true,
backups: true,
restorations: true,
},
orderBy: (db, {desc}) => [desc(db.createdAt)],
})
).filter((db) => db.project == null) as DatabaseWith[];
const availableDatabases = await getOrganizationAvailableDatabases(organization.id)
return (
@@ -71,7 +61,7 @@ export default async function RoutePage(props: PageParams<{}>) {
pageSizeOptions={[12, 24, 48]}
/>
) : isMember ? (
<EmptyStatePlaceholder text="No project available"/>
<EmptyStatePlaceholder state={"empty"} text="No project available"/>
) : (
<ProjectDialog databases={availableDatabases} organization={organization} isEmpty={true}/>
)}
@@ -0,0 +1,101 @@
import {PageParams} from "@/types/next";
import {
Page,
PageContent,
PageDescription,
PageTitle,
} from "@/features/layout/components/page";
import {db} from "@/db";
import * as drizzleDb from "@/db";
import {eq} from "drizzle-orm";
import {notFound} from "next/navigation";
import {ButtonDeleteAgent} from "@/features/agents/components/agent-delete-button";
import {capitalizeFirstLetter} from "@/utils/text";
import {generateEdgeKey} from "@/utils/edge_key";
import {getServerUrl} from "@/utils/get-server-url";
import {AgentContentPage} from "@/features/agents/components/agent-content";
import {AgentDialog} from "@/features/agents/components/agent-dialog";
import {getActiveMember, getOrganization} from "@/lib/auth/auth";
import {currentUser} from "@/lib/auth/current-user";
import {computeOrganizationPermissions} from "@/lib/acl/organization-acl";
export default async function RoutePage(
props: PageParams<{ agentId: string }>,
) {
const {agentId} = await props.params;
const organization = await getOrganization({});
const user = await currentUser();
const activeMember = await getActiveMember();
if (!organization || !activeMember || !user) {
notFound();
}
const {canManageAgents} = computeOrganizationPermissions(activeMember);
if (!canManageAgents){
notFound();
}
const agent = await db.query.agent.findFirst({
where: eq(drizzleDb.schemas.agent.id, agentId),
with: {
databases: true,
organizations: true,
},
});
if (!agent) {
notFound();
}
const hasAccess =
agent.organizationId === organization.id ||
agent.organizations.some(org => org.organizationId === organization.id);
if (!hasAccess) {
notFound();
}
const isOwned = agent.organizationId
const edgeKey = await generateEdgeKey(getServerUrl(), agent.id);
return (
<Page>
<div className="justify-between gap-2 sm:flex">
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full ">
<div className="min-w-full md:min-w-fit ">
{capitalizeFirstLetter(agent.name)}
</div>
{isOwned && (
<div className="flex items-center gap-2 md:justify-between w-full ">
<div className="flex items-center gap-2">
<AgentDialog
agent={agent}
typeTrigger={"edit"}
/>
</div>
<div className="flex items-center gap-2">
<ButtonDeleteAgent organizationId={organization.id ?? null} agentId={agentId} text={"Delete Agent"}/>
</div>
</div>
)}
</PageTitle>
</div>
{agent.description && (
<PageDescription className="mt-5 sm:mt-0">
{agent.description}
</PageDescription>
)}
<PageContent className="flex flex-col w-full h-full justify-between gap-6">
<AgentContentPage agent={agent} edgeKey={edgeKey} canDeleteDatabases={!!agent.organizationId}/>
</PageContent>
</Page>
);
}
@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default async function RoutePage() {
redirect("/dashboard/settings?tab=agents");
}
@@ -1,95 +1,116 @@
import { PageParams } from "@/types/next";
import {PageParams} from "@/types/next";
import {
Page,
PageActions,
PageContent,
PageHeader,
PageTitle,
} from "@/features/layout/page";
import { currentUser } from "@/lib/auth/current-user";
import { getActiveMember, getOrganization } from "@/lib/auth/auth";
import { notFound } from "next/navigation";
import { Metadata } from "next";
import { OrganizationTabs } from "@/components/wrappers/dashboard/organization/tabs/organization-tabs";
import { getOrganizationChannels } from "@/db/services/notification-channel";
import { computeOrganizationPermissions } from "@/lib/acl/organization-acl";
import { getOrganizationStorageChannels } from "@/db/services/storage-channel";
import { DeleteOrganizationButton } from "@/components/wrappers/dashboard/organization/delete-organization-button";
import { EditOrganizationDialog } from "@/features/organization/components/edit-organization.dialog";
import { db } from "@/db";
import { isNull } from "drizzle-orm";
Page,
PageContent,
PageHeader,
PageTitle,
} from "@/features/layout/components/page";
import {currentUser} from "@/lib/auth/current-user";
import {getActiveMember, getOrganization} from "@/lib/auth/auth";
import {notFound} from "next/navigation";
import {Metadata} from "next";
import {OrganizationTabs} from "@/features/organizations/components/organization-tabs";
import {getOrganizationChannels} from "@/db/services/notification-channel";
import {computeOrganizationPermissions} from "@/lib/acl/organization-acl";
import {getOrganizationStorageChannels} from "@/db/services/storage-channel";
import {DeleteOrganizationButton} from "@/features/organizations/components/organization-delete-button";
import {EditOrganizationDialog} from "@/features/organizations/components/organization-edit-dialog";
import {db} from "@/db";
import {isNull} from "drizzle-orm";
import * as drizzleDb from "@/db";
import { eq } from "drizzle-orm";
import {eq} from "drizzle-orm";
import {getOrganizationAgents} from "@/db/services/agent";
import {Tooltip, TooltipContent, TooltipTrigger} from "@/components/ui/tooltip";
export const metadata: Metadata = {
title: "Settings",
title: "Settings",
};
export default async function RoutePage(props: PageParams<{ slug: string }>) {
const organization = await getOrganization({});
const user = await currentUser();
const activeMember = await getActiveMember();
const organization = await getOrganization({});
const user = await currentUser();
const activeMember = await getActiveMember();
if (!organization || !activeMember || !user) {
notFound();
}
if (!organization || !activeMember || !user) {
notFound();
}
const notificationChannels = await getOrganizationChannels(organization.id);
const storageChannels = await getOrganizationStorageChannels(organization.id);
const permissions = computeOrganizationPermissions(activeMember);
const notificationChannels = await getOrganizationChannels(organization.id);
const storageChannels = await getOrganizationStorageChannels(organization.id);
const agents = await getOrganizationAgents(organization.id);
const permissions = computeOrganizationPermissions(activeMember);
const users = await db.query.user.findMany({
where: (fields) => isNull(fields.deletedAt),
});
const users = await db.query.user.findMany({
where: (fields) => isNull(fields.deletedAt),
});
const organizationWithMembers = await db.query.organization.findFirst({
where: eq(drizzleDb.schemas.organization.id, organization.id),
with: {
members: {
const organizationWithMembers = await db.query.organization.findFirst({
where: eq(drizzleDb.schemas.organization.id, organization.id),
with: {
user: true,
projects: {
where: eq(drizzleDb.schemas.project.isArchived, false),
},
members: {
with: {
user: true,
},
},
},
},
},
});
});
if (!organizationWithMembers) notFound();
if (!organizationWithMembers) notFound();
return (
<Page>
<PageHeader>
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full ">
<div className="min-w-full md:min-w-fit ">Organization settings</div>
<div className="flex items-center gap-2 md:justify-between w-full ">
<div className="flex items-center gap-2">
{permissions.canManageSettings &&
organization.slug !== "default" && (
<EditOrganizationDialog
organization={organizationWithMembers}
users={users}
currentUser={user}
/>
)}
</div>
<div className="flex items-center gap-2">
{permissions.canManageDangerZone &&
organization.slug !== "default" && (
<DeleteOrganizationButton
organizationSlug={organization.slug}
/>
)}
</div>
</div>
</PageTitle>
</PageHeader>
<PageContent>
<OrganizationTabs
activeMember={activeMember}
organization={organization}
notificationChannels={notificationChannels}
storageChannels={storageChannels}
/>
</PageContent>
</Page>
);
return (
<Page>
<PageHeader>
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full ">
<div className="min-w-full md:min-w-fit ">Organization settings</div>
<div className="flex items-center gap-2 md:justify-between w-full ">
<div className="flex items-center gap-2">
{permissions.canManageSettings &&
organization.slug !== "default" && (
<EditOrganizationDialog
organization={organizationWithMembers}
users={users}
currentUser={user}
/>
)}
</div>
<div className="flex items-center gap-2">
{permissions.canManageDangerZone &&
organization.slug !== "default" && (
<Tooltip>
<TooltipTrigger asChild>
<div>
<DeleteOrganizationButton
disabled={organizationWithMembers.projects.length > 0}
organizationSlug={organization.slug}
/>
</div>
</TooltipTrigger>
{organizationWithMembers.projects.length > 0 && (
<TooltipContent>
<p>Your organization has some projects associated with it. Please delete them before deleting the organization.</p>
</TooltipContent>
)}
</Tooltip>
)}
</div>
</div>
</PageTitle>
</PageHeader>
<PageContent>
<OrganizationTabs
activeMember={activeMember}
organization={organization}
notificationChannels={notificationChannels}
storageChannels={storageChannels}
agents={agents}
/>
</PageContent>
</Page>
);
}
@@ -1,8 +1,8 @@
import {PageParams} from "@/types/next";
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/components/page";
import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card";
import {EvolutionLineChart} from "@/components/wrappers/dashboard/statistics/charts/evolution-line-chart";
import {PercentageLineChart} from "@/components/wrappers/dashboard/statistics/charts/percentage-line-chart";
import {EvolutionLineChart} from "@/features/statistics/components/evolution-line-chart";
import {PercentageLineChart} from "@/features/statistics/components/percentage-line-chart";
import {notFound} from "next/navigation";
import {db} from "@/db";
import {and, asc, count, eq, inArray} from "drizzle-orm";
@@ -29,7 +29,7 @@ export default async function RoutePage(props: PageParams<{}>) {
if (!org) notFound();
const projects = await db.query.project.findMany({
where: eq(drizzleDb.schemas.project.organizationId, org.id),
where: and(eq(drizzleDb.schemas.project.organizationId, org.id), eq(drizzleDb.schemas.project.isArchived, false))
});
const projectIds = projects.map(project => project.id);
+7 -4
View File
@@ -1,11 +1,12 @@
import {PageParams} from "@/types/next";
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/components/page";
import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card";
import {Building2, Database, DatabaseBackup, Folder, RefreshCcw, Server, Workflow} from "lucide-react";
import {currentUser} from "@/lib/auth/current-user";
import {notFound} from "next/navigation";
import {db} from "@/db";
import {asc, inArray} from "drizzle-orm";
import {and, asc, eq, inArray, not} from "drizzle-orm";
import * as drizzleDb from "@/db";
import {listOrganizations} from "@/lib/auth/auth";
import {Metadata} from "next";
@@ -23,10 +24,12 @@ export default async function RoutePage(props: PageParams<{}>) {
const organizationIds = organizations.map(project => project.id);
const agents = await db.query.agent.findMany({});
const agents = await db.query.agent.findMany({
where: not(eq(drizzleDb.schemas.agent.isArchived, true)),
});
const projects = await db.query.project.findMany({
where: inArray(drizzleDb.schemas.project.organizationId, organizationIds),
where: and(inArray(drizzleDb.schemas.project.organizationId, organizationIds), eq(drizzleDb.schemas.project.isArchived, false)),
});
const projectIds = projects.map(project => project.id);
+12 -6
View File
@@ -2,22 +2,28 @@ import { ReactNode } from "react";
import { redirect } from "next/navigation";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { AppSidebar } from "@/components/wrappers/dashboard/common/sidebar/app-sidebar";
import { Header } from "@/features/layout/Header";
import { AppSidebar } from "@/features/layout/components/app-sidebar";
import { Header } from "@/features/layout/components/header";
import { currentUser } from "@/lib/auth/current-user";
import { ThemeMetaUpdater } from "@/features/browser/theme-meta-updater";
import { isOnboardingDone } from "@/db/services/setting";
import { env } from "@/env.mjs";
import { ModeToggle } from "@/features/theme/components/mode-toggle";
import { UpdateNotification } from "@/features/updates/components/update-notification";
export default async function Layout({ children }: { children: ReactNode }) {
if (env.SKIP_ONBOARDING !== "true" && !(await isOnboardingDone())) {
redirect("/welcome");
}
const user = await currentUser();
if (!user) redirect("/login");
return (
<SidebarProvider>
<div className="flex flex-col lg:flex-row w-full">
<ThemeMetaUpdater />
<AppSidebar />
<AppSidebar updateNotification={<UpdateNotification />} />
<SidebarInset>
<Header />
<Header actions={<ModeToggle />} />
<main className="h-full">{children}</main>
</SidebarInset>
</div>
+1 -1
View File
@@ -1,4 +1,4 @@
import { LoadingSpinner } from "@/components/wrappers/common/loading/loading-spinner";
import { LoadingSpinner } from "@/components/common/loading-spinner";
export default function Loading() {
return (
+7 -5
View File
@@ -1,16 +1,18 @@
import { redirect } from "next/navigation";
import { getCurrentOrganizationSlug } from "@/features/dashboard/organization-cookie";
import { getCurrentOrganizationSlug } from "@/features/organizations/utils/organization-cookie";
import { currentUser } from "@/lib/auth/current-user";
import { isOnboardingDone } from "@/db/services/setting";
import { env } from "@/env.mjs";
export default async function Index() {
if (env.SKIP_ONBOARDING !== "true" && !(await isOnboardingDone())) {
redirect("/welcome");
}
const user = await currentUser();
if (user) {
const currentOrganizationSlug = await getCurrentOrganizationSlug();
redirect(`/dashboard/home`);
}
redirect("/login");
//Do not delete
// return (
// <Home/>
// );
}
@@ -0,0 +1,29 @@
"use client";
import { useRouter } from "next/navigation";
import { OnboardingProvider } from "@onboardjs/react";
import { onboardingSteps } from "@/features/onboarding/onboarding-steps";
import { OnboardingShell } from "@/features/onboarding/onboarding-shell";
import type { OnboardingFlowData } from "@/features/onboarding/types";
type Props = {
initialStepId: string;
initialFlowData: Partial<OnboardingFlowData>;
};
export const OnboardingClient = ({ initialStepId, initialFlowData }: Props) => {
const router = useRouter();
return (
<OnboardingProvider
steps={onboardingSteps}
initialStepId={initialStepId}
initialContext={{ flowData: initialFlowData }}
onFlowComplete={async () => {
router.push("/dashboard/home");
}}
>
<OnboardingShell />
</OnboardingProvider>
);
};
+19
View File
@@ -0,0 +1,19 @@
import { redirect } from "next/navigation";
import { resolveOnboardingState } from "@/features/onboarding/onboarding-state";
import { OnboardingClient } from "./onboarding-client";
export default async function WelcomePage() {
const result = await resolveOnboardingState();
if ("redirect" in result) {
redirect(result.redirect);
}
return (
<OnboardingClient
key={result.stepId}
initialStepId={result.stepId}
initialFlowData={result.flowData}
/>
);
}
+2 -2
View File
@@ -1,5 +1,5 @@
import {NextResponse} from "next/server";
import {eq} from "drizzle-orm";
import {and, eq} from "drizzle-orm";
import {db} from "@/db";
import * as drizzleDb from "@/db";
import {logger} from "@/lib/logger";
@@ -12,7 +12,7 @@ export function withAgentCheck(handler: Function) {
const agentId = (await context.params).agentId;
const agent = await db.query.agent.findFirst({
where: eq(drizzleDb.schemas.agent.id, agentId),
where: and(eq(drizzleDb.schemas.agent.id, agentId), eq(drizzleDb.schemas.agent.isArchived, false)),
});
if (!agent) {
+32 -4
View File
@@ -5,10 +5,11 @@ import {db as dbClient, db} from "@/db";
import {getDatabaseOrThrow, withAgentCheck} from "./helpers";
import {Backup} from "@/db/schema/07_database";
import {withUpdatedAt} from "@/db/utils";
import {eventEmitter} from "@/features/shared/event";
import {sendNotificationsBackupRestore} from "@/features/notifications/helpers";
import {eventEmitter} from "@/lib/event";
import {sendNotificationsBackupRestore} from "@/features/notifications/utils/notifications.helpers";
import {EventKind} from "@/features/notifications/types";
import {logger} from "@/lib/logger";
import {JobLogEntry} from "@/features/logs/types";
const log = logger.child({module: "api/agent/backup/route"});
@@ -22,6 +23,8 @@ export type BodyPatch = {
status: "success" | "failed"
size: number
generatedId: string
logs: JobLogEntry[]
durationMs: number
}
export const POST = withAgentCheck(async (request: Request, {params, agent}: {
@@ -30,6 +33,7 @@ export const POST = withAgentCheck(async (request: Request, {params, agent}: {
}) => {
try {
const body: BodyPost = await request.json();
const method = body.method
const database = await getDatabaseOrThrow(body.generatedId);
@@ -104,7 +108,6 @@ export const PATCH = withAgentCheck(async (request: Request, {params, agent}: {
}) => {
try {
const body: BodyPatch = await request.json();
log.info({data: body}, "Body from PATH in backup route");
const status = body.status
const backupId = body.backupId
@@ -127,12 +130,37 @@ export const PATCH = withAgentCheck(async (request: Request, {params, agent}: {
.update(drizzleDb.schemas.backup)
.set(withUpdatedAt({
status: status,
fileSize: backupSize
fileSize: backupSize,
durationMs: body.durationMs
}))
.where(eq(drizzleDb.schemas.backup.id, backup.id))
.returning();
const logsToInsert = body.logs.map((entry) => ({
backupId: backup.id,
restorationId: null,
loggedAt: new Date(entry.timestamp),
entryType: entry.type,
level: entry.level,
message: entry.message,
command: entry.command ?? null,
output: entry.output ?? null,
exitCode: entry.exit_code ?? null,
durationMs: entry.duration_ms ?? null,
}));
if (logsToInsert.length > 0) {
await dbClient
.insert(drizzleDb.schemas.jobLog)
.values(logsToInsert);
}
eventEmitter.emit('modification', {update: true});
await sendNotificationsBackupRestore(database, status == "failed" ? "error_backup" : "success_backup" as EventKind);
@@ -1,79 +1,86 @@
import {NextResponse} from "next/server";
import {and, eq} from "drizzle-orm";
import { NextResponse } from "next/server";
import { and, eq } from "drizzle-orm";
import * as drizzleDb from "@/db";
import {db} from "@/db";
import {getDatabaseOrThrow, withAgentCheck} from "../../helpers";
import {isUuidv4} from "@/utils/verify-uuid";
import {eventEmitter} from "@/features/shared/event";
import {logger} from "@/lib/logger";
import { db } from "@/db";
import { getDatabaseOrThrow, withAgentCheck } from "../../helpers";
import { eventEmitter } from "@/lib/event";
import { logger } from "@/lib/logger";
import { isUUID } from "@/utils/text";
const log = logger.child({module: "api/agent/backup/upload/init"});
const log = logger.child({ module: "api/agent/backup/upload/init" });
export type Body = {
generatedId: string
storageChannelId: string
backupId: string
}
export const POST = withAgentCheck(async (request: Request, {params, agent}: {
params: Promise<{ agentId: string }>,
agent: any
}) => {
generatedId: string;
storageChannelId: string;
backupId: string;
};
export const POST = withAgentCheck(
async (
request: Request,
{
params,
agent,
}: {
params: Promise<{ agentId: string }>;
agent: any;
},
) => {
try {
const body: Body = await request.json();
const body: Body = await request.json();
log.info({data: body}, "Body for backup upload init");
log.info({ data: body }, "Body for backup upload init");
const generatedId = body.generatedId;
const storageChannelId = body.storageChannelId;
const backupId = body.backupId;
if (!generatedId || !isUuidv4(generatedId)) {
return NextResponse.json(
{error: "generatedId is not a valid UUID"},
{status: 400}
);
}
const database = await getDatabaseOrThrow(generatedId);
const backup = await db.query.backup.findFirst({
where: and(
eq(drizzleDb.schemas.backup.id, backupId),
eq(drizzleDb.schemas.backup.databaseId, database.id),
),
});
if (!backup) {
return NextResponse.json(
{error: "Unable to find the corresponding backup"},
{status: 404}
);
}
const [backupStorage] = await db
.insert(drizzleDb.schemas.backupStorage)
.values({
backupId: backup.id,
storageChannelId: storageChannelId,
status: "pending",
})
.returning();
eventEmitter.emit('modification', {update: true});
const generatedId = body.generatedId;
const storageChannelId = body.storageChannelId;
const backupId = body.backupId;
if (!generatedId || !isUUID(generatedId)) {
return NextResponse.json(
{
message: "Backup storage successfully created",
backupStorage: backupStorage
},
{status: 200}
{ error: "generatedId is not a valid UUID" },
{ status: 400 },
);
}
const database = await getDatabaseOrThrow(generatedId);
const backup = await db.query.backup.findFirst({
where: and(
eq(drizzleDb.schemas.backup.id, backupId),
eq(drizzleDb.schemas.backup.databaseId, database.id),
),
});
if (!backup) {
return NextResponse.json(
{ error: "Unable to find the corresponding backup" },
{ status: 404 },
);
}
const [backupStorage] = await db
.insert(drizzleDb.schemas.backupStorage)
.values({
backupId: backup.id,
storageChannelId: storageChannelId,
status: "pending",
})
.returning();
eventEmitter.emit("modification", { update: true });
return NextResponse.json(
{
message: "Backup storage successfully created",
backupStorage: backupStorage,
},
{ status: 200 },
);
} catch (error) {
log.error({error: error}, "Error in POST for INIT backup");
return NextResponse.json(
{error: "Internal server error"},
{status: 500}
);
log.error({ error: error }, "Error in POST for INIT backup");
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
});
},
);
@@ -4,7 +4,7 @@ import * as drizzleDb from "@/db";
import {db as dbClient, db} from "@/db";
import {withUpdatedAt} from "@/db/utils";
import {getDatabaseOrThrow, withAgentCheck} from "../../helpers";
import {eventEmitter} from "@/features/shared/event";
import {eventEmitter} from "@/lib/event";
import {logger} from "@/lib/logger";
const log = logger.child({module: "api/agent/backup/upload/status"});
@@ -70,10 +70,10 @@ export const PATCH = withAgentCheck(async (request: Request, {params, agent}: {
if (hasSuccessfulStorage && backup.status !== "success") {
await db
.update(drizzleDb.schemas.backup)
.set({
.set(withUpdatedAt({
status: "success",
fileSize: fileSize,
})
}))
.where(eq(drizzleDb.schemas.backup.id, backup.id));
}
}
+115 -74
View File
@@ -1,83 +1,124 @@
import {NextResponse} from "next/server";
import {isUuidv4} from "@/utils/verify-uuid";
import { NextResponse } from "next/server";
import * as drizzleDb from "@/db";
import {db} from "@/db";
import {and, eq} from "drizzle-orm";
import {sendNotificationsBackupRestore} from "@/features/notifications/helpers";
import {logger} from "@/lib/logger";
import { db as dbClient, db } from "@/db";
import { and, eq } from "drizzle-orm";
import { sendNotificationsBackupRestore } from "@/features/notifications/utils/notifications.helpers";
import { logger } from "@/lib/logger";
import { withUpdatedAt } from "@/db/utils";
import { JobLogEntry } from "@/features/logs/types";
import { isUUID } from "@/utils/text";
const log = logger.child({module: "api/agent/restore"});
const log = logger.child({ module: "api/agent/restore" });
export type BodyResultRestore = {
generatedId: string
status: string
}
type RestorationStatus = 'waiting' | 'ongoing' | 'failed' | 'success';
generatedId: string;
status: string;
logs: JobLogEntry[];
durationMs: number;
};
type RestorationStatus = "waiting" | "ongoing" | "failed" | "success";
export async function POST(
request: Request,
{params}: { params: Promise<{ agentId: string }> }
request: Request,
{ params }: { params: Promise<{ agentId: string }> },
) {
try {
const agentId = (await params).agentId;
const body: BodyResultRestore = await request.json();
try {
const agentId = (await params).agentId
const body: BodyResultRestore = await request.json();
if (!isUuidv4(body.generatedId)) {
return NextResponse.json(
{error: "generatedId is not a valid uuid"},
{status: 500}
);
}
const agent = await db.query.agent.findFirst({
where: eq(drizzleDb.schemas.agent.id, agentId)
})
if (!agent) {
return NextResponse.json({error: "Agent not found"}, {status: 404})
}
const database = await db.query.database.findFirst({
where: eq(drizzleDb.schemas.database.agentDatabaseId, body.generatedId),
with: {
alertPolicies: true
}
})
if (!database) {
return NextResponse.json({error: "Database associated with generatedId provided not found"}, {status: 404})
}
const restoration = await db.query.restoration.findFirst({
where: and(eq(drizzleDb.schemas.restoration.status, "ongoing"), eq(drizzleDb.schemas.restoration.databaseId, database.id),)
})
if (!restoration) {
return NextResponse.json({error: "Unable to fin the corresponding restoration"}, {status: 404})
}
await db
.update(drizzleDb.schemas.restoration)
.set({status: body.status as RestorationStatus})
.where(eq(drizzleDb.schemas.restoration.id, restoration.id));
await sendNotificationsBackupRestore(database, body.status == "failed" ? "error_restore" : "success_restore");
const response = {
status: true,
message: "Restoration successfully updated"
}
return Response.json(response, {status: 200})
} catch (error) {
log.error({error: error}, "Error in POST handler")
return NextResponse.json(
{error: 'Internal server error'},
{status: 500}
);
if (!isUUID(body.generatedId)) {
return NextResponse.json(
{ error: "generatedId is not a valid uuid" },
{ status: 500 },
);
}
}
const agent = await db.query.agent.findFirst({
where: and(
eq(drizzleDb.schemas.agent.id, agentId),
eq(drizzleDb.schemas.agent.isArchived, false),
),
});
if (!agent) {
return NextResponse.json({ error: "Agent not found" }, { status: 404 });
}
const database = await db.query.database.findFirst({
where: eq(drizzleDb.schemas.database.agentDatabaseId, body.generatedId),
with: {
alertPolicies: true,
},
});
if (!database) {
return NextResponse.json(
{ error: "Database associated with generatedId provided not found" },
{ status: 404 },
);
}
const restoration = await db.query.restoration.findFirst({
where: and(
eq(drizzleDb.schemas.restoration.status, "ongoing"),
eq(drizzleDb.schemas.restoration.databaseId, database.id),
),
});
if (!restoration) {
return NextResponse.json(
{ error: "Unable to fin the corresponding restoration" },
{ status: 404 },
);
}
const [restorationUpdated] = await db
.update(drizzleDb.schemas.restoration)
.set(
withUpdatedAt({
status: body.status as RestorationStatus,
durationMs: body.durationMs,
}),
)
.where(eq(drizzleDb.schemas.restoration.id, restoration.id))
.returning();
const logsToInsert = body.logs.map((entry) => ({
backupId: null,
restorationId: restorationUpdated.id,
loggedAt: new Date(entry.timestamp),
entryType: entry.type,
level: entry.level,
message: entry.message,
command: entry.command ?? null,
output: entry.output ?? null,
exitCode: entry.exit_code ?? null,
durationMs: entry.duration_ms ?? null,
}));
if (logsToInsert.length > 0) {
await dbClient.insert(drizzleDb.schemas.jobLog).values(logsToInsert);
}
await sendNotificationsBackupRestore(
database,
body.status == "failed" ? "error_restore" : "success_restore",
);
const response = {
status: true,
message: "Restoration successfully updated",
};
return Response.json(response, { status: 200 });
} catch (error) {
log.error({ error: error }, "Error in POST handler");
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
+20 -16
View File
@@ -1,24 +1,24 @@
import {NextResponse} from "next/server";
import {Body} from "./route";
import {isUuidv4} from "@/utils/verify-uuid";
import {Agent} from "@/db/schema/08_agent";
import {DatabaseWith} from "@/db/schema/07_database";
import * as drizzleDb from "@/db";
import {db, db as dbClient} from "@/db";
import {and, eq, inArray} from "drizzle-orm";
import {and, eq, inArray, desc, sql} from "drizzle-orm";
import {dbmsEnumSchema, EDbmsSchema} from "@/db/schema/types";
import {withUpdatedAt} from "@/db/utils";
import type {StorageInput} from "@/features/storages/types";
import {dispatchStorage} from "@/features/storages/dispatch";
import {Setting} from "@/db/schema/01_setting";
import {logger} from "@/lib/logger";
import {isUUID} from "@/utils/text";
import {StorageInput} from "@/features/storages/types";
import {dispatchStorage} from "@/features/storages/utils/storages.dispatch";
const log = logger.child({module: "api/agent/status/helpers"});
export async function handleDatabases(body: Body, agent: Agent, lastContact: Date, settings: Setting) {
const databasesResponse = [];
const formatDatabase = (database: DatabaseWith, backupAction: boolean, restoreAction: boolean, UrlBackup: string | null, storages: PingDatabaseStorageChannels[], urlMeta: string | null) => ({
const formatDatabase = (database: DatabaseWith, backupAction: boolean, restoreAction: boolean, UrlBackup: string | null, storages: PingDatabaseStorageChannels[], urlMeta: string | null, backupSize: number | null) => ({
generatedId: database.agentDatabaseId,
dbms: database.dbms,
storages: storages,
@@ -31,7 +31,8 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
restore: {
action: restoreAction,
file: UrlBackup,
metaFile: urlMeta
metaFile: urlMeta,
size: backupSize
},
},
});
@@ -49,9 +50,10 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
let restoreAction: boolean = false
let urlBackup: string | null = null;
let urlMeta: string | null = null
let backupSize: number | null = null
if (!existingDatabase) {
if (!isUuidv4(db.generatedId)) {
if (!isUUID(db.generatedId)) {
return NextResponse.json(
{error: "generatedId is not a valid uuid"},
{status: 500}
@@ -75,10 +77,7 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
})
.returning();
if (databaseCreated) {
await dbClient
.insert(drizzleDb.schemas.healthcheckLog)
.values({
@@ -90,7 +89,7 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
const storages = await getDatabaseStorageChannels(databaseCreated.id)
databasesResponse.push(formatDatabase(databaseCreated, backupAction, restoreAction, urlBackup, storages, null));
databasesResponse.push(formatDatabase(databaseCreated, backupAction, restoreAction, urlBackup, storages, null, null));
}
} else {
@@ -121,7 +120,11 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
where: and(
eq(drizzleDb.schemas.backup.databaseId, databaseUpdated.id),
inArray(drizzleDb.schemas.backup.status, ["waiting", "ongoing"])
)
),
orderBy: [
sql`case when ${drizzleDb.schemas.backup.status} = 'waiting' then 0 else 1 end`,
desc(drizzleDb.schemas.backup.createdAt)
]
})
const restoration = await dbClient.query.restoration.findFirst({
@@ -180,10 +183,11 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
if (result.success) {
urlBackup = result.url ?? null;
urlMeta = resultMeta.url ?? null
backupSize = restoration.backupStorage.size
} else {
await dbClient
.update(drizzleDb.schemas.restoration)
.set({status: "failed"})
.set(withUpdatedAt({status: "failed"}))
.where(eq(drizzleDb.schemas.restoration.id, restoration.id));
const errorMessage = "Failed to get backup URL";
@@ -194,18 +198,18 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
log.error({error: err, name: "handleDatabases"}, "Restoration crashed unexpectedly");
await dbClient
.update(drizzleDb.schemas.restoration)
.set({status: "failed"})
.set(withUpdatedAt({status: "failed"}))
.where(eq(drizzleDb.schemas.restoration.id, restoration.id));
continue;
}
await dbClient
.update(drizzleDb.schemas.restoration)
.set({status: "ongoing"})
.set(withUpdatedAt({status: "ongoing"}))
.where(eq(drizzleDb.schemas.restoration.id, restoration.id));
}
const storages = await getDatabaseStorageChannels(databaseUpdated.id)
databasesResponse.push(formatDatabase(databaseUpdated, backupAction, restoreAction, urlBackup, storages, urlMeta));
databasesResponse.push(formatDatabase(databaseUpdated, backupAction, restoreAction, urlBackup, storages, urlMeta, backupSize));
}
}
return databasesResponse;
+95 -87
View File
@@ -1,99 +1,107 @@
import {NextResponse} from "next/server";
import {handleDatabases} from "./helpers";
import { NextResponse } from "next/server";
import { handleDatabases } from "./helpers";
import * as drizzleDb from "@/db";
import {db} from "@/db";
import {EDbmsSchema} from "@/db/schema/types";
import {eq} from "drizzle-orm";
import {isUuidv4} from "@/utils/verify-uuid";
import {withUpdatedAt} from "@/db/utils";
import {logger} from "@/lib/logger";
const log = logger.child({module: "api/agent/status/route"});
import { db } from "@/db";
import { EDbmsSchema } from "@/db/schema/types";
import { and, eq } from "drizzle-orm";
import { withUpdatedAt } from "@/db/utils";
import { logger } from "@/lib/logger";
import { isUUID } from "@/utils/text";
const log = logger.child({ module: "api/agent/status/route" });
export type databaseAgent = {
name: string,
dbms: EDbmsSchema,
generatedId: string
pingStatus: boolean
}
name: string;
dbms: EDbmsSchema;
generatedId: string;
pingStatus: boolean;
};
export type Body = {
version: string,
databases: databaseAgent[]
}
version: string;
databases: databaseAgent[];
};
export async function POST(
request: Request,
{params}: { params: Promise<{ agentId: string }> }
request: Request,
{ params }: { params: Promise<{ agentId: string }> },
) {
try {
const agentId = (await params).agentId
log.info(`Agent ID: ${agentId}`)
const body: Body = await request.json();
const lastContact = new Date();
let message: string
try {
const agentId = (await params).agentId;
log.debug(`Agent ID: ${agentId}`);
const body: Body = await request.json();
const lastContact = new Date();
let message: string;
if (!isUuidv4(agentId)) {
message = "agentId is not a valid uuid"
log.error({error: message}, "An error occurred")
return NextResponse.json(
{error: "agentId is not a valid uuid"},
{status: 500}
);
}
const agent = await db.query.agent.findFirst({
where: eq(drizzleDb.schemas.agent.id, agentId),
})
if (!agent) {
message = "Agent not found"
return NextResponse.json({error: message}, {status: 404})
}
const [settings] = await db.select().from(drizzleDb.schemas.setting).where(eq(drizzleDb.schemas.setting.name, "system")).limit(1);
if (!settings) {
return NextResponse.json({error: "An error occured"}, {status: 404})
}
const databasesResponse = await handleDatabases(body, agent, lastContact, settings)
await db
.update(drizzleDb.schemas.agent)
.set(withUpdatedAt({
version: body.version,
lastContact: lastContact,
healthErrorCount: null
}))
.where(eq(drizzleDb.schemas.agent.id, agentId));
await db
.insert(drizzleDb.schemas.healthcheckLog)
.values({
kind: "agent",
status: "success",
objectId: agentId,
date: lastContact
})
const response = {
agent: {
id: agentId,
lastContact: lastContact
},
databases: databasesResponse
}
return Response.json(response)
} catch (error) {
log.error({error: error}, "Error in POST handler")
return NextResponse.json(
{error: 'Internal server error'},
{status: 500}
);
if (!isUUID(agentId)) {
message = "agentId is not a valid uuid";
log.error({ error: message }, "An error occurred");
return NextResponse.json(
{ error: "agentId is not a valid uuid" },
{ status: 500 },
);
}
}
const agent = await db.query.agent.findFirst({
where: and(
eq(drizzleDb.schemas.agent.id, agentId),
eq(drizzleDb.schemas.agent.isArchived, false),
),
});
if (!agent) {
message = "Agent not found";
return NextResponse.json({ error: message }, { status: 404 });
}
const [settings] = await db
.select()
.from(drizzleDb.schemas.setting)
.where(eq(drizzleDb.schemas.setting.name, "system"))
.limit(1);
if (!settings) {
return NextResponse.json({ error: "An error occured" }, { status: 404 });
}
const databasesResponse = await handleDatabases(
body,
agent,
lastContact,
settings,
);
await db
.update(drizzleDb.schemas.agent)
.set(
withUpdatedAt({
version: body.version,
lastContact: lastContact,
healthErrorCount: null,
}),
)
.where(eq(drizzleDb.schemas.agent.id, agentId));
await db.insert(drizzleDb.schemas.healthcheckLog).values({
kind: "agent",
status: "success",
objectId: agentId,
date: lastContact,
});
const response = {
agent: {
id: agentId,
lastContact: lastContact,
},
databases: databasesResponse,
};
return Response.json(response);
} catch (error) {
log.error({ error: error }, "Error in POST handler");
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
+34 -1
View File
@@ -1,4 +1,37 @@
import { auth } from "@/lib/auth/auth";
import { toNextJsHandler } from "better-auth/next-js";
import { NextRequest, NextResponse } from "next/server";
import { headers } from "next/headers";
export const { GET, POST } = toNextJsHandler(auth.handler);
const authHandler = toNextJsHandler(auth.handler);
async function blockApiKeyCreateForRestrictedUsers(
req: NextRequest,
): Promise<NextResponse | null> {
const url = req.nextUrl;
if (req.method !== "POST" || !url.pathname.endsWith("/api-key/create")) {
return null;
}
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user) {
return null;
}
// @ts-ignore
if (session.user.banned || (session.user.role as string) === "pending") {
return NextResponse.json(
{ error: "Account not eligible to create API keys" },
{ status: 403 },
);
}
return null;
}
export async function GET(req: NextRequest) {
return authHandler.GET(req);
}
export async function POST(req: NextRequest) {
const guard = await blockApiKeyCreateForRestrictedUsers(req);
if (guard) return guard;
return authHandler.POST(req);
}
+1 -1
View File
@@ -4,6 +4,6 @@ export async function GET() {
return NextResponse.json({
PROJECT_URL: process.env.PROJECT_URL,
PROJECT_NAME: process.env.PROJECT_NAME,
PROJECT_DESCRIPTION: process.env.PROJECT_DESCRIPTION,
PROJECT_DESCRIPTION: process.env.PROJECT_DESCRIPTION
});
}
+1 -1
View File
@@ -1,7 +1,7 @@
import {auth} from "@/lib/auth/auth";
import {headers} from "next/headers";
import {NextResponse} from "next/server";
import {eventEmitter} from "@/features/shared/event";
import {eventEmitter} from "@/lib/event";
import {logger} from "@/lib/logger";
const log = logger.child({module: "api/events"});
+1 -1
View File
@@ -1,7 +1,7 @@
import {NextResponse} from "next/server";
import path from "path";
import type {StorageInput} from "@/features/storages/types";
import {dispatchStorage} from "@/features/storages/dispatch";
import {dispatchStorage} from "@/features/storages/utils/storages.dispatch";
import {Readable} from "node:stream";
import {logger} from "@/lib/logger";
+1 -1
View File
@@ -2,7 +2,7 @@ import {NextResponse} from "next/server";
import {auth} from "@/lib/auth/auth";
import {headers} from "next/headers";
import {StorageInput} from "@/features/storages/types";
import {dispatchStorage} from "@/features/storages/dispatch";
import {dispatchStorage} from "@/features/storages/utils/storages.dispatch";
import {Readable} from "node:stream";
import {logger} from "@/lib/logger";
+5
View File
@@ -0,0 +1,5 @@
import { NextResponse } from "next/server";
export async function GET() {
return NextResponse.json({ success: true });
}
+55 -6
View File
@@ -1,11 +1,16 @@
import {NextResponse} from "next/server";
import fs from "fs";
import path from "path";
import {and, eq} from "drizzle-orm";
import {db} from "@/db";
import * as drizzleDb from "@/db";
import {env} from "@/env.mjs";
import {logger} from "@/lib/logger";
const log = logger.child({module: "api/tus/hooks"});
const FILE_PATH_RE = /^backups\/\d{4}-\d{2}-\d{2}\/[A-Za-z0-9._-]+$/;
export async function POST(request: Request) {
try {
const body = await request.json();
@@ -24,17 +29,61 @@ export async function POST(request: Request) {
event.Upload.Offset === event.Upload.Size
) {
const id = event.Upload.ID;
const fileName = headers["X-File-Name"]?.[0];
const filePath = headers["X-File-Path"]?.[0];
if (!filePath) {
return NextResponse.json({error: "Missing X-File-Path"}, {status: 500});
if (!filePath || !FILE_PATH_RE.test(filePath)) {
log.warn({filePath}, "Rejected invalid X-File-Path in TUS hook");
return NextResponse.json({error: "Invalid file path"}, {status: 400});
}
const generatedId = headers["X-Generated-Id"]?.[0];
const backupStorageId = headers["X-Backup-Storage-Id"]?.[0];
console.log(`Backup Storage ID : ${backupStorageId}`);
console.log(`generatedId : ${generatedId}`);
if (backupStorageId) {
if (!generatedId) {
log.warn("Missing X-Generated-Id in TUS hook");
return NextResponse.json({error: "Forbidden"}, {status: 403});
}
const database = await db.query.database.findFirst({
where: eq(drizzleDb.schemas.database.agentDatabaseId, generatedId),
});
if (!database) {
log.warn({generatedId}, "No database for X-Generated-Id in TUS hook");
return NextResponse.json({error: "Forbidden"}, {status: 403});
}
const storage = await db.query.backupStorage.findFirst({
where: and(
eq(drizzleDb.schemas.backupStorage.id, backupStorageId),
),
with: {backup: true},
});
if (!storage || storage.backup?.databaseId !== database.id) {
log.warn({backupStorageId, generatedId}, "No pending backup storage matches TUS hook");
return NextResponse.json({error: "Forbidden"}, {status: 403});
}
} else {
log.warn("TUS hook without X-Backup-Storage-Id (legacy agent)");
}
const uploadDir = path.join(env.PRIVATE_PATH!, "/uploads/");
const oldFilePath = path.join(uploadDir, "tmp", id);
const newFilePath = path.join(uploadDir, filePath);
const resolvedBase = path.resolve(uploadDir);
const newFilePath = path.resolve(resolvedBase, filePath);
const rel = path.relative(resolvedBase, newFilePath);
if (rel.startsWith("..") || path.isAbsolute(rel)) {
log.warn({filePath}, "Rejected path traversal in TUS hook");
return NextResponse.json({error: "Invalid file path"}, {status: 400});
}
fs.mkdirSync(path.dirname(newFilePath), {recursive: true});
@@ -74,7 +123,7 @@ export async function POST(request: Request) {
}
return NextResponse.json({});
} catch (error) {
log.error({error: error},"TUS Hook error");
log.error({error: error}, "TUS Hook error");
return NextResponse.json({error: "Internal server error"}, {status: 500});
}
}
}
+34
View File
@@ -0,0 +1,34 @@
import {withApiKey} from "@/lib/api-v1/middleware";
import {ApiKeyContext} from "@/lib/api-v1/types";
import {NextResponse} from "next/server";
import {getAgent, resolveAgentAccess} from "@/lib/api-v1/services/agents";
import {logger} from "@/lib/logger";
import {generateEdgeKey} from "@/utils/edge_key";
import {getServerUrl} from "@/utils/get-server-url";
const log = logger.child({ module: "api/v1/agents/[id]/key" });
export const GET = withApiKey(
async (_req: Request, ctx: ApiKeyContext, params?: Record<string, string>) => {
try {
const id = params?.id;
if (!id) return NextResponse.json({ error: "Not found" }, { status: 404 });
const access = await resolveAgentAccess(id, ctx.user);
if (access === "forbidden") return NextResponse.json({ error: "Forbidden" }, { status: 403 });
if (access === "not_found") return NextResponse.json({ error: "Not found" }, { status: 404 });
const agent = await getAgent(id);
if (!agent) return NextResponse.json({ error: "Agent not found" }, { status: 404 });
const edgeKey = await generateEdgeKey(getServerUrl(), agent.id);
return NextResponse.json({ data: edgeKey });
} catch (error) {
log.error({ error }, "Error in GET /api/v1/agents/[id]");
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}
);
+77
View File
@@ -0,0 +1,77 @@
import {NextResponse} from "next/server";
import {withApiKey} from "@/lib/api-v1/middleware";
import {logger} from "@/lib/logger";
import {ApiKeyContext} from "@/lib/api-v1/types";
import {getAgent, resolveAgentAccess} from "@/lib/api-v1/services/agents";
import {deleteAgentService} from "@/features/agents/actions/agent-delete.action";
const log = logger.child({module: "api/v1/agents/[id]"});
export const GET = withApiKey(
async (_req: Request, ctx: ApiKeyContext, params?: Record<string, string>) => {
try {
const id = params?.id;
if (!id) return NextResponse.json({error: "Not found"}, {status: 404});
const access = await resolveAgentAccess(id, ctx.user);
if (access === "forbidden") return NextResponse.json({error: "Forbidden"}, {status: 403});
if (access === "not_found") return NextResponse.json({error: "Not found"}, {status: 404});
const agent = await getAgent(id, {
includeDatabases: true,
includeOrganizations: false,
});
if (!agent) return NextResponse.json({error: "Not found"}, {status: 404});
return NextResponse.json({data: agent});
} catch (error) {
log.error({error}, "Error in GET /api/v1/agents/[id]");
return NextResponse.json({error: "Internal server error"}, {status: 500});
}
}
);
export const DELETE = withApiKey(
async (_req: Request, ctx: ApiKeyContext, params?: Record<string, string>) => {
try {
const id = params?.id;
if (!id) return NextResponse.json({error: "Not found"}, {status: 404});
const access = await resolveAgentAccess(id, ctx.user);
if (access === "forbidden") return NextResponse.json({error: "Forbidden"}, {status: 403});
if (access === "not_found") return NextResponse.json({error: "Not found"}, {status: 404});
const agent = await getAgent(id, {
includeDatabases: false,
includeOrganizations: true,
});
if (!agent) return NextResponse.json({error: "Agent no found"}, {status: 404});
const organizationIds = agent.organizations.map(org => org.organizationId)
const canDeleteGlobalAgent =
ctx.user.permissions.isAdmin || ctx.user.permissions.isSuperAdmin;
if (!agent.organizationId && !canDeleteGlobalAgent) {
return NextResponse.json(
{ error: "Forbidden" },
{ status: 403 }
);
}
await deleteAgentService({
agentId: agent.id,
organizationId: agent.organizationId ?? undefined,
organizationIds: organizationIds
});
return new Response(null, {status: 204});
} catch (error) {
log.error({error}, "Error in DELETE /api/v1/agents/[id]");
return NextResponse.json({error: "Internal server error"}, {status: 500});
}
}
);
+112
View File
@@ -0,0 +1,112 @@
import { NextResponse } from "next/server";
import { withApiKey } from "@/lib/api-v1/middleware";
import { db } from "@/db";
import * as drizzleDb from "@/db";
import { inArray, eq, and, or, isNull } from "drizzle-orm";
import { z } from "zod";
import { logger } from "@/lib/logger";
import {createAgentService} from "@/features/agents/actions/agents.action";
import { ActionError } from "@/lib/safe-actions/actions";
import {getAccessibleAgentIds} from "@/lib/api-v1/services/agents";
import {ApiKeyContext} from "@/lib/api-v1/types";
import {parseJsonBody} from "@/lib/api-v1/validation/json-body";
const log = logger.child({ module: "api/v1/agents" });
export const GET = withApiKey(async (_req: Request, ctx: ApiKeyContext) => {
try {
const agentIds = await getAccessibleAgentIds(ctx.user);
if (agentIds.length === 0) {
return NextResponse.json({ data: [] });
}
const agents = await db.query.agent.findMany({
where: and(
inArray(drizzleDb.schemas.agent.id, agentIds),
or(
eq(drizzleDb.schemas.agent.isArchived, false),
isNull(drizzleDb.schemas.agent.isArchived)
)
),
});
return NextResponse.json({ data: agents });
} catch (error) {
log.error({ error }, "Error in GET /api/v1/agents");
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
});
const CreateAgentSchema = z.object({
name: z.string().min(1, "name is required"),
organizationId: z.string().uuid("organizationId must be a valid UUID").optional(),
});
export const POST = withApiKey(
async (req: Request, ctx: ApiKeyContext) => {
try {
const body = await parseJsonBody(req, CreateAgentSchema);
if (!body.ok) {
return body.response;
}
const { name, organizationId } = body.data;
const org = ctx.organizations.find(
(org) => org.id === organizationId
);
if (
organizationId &&
(!org || !org.permissions.canManageAgents)
) {
return NextResponse.json(
{ error: "Forbidden" },
{ status: 403 }
);
}
const canCreateGlobalAgent =
ctx.user.permissions.isAdmin || ctx.user.permissions.isSuperAdmin;
if (!organizationId && !canCreateGlobalAgent) {
return NextResponse.json(
{ error: "Forbidden" },
{ status: 403 }
);
}
const createdAgent = await createAgentService({
organizationId,
data: {
name,
description: "",
},
});
return NextResponse.json(
{ data: createdAgent },
{ status: 201 }
);
} catch (error: unknown) {
if (error instanceof ActionError) {
return NextResponse.json(
{ error: error.message },
{ status: 400 }
);
}
log.error({error},
"Error in POST /api/v1/agents"
);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
);
@@ -0,0 +1,61 @@
import { NextResponse } from "next/server";
import { withApiKey } from "@/lib/api-v1/middleware";
import { db } from "@/db";
import * as drizzleDb from "@/db";
import { and, eq, isNull } from "drizzle-orm";
import { logger } from "@/lib/logger";
import { ApiKeyContext } from "@/lib/api-v1/types";
import {requireDatabaseAccess} from "@/lib/api-v1/services/databases";
const log = logger.child({
module: "api/v1/databases/[id]/backup/[backupId]",
});
export const GET = withApiKey(
async (_req: Request, ctx: ApiKeyContext, params?: Record<string, string>) => {
try {
const guard = await requireDatabaseAccess(params, ctx.user);
if (!guard.ok) {
return guard.response;
}
const { id } = guard.data;
const backupId = params?.backupId;
if (!backupId) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
const backup = await db.query.backup.findFirst({
where: and(
eq(drizzleDb.schemas.backup.id, backupId),
eq(drizzleDb.schemas.backup.databaseId, id),
isNull(drizzleDb.schemas.backup.deletedAt)
),
with: {
storages: {
where: (backupStorage, { isNull }) =>
isNull(backupStorage.deletedAt),
},
},
});
if (!backup) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
return NextResponse.json({ data: backup });
} catch (error) {
log.error(
{ error },
"Error in GET /api/v1/databases/[id]/backup/[backupId]"
);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
);
+96
View File
@@ -0,0 +1,96 @@
import { NextResponse } from "next/server";
import { withApiKey } from "@/lib/api-v1/middleware";
import { db } from "@/db";
import * as drizzleDb from "@/db";
import {and, desc, eq, inArray, isNull} from "drizzle-orm";
import { logger } from "@/lib/logger";
import { ApiKeyContext } from "@/lib/api-v1/types";
import {requireDatabaseAccess} from "@/lib/api-v1/services/databases";
const log = logger.child({ module: "api/v1/databases/[id]/backup" });
export const GET = withApiKey(
async (_req: Request, ctx: ApiKeyContext, params?: Record<string, string>) => {
try {
const guard = await requireDatabaseAccess(params, ctx.user);
if (!guard.ok) {
return guard.response;
}
const { id } = guard.data;
const backups = await db.query.backup.findMany({
where: and(
eq(drizzleDb.schemas.backup.databaseId, id),
isNull(drizzleDb.schemas.backup.deletedAt)
),
orderBy: [desc(drizzleDb.schemas.backup.createdAt)],
});
return NextResponse.json({ data: backups });
} catch (error) {
log.error({ error }, "Error in GET /api/v1/databases/[id]/backup");
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
);
export const POST = withApiKey(
async (_req: Request, ctx: ApiKeyContext, params?: Record<string, string>) => {
try {
const guard = await requireDatabaseAccess(params, ctx.user);
if (!guard.ok) {
return guard.response;
}
const { id } = guard.data;
const existingBackup = await db.query.backup.findFirst({
where: and(
eq(drizzleDb.schemas.backup.databaseId, id),
inArray(drizzleDb.schemas.backup.status, ["waiting", "ongoing"])
),
});
if (existingBackup) {
return NextResponse.json(
{
error:
"A backup is already waiting or ongoing for this database",
},
{ status: 409 }
);
}
const [createdBackup] = await db
.insert(drizzleDb.schemas.backup)
.values({
databaseId: id,
status: "waiting",
})
.returning();
if (!createdBackup) {
return NextResponse.json(
{ error: "Failed to create backup" },
{ status: 500 }
);
}
return NextResponse.json({ data: createdBackup }, { status: 201 });
} catch (error) {
log.error({ error }, "Error in POST /api/v1/databases/[id]/backup");
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
);
+122
View File
@@ -0,0 +1,122 @@
import { NextResponse } from "next/server";
import { withApiKey } from "@/lib/api-v1/middleware";
import { db } from "@/db";
import * as drizzleDb from "@/db";
import {and, eq, inArray, isNull} from "drizzle-orm";
import { z } from "zod";
import { logger } from "@/lib/logger";
import { ApiKeyContext } from "@/lib/api-v1/types";
import { parseJsonBody } from "@/lib/api-v1/validation/json-body";
import {requireDatabaseAccess} from "@/lib/api-v1/services/databases";
const log = logger.child({ module: "api/v1/databases/[id]/restore" });
const RestoreSchema = z.object({
backupId: z.string().uuid("backupId must be a valid UUID"),
backupStorageId: z.string().uuid("backupStorageId must be a valid UUID"),
});
export const POST = withApiKey(
async (req: Request, ctx: ApiKeyContext, params?: Record<string, string>) => {
try {
const guard = await requireDatabaseAccess(params, ctx.user);
if (!guard.ok) {
return guard.response;
}
const { id } = guard.data;
const body = await parseJsonBody(req, RestoreSchema);
if (!body.ok) {
return body.response;
}
const { backupId, backupStorageId } = body.data;
const backupRecord = await db.query.backup.findFirst({
where: and(
eq(drizzleDb.schemas.backup.id, backupId),
eq(drizzleDb.schemas.backup.databaseId, id),
isNull(drizzleDb.schemas.backup.deletedAt)
),
columns: {
id: true,
},
});
if (!backupRecord) {
return NextResponse.json(
{ error: "Backup not found for this database" },
{ status: 404 }
);
}
const backupStorage = await db.query.backupStorage.findFirst({
where: and(
eq(drizzleDb.schemas.backupStorage.id, backupStorageId),
eq(drizzleDb.schemas.backupStorage.backupId, backupId),
isNull(drizzleDb.schemas.backupStorage.deletedAt)
),
});
if (!backupStorage) {
return NextResponse.json(
{ error: "Backup storage not found" },
{ status: 404 }
);
}
if (backupStorage.status !== "success") {
return NextResponse.json(
{ error: "Backup storage is not in a successful state" },
{ status: 422 }
);
}
const existingRestorationBackup = await db.query.restoration.findFirst({
where: and(
eq(drizzleDb.schemas.restoration.databaseId, id),
inArray(drizzleDb.schemas.restoration.status, ["waiting", "ongoing"])
),
});
if (existingRestorationBackup) {
return NextResponse.json(
{
error:
"A restoration is already waiting or ongoing for this database",
},
{ status: 409 }
);
}
const [restoration] = await db
.insert(drizzleDb.schemas.restoration)
.values({
databaseId: id,
backupId,
backupStorageId,
status: "waiting",
})
.returning();
if (!restoration) {
return NextResponse.json(
{ error: "Failed to create restoration" },
{ status: 500 }
);
}
return NextResponse.json({ data: restoration }, { status: 201 });
} catch (error) {
log.error({ error }, "Error in POST /api/v1/databases/[id]/restore");
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
);
+44
View File
@@ -0,0 +1,44 @@
import { NextResponse } from "next/server";
import { withApiKey } from "@/lib/api-v1/middleware";
import { db } from "@/db";
import * as drizzleDb from "@/db";
import { and, eq, isNull } from "drizzle-orm";
import { logger } from "@/lib/logger";
import { ApiKeyContext } from "@/lib/api-v1/types";
import {requireDatabaseAccess} from "@/lib/api-v1/services/databases";
const log = logger.child({ module: "api/v1/databases/[id]" });
export const GET = withApiKey(
async (_req: Request, ctx: ApiKeyContext, params?: Record<string, string>) => {
try {
const guard = await requireDatabaseAccess(params, ctx.user);
if (!guard.ok) {
return guard.response;
}
const { id } = guard.data;
const database = await db.query.database.findFirst({
where: and(
eq(drizzleDb.schemas.database.id, id),
isNull(drizzleDb.schemas.database.deletedAt)
),
});
if (!database) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
return NextResponse.json({ data: database });
} catch (error) {
log.error({ error }, "Error in GET /api/v1/databases/[id]");
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
);
+72
View File
@@ -0,0 +1,72 @@
import { NextResponse } from "next/server";
import { withApiKey } from "@/lib/api-v1/middleware";
import { db } from "@/db";
import * as drizzleDb from "@/db";
import { and, desc, eq, isNull } from "drizzle-orm";
import { logger } from "@/lib/logger";
import { ApiKeyContext } from "@/lib/api-v1/types";
import {requireDatabaseAccess} from "@/lib/api-v1/services/databases";
const log = logger.child({ module: "api/v1/databases/[id]/status" });
export const GET = withApiKey(
async (_req: Request, ctx: ApiKeyContext, params?: Record<string, string>) => {
try {
const guard = await requireDatabaseAccess(params, ctx.user);
if (!guard.ok) {
return guard.response;
}
const { id } = guard.data;
const [database, latestBackup, latestRestoration] = await Promise.all([
db.query.database.findFirst({
where: and(
eq(drizzleDb.schemas.database.id, id),
isNull(drizzleDb.schemas.database.deletedAt)
),
}),
db.query.backup.findFirst({
where: and(
eq(drizzleDb.schemas.backup.databaseId, id),
isNull(drizzleDb.schemas.backup.deletedAt)
),
orderBy: [desc(drizzleDb.schemas.backup.createdAt)],
}),
db.query.restoration.findFirst({
where: and(
eq(drizzleDb.schemas.restoration.databaseId, id),
isNull(drizzleDb.schemas.restoration.deletedAt)
),
orderBy: [desc(drizzleDb.schemas.restoration.createdAt)],
}),
]);
if (!database) {
return NextResponse.json(
{ error: "Database not found" },
{ status: 404 }
);
}
return NextResponse.json({
data: {
isWaitingForBackup: database.isWaitingForBackup,
lastContact: database.lastContact,
latestBackup: latestBackup ?? null,
latestRestoration: latestRestoration ?? null,
},
});
} catch (error) {
log.error({ error }, "Error in GET /api/v1/databases/[id]/status");
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
);
+24
View File
@@ -0,0 +1,24 @@
import { NextResponse } from "next/server";
import { withApiKey } from "@/lib/api-v1/middleware";
import { logger } from "@/lib/logger";
import { ApiKeyContext } from "@/lib/api-v1/types";
import { getAccessibleDatabases } from "@/lib/api-v1/services/databases";
const log = logger.child({ module: "api/v1/databases" });
export const GET = withApiKey(async (_req: Request, ctx: ApiKeyContext) => {
try {
const databases = await getAccessibleDatabases(ctx.user);
return NextResponse.json({
data: databases,
});
} catch (error) {
log.error({ error }, "Error in GET /api/v1/databases");
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
});
+45
View File
@@ -0,0 +1,45 @@
export const dynamic = "force-dynamic";
const html = `<!DOCTYPE html>
<html>
<head>
<title>Portabase API Docs</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.32.6/swagger-ui.css"
integrity="sha384-9Q2fpS+xeS4ffJy6CagnwoUl+4ldAYhOs9pgZuEKxypVModhmZFzeMlvVsAjf7uT"
crossorigin="anonymous"
/>
<style>
body { margin: 0; }
</style>
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.32.6/swagger-ui-bundle.js" integrity="sha384-EYdOaiRwn44zNjrw+Tfs06qYz9BGQVo2f4/pLY5i7VorbjnZNhdplAbTBk8FXHUJ" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.32.6/swagger-ui-standalone-preset.js" integrity="sha384-49fpFaVrAWI/qdgl9Vv5E/4NXxRUiJX5vGuLws1NUpTWGtEqzWEx8gHTw2UTehFK" crossorigin="anonymous"></script>
<script>
window.onload = function () {
SwaggerUIBundle({
url: "/api/v1/openapi",
dom_id: "#swagger-ui",
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIStandalonePreset,
],
layout: "StandaloneLayout",
persistAuthorization: true,
filter: true,
});
};
</script>
</body>
</html>`;
export function GET() {
return new Response(html, {
headers: { "Content-Type": "text/html; charset=utf-8" },
});
}
+31
View File
@@ -0,0 +1,31 @@
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
import { NextResponse } from "next/server";
import { withApiKey } from "@/lib/api-v1/middleware";
import { createPortabaseMcpServer } from "@/lib/mcp/server";
import type { ApiKeyContext } from "@/lib/api-v1/types";
import { env } from "@/env.mjs";
/**
* MCP endpoint — Streamable HTTP transport, stateless mode.
*
* Auth is handled by withApiKey before MCP is ever touched.
* The validated key is forwarded by MCP tools to downstream /api/v1 REST calls.
*/
const apiEnabled = env.API_ENABLED;
const mcpEnabled = env.MCP_ENABLED;
export const POST = apiEnabled && mcpEnabled
? withApiKey(async (req: Request, ctx: ApiKeyContext) => {
const apiKey = req.headers.get("x-api-key")!;
const server = createPortabaseMcpServer(ctx, apiKey);
const transport = new WebStandardStreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});
await server.connect(transport);
return transport.handleRequest(req);
})
: () => NextResponse.json({ error: "Not found" }, { status: 404 });
+8
View File
@@ -0,0 +1,8 @@
import { NextResponse } from "next/server";
import { buildSpec } from "@/lib/api-v1/openapi/spec";
export const dynamic = "force-dynamic";
export function GET() {
return NextResponse.json(buildSpec());
}
+1 -1
View File
@@ -1,7 +1,7 @@
import type { Metadata } from "next";
import type React from "react";
import "./globals.css";
import { ConsoleSilencer } from "@/components/wrappers/common/console-silencer";
import { ConsoleSilencer } from "@/components/common/console-silencer";
import { author, geistMono, poppins } from "@/fonts/fonts";
import { cn } from "@/lib/utils";
import { Providers } from "./providers";
+5 -2
View File
@@ -1,4 +1,5 @@
import BackButton from "@/components/wrappers/common/button/back-button";
import Link from "next/link";
import { Button } from "@/components/ui/button";
export default async function NotFound() {
return (
@@ -12,7 +13,9 @@ export default async function NotFound() {
The content you are trying to view is not available.
</p>
</div>
<BackButton>Go home</BackButton>
<Button asChild>
<Link href="/">Go home</Link>
</Button>
</div>
</div>
);
+10 -5
View File
@@ -4,9 +4,9 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { type PropsWithChildren, Suspense } from "react";
import { Toaster } from "@/components/ui/sonner";
import { ErrorLayout } from "@/components/wrappers/common/error-layout";
import { ThemeMetaUpdaterRoot } from "@/features/browser/theme-meta-updater-root";
import { ThemeProvider } from "@/features/theme/theme-provider";
import { ErrorLayout } from "@/components/common/error-layout";
import { ThemeMetaUpdater } from "@/features/theme/components/theme-meta-updater";
import { ThemeProvider } from "@/features/theme/components/theme-provider";
export type ProviderProps = PropsWithChildren<{}>;
const queryClient = new QueryClient();
@@ -14,8 +14,13 @@ const queryClient = new QueryClient();
export const Providers = (props: ProviderProps) => {
return (
<Suspense fallback={null}>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<ThemeMetaUpdaterRoot />
<ThemeProvider
attribute="class"
defaultTheme="light"
enableSystem
storageKey="portabase_theme"
>
<ThemeMetaUpdater />
<QueryClientProvider client={queryClient}>
<ErrorLayout>
<Toaster />
-37
View File
@@ -1,37 +0,0 @@
services:
app:
build:
context: .
dockerfile: docker/dockerfile/Dockerfile
target: prod
ports:
- '8887:80'
environment:
TZ: "Europe/Paris"
env_file:
- .env
volumes:
- portabase-e2e-data:/data
depends_on:
db:
condition: service_healthy
db:
image: postgres:18-alpine
ports:
- "5433:5432"
volumes:
- postgres-e2e-data:/var/lib/postgresql/data
environment:
- POSTGRES_DB=devdb
- POSTGRES_USER=devuser
- POSTGRES_PASSWORD=changeme
healthcheck:
test: ["CMD-SHELL", "pg_isready -U devuser -d devdb"]
interval: 10s
timeout: 5s
retries: 5
volumes:
portabase-e2e-data:
postgres-e2e-data:
+13 -7
View File
@@ -1,12 +1,12 @@
services:
app:
# build:
# context: .
# dockerfile: docker/dockerfile/Dockerfile
# target: prod
image: portabase/portabase:1.7.1
build:
context: .
dockerfile: docker/dockerfile/Dockerfile
target: prod
# image: portabase/portabase:1
ports:
- '8887:80'
- "8887:80"
environment:
TZ: "Europe/Paris"
env_file:
@@ -17,6 +17,12 @@ services:
db:
condition: service_healthy
container_name: portabase-app-prod
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost/api/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 60s
db:
image: postgres:16-alpine
@@ -29,7 +35,7 @@ services:
- POSTGRES_USER=devuser
- POSTGRES_PASSWORD=changeme
healthcheck:
test: [ "CMD-SHELL", "pg_isready -U devuser -d devdb" ]
test: ["CMD-SHELL", "pg_isready -U devuser -d devdb"]
interval: 10s
timeout: 5s
retries: 5
+123 -1
View File
@@ -12,7 +12,7 @@ services:
- POSTGRES_USER=devuser
- POSTGRES_PASSWORD=changeme
healthcheck:
test: ["CMD-SHELL", "pg_isready -U devuser -d devdb"]
test: [ "CMD-SHELL", "pg_isready -U devuser -d devdb" ]
interval: 10s
timeout: 5s
retries: 5
@@ -32,5 +32,127 @@ services:
- ./private/uploads/tmp:/data/uploads/tmp
user: "1000:1000"
mailpit:
image: axllent/mailpit:latest
container_name: portabase-mailpit-dev
hostname: mailpit
restart: unless-stopped
ports:
- "8025:8025"
- "1025:1025"
azurite:
image: mcr.microsoft.com/azure-storage/azurite
command: azurite --blobHost 0.0.0.0 --skipApiVersionCheck
ports:
- "10000:10000"
volumes:
- azurite-data:/data
extra_hosts:
- "localhost:host-gateway"
networks:
- portabase
# DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://127.0.0.1:10000/devstoreaccount1;QueueEndpoint=http://127.0.0.1:10001/devstoreaccount1;TableEndpoint=http://127.0.0.1:10002/devstoreaccount1;
gcs:
image: fsouza/fake-gcs-server:latest
container_name: fake-gcs
ports:
- "4443:4443"
command:
- "-scheme"
- "http"
- "-port"
- "4443"
- "-public-host"
- "localhost:4443"
- "-external-url"
- "http://localhost:4443"
- "-backend"
- "filesystem"
- "-filesystem-root"
- "/storage"
volumes:
- gcs-data:/data
- gcs-storage:/storage
networks:
- portabase
gcs-init:
image: curlimages/curl:latest
depends_on:
- gcs
entrypoint: ["/bin/sh", "-c"]
command: >
'until curl -fsS http://gcs:4443/storage/v1/b >/dev/null; do sleep 1; done;
curl -fsS http://gcs:4443/storage/v1/b/portabase >/dev/null
|| curl -fsS -X POST "http://gcs:4443/storage/v1/b?project=local"
-H "Content-Type: application/json"
-d "{\"name\":\"portabase\"}"'
networks:
- portabase
# curl http://localhost:4443/storage/v1/b
# projectId: "test-project",
# bucketName: "portabase",
# clientEmail: "fake@test.iam.gserviceaccount.com",
# privateKey: "unused-when-emulated",
rustfs:
image: rustfs/rustfs:latest
container_name: rustfs
environment:
- RUSTFS_ROOT_USER=admin
- RUSTFS_ROOT_PASSWORD=root-password
- RUSTFS_ACCESS_KEY=access-key
- RUSTFS_SECRET_KEY=secret-key
- RUSTFS_VOLUMES=/data
- RUSTFS_ADDRESS=:9000
- RUSTFS_CONSOLE_ENABLE=true
- RUSTFS_CONSOLE_ADDRESS=:9001
volumes:
- rustfs-data:/data
healthcheck:
test: [ "CMD", "curl", "-sf", "http://localhost:9000/health" ]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
ports:
- "9001:9001"
- "9000:9000"
extra_hosts:
- "localhost:host-gateway"
networks:
- portabase
rustfs-init:
image: rustfs/rc:latest
container_name: rustfs-init
depends_on:
rustfs:
condition: service_healthy
entrypoint: >
/bin/sh -c "
until (/usr/bin/rc alias set rustfs http://rustfs:9000 access-key secret-key) do echo '...waiting...' && sleep 1; done;
/usr/bin/rc mb rustfs/portabase;
echo 'RustFS initialization complete';
tail -f /dev/null
"
networks:
- portabase
volumes:
postgres-data:
azurite-data:
gcs-data:
gcs-storage:
rustfs-data:
networks:
portabase:
name: portabase_network
external: true
+27 -21
View File
@@ -1,11 +1,33 @@
FROM --platform=$BUILDPLATFORM node:22-bullseye AS base
FROM --platform=$BUILDPLATFORM node:22-slim AS runtime-base
RUN apt-get update && apt-get install -y \
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
ca-certificates \
bash \
tzdata \
libc6 \
nginx \
gnupg \
&& rm -rf /var/lib/apt/lists/*
RUN mkdir -p /etc/apt/keyrings \
&& curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc \
| gpg --dearmor -o /etc/apt/keyrings/postgresql.gpg \
&& echo "deb [signed-by=/etc/apt/keyrings/postgresql.gpg] http://apt.postgresql.org/pub/repos/apt bookworm-pgdg main" \
> /etc/apt/sources.list.d/pgdg.list
RUN apt-get update && apt-get install -y --no-install-recommends \
postgresql-18 \
postgresql-client-18 \
postgresql-contrib-18 \
&& rm -rf /var/lib/apt/lists/*
ENV PATH="/usr/lib/postgresql/18/bin:${PATH}"
FROM runtime-base AS build-base
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
g++ \
make \
@@ -13,25 +35,9 @@ RUN apt-get update && apt-get install -y \
automake \
libtool \
git \
nginx \
&& rm -rf /var/lib/apt/lists/*
RUN mkdir -p /etc/apt/keyrings \
&& curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc \
| gpg --dearmor -o /etc/apt/keyrings/postgresql.gpg \
&& echo "deb [signed-by=/etc/apt/keyrings/postgresql.gpg] http://apt.postgresql.org/pub/repos/apt bullseye-pgdg main" \
> /etc/apt/sources.list.d/pgdg.list
RUN apt-get update && apt-get install -y \
postgresql-18 \
postgresql-client-18 \
postgresql-contrib-18 \
&& rm -rf /var/lib/apt/lists/*
ENV PATH="/usr/lib/postgresql/18/bin:${PATH}"
FROM --platform=$BUILDPLATFORM golang:1.23-bookworm AS tusd-base
ARG TUSD_VERSION=2.8.0
@@ -51,7 +57,7 @@ FROM scratch AS tusd-dist
COPY --from=tusd-base /tusddist/tusd /tusd
FROM base AS build-env
FROM build-base AS build-env
RUN corepack enable && corepack prepare pnpm@latest --activate
@@ -83,7 +89,7 @@ COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN pnpm run build
FROM base AS prod
FROM runtime-base AS prod
WORKDIR /app
ENV NODE_ENV=production
@@ -92,6 +98,7 @@ ENV PORT=80
ENV HOSTNAME="0.0.0.0"
ENV PGDATA=/data/postgres
ENV PRIVATE_PATH=/data/private
ENV TUSD_BEHIND_PROXY=false
RUN addgroup --system --gid 1001 nodejs
@@ -104,7 +111,6 @@ COPY --from=builder /app/drizzle.config.ts ./
COPY --from=builder --chown=1001:1001 /app/.next/standalone ./
COPY --from=builder --chown=1001:1001 /app/.next/static ./.next/static
COPY --chown=1001:1001 src/db ./src/db
COPY --from=deps /app/node_modules ./node_modules
COPY --from=tusd-dist /tusd /usr/local/bin/tusd
RUN chmod +x /usr/local/bin/tusd
+12 -1
View File
@@ -72,7 +72,18 @@ fi
mkdir -p /data/private/uploads/tmp
echo "▶ Starting tusd server..."
tusd --base-path /tus/files/ --upload-dir /data/private/uploads/tmp --hooks-http http://127.0.0.1:3000/api/tus/hooks --port 1080 --max-size 21474836480 &
TUSD_BEHIND_PROXY_FLAG=""
if [ "${TUSD_BEHIND_PROXY:-false}" = "true" ]; then
TUSD_BEHIND_PROXY_FLAG="--behind-proxy"
echo "[INFO] TUSD_BEHIND_PROXY=true, enabling tusd proxy mode"
fi
tusd \
--base-path /tus/files/ \
--upload-dir /data/private/uploads/tmp \
--hooks-http http://127.0.0.1:3000/api/tus/hooks \
--port 1080 \
--max-size 21474836480 \
$TUSD_BEHIND_PROXY_FLAG &
echo "▶ Starting Next.js server..."
PORT=3000 node server.js &
+12 -2
View File
@@ -1,6 +1,11 @@
events {}
http {
map $http_x_forwarded_proto $forwarded_proto {
default $scheme;
"~*^\s*(https?)\s*(?:,|$)" $1;
}
client_max_body_size 20G;
ignore_invalid_headers off;
@@ -18,13 +23,18 @@ http {
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Proto $forwarded_proto;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
location = /api/tus/hooks {
deny all;
return 404;
}
location / {
proxy_pass http://127.0.0.1:3000;
@@ -34,7 +44,7 @@ http {
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Proto $forwarded_proto;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
-74
View File
@@ -1,74 +0,0 @@
import {expect, test} from "@playwright/test";
import {users} from "./helpers/auth";
import {changeUserRole, create, switchToDefault} from "./helpers/access-management";
import {LOCAL_STORAGE_PATH} from "./helpers/session";
test.use({storageState: LOCAL_STORAGE_PATH});
const firstOrganization = "Organization A";
const secondOrganization = "Organization B";
test.describe.serial(() => {
test("Create an organization from organizations page", async ({page}) => {
await page.goto("/dashboard/admin/organizations");
await expect(page.getByRole("heading", {name: "Active organizations"})).toBeVisible();
await create(page, "button", firstOrganization);
await expect(page.getByText(/Organization has been successfully created\./i)).toBeVisible();
await expect(page).toHaveURL("/dashboard/admin/organizations");
await expect(page.getByRole("button", {name: firstOrganization, exact: true})).toBeVisible();
await switchToDefault(page);
});
test("Create an organization from sidebar button", async ({page}) => {
await page.goto("/dashboard/home");
await expect(page.getByRole("heading", {name: "Dashboard"})).toBeVisible();
await create(page, "sidebar", secondOrganization);
await expect(page.getByText(/Organization has been successfully created\./i)).toBeVisible();
await expect(page).toHaveURL("/dashboard/home");
await expect(page.getByRole("button", {name: secondOrganization, exact: true})).toBeVisible();
await switchToDefault(page);
});
test("Change John Doe's role from pending to user", async ({page}) => {
await page.goto("/dashboard/admin/users");
await expect(page.getByText(users.normal.email, {exact: true})).toBeVisible();
const userRow = page.locator("tr").filter({hasText: users.normal.email}).first();
await expect(userRow).toBeVisible();
await userRow.locator("button").last().click();
await page.getByRole('menuitem', {name: 'Role'}).click();
await expect(page.getByRole("heading", {name: "Change the user's role"})).toBeVisible();
await changeUserRole(page, "user")
await expect(page.getByText("User role changed successfully.")).toBeVisible();
await expect(page.locator("tr").filter({hasText: users.normal.email}).getByText("user", {exact: true})).toBeVisible();
});
test("Add John Doe to Organization A", async ({page}) => {
await page.goto("/dashboard/admin/organizations");
await expect(page.getByRole("heading", {name: "Active organizations"})).toBeVisible();
const organizationRow = page.locator("tr").filter({hasText: firstOrganization}).first();
await expect(organizationRow).toBeVisible();
await organizationRow.locator('a[href*="/dashboard/admin/organizations/"], a[href*="organizations/"]').click();
await expect(page.getByText(firstOrganization, {exact: true})).toBeVisible();
await page.getByRole("button", {name: /Add member/i}).click();
await expect(page.getByRole("heading", {name: "Add member to your organization"})).toBeVisible();
await page.getByPlaceholder("Enter a user email").fill(users.normal.email);
await page.getByRole("option", {name: new RegExp(users.normal.email, "i")}).click();
await page.getByRole("button", {name: "Confirm"}).click();
await expect(page.getByText("Member successfully added!")).toBeVisible();
await expect(page.getByText(users.normal.email, {exact: true})).toBeVisible();
});
});
-116
View File
@@ -1,116 +0,0 @@
import {expect, test} from "@playwright/test";
import {execSync} from "child_process";
import fs from "fs";
import os from "os";
import path from "path";
import {createAgentWithDockerDatabases} from "./helpers/agent-cli";
import {create, edit, get, remove} from "./helpers/agent";
import {LOCAL_STORAGE_PATH} from "./helpers/session";
const agent = {
aName: "Agent A",
aUpdatedName: "Agent A Updated",
bName: "Agent B",
description: "Agent created by Playwright E2E",
updatedDescription: "Agent updated by Playwright E2E",
};
test.use({storageState: LOCAL_STORAGE_PATH});
test.describe.serial(() => {
let agentWorkspace: string | null = null;
test("Create agent A from empty state", async ({page}) => {
await page.goto("/dashboard/agents");
await expect(page.getByRole("heading", {name: "Agents"})).toBeVisible();
await expect(page.getByText("Create new Agent", {exact: true})).toBeVisible();
await create(page, "emptyState", agent.aName, agent.description);
await expect(page.getByText("Success creating agent")).toBeVisible();
await expect(get(page, agent.aName)).toBeVisible();
await expect(page.getByText("Create new Agent", {exact: true})).toHaveCount(0);
});
test("Edit agent A", async ({page}) => {
await page.goto("/dashboard/agents");
await expect(page.getByRole("heading", {name: "Agents"})).toBeVisible();
await expect(get(page, agent.aName)).toBeVisible();
await edit(page, agent.aName, agent.aUpdatedName, agent.updatedDescription);
await expect(page.getByText("Success updating agent")).toBeVisible();
await expect(page.getByText(agent.aUpdatedName, {exact: true})).toBeVisible();
await expect(page.getByText(agent.updatedDescription, {exact: true})).toBeVisible();
});
test("Create agent B from classic button", async ({page}) => {
await page.goto("/dashboard/agents");
await expect(page.getByRole("heading", {name: "Agents"})).toBeVisible();
await expect(page.getByRole("button", {name: /Create Agent/i})).toBeVisible();
await create(page, "button", agent.bName, agent.description);
await expect(page.getByText("Success creating agent")).toBeVisible();
await expect(get(page, agent.bName)).toBeVisible();
});
test("Delete Agent B", async ({page}) => {
await page.goto("/dashboard/agents");
await expect(page.getByRole("heading", {name: "Agents"})).toBeVisible();
await expect(get(page, agent.bName)).toBeVisible();
await remove(page, agent.bName);
await expect(page.getByText("Agent has been successfully deleted.")).toBeVisible();
await expect(page).toHaveURL("/dashboard/agents");
await expect(page.getByText(agent.bName)).toHaveCount(0);
});
// test("Launch the updated agent", async ({page}) => {
// await page.goto("/dashboard/agents");
// await expect(page.getByRole("heading", {name: "Agents"})).toBeVisible();
// await get(page, agent.aName).click();
//
// await expect(page).toHaveURL(/\/dashboard\/agents\/.+/);
// await expect(page.getByText(agent.aName, {exact: true})).toBeVisible();
// await expect(page.getByText("Registration & Setup")).toBeVisible();
//
// const commandInput = page.locator("input[readonly]").first();
// await page.locator("input[readonly]").first().locator("xpath=following-sibling::button[1]").click();
// const command = await commandInput.inputValue();
//
// agentWorkspace = fs.mkdtempSync(path.join(os.tmpdir(), "portabase-agent-"));
// await createAgentWithDockerDatabases(command, agentWorkspace);
// execSync(`portabase start "${agent.aName}"`, {
// cwd: agentWorkspace,
// stdio: "pipe",
// timeout: 120_000,
// });
//
// await expect(page.getByText("Never connected.")).toHaveCount(0, {timeout: 120_000});
// await expect(page.getByText("Action Required")).toHaveCount(0);
// });
test.afterAll(async () => {
if (agentWorkspace) {
try {
execSync(`portabase stop "${agent.aName}"`, {
cwd: agentWorkspace,
stdio: "pipe",
timeout: 30_000,
});
} catch {
}
try {
execSync(`portabase uninstall --force "${agent.aName}"`, {
cwd: agentWorkspace,
stdio: "pipe",
timeout: 30_000,
});
} catch {
}
fs.rmSync(agentWorkspace, {recursive: true, force: true});
agentWorkspace = null;
}
});
});
-104
View File
@@ -1,104 +0,0 @@
import {test, expect} from '@playwright/test';
import {login, register, users} from "./helpers/auth";
import {LOCAL_STORAGE_PATH} from "./helpers/session";
const TIMEOUT = undefined
// const TIMEOUT = 5000
test.use({storageState: LOCAL_STORAGE_PATH})
test.describe.serial( () => {
test('Redirect to login if not connected', async ({page}) => {
await page.goto('/dashboard/projects');
await expect(page).toHaveURL("login?redirect=%2Fdashboard%2Fprojects", {timeout: TIMEOUT});
});
test('Password too short', async ({page}) => {
await page.goto('')
await page.click('text=Sign up')
await expect(page).toHaveURL('/register')
let password = '123456'
await register(page, users["admin"].username, users["admin"].email, password, password)
const toast = page.locator('text=Must have at least 8 character')
await expect(toast).toBeVisible()
})
test('Password too simple', async ({page}) => {
await page.goto('')
await page.click('text=Sign up')
await expect(page).toHaveURL('/register')
let password = '12345678'
await register(page, users["admin"].username, users["admin"].email, password, password)
const toast = page.locator('text=Your password must contain at least one uppercase letter, one lowercase letter, one number, and one special character.')
await expect(toast).toBeVisible()
})
test('Password and confirm password mismatch', async ({page}) => {
await page.goto('/')
await page.click('text=Sign up')
await expect(page).toHaveURL('/register')
let password = 'testPASS123456!'
let confirmPassword = 'testPASS123456!!'
await register(page, users["admin"].username, users["admin"].email, password, confirmPassword)
const toast = page.locator('text=The passwords did not match')
await expect(toast).toBeVisible()
})
test('Successful register for admin', async ({page}) => {
await page.goto('/')
await page.click('text=Sign up')
await expect(page).toHaveURL('/register')
await register(page, users["admin"].username, users["admin"].email, users["admin"].password, users["admin"].password)
await expect(page).toHaveURL('/login', {timeout: TIMEOUT})
})
test('User already exists.', async ({page}) => {
await page.goto('/')
await page.click('text=Sign up')
await expect(page).toHaveURL('/register')
await register(page, users["admin"].username, users["admin"].email, users["admin"].password, users["admin"].password)
const toast = page.locator('text=User already exists. Use another email.')
await expect(toast).toBeVisible()
})
test('Successful register for normal', async ({page}) => {
await page.goto('/')
await page.click('text=Sign up')
await expect(page).toHaveURL('/register')
await register(page, users["normal"].username, users["normal"].email, users["normal"].password, users["normal"].password)
await expect(page).toHaveURL('/login', {timeout: TIMEOUT})
})
test('Failed login because account not active', async ({page}) => {
await page.goto('/login')
await login(page, users["normal"].email, users["normal"].password)
const toast = page.locator('text=Your account is not active.')
await expect(toast).toBeVisible()
})
test('Successful login', async ({page}) => {
await page.goto('/login')
await login(page, users["admin"].email, users["admin"].password)
await expect(page).toHaveURL('/dashboard/home', {timeout: TIMEOUT})
await expect(page.getByRole('link', {name: 'Logo Portabase'})).toBeVisible()
await page.context().storageState({path: LOCAL_STORAGE_PATH})
})
})
-32
View File
@@ -1,32 +0,0 @@
import {expect, test} from "@playwright/test";
import fs from "fs";
import {logout} from "./helpers/auth";
import {LOCAL_STORAGE_PATH} from "./helpers/session";
test.use({storageState: LOCAL_STORAGE_PATH});
test.describe( () => {
test.afterAll(async () => {
if (fs.existsSync(LOCAL_STORAGE_PATH)) {
fs.unlinkSync(LOCAL_STORAGE_PATH);
}
});
test("Remove shared storage state", async ({page}) => {
await test.step("Logout shared authenticated session", async () => {
if (!fs.existsSync(LOCAL_STORAGE_PATH)) {
return;
}
const content = fs.readFileSync(LOCAL_STORAGE_PATH, "utf-8").trim();
if (!content || content === "{}") {
return;
}
await page.goto('/dashboard/home');
await logout(page);
await expect(page).toHaveURL(/\/login(?:\?.*)?$/);
});
});
});
-70
View File
@@ -1,70 +0,0 @@
import {Page} from "@playwright/test";
export function getUserRow(page: Page, email: string) {
return page.locator("tr").filter({hasText: email}).first();
}
export function getOrganizationRow(page: Page, organizationName: string) {
return page.locator("tr").filter({hasText: organizationName}).first();
}
/**
* Create an organization from the selected entrypoint.
*
* Available entrypoints:
* - `button`: the organizations page create button.
* - `sidebar`: the sidebar organization switcher.
*
* Executes from:
* - `/dashboard/admin/organizations` for "button" entrypoint
* - any `/dashboard/**` page for "sidebar" entrypoint
*/
export async function create(page: Page, entrypoint: "button" | "sidebar", name: string) {
if (entrypoint === "sidebar") {
await page.getByRole("button", {name: "Default Organization"}).click();
await page.getByText("Create organization", {exact: true}).click();
} else {
await page.getByRole("button", {name: /Create a new organization/i}).click();
}
await page.getByLabel("Name").fill(name);
await page.getByRole("button", {name: "Create"}).click();
}
/**
* Switch the active organization from the sidebar switcher.
*
* Executes from: any `/dashboard/**` if the sidebar is visible.
*/
export async function switchTo(page: Page, name: string) {
await page.getByTestId('organization-dropdown').click();
await page.getByRole('menuitem', {name: name}).click();
}
/**
* Switch back to the default organization.
*
* Executes from: any `/dashboard/**` if the sidebar is visible.
*/
export async function switchToDefault(page: Page) {
await switchTo(page, "Default Organization");
}
const ROLE_LABELS = {
pending: "Pending",
user: "User",
admin: "Admin",
} as const;
/**
* Change the global role of a user from the admin users role modal.
*
* Executes from: the "Change the user's role" dialog opened from `/dashboard/admin/users`.
*/
export async function changeUserRole(page: Page, role: keyof typeof ROLE_LABELS) {
const roleOption = ROLE_LABELS[role];
await page.getByRole("combobox").click();
await page.getByRole("option", {name: roleOption}).click();
await page.getByRole("button", {name: "Validate"}).click();
}
-107
View File
@@ -1,107 +0,0 @@
import * as pty from "node-pty";
type InteractiveStep = {
match: RegExp;
reply: string;
};
function normalizeOutput(output: string) {
return output.replace(/\u001b\[[0-9;?]*[ -/]*[@-~]/g, "");
}
/**
* Run an interactive CLI command and answer prompts in sequence.
* Entry point:
* - `command` is the full CLI command executed in the PTY.
* - `cwd` is the local workspace where the command is launched.
* - `steps` defines which prompt text is matched and which reply is sent.
* - `timeoutMs` controls when the command is aborted if it stalls.
* Executes from: not page-bound; runs from a local test workspace on the Node.js side.
*/
export async function runInteractiveCommand(
command: string,
cwd: string,
steps: InteractiveStep[],
timeoutMs: number = 120_000,
) {
await new Promise<void>((resolve, reject) => {
const child = pty.spawn("sh", ["-lc", command], {
cwd,
env: {
...process.env,
TERM: "xterm-256color",
},
cols: 120,
rows: 30,
});
let currentStep = 0;
let output = "";
const timer = setTimeout(() => {
child.kill();
reject(new Error(`Interactive command timed out.\n\nCommand: ${command}\n\nOutput:\n${normalizeOutput(output)}`));
}, timeoutMs);
const handleChunk = (chunk: string) => {
output += chunk;
const normalizedOutput = normalizeOutput(output);
while (currentStep < steps.length && steps[currentStep].match.test(normalizedOutput)) {
child.write(steps[currentStep].reply);
currentStep += 1;
}
};
child.onData(handleChunk);
child.onExit(({exitCode}) => {
clearTimeout(timer);
if (exitCode === 0) {
resolve();
return;
}
reject(new Error(`Interactive command failed with exit code ${exitCode}.\n\nCommand: ${command}\n\nOutput:\n${normalizeOutput(output)}`));
});
});
}
/**
* Create and configure a Portabase agent with two Docker-backed databases.
* Entry point:
* - `command` is the CLI setup command copied from the agent details page.
* - `cwd` is the local workspace where the agent is installed.
* - this helper answers the wizard for PostgreSQL first, then MariaDB.
* Executes from: not page-bound; runs from a local test workspace on the Node.js side.
*/
export async function createAgentWithDockerDatabases(command: string, cwd: string) {
await runInteractiveCommand(command, cwd, [
{
match: /configure.*database|add.*database/i,
reply: "y\n",
},
{
match: /docker.*new local container|manual.*external|external.*existing/i,
reply: "\r",
},
{
match: /postgresql|mariadb/i,
reply: "\r",
},
{
match: /another.*database|add.*another|configure.*another/i,
reply: "y\n",
},
{
match: /docker.*new local container|manual.*external|external.*existing/i,
reply: "\r",
},
{
match: /postgresql|mariadb/i,
reply: "\u001B[B\r",
},
{
match: /another.*database|add.*another|configure.*another/i,
reply: "n\n",
},
]);
}
-66
View File
@@ -1,66 +0,0 @@
import {Page} from "@playwright/test";
/**
* Locate an agent card in the list.
* Executes from: `/dashboard/agents`.
*/
export function get(page: Page, name: string) {
return page.locator('a[href^="/dashboard/agents"]').filter({hasText: name}).first();
}
/**
* Create an agent from the selected entrypoint.
*
* Available entrypoints:
* - `button`: the classic create button.
* - `emptyState`: the empty-state CTA.
* - `auto`: uses the classic create button and falls back to the empty-state CTA.
*
* Executes from: `/dashboard/agents`.
*/
export async function create(page: Page, entrypoint: "auto" | "emptyState" | "button" = "auto", agentName: string, description: string) {
if (entrypoint === "auto") {
const createButton = page.getByRole("button", {name: /Create Agent/i});
if (await createButton.isVisible()) await page.getByRole("button", {name: /Create Agent/i}).click();
await page.getByText("Create new Agent", {exact: true}).click();
} else if (entrypoint === "button") {
await page.getByRole("button", {name: /Create Agent/i}).click();
} else if (entrypoint === "emptyState") {
await page.getByText("Create new Agent", {exact: true}).click();
}
await page.getByLabel("Name").fill(agentName);
await page.getByLabel("Description").fill(description);
await page.getByRole("button", {name: "Create"}).click();
}
/**
* Edit an existing agent (name and description) from its details page.
*
* Executes from: `/dashboard/agents/[agentId]`.
*/
export async function edit(page: Page, currentName: string, updatedName: string, updatedDescription: string) {
await get(page, currentName).click();
await page
.getByRole("button", {name: /Delete Agent/i})
.locator("xpath=ancestor::div[1]/preceding-sibling::div[1]/*[1]")
.click();
await page.getByLabel("Name").fill(updatedName);
await page.getByLabel("Description").fill(updatedDescription);
await page.getByRole("button", {name: "Update"}).click();
}
/**
* Delete an agent from its details page.
* Executes from: `/dashboard/agents/[agentId]`.
*/
export async function remove(page: Page, name: string) {
await get(page, name).click();
await page.getByRole("button", {name: /Delete Agent/i}).click();
await page.getByRole("button", {name: "Delete", exact: true}).click();
}
-50
View File
@@ -1,50 +0,0 @@
import {Page} from "@playwright/test";
export type UserCredentials = {
username: string
email: string
password: string
}
export const users: Record<string, UserCredentials> = {
admin: {username: "Admin", email: "admin@example.com", password: "testPASS123456!"},
normal: {username: "John Doe", email: "john.doe@example.com", password: "testPASS123456!"},
}
/**
* Fill and submit the registration form.
*
* Executes from: `/register`.
*/
export async function register(page: Page, name: string, email: string, password: string, confirmPassword: string) {
await page.locator('input[name="name"]').fill(name)
await page.locator('input[name="email"]').fill(email)
await page.locator('input[name="password"]').fill(password)
await page.locator('input[name="confirmPassword"]').fill(confirmPassword)
await page.click('button[type="submit"]')
}
/**
* Fill and submit the login form.
*
* Executes from: `/login`.
*/
export async function login(page: Page, email: string, password: string) {
await page.locator('input[name="email"]').fill(email)
await page.locator('input[name="password"]').fill(password)
await page.locator('button:has-text("Login")').click()
}
/**
* Log out the current authenticated user.
*
* Executes from: any authenticated `/dashboard/**` page.
*/
export async function logout(page: Page) {
const profileButton = page.getByTestId('profile-dropdown')
await profileButton.first().click();
await page.getByRole("menuitem").filter({hasText: /Logout/i}).click();
}
-62
View File
@@ -1,62 +0,0 @@
const REQUIRED_E2E_ENV_VARS = [
"E2E_NOTIFICATION_SMTP_HOST",
"E2E_NOTIFICATION_SMTP_PORT",
"E2E_NOTIFICATION_SMTP_USER",
"E2E_NOTIFICATION_SMTP_PASSWORD",
"E2E_NOTIFICATION_SMTP_FROM",
"E2E_NOTIFICATION_SMTP_TO",
"E2E_NOTIFICATION_SLACK_WEBHOOK",
"E2E_NOTIFICATION_DISCORD_WEBHOOK",
"E2E_NOTIFICATION_TELEGRAM_BOT_TOKEN",
"E2E_NOTIFICATION_TELEGRAM_CHAT_ID",
"E2E_NOTIFICATION_TELEGRAM_TOPIC_ID",
"E2E_NOTIFICATION_GOTIFY_SERVER_URL",
"E2E_NOTIFICATION_GOTIFY_APP_TOKEN",
"E2E_NOTIFICATION_NTFY_TOPIC",
"E2E_NOTIFICATION_NTFY_SERVER_URL",
"E2E_NOTIFICATION_NTFY_TOKEN",
"E2E_NOTIFICATION_NTFY_USERNAME",
"E2E_NOTIFICATION_NTFY_PASSWORD",
"E2E_NOTIFICATION_WEBHOOK_URL",
"E2E_NOTIFICATION_WEBHOOK_SECRET_HEADER",
"E2E_NOTIFICATION_WEBHOOK_SECRET",
"E2E_STORAGE_AWS_S3_ENDPOINT_URL",
"E2E_STORAGE_AWS_S3_REGION",
"E2E_STORAGE_AWS_S3_ACCESS_KEY",
"E2E_STORAGE_AWS_S3_SECRET_KEY",
"E2E_STORAGE_AWS_S3_BUCKET_NAME",
"E2E_STORAGE_AWS_S3_PORT",
"E2E_STORAGE_R2_ENDPOINT_URL",
"E2E_STORAGE_R2_REGION",
"E2E_STORAGE_R2_ACCESS_KEY",
"E2E_STORAGE_R2_SECRET_KEY",
"E2E_STORAGE_R2_BUCKET_NAME",
"E2E_STORAGE_R2_PORT",
"E2E_STORAGE_GOOGLE_DRIVE_CLIENT_ID",
"E2E_STORAGE_GOOGLE_DRIVE_CLIENT_SECRET",
"E2E_STORAGE_GOOGLE_DRIVE_FOLDER_ID",
] as const;
function assertRequiredEnvVars() {
const missingVars = REQUIRED_E2E_ENV_VARS.filter((name) => {
const value = process.env[name]?.trim();
return !value;
});
if (missingVars.length > 0) {
throw new Error(
`Missing required environment variables:\n${missingVars.map((name) => `- ${name}`).join("\n")}`,
);
}
}
assertRequiredEnvVars();
export function getEnv(name: string): string {
const value = process.env[name]?.trim();
if (!value) {
throw new Error(`Missing required environment variable: ${name}`);
}
return value;
}
-94
View File
@@ -1,94 +0,0 @@
import {Page} from "@playwright/test";
/**
* Locate a notification channel card in the list.
*
* Executes from: `/dashboard/notifications/channels`.
*/
export function get(page: Page, channelName: string) {
return page.locator('div.block.transition-all.duration-200.rounded-xl', {
has: page.locator('h3', {hasText: channelName}),
}).first();
}
/**
* Open the edit dialog for an existing notification channel.
*
* Executes from: `/dashboard/notifications/channels`.
*/
export async function edit(page: Page, channelName: string) {
const card = get(page, channelName);
await card.locator("button").nth(1).click();
}
/**
* Delete an existing notification channel.
*
* Executes from: `/dashboard/notifications/channels`.
*/
export async function remove(page: Page, channelName: string) {
const card = get(page, channelName);
await card.locator("button").nth(2).click();
await page.getByRole("button", {name: "Delete"}).click();
}
/**
* Fill the notification channel creation form.
*
* Available entrypoints:
* - `button`: the classic add button.
* - `emptyState`: the empty-state CTA.
* - `auto` use the classic add button and falls back to the empty-state CTA.
*
* Executes from: `/dashboard/notifications/channels`.
*/
export async function create(
page: Page,
provider: "Discord" | "Gotify" | "ntfy.sh" | "Slack" | "Email" | "Telegram" | "Webhook",
channelName: string,
fillConfig: (page: Page) => Promise<void>,
entrypoint: "auto" | "emptyState" | "button" = "auto",
) {
if (entrypoint === "auto") {
const addButton = page.getByRole("button", {name: /Add notification channel/i});
if (await addButton.isVisible()) await page.getByRole("button", {name: /Add notification channel/i}).click();
else await page.getByRole("button", {name: /No notification channels configured yet/i}).click();
} else if (entrypoint === "button") {
await page.getByRole("button", {name: /Add notification channel/i}).click();
} else if (entrypoint === "emptyState") {
await page.getByRole("button", {name: /No notification channels configured yet/i}).click();
}
await page.getByText(provider, {exact: true}).click();
await page.getByLabel(/Channel Name/).fill(channelName);
await fillConfig(page);
}
/**
* Submit the notification channel creation form.
*
* Executes from: the add notification channel dialog opened from `/dashboard/notifications/channels`.
*/
export async function submit(page: Page) {
await page.getByRole("button", {name: "Add Channel"}).click();
}
/**
* Open the edit dialog for a notification channel and trigger the test action.
*
* Executes from: `/dashboard/notifications/channels`.
*/
export async function testFromEdit(page: Page, channelName: string) {
await edit(page, channelName);
await page.getByRole("button", {name: /Test Channel/i}).click();
}
/**
* Close the current notification channel dialog without saving.
*
* Executes from: the add or edit notification channel dialog.
*/
export async function cancel(page: Page) {
await page.getByRole("button", {name: "Cancel"}).click();
}
-59
View File
@@ -1,59 +0,0 @@
import {Page} from "@playwright/test";
/**
* Locate a project card in the list.
*
* Executes from: `/dashboard/projects`.
*/
export function get(page: Page, projectName: string) {
return page.locator('a[href^="/dashboard/projects/"]').filter({hasText: projectName}).first();
}
/**
* Create a project from the selected entrypoint.
*
* Available entrypoints:
* - `emptyState`: the empty-state CTA.
* - `button`: the classic create button.
*
* * Executes from: `/dashboard/projects`.
*/
export async function create(page: Page, entrypoint: "emptyState" | "button", projectName: string) {
if (entrypoint === "emptyState") {
await page.getByText("Create new Project", {exact: true}).click();
} else {
await page.getByRole("button", {name: /Create Project/i}).click();
}
await page.getByLabel("Name").fill(projectName);
await page.getByRole("button", {name: "Create"}).click();
}
/**
* Edit an existing project from its details page.
*
* Executes from: `/dashboard/projects/[projectId]`.
*/
export async function edit(page: Page, currentName: string, updatedName: string) {
await get(page, currentName).click();
await page
.getByRole("button", {name: /Delete Project/i})
.locator("xpath=ancestor::div[1]/preceding-sibling::div[1]/*[1]")
.click();
await page.getByLabel("Name").fill(updatedName);
await page.getByRole("button", {name: "Update"}).click();
}
/**
* Delete an existing project from its details page.
*
* Executes from: `/dashboard/projects/[projectId]`.
* */
export async function remove(page: Page, projectName: string) {
await get(page, projectName).click();
await page.getByRole("button", {name: /Delete Project/i}).click();
await page.getByRole("button", {name: "Delete", exact: true}).click();
}
-1
View File
@@ -1 +0,0 @@
export const LOCAL_STORAGE_PATH = "./e2e/local-storage.json";
-103
View File
@@ -1,103 +0,0 @@
import {Page} from "@playwright/test";
/**
* Locate a storage channel card in the channels list.
*
* Executes from: `/dashboard/storages/channels`.
*/
export function get(page: Page, channelName: string) {
return page.locator('div.block.transition-all.duration-200.rounded-xl', {
has: page.locator('h3', {hasText: channelName}),
}).first();
}
/**
* Fill the storage channel creation form.
*
* Available entrypoints:
* - `button`: the classic add button.
* - `emptyState`: the empty-state CTA.
* - `auto`: uses the classic add button and falls back to the empty-state CTA.
*
* Executes from: `/dashboard/storages/channels`.
*/
export async function create(
page: Page,
provider: "S3" | "Google Drive",
channelName: string,
fillConfig: (page: Page) => Promise<void>,
entrypoint: "auto" | "emptyState" | "button" = "auto",
) {
if (entrypoint === "auto") {
const addButton = page.getByRole("button", {name: /Add storage channel/i});
if (await addButton.isVisible()) await page.getByRole("button", {name: /Add storage channel/i}).click();
else await page.getByText("No storage channels configured yet", {exact: true}).click();
} else if (entrypoint === "button") {
await page.getByRole("button", {name: /Add storage channel/i}).click();
} else if (entrypoint === "emptyState") {
await page.getByText("No storage channels configured yet", {exact: true}).click();
}
await page.getByText(provider, {exact: true}).click();
await page.getByLabel(/Channel Name/).fill(channelName);
await fillConfig(page);
}
/**
* Open the edit dialog for an existing storage channel.
*
* Executes from: `/dashboard/storages/channels`.
*/
export async function edit(page: Page, channelName: string) {
const card = get(page, channelName);
await card.locator("button").nth(1).click();
}
/**
* Delete an existing storage channel.
*
* Executes from: `/dashboard/storages/channels`.
*/
export async function remove(page: Page, channelName: string) {
const card = get(page, channelName);
await card.locator("button").nth(2).click();
await page.getByRole("button", {name: "Delete"}).click();
}
/**
* Trigger the storage connection test in the current dialog.
*
* Executes from: the add or edit storage channel dialog opened from `/dashboard/storages/channels`.
*/
export async function testConnection(page: Page) {
await page.getByRole("button", {name: /Test Storage/i}).click();
}
/**
* Submit the storage channel creation form.
*
* Executes from: the add storage channel dialog opened from `/dashboard/storages/channels`.
*/
export async function submit(page: Page) {
await page.getByRole("button", {name: "Add Channel"}).click();
}
/**
* Open the edit dialog for a storage channel and trigger the test action.
*
* Executes from: `/dashboard/storages/channels`.
*/
export async function testFromEdit(page: Page, channelName: string) {
await edit(page, channelName);
await testConnection(page);
}
/**
* Close the current storage channel dialog without saving.
*
* Executes from: the add or edit storage channel dialog.
*/
export async function cancel(page: Page) {
await page.getByRole("button", {name: "Cancel"}).click();
}
-71
View File
@@ -1,71 +0,0 @@
import {expect, test} from "@playwright/test";
import {
cancel, create, get, remove, submit, testFromEdit,
} from "../helpers/notification";
import {getEnv} from "../helpers/env";
import {LOCAL_STORAGE_PATH} from "../helpers/session";
test.use({storageState: LOCAL_STORAGE_PATH});
const validChannelName = "Discord E2E Required";
const invalidChannelName = "Discord E2E Invalid";
// test.describe.serial("Valid channel", () => {
// test("Create and test a valid Discord channel", async ({page}) => {
// await page.goto("/dashboard/notifications/channels");
// await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
// await create(page, "Discord", validChannelName, async (page) => {
// await page.getByLabel(/Discord Webhook URL/).fill(getEnv("E2E_NOTIFICATION_DISCORD_WEBHOOK"));
// });
// await expect(page.getByRole("heading", {name: "Add Notification Channel"})).toBeVisible();
// await submit(page);
// await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
// await expect(get(page, validChannelName)).toBeVisible();
//
// await testFromEdit(page, validChannelName);
// await expect(page.getByRole("heading", {name: "Edit Notification Channel"})).toBeVisible();
// await expect(page.getByText("Sent to Discord")).toBeVisible();
// await cancel(page);
// await expect(page.getByRole("heading", {name: "Edit Notification Channel"})).toHaveCount(0);
// });
//
// test("Edit and test a valid Discord channel", async ({page}) => {
// await page.goto("/dashboard/notifications/channels");
// await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
// await expect(get(page, validChannelName)).toBeVisible();
// await testFromEdit(page, validChannelName);
// await expect(page.getByRole("heading", {name: "Edit Notification Channel"})).toBeVisible();
// await expect(page.getByText("Sent to Discord")).toBeVisible();
// await cancel(page);
// await expect(page.getByRole("heading", {name: "Edit Notification Channel"})).toHaveCount(0);
// });
// });
test.describe.serial("Invalid channel", () => {
test("Create and test invalid Discord channel", async ({page}) => {
await page.goto("/dashboard/notifications/channels");
await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
await create(page, "Discord", invalidChannelName, async (page) => {
await page.getByLabel(/Discord Webhook URL/).fill("https://discord.com/api/webhooks/123456789012345678/wrong-discord-webhook-token");
});
await expect(page.getByRole("heading", {name: "Add Notification Channel"})).toBeVisible();
await submit(page);
await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
await expect(get(page, invalidChannelName)).toBeVisible();
await testFromEdit(page, invalidChannelName);
await expect(page.getByRole("heading", {name: "Edit Notification Channel"})).toBeVisible();
await expect(page.getByText("An error occurred while testing the notification channel, check your configuration")).toBeVisible();
await cancel(page);
await expect(page.getByRole("heading", {name: "Edit Notification Channel"})).toHaveCount(0);
});
test("Delete invalid Discord channel", async ({page}) => {
await page.goto("/dashboard/notifications/channels");
await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
await expect(get(page, invalidChannelName)).toBeVisible();
await remove(page, invalidChannelName);
await expect(page.getByText("Notification channel has been successfully removed.")).toBeVisible();
await expect(page.getByText(invalidChannelName)).toHaveCount(0);
});
});
-66
View File
@@ -1,66 +0,0 @@
import {expect, test} from "@playwright/test";
import {
cancel, create, get, remove, submit, testFromEdit,
} from "../helpers/notification";
import {getEnv} from "../helpers/env";
import {LOCAL_STORAGE_PATH} from "../helpers/session";
test.use({storageState: LOCAL_STORAGE_PATH});
const validChannelName = "Gotify E2E Required";
const invalidChannelName = "Gotify E2E Invalid";
// test.describe.serial("Valid channel", () => {
// test("Create and test a valid Gotify channel", async ({page}) => {
// await page.goto("/dashboard/notifications/channels");
// await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
// await create(page, "Gotify", validChannelName, async (page) => {
// await page.getByLabel(/Gotify Server URL/).fill(getEnv("E2E_NOTIFICATION_GOTIFY_SERVER_URL"));
// await page.getByLabel(/Application Token/).fill(getEnv("E2E_NOTIFICATION_GOTIFY_APP_TOKEN"));
// });
// await expect(page.getByRole("heading", {name: "Add Notification Channel"})).toBeVisible();
// await submit(page);
// await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
// await expect(get(page, validChannelName)).toBeVisible();
//
// await testFromEdit(page, validChannelName);
// await expect(page.getByText("Sent to Gotify")).toBeVisible();
// await cancel(page);
// });
//
// test("Edit and test a valid Gotify channel", async ({page}) => {
// await page.goto("/dashboard/notifications/channels");
// await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
// await expect(get(page, validChannelName)).toBeVisible();
// await testFromEdit(page, validChannelName);
// await expect(page.getByText("Sent to Gotify")).toBeVisible();
// await cancel(page);
// });
// });
test.describe.serial("Invalid channel", () => {
test("Create and test invalid Gotify channel", async ({page}) => {
await page.goto("/dashboard/notifications/channels");
await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
await create(page, "Gotify", invalidChannelName, async (page) => {
await page.getByLabel(/Gotify Server URL/).fill(getEnv("E2E_NOTIFICATION_GOTIFY_SERVER_URL"));
await page.getByLabel(/Application Token/).fill("wrong-gotify-app-token");
});
await expect(page.getByRole("heading", {name: "Add Notification Channel"})).toBeVisible();
await submit(page);
await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
await expect(get(page, invalidChannelName)).toBeVisible();
await testFromEdit(page, invalidChannelName);
await expect(page.getByText("An error occurred while testing the notification channel, check your configuration")).toBeVisible();
await cancel(page);
});
test("Delete invalid Gotify channel", async ({page}) => {
await page.goto("/dashboard/notifications/channels");
await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
await expect(get(page, invalidChannelName)).toBeVisible();
await remove(page, invalidChannelName);
await expect(page.getByText("Notification channel has been successfully removed.")).toBeVisible();
await expect(page.getByText(invalidChannelName)).toHaveCount(0);
});
});
-73
View File
@@ -1,73 +0,0 @@
import {expect, test} from "@playwright/test";
import {
cancel, create, get, remove, submit, testFromEdit,
} from "../helpers/notification";
import {getEnv} from "../helpers/env";
import {LOCAL_STORAGE_PATH} from "../helpers/session";
test.use({storageState: LOCAL_STORAGE_PATH});
const requiredChannelName = "Ntfy E2E Required";
const optionalChannelName = "Ntfy E2E Optional";
const invalidChannelName = "Ntfy E2E Invalid";
// test.describe.serial("Valid channels", () => {
// test("Create and test a valid Ntfy channel with only required fields", async ({page}) => {
// await page.goto("/dashboard/notifications/channels");
// await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
// await create(page, "ntfy.sh", requiredChannelName, async (page) => {
// await page.getByLabel(/Topic Name/).fill(getEnv("E2E_NOTIFICATION_NTFY_TOPIC"));
// });
// await submit(page);
// await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
// await expect(get(page, requiredChannelName)).toBeVisible();
// await testFromEdit(page, requiredChannelName);
// await expect(page.getByText("Sent to ntfy")).toBeVisible();
// await cancel(page);
// });
//
// test("Create and test a valid Ntfy channel with optional fields", async ({page}) => {
// await page.goto("/dashboard/notifications/channels");
// await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
// await create(page, "ntfy.sh", optionalChannelName, async (page) => {
// await page.getByLabel(/Topic Name/).fill(getEnv("E2E_NOTIFICATION_NTFY_TOPIC"));
// await page.getByLabel(/^Server URL$/).fill(getEnv("E2E_NOTIFICATION_NTFY_SERVER_URL"));
// await page.getByLabel(/^Access Token$/).fill(getEnv("E2E_NOTIFICATION_NTFY_TOKEN"));
// await page.getByLabel(/^Basic Auth Username$/).fill(getEnv("E2E_NOTIFICATION_NTFY_USERNAME"));
// await page.getByLabel(/^Basic Auth Password$/).fill(getEnv("E2E_NOTIFICATION_NTFY_PASSWORD"));
// });
// await submit(page);
// await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
// await expect(get(page, optionalChannelName)).toBeVisible();
// await testFromEdit(page, optionalChannelName);
// await expect(page.getByText("Sent to ntfy")).toBeVisible();
// await cancel(page);
// });
// });
test.describe.serial("Invalid channel", () => {
test("Create and test invalid Ntfy channel", async ({page}) => {
await page.goto("/dashboard/notifications/channels");
await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
await create(page, "ntfy.sh", invalidChannelName, async (page) => {
await page.getByLabel(/Topic Name/).fill("wrong-ntfy-topic");
await page.getByLabel(/^Server URL$/).fill(getEnv("E2E_NOTIFICATION_NTFY_SERVER_URL"));
await page.getByLabel(/^Access Token$/).fill("wrong-ntfy-token");
});
await submit(page);
await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
await expect(get(page, invalidChannelName)).toBeVisible();
await testFromEdit(page, invalidChannelName);
await expect(page.getByText("An error occurred while testing the notification channel, check your configuration")).toBeVisible();
await cancel(page);
});
test("Delete invalid Ntfy channel", async ({page}) => {
await page.goto("/dashboard/notifications/channels");
await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
await expect(get(page, invalidChannelName)).toBeVisible();
await remove(page, invalidChannelName);
await expect(page.getByText("Notification channel has been successfully removed.")).toBeVisible();
await expect(page.getByText(invalidChannelName)).toHaveCount(0);
});
});
-61
View File
@@ -1,61 +0,0 @@
import {expect, test} from "@playwright/test";
import {
cancel, create, get, remove, submit, testFromEdit,
} from "../helpers/notification";
import {getEnv} from "../helpers/env";
import {LOCAL_STORAGE_PATH} from "../helpers/session";
test.use({storageState: LOCAL_STORAGE_PATH});
const validChannelName = "Slack E2E Required";
const invalidChannelName = "Slack E2E Invalid";
// test.describe.serial("Valid channel", () => {
// test("Create and test a valid Slack channel", async ({page}) => {
// await page.goto("/dashboard/notifications/channels");
// await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
// await create(page, "Slack", validChannelName, async (page) => {
// await page.getByLabel(/Slack Webhook URL/).fill(getEnv("E2E_NOTIFICATION_SLACK_WEBHOOK"));
// });
// await submit(page);
// await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
// await expect(get(page, validChannelName)).toBeVisible();
// await testFromEdit(page, validChannelName);
// await expect(page.getByText("Sent to Slack")).toBeVisible();
// await cancel(page);
// });
//
// test("Edit and test a valid Slack E2E channel", async ({page}) => {
// await page.goto("/dashboard/notifications/channels");
// await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
// await expect(get(page, validChannelName)).toBeVisible();
// await testFromEdit(page, validChannelName);
// await expect(page.getByText("Sent to Slack")).toBeVisible();
// await cancel(page);
// });
// });
test.describe.serial("Invalid channel", () => {
test("Create and test invalid Slack channel", async ({page}) => {
await page.goto("/dashboard/notifications/channels");
await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
await create(page, "Slack", invalidChannelName, async (page) => {
await page.getByLabel(/Slack Webhook URL/).fill("https://WRONG_SLACK_WEBHOOK");
});
await submit(page);
await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
await expect(get(page, invalidChannelName)).toBeVisible();
await testFromEdit(page, invalidChannelName);
await expect(page.getByText("An error occurred while testing the notification channel, check your configuration")).toBeVisible();
await cancel(page);
});
test("Delete invalid Slack channel", async ({page}) => {
await page.goto("/dashboard/notifications/channels");
await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
await expect(get(page, invalidChannelName)).toBeVisible();
await remove(page, invalidChannelName);
await expect(page.getByText("Notification channel has been successfully removed.")).toBeVisible();
await expect(page.getByText(invalidChannelName)).toHaveCount(0);
});
});
-72
View File
@@ -1,72 +0,0 @@
import {expect, test} from "@playwright/test";
import {getEnv} from "../helpers/env";
import {
cancel, create, get, remove, submit, testFromEdit,
} from "../helpers/notification";
import {LOCAL_STORAGE_PATH} from "../helpers/session";
test.use({storageState: LOCAL_STORAGE_PATH});
const validChannelName = "SMTP E2E Required";
const invalidChannelName = "SMTP E2E Invalid";
const successMessage = `Email sent: ${getEnv("E2E_NOTIFICATION_SMTP_TO")}`;
// test.describe.serial("Valid channel", () => {
// test("Create and test a valid SMTP channel", async ({page}) => {
// await page.goto("/dashboard/notifications/channels");
// await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
// await create(page, "Email", validChannelName, async (page) => {
// await page.getByLabel(/SMTP Host/).fill(getEnv("E2E_NOTIFICATION_SMTP_HOST"));
// await page.getByLabel(/SMTP Port/).fill(getEnv("E2E_NOTIFICATION_SMTP_PORT"));
// await page.getByLabel(/Username/).fill(getEnv("E2E_NOTIFICATION_SMTP_USER"));
// await page.getByLabel(/Password/).fill(getEnv("E2E_NOTIFICATION_SMTP_PASSWORD"));
// await page.getByLabel(/From Email/).fill(getEnv("E2E_NOTIFICATION_SMTP_FROM"));
// await page.getByLabel(/To Email/).fill(getEnv("E2E_NOTIFICATION_SMTP_TO"));
// });
// await submit(page);
// await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
// await expect(get(page, validChannelName)).toBeVisible();
// await testFromEdit(page, validChannelName);
// await expect(page.getByText(successMessage)).toBeVisible();
// await cancel(page);
// });
//
// test("Edit and test a valid SMTP channel", async ({page}) => {
// await page.goto("/dashboard/notifications/channels");
// await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
// await expect(get(page, validChannelName)).toBeVisible();
// await testFromEdit(page, validChannelName);
// await expect(page.getByText(successMessage)).toBeVisible();
// await cancel(page);
// });
// });
test.describe.serial("Invalid channel", () => {
test("Create and test invalid SMTP E2E channel", async ({page}) => {
await page.goto("/dashboard/notifications/channels");
await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
await create(page, "Email", invalidChannelName, async (page) => {
await page.getByLabel(/SMTP Host/).fill("smtp.invalid");
await page.getByLabel(/SMTP Port/).fill(getEnv("E2E_NOTIFICATION_SMTP_PORT"));
await page.getByLabel(/Username/).fill(getEnv("E2E_NOTIFICATION_SMTP_USER"));
await page.getByLabel(/Password/).fill("wrong-password");
await page.getByLabel(/From Email/).fill(getEnv("E2E_NOTIFICATION_SMTP_FROM"));
await page.getByLabel(/To Email/).fill(getEnv("E2E_NOTIFICATION_SMTP_TO"));
});
await submit(page);
await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
await expect(get(page, invalidChannelName)).toBeVisible();
await testFromEdit(page, invalidChannelName);
await expect(page.getByText("An error occurred while testing the notification channel, check your configuration")).toBeVisible();
await cancel(page);
});
test("Delete invalid SMTP channel", async ({page}) => {
await page.goto("/dashboard/notifications/channels");
await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
await expect(get(page, invalidChannelName)).toBeVisible();
await remove(page, invalidChannelName);
await expect(page.getByText("Notification channel has been successfully removed.")).toBeVisible();
await expect(page.getByText(invalidChannelName)).toHaveCount(0);
});
});
-72
View File
@@ -1,72 +0,0 @@
import {expect, test} from "@playwright/test";
import {
cancel, create, get, remove, submit, testFromEdit,
} from "../helpers/notification";
import {getEnv} from "../helpers/env";
import {LOCAL_STORAGE_PATH} from "../helpers/session";
test.use({storageState: LOCAL_STORAGE_PATH});
const requiredChannelName = "Telegram E2E Required";
const optionalChannelName = "Telegram E2E Optional";
const invalidChannelName = "Telegram E2E Invalid";
// test.describe.serial("Valid channels", () => {
// test("Create and test a valid Telegram channel", async ({page}) => {
// await page.goto("/dashboard/notifications/channels");
// await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
// await create(page, "Telegram", requiredChannelName, async (page) => {
// await page.getByLabel(/Telegram Bot Token/).fill(getEnv("E2E_NOTIFICATION_TELEGRAM_BOT_TOKEN"));
// await page.getByLabel(/Telegram Chat ID/).fill(getEnv("E2E_NOTIFICATION_TELEGRAM_CHAT_ID"));
// });
// await submit(page);
// await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
// await expect(get(page, requiredChannelName)).toBeVisible();
// await testFromEdit(page, requiredChannelName);
// await expect(page.getByText("Sent to Telegram")).toBeVisible();
// await cancel(page);
// });
//
// test("Create and test a valid Telegram channel with Topic ID", async ({page}) => {
// await page.goto("/dashboard/notifications/channels");
// await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
// await create(page, "Telegram", optionalChannelName, async (page) => {
// await page.getByLabel(/Telegram Bot Token/).fill(getEnv("E2E_NOTIFICATION_TELEGRAM_BOT_TOKEN"));
// await page.getByLabel(/Telegram Chat ID/).fill(getEnv("E2E_NOTIFICATION_TELEGRAM_CHAT_ID"));
// await page.getByLabel(/Telegram Topic ID/).fill(getEnv("E2E_NOTIFICATION_TELEGRAM_TOPIC_ID"));
// });
// await submit(page);
// await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
// await expect(get(page, optionalChannelName)).toBeVisible();
// await testFromEdit(page, optionalChannelName);
// await expect(page.getByText("Sent to Telegram")).toBeVisible();
// await cancel(page);
// });
// });
test.describe.serial("Invalid channel", () => {
test("Create and test invalid Telegram channel", async ({page}) => {
await page.goto("/dashboard/notifications/channels");
await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
await create(page, "Telegram", invalidChannelName, async (page) => {
await page.getByLabel(/Telegram Bot Token/).fill("wrong-telegram-bot-token");
await page.getByLabel(/Telegram Chat ID/).fill(getEnv("E2E_NOTIFICATION_TELEGRAM_CHAT_ID"));
await page.getByLabel(/Telegram Topic ID/).fill(getEnv("E2E_NOTIFICATION_TELEGRAM_TOPIC_ID"));
});
await submit(page);
await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
await expect(get(page, invalidChannelName)).toBeVisible();
await testFromEdit(page, invalidChannelName);
await expect(page.getByText("An error occurred while testing the notification channel, check your configuration")).toBeVisible();
await cancel(page);
});
test("Delete invalid Telegram channel", async ({page}) => {
await page.goto("/dashboard/notifications/channels");
await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
await expect(get(page, invalidChannelName)).toBeVisible();
await remove(page, invalidChannelName);
await expect(page.getByText("Notification channel has been successfully removed.")).toBeVisible();
await expect(page.getByText(invalidChannelName)).toHaveCount(0);
});
});
-71
View File
@@ -1,71 +0,0 @@
import {expect, test} from "@playwright/test";
import {
cancel, create, get, remove, submit, testFromEdit,
} from "../helpers/notification";
import {getEnv} from "../helpers/env";
import {LOCAL_STORAGE_PATH} from "../helpers/session";
test.use({storageState: LOCAL_STORAGE_PATH});
const requiredChannelName = "Webhook E2E Required";
const optionalChannelName = "Webhook E2E Optional";
const invalidChannelName = "Webhook E2E Invalid";
// test.describe.serial("Valid channels", () => {
// test("Create and test a valid Webhook channel", async ({page}) => {
// await page.goto("/dashboard/notifications/channels");
// await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
// await create(page, "Webhook", requiredChannelName, async (page) => {
// await page.getByLabel(/Webhook URL/).fill(getEnv("E2E_NOTIFICATION_WEBHOOK_URL"));
// });
// await submit(page);
// await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
// await expect(get(page, requiredChannelName)).toBeVisible();
// await testFromEdit(page, requiredChannelName);
// await expect(page.getByText("Sent to Webhook")).toBeVisible();
// await cancel(page);
// });
//
// test("Create and test a valid Webhook channel with optional header", async ({page}) => {
// await page.goto("/dashboard/notifications/channels");
// await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
// await create(page, "Webhook", optionalChannelName, async (page) => {
// await page.getByLabel(/Webhook URL/).fill(getEnv("E2E_NOTIFICATION_WEBHOOK_URL"));
// await page.getByLabel(/^Header Name$/).fill(getEnv("E2E_NOTIFICATION_WEBHOOK_SECRET_HEADER"));
// await page.getByLabel(/^Secret Value$/).fill(getEnv("E2E_NOTIFICATION_WEBHOOK_SECRET"));
// });
// await submit(page);
// await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
// await expect(get(page, optionalChannelName)).toBeVisible();
// await testFromEdit(page, optionalChannelName);
// await expect(page.getByText("Sent to Webhook")).toBeVisible();
// await cancel(page);
// });
// });
test.describe.serial("Invalid channel", () => {
test("Create and test invalid Webhook channel", async ({page}) => {
await page.goto("/dashboard/notifications/channels");
await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
await create(page, "Webhook", invalidChannelName, async (page) => {
await page.getByLabel(/Webhook URL/).fill("https://webhook.example.com/api/wrong-webhook");
await page.getByLabel(/^Header Name$/).fill(getEnv("E2E_NOTIFICATION_WEBHOOK_SECRET_HEADER"));
await page.getByLabel(/^Secret Value$/).fill("wrong-webhook-secret");
});
await submit(page);
await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
await expect(get(page, invalidChannelName)).toBeVisible();
await testFromEdit(page, invalidChannelName);
await expect(page.getByText("An error occurred while testing the notification channel, check your configuration")).toBeVisible();
await cancel(page);
});
test("Delete invalid Webhook E2E channel", async ({page}) => {
await page.goto("/dashboard/notifications/channels");
await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
await expect(get(page, invalidChannelName)).toBeVisible();
await remove(page, invalidChannelName);
await expect(page.getByText("Notification channel has been successfully removed.")).toBeVisible();
await expect(page.getByText(invalidChannelName)).toHaveCount(0);
});
});
-59
View File
@@ -1,59 +0,0 @@
import {expect, test} from "@playwright/test";
import {create, edit, get, remove} from "./helpers/project";
import {LOCAL_STORAGE_PATH} from "./helpers/session";
test.use({storageState: LOCAL_STORAGE_PATH});
const project = {
emptyStateName: "Project A",
buttonName: "Project B",
updatedButtonName: "Project B Updated",
};
test.describe.serial(() => {
test("Create a project from empty state", async ({page}) => {
await page.goto("/dashboard/projects");
await expect(page.getByRole("heading", {name: "Projects"})).toBeVisible();
await expect(page.getByText("Create new Project", {exact: true})).toBeVisible();
await create(page, "emptyState", project.emptyStateName);
await expect(page.getByText("Project has been successfully created.")).toBeVisible();
await expect(get(page, project.emptyStateName)).toBeVisible();
await expect(page.getByText("Create new Project", {exact: true})).toHaveCount(0);
});
test("Create a project from classic button", async ({page}) => {
await page.goto("/dashboard/projects");
await expect(page.getByRole("heading", {name: "Projects"})).toBeVisible();
await expect(get(page, project.emptyStateName)).toBeVisible();
await create(page, "button", project.buttonName);
await expect(page.getByText("Project has been successfully created.")).toBeVisible();
await expect(get(page, project.buttonName)).toBeVisible();
});
test("Edit the second created project", async ({page}) => {
await page.goto("/dashboard/projects");
await expect(page.getByRole("heading", {name: "Projects"})).toBeVisible();
await expect(get(page, project.buttonName)).toBeVisible();
await edit(page, project.buttonName, project.updatedButtonName);
await expect(page.getByText("Project has been successfully updated.")).toBeVisible();
await expect(page.getByText(project.updatedButtonName, {exact: true})).toBeVisible();
});
test("Delete the second created project", async ({page}) => {
await page.goto("/dashboard/projects");
await expect(page.getByRole("heading", {name: "Projects"})).toBeVisible();
await expect(get(page, project.updatedButtonName)).toBeVisible();
await remove(page, project.updatedButtonName);
await expect(page).toHaveURL("/dashboard/projects");
await expect(page.getByText("Projects has been successfully archived.")).toBeVisible();
await expect(page.getByText(project.updatedButtonName)).toHaveCount(0);
});
});
-13
View File
@@ -1,13 +0,0 @@
import {test} from "@playwright/test";
import fs from "fs";
import {LOCAL_STORAGE_PATH} from "./helpers/session";
test.describe(() => {
test.beforeAll(async () => {
if (fs.existsSync(LOCAL_STORAGE_PATH)) fs.unlinkSync(LOCAL_STORAGE_PATH);
fs.writeFileSync(LOCAL_STORAGE_PATH, JSON.stringify({}));
});
test("Prepare shared storage state", async () => {
});
});
View File
View File
-60
View File
@@ -1,60 +0,0 @@
import {expect, test} from "@playwright/test";
import {getEnv} from "../helpers/env";
import {LOCAL_STORAGE_PATH} from "../helpers/session";
import {
cancel, create, get, remove, submit, testConnection, testFromEdit,
} from "../helpers/storage";
test.use({storageState: LOCAL_STORAGE_PATH});
const requiredChannelName = "Google Drive E2E Required";
const invalidChannelName = "Google Drive E2E Invalid";
// test.describe.serial("Valid channel", () => {
// test("Create and test a valid Google Drive channel", async ({page}) => {
// await page.goto("/dashboard/storages/channels");
// await expect(page.getByRole("heading", {name: "Storage channels"})).toBeVisible();
// await create(page, "Google Drive", requiredChannelName, async (page) => {
// await page.getByLabel(/Client ID/).fill(getEnv("E2E_STORAGE_GOOGLE_DRIVE_CLIENT_ID"));
// await page.getByLabel(/Client Secret/).fill(getEnv("E2E_STORAGE_GOOGLE_DRIVE_CLIENT_SECRET"));
// await page.getByLabel(/Folder ID/).fill(getEnv("E2E_STORAGE_GOOGLE_DRIVE_FOLDER_ID"));
// });
// await expect(page.getByRole("heading", {name: "Add Storage Channel"})).toBeVisible();
// await testConnection(page);
// await expect(page.getByText("Successfully connected to storage channel")).toBeVisible();
// await submit(page);
// await expect(page.getByText("Storage channel has been successfully created.")).toBeVisible();
// await expect(get(page, requiredChannelName)).toBeVisible();
//
// await testFromEdit(page, requiredChannelName);
// await expect(page.getByRole("heading", {name: "Edit Storage Channel"})).toBeVisible();
// await expect(page.getByText("Successfully connected to storage channel")).toBeVisible();
// await cancel(page);
// });
// });
test.describe.serial("Invalid channel", () => {
test("Create and test invalid Google Drive channel", async ({page}) => {
await page.goto("/dashboard/storages/channels");
await expect(page.getByRole("heading", {name: "Storage channels"})).toBeVisible();
await create(page, "Google Drive", invalidChannelName, async (page) => {
await page.getByLabel(/Client ID/).fill(getEnv("E2E_STORAGE_GOOGLE_DRIVE_CLIENT_ID"));
await page.getByLabel(/Client Secret/).fill("wrong-google-drive-client-secret");
await page.getByLabel(/Folder ID/).fill(getEnv("E2E_STORAGE_GOOGLE_DRIVE_FOLDER_ID"));
});
await testConnection(page);
await expect(page.getByText("An error occurred while testing the storage channel")).toBeVisible();
await submit(page);
await expect(page.getByText("Storage channel has been successfully created.")).toBeVisible();
await expect(get(page, invalidChannelName)).toBeVisible();
});
test("Delete invalid Google Drive E2E channel", async ({page}) => {
await page.goto("/dashboard/storages/channels");
await expect(page.getByRole("heading", {name: "Storage channels"})).toBeVisible();
await expect(get(page, invalidChannelName)).toBeVisible();
await remove(page, invalidChannelName);
await expect(page.getByText("Storage channel has been successfully removed.")).toBeVisible();
await expect(page.getByText(invalidChannelName)).toHaveCount(0);
});
});

Some files were not shown because too many files have changed in this diff Show More