Repository: Portabase/portabase Branch: main Commit: dc80d1d752bb Files: 665 Total size: 4.5 MB Directory structure: gitextract_jp3gfjy6/ ├── .dockerignore ├── .eslintrc.json ├── .github/ │ ├── CODE_OF_CONDUCT.md │ ├── CONTRIBUTING.md │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.md │ │ └── feature_request.md │ ├── SECURITY.md │ └── workflows/ │ ├── discord.yml │ ├── docker.yml │ ├── e2e.yml │ ├── helm.yml │ ├── release-candidate.yml │ ├── release.yml │ └── security.yml ├── .gitignore ├── .gitleaks.toml ├── .pre-commit-config.yaml ├── .release-it.json ├── CITATION.cff ├── LICENSE ├── Makefile ├── README.md ├── app/ │ ├── (auth)/ │ │ ├── forgot-password/ │ │ │ └── page.tsx │ │ ├── guard/ │ │ │ └── page.tsx │ │ ├── layout.tsx │ │ ├── login/ │ │ │ └── page.tsx │ │ ├── register/ │ │ │ └── page.tsx │ │ └── reset-password/ │ │ └── page.tsx │ ├── (customer)/ │ │ └── dashboard/ │ │ ├── (admin)/ │ │ │ ├── admin/ │ │ │ │ ├── organizations/ │ │ │ │ │ ├── [organizationId]/ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ └── page.tsx │ │ │ │ ├── settings/ │ │ │ │ │ └── page.tsx │ │ │ │ └── users/ │ │ │ │ └── page.tsx │ │ │ ├── agents/ │ │ │ │ ├── [agentId]/ │ │ │ │ │ └── page.tsx │ │ │ │ └── page.tsx │ │ │ ├── layout.tsx │ │ │ ├── notifications/ │ │ │ │ ├── channels/ │ │ │ │ │ └── page.tsx │ │ │ │ └── logs/ │ │ │ │ └── page.tsx │ │ │ └── storages/ │ │ │ └── channels/ │ │ │ └── page.tsx │ │ ├── (organization)/ │ │ │ ├── migration/ │ │ │ │ └── page.tsx │ │ │ ├── projects/ │ │ │ │ ├── [projectId]/ │ │ │ │ │ ├── database/ │ │ │ │ │ │ └── [databaseId]/ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ └── page.tsx │ │ │ │ └── page.tsx │ │ │ ├── settings/ │ │ │ │ ├── agents/ │ │ │ │ │ ├── [agentId]/ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ └── page.tsx │ │ │ │ └── page.tsx │ │ │ └── statistics/ │ │ │ └── page.tsx │ │ ├── home/ │ │ │ └── page.tsx │ │ ├── layout.tsx │ │ └── loading.tsx │ ├── (landing)/ │ │ ├── home/ │ │ │ └── page.tsx │ │ └── page.tsx │ ├── api/ │ │ ├── agent/ │ │ │ └── [agentId]/ │ │ │ ├── backup/ │ │ │ │ ├── helpers.ts │ │ │ │ ├── route.ts │ │ │ │ └── upload/ │ │ │ │ ├── init/ │ │ │ │ │ └── route.ts │ │ │ │ └── status/ │ │ │ │ └── route.ts │ │ │ ├── restore/ │ │ │ │ └── route.ts │ │ │ └── status/ │ │ │ ├── helpers.ts │ │ │ └── route.ts │ │ ├── auth/ │ │ │ └── [...all]/ │ │ │ └── route.ts │ │ ├── config/ │ │ │ └── route.ts │ │ ├── events/ │ │ │ └── route.ts │ │ ├── files/ │ │ │ ├── backups/ │ │ │ │ └── route.ts │ │ │ └── images/ │ │ │ └── [fileName]/ │ │ │ └── route.ts │ │ ├── google/ │ │ │ └── drive/ │ │ │ └── callback/ │ │ │ └── route.ts │ │ └── tus/ │ │ └── hooks/ │ │ └── route.ts │ ├── error/ │ │ └── page.tsx │ ├── globals.css │ ├── layout.tsx │ ├── manifest.json │ ├── not-found.tsx │ └── providers.tsx ├── components.json ├── docker/ │ ├── dockerfile/ │ │ └── Dockerfile │ ├── entrypoints/ │ │ ├── app-dev-entrypoint.sh │ │ └── app-prod-entrypoint.sh │ └── nginx/ │ └── nginx.conf ├── docker-compose.e2e.yml ├── docker-compose.func.yml ├── docker-compose.prod.yml ├── docker-compose.yml ├── drizzle.config.ts ├── e2e/ │ ├── access-management.spec.ts │ ├── agent.spec.ts │ ├── auth.spec.ts │ ├── cleanup.spec.ts │ ├── helpers/ │ │ ├── access-management.ts │ │ ├── agent-cli.ts │ │ ├── agent.ts │ │ ├── auth.ts │ │ ├── env.ts │ │ ├── notification.ts │ │ ├── project.ts │ │ ├── session.ts │ │ └── storage.ts │ ├── notification/ │ │ ├── discord.spec.ts │ │ ├── gotify.spec.ts │ │ ├── ntfy.spec.ts │ │ ├── slack.spec.ts │ │ ├── smtp.spec.ts │ │ ├── teams.spec.ts │ │ ├── telegram.spec.ts │ │ └── webhook.spec.ts │ ├── project.spec.ts │ ├── setup.spec.ts │ └── storage/ │ ├── azure.spec.ts │ ├── gcs.spec.ts │ ├── google-drive.spec.ts │ └── s3.spec.ts ├── eslint.config.mjs ├── helm/ │ ├── .helmignore │ ├── Chart.yaml │ ├── README.md │ ├── templates/ │ │ ├── deployment.yaml │ │ ├── pvc.yaml │ │ └── service.yaml │ └── values.yaml ├── instrumentation.ts ├── next.config.ts ├── package.json ├── playwright.config.ts ├── pnpm-workspace.yaml ├── portabase.config.ts ├── postcss.config.mjs ├── proxy.ts ├── seeds/ │ └── keycloak/ │ └── master-realm.json ├── src/ │ ├── components/ │ │ ├── emails/ │ │ │ ├── auth/ │ │ │ │ ├── email-forgot-password.tsx │ │ │ │ ├── email-new-login.tsx │ │ │ │ └── email-verification.tsx │ │ │ ├── email-create-user.tsx │ │ │ ├── email-layout.tsx │ │ │ ├── email-notification.tsx │ │ │ ├── email-settings-test.tsx │ │ │ └── email-text.tsx │ │ ├── layout.tsx │ │ ├── ui/ │ │ │ ├── accordion.tsx │ │ │ ├── alert-dialog.tsx │ │ │ ├── alert.tsx │ │ │ ├── aspect-ratio.tsx │ │ │ ├── avatar.tsx │ │ │ ├── badge.tsx │ │ │ ├── breadcrumb.tsx │ │ │ ├── button.tsx │ │ │ ├── calendar.tsx │ │ │ ├── card.tsx │ │ │ ├── carousel.tsx │ │ │ ├── chart.tsx │ │ │ ├── checkbox.tsx │ │ │ ├── collapsible.tsx │ │ │ ├── command.tsx │ │ │ ├── context-menu.tsx │ │ │ ├── dialog.tsx │ │ │ ├── drawer.tsx │ │ │ ├── dropdown-menu.tsx │ │ │ ├── dropzone.tsx │ │ │ ├── form.tsx │ │ │ ├── github-button.tsx │ │ │ ├── hover-card.tsx │ │ │ ├── input-otp.tsx │ │ │ ├── input.tsx │ │ │ ├── label.tsx │ │ │ ├── menubar.tsx │ │ │ ├── navigation-menu.tsx │ │ │ ├── pagination.tsx │ │ │ ├── password-input-indicator.tsx │ │ │ ├── password-input.tsx │ │ │ ├── popover.tsx │ │ │ ├── progress.tsx │ │ │ ├── radio-group.tsx │ │ │ ├── resizable.tsx │ │ │ ├── scroll-area.tsx │ │ │ ├── search-input.tsx │ │ │ ├── select.tsx │ │ │ ├── separator.tsx │ │ │ ├── sheet.tsx │ │ │ ├── sidebar.tsx │ │ │ ├── skeleton.tsx │ │ │ ├── slider.tsx │ │ │ ├── sliding-number.tsx │ │ │ ├── sonner.tsx │ │ │ ├── switch.tsx │ │ │ ├── table.tsx │ │ │ ├── tabs.tsx │ │ │ ├── textarea.tsx │ │ │ ├── toast.tsx │ │ │ ├── toaster.tsx │ │ │ ├── toggle-group.tsx │ │ │ ├── toggle.tsx │ │ │ └── tooltip.tsx │ │ └── wrappers/ │ │ ├── auth/ │ │ │ ├── auth-logo-section.tsx │ │ │ ├── guard/ │ │ │ │ └── guard-form.tsx │ │ │ ├── login/ │ │ │ │ ├── forgot-password-form/ │ │ │ │ │ ├── forgot-password-form.schema.ts │ │ │ │ │ ├── forgot-password-form.tsx │ │ │ │ │ └── forgot-password.actions.ts │ │ │ │ ├── login-form/ │ │ │ │ │ ├── login-form.schema.ts │ │ │ │ │ └── login-form.tsx │ │ │ │ └── reset-password-form/ │ │ │ │ ├── reset-password-form.action.ts │ │ │ │ ├── reset-password-form.schema.ts │ │ │ │ └── reset-password-form.tsx │ │ │ ├── register/ │ │ │ │ └── register-form/ │ │ │ │ ├── register-form.schema.ts │ │ │ │ └── register-form.tsx │ │ │ ├── reset-password/ │ │ │ │ ├── reset-password-form.tsx │ │ │ │ ├── reset-password-schema.ts │ │ │ │ └── reset-password-section.tsx │ │ │ └── social-buttons.tsx │ │ ├── common/ │ │ │ ├── bread-crumbs/ │ │ │ │ └── bread-crumbs.tsx │ │ │ ├── button/ │ │ │ │ ├── back-button.tsx │ │ │ │ ├── button-with-confirm.tsx │ │ │ │ ├── button-with-loading.tsx │ │ │ │ └── copy-button.tsx │ │ │ ├── cards-with-pagination.tsx │ │ │ ├── code-snippet.tsx │ │ │ ├── combobox.tsx │ │ │ ├── connection-indicator.tsx │ │ │ ├── console-silencer.tsx │ │ │ ├── day-time-picker.tsx │ │ │ ├── dialog.tsx │ │ │ ├── dropzone/ │ │ │ │ └── dropzone-file.tsx │ │ │ ├── empty-state-placeholder.tsx │ │ │ ├── error-layout.tsx │ │ │ ├── file-uploader.tsx │ │ │ ├── github/ │ │ │ │ └── github-button.tsx │ │ │ ├── loading/ │ │ │ │ └── loading-spinner.tsx │ │ │ ├── multiselect/ │ │ │ │ └── multi-select.tsx │ │ │ ├── pagination/ │ │ │ │ ├── pagination-indexes.tsx │ │ │ │ ├── pagination-navigation.tsx │ │ │ │ └── pagination-size.tsx │ │ │ ├── provider-switch.tsx │ │ │ ├── status-badge.tsx │ │ │ ├── table/ │ │ │ │ ├── data-table.tsx │ │ │ │ ├── filters.tsx │ │ │ │ ├── table-pagination-navigation.tsx │ │ │ │ ├── table-pagination-size.tsx │ │ │ │ ├── table-pagination.tsx │ │ │ │ └── table-sort-button.tsx │ │ │ └── tooltip-custom.tsx │ │ └── dashboard/ │ │ ├── admin/ │ │ │ ├── channels/ │ │ │ │ ├── channel/ │ │ │ │ │ ├── channel-add-edit-modal.tsx │ │ │ │ │ ├── channel-card/ │ │ │ │ │ │ ├── button-delete-channel.tsx │ │ │ │ │ │ ├── button-edit-channel.tsx │ │ │ │ │ │ └── channel-card.tsx │ │ │ │ │ └── channel-form/ │ │ │ │ │ ├── channel-form.schema.ts │ │ │ │ │ ├── channel-form.tsx │ │ │ │ │ ├── channel-test-button.tsx │ │ │ │ │ └── providers/ │ │ │ │ │ ├── notifications/ │ │ │ │ │ │ ├── action.ts │ │ │ │ │ │ └── forms/ │ │ │ │ │ │ ├── discord.form.tsx │ │ │ │ │ │ ├── discord.schema.ts │ │ │ │ │ │ ├── gotify.form.tsx │ │ │ │ │ │ ├── gotify.schema.ts │ │ │ │ │ │ ├── ntfy.form.tsx │ │ │ │ │ │ ├── ntfy.schema.ts │ │ │ │ │ │ ├── slack.form.tsx │ │ │ │ │ │ ├── slack.schema.ts │ │ │ │ │ │ ├── smtp.form.tsx │ │ │ │ │ │ ├── smtp.schema.ts │ │ │ │ │ │ ├── telegram.form.tsx │ │ │ │ │ │ ├── telegram.schema.ts │ │ │ │ │ │ ├── webhook.form.tsx │ │ │ │ │ │ └── webhook.schema.ts │ │ │ │ │ └── storages/ │ │ │ │ │ ├── action.ts │ │ │ │ │ └── forms/ │ │ │ │ │ ├── google-drive/ │ │ │ │ │ │ └── helpers.ts │ │ │ │ │ ├── google-drive.form.tsx │ │ │ │ │ ├── google-drive.schema.ts │ │ │ │ │ ├── local.schema.ts │ │ │ │ │ ├── s3.form.tsx │ │ │ │ │ └── s3.schema.ts │ │ │ │ ├── channels-section.tsx │ │ │ │ ├── helpers/ │ │ │ │ │ ├── common.tsx │ │ │ │ │ ├── notification.tsx │ │ │ │ │ └── storage.tsx │ │ │ │ └── organization/ │ │ │ │ ├── channels-organization-form.tsx │ │ │ │ ├── channels-organization.action.ts │ │ │ │ └── channels-organization.schema.ts │ │ │ ├── notifications/ │ │ │ │ └── logs/ │ │ │ │ ├── columns.tsx │ │ │ │ ├── notification-log-modal.tsx │ │ │ │ └── notification-logs-list.tsx │ │ │ ├── organizations/ │ │ │ │ ├── admin-organizations-table.tsx │ │ │ │ ├── columns-organizations.tsx │ │ │ │ └── organization/ │ │ │ │ ├── admin-organization-add-modal.tsx │ │ │ │ ├── admin-organization-form.tsx │ │ │ │ ├── admin-organization-section.tsx │ │ │ │ ├── admin-orgnization-list.tsx │ │ │ │ ├── button-delete-organization.tsx │ │ │ │ ├── details/ │ │ │ │ │ ├── add-member.action.ts │ │ │ │ │ ├── organization-add-member-form.tsx │ │ │ │ │ ├── organization-add-member-modal.tsx │ │ │ │ │ ├── organization-delete-member-modal.tsx │ │ │ │ │ ├── organization-member-card.tsx │ │ │ │ │ ├── organization-member-change-role.tsx │ │ │ │ │ ├── role-member.action.ts │ │ │ │ │ └── update-organization-form.tsx │ │ │ │ ├── organization-management.tsx │ │ │ │ ├── organization.schema.ts │ │ │ │ └── table-colums.tsx │ │ │ ├── settings/ │ │ │ │ ├── email/ │ │ │ │ │ ├── email-form/ │ │ │ │ │ │ ├── email-form.action.ts │ │ │ │ │ │ ├── email-form.schema.ts │ │ │ │ │ │ └── email-form.tsx │ │ │ │ │ └── settings-email-section.tsx │ │ │ │ ├── notification/ │ │ │ │ │ ├── settings-notification-section.tsx │ │ │ │ │ ├── settings-notification.action.ts │ │ │ │ │ └── settings-notification.schema.ts │ │ │ │ ├── settings-tabs.tsx │ │ │ │ └── storage/ │ │ │ │ ├── settings-storage-section.tsx │ │ │ │ ├── settings-storage.action.ts │ │ │ │ ├── settings-storage.schema.ts │ │ │ │ └── storage-s3/ │ │ │ │ ├── s3-form.action.ts │ │ │ │ ├── s3-form.schema.ts │ │ │ │ └── storage-s3-form.tsx │ │ │ └── users/ │ │ │ ├── admin-user-add-modal.tsx │ │ │ ├── admin-user-change-password-modal.tsx │ │ │ ├── admin-user-change-role-modal.tsx │ │ │ ├── admin-user-delete-modal.tsx │ │ │ ├── admin-user-edit-form.tsx │ │ │ ├── admin-user-edit-modal.tsx │ │ │ ├── admin-user-form.tsx │ │ │ ├── admin-user-list.tsx │ │ │ ├── table-colums.tsx │ │ │ ├── user-actions-cell.tsx │ │ │ ├── user.action.ts │ │ │ └── user.schema.ts │ │ ├── agent/ │ │ │ ├── agent-card/ │ │ │ │ └── agent-card.tsx │ │ │ ├── agent-card-key/ │ │ │ │ └── agent-card-key.tsx │ │ │ ├── agent-content.tsx │ │ │ ├── agent-database-card.tsx │ │ │ ├── agent-database-columns.tsx │ │ │ ├── agent-modal-key/ │ │ │ │ └── agent-modal-key.tsx │ │ │ └── button-delete-agent/ │ │ │ ├── button-delete-agent.tsx │ │ │ └── delete-agent.action.ts │ │ ├── backup/ │ │ │ └── backup-button/ │ │ │ ├── backup-button.action.ts │ │ │ └── backup-button.tsx │ │ ├── common/ │ │ │ ├── logged-in/ │ │ │ │ ├── logged-in-button.server.tsx │ │ │ │ ├── logged-in-button.tsx │ │ │ │ └── logged-in-dropdown.tsx │ │ │ ├── profile/ │ │ │ │ ├── profile-modal.tsx │ │ │ │ └── profile-sidebar.tsx │ │ │ └── sidebar/ │ │ │ ├── app-sidebar.tsx │ │ │ ├── logo-sidebar.tsx │ │ │ ├── menu-sidebar-main.tsx │ │ │ ├── menu-sidebar.tsx │ │ │ ├── side-bar-footer-credit.tsx │ │ │ └── side-bar-logo.tsx │ │ ├── database/ │ │ │ ├── backup/ │ │ │ │ ├── actions/ │ │ │ │ │ ├── backup-actions-cell.tsx │ │ │ │ │ ├── backup-actions-form.tsx │ │ │ │ │ ├── backup-actions-modal.tsx │ │ │ │ │ ├── backup-actions.action.ts │ │ │ │ │ ├── backup-actions.schema.ts │ │ │ │ │ └── get-data.action.ts │ │ │ │ └── backup-modal-context.tsx │ │ │ ├── channels-policy/ │ │ │ │ ├── policy-form.tsx │ │ │ │ ├── policy-modal.tsx │ │ │ │ ├── policy.action.ts │ │ │ │ └── policy.schema.ts │ │ │ ├── cron-button/ │ │ │ │ ├── advanced-cron-select.tsx │ │ │ │ ├── cron-button.tsx │ │ │ │ ├── cron-input.tsx │ │ │ │ └── cron.action.ts │ │ │ ├── database-form/ │ │ │ │ ├── database-form.tsx │ │ │ │ ├── form-database.action.ts │ │ │ │ └── form-database.schema.ts │ │ │ ├── health/ │ │ │ │ └── health-modal.tsx │ │ │ ├── import/ │ │ │ │ ├── import-modal.tsx │ │ │ │ ├── upload-backup-zone.tsx │ │ │ │ └── upload-backup.action.ts │ │ │ ├── restore-form.schema.ts │ │ │ ├── restore-form.tsx │ │ │ └── retention-policy/ │ │ │ ├── backup-retention-settings-form.tsx │ │ │ ├── backup-retention-settings.action.tsx │ │ │ ├── backup-retention-settings.schema.ts │ │ │ ├── backup-retention-settings.tsx │ │ │ └── retention-policy-sheet.tsx │ │ ├── health/ │ │ │ └── heath-grid.tsx │ │ ├── organization/ │ │ │ ├── create-organisation-modal.tsx │ │ │ ├── delete-organization-button.tsx │ │ │ ├── migration/ │ │ │ │ ├── migration-flow.tsx │ │ │ │ ├── migration-tool.tsx │ │ │ │ ├── migration.action.ts │ │ │ │ ├── source-panel.tsx │ │ │ │ └── target-panel.tsx │ │ │ ├── organization-combobox.tsx │ │ │ ├── settings/ │ │ │ │ ├── columns-organization-members.tsx │ │ │ │ ├── member.schema.ts │ │ │ │ ├── settings-organization-members-table.tsx │ │ │ │ └── update-member.action.ts │ │ │ └── tabs/ │ │ │ ├── organization-channels-tab/ │ │ │ │ ├── organization-agents-tab.tsx │ │ │ │ ├── organization-notifiers-tab.tsx │ │ │ │ └── organization-storages-tab.tsx │ │ │ └── organization-tabs.tsx │ │ ├── profile/ │ │ │ ├── actions/ │ │ │ │ ├── avatar.action.ts │ │ │ │ ├── profile.action.ts │ │ │ │ ├── provider.action.ts │ │ │ │ └── security.action.ts │ │ │ ├── components/ │ │ │ │ ├── avatar-with-upload.tsx │ │ │ │ └── backup-codes-list.tsx │ │ │ ├── form/ │ │ │ │ ├── 2fa-form.tsx │ │ │ │ ├── 2fa.schema.ts │ │ │ │ ├── reset-password-form.tsx │ │ │ │ └── set-password-form.tsx │ │ │ ├── modal/ │ │ │ │ ├── disable-2fa-modal.tsx │ │ │ │ ├── reset-password-modal.tsx │ │ │ │ ├── set-password-modal.tsx │ │ │ │ ├── setup-2fa-modal.tsx │ │ │ │ └── view-backup-codes-modal.tsx │ │ │ ├── profile-account.tsx │ │ │ ├── profile-apperance.tsx │ │ │ ├── profile-general.tsx │ │ │ ├── profile-providers.tsx │ │ │ ├── profile-security.tsx │ │ │ └── schemas/ │ │ │ ├── account.schema.ts │ │ │ ├── general.schema.ts │ │ │ ├── provider.schema.ts │ │ │ └── security.schema.ts │ │ ├── projects/ │ │ │ ├── button-delete-project/ │ │ │ │ ├── button-delete-project.tsx │ │ │ │ └── delete-project.action.ts │ │ │ ├── database/ │ │ │ │ ├── database-backup-list.tsx │ │ │ │ ├── database-content.tsx │ │ │ │ ├── database-kpi.tsx │ │ │ │ ├── database-restore-list.tsx │ │ │ │ └── database-tabs.tsx │ │ │ └── project-card/ │ │ │ ├── project-card.tsx │ │ │ └── project-database-card.tsx │ │ └── statistics/ │ │ └── charts/ │ │ ├── evolution-line-chart.tsx │ │ ├── fake-data.ts │ │ ├── line-chart.tsx │ │ ├── percentage-line-chart.tsx │ │ └── utils/ │ │ └── placeholder.tsx │ ├── db/ │ │ ├── index.ts │ │ ├── migrations/ │ │ │ ├── 0000_awesome_nomad.sql │ │ │ ├── 0001_wealthy_leo.sql │ │ │ ├── 0002_pink_groot.sql │ │ │ ├── 0003_absent_maestro.sql │ │ │ ├── 0004_dazzling_hawkeye.sql │ │ │ ├── 0005_old_swarm.sql │ │ │ ├── 0006_moaning_pete_wisdom.sql │ │ │ ├── 0007_last_umar.sql │ │ │ ├── 0008_aberrant_scorpion.sql │ │ │ ├── 0009_lucky_edwin_jarvis.sql │ │ │ ├── 0010_past_trauma.sql │ │ │ ├── 0011_outgoing_blob.sql │ │ │ ├── 0012_peaceful_leopardon.sql │ │ │ ├── 0013_past_logan.sql │ │ │ ├── 0014_strong_galactus.sql │ │ │ ├── 0015_absurd_next_avengers.sql │ │ │ ├── 0016_broken_morgan_stark.sql │ │ │ ├── 0017_wild_purple_man.sql │ │ │ ├── 0018_smiling_mole_man.sql │ │ │ ├── 0019_overjoyed_butterfly.sql │ │ │ ├── 0020_low_giant_girl.sql │ │ │ ├── 0020_thankful_sunspot.sql │ │ │ ├── 0021_soft_blockbuster.sql │ │ │ ├── 0022_purple_retro_girl.sql │ │ │ ├── 0023_common_the_captain.sql │ │ │ ├── 0024_lush_blindfold.sql │ │ │ ├── 0025_past_franklin_richards.sql │ │ │ ├── 0026_storage-backend.sql │ │ │ ├── 0027_special_the_santerians.sql │ │ │ ├── 0028_graceful_ben_parker.sql │ │ │ ├── 0029_lowly_white_tiger.sql │ │ │ ├── 0030_dizzy_morlocks.sql │ │ │ ├── 0031_chemical_edwin_jarvis.sql │ │ │ ├── 0032_sparkling_thunderbolt_ross.sql │ │ │ ├── 0033_handy_valeria_richards.sql │ │ │ ├── 0034_lush_speed.sql │ │ │ ├── 0035_late_young_avengers.sql │ │ │ ├── 0036_chief_night_thrasher.sql │ │ │ ├── 0037_neat_talon.sql │ │ │ ├── 0038_misty_red_hulk.sql │ │ │ ├── 0039_conscious_solo.sql │ │ │ ├── 0040_quick_lester.sql │ │ │ ├── 0041_spooky_radioactive_man.sql │ │ │ ├── 0042_breezy_namora.sql │ │ │ ├── 0043_peaceful_chat.sql │ │ │ ├── 0044_steep_wiccan.sql │ │ │ ├── 0045_needy_martin_li.sql │ │ │ ├── 0046_mysterious_menace.sql │ │ │ ├── 0047_wet_carnage.sql │ │ │ ├── 0048_yellow_eddie_brock.sql │ │ │ ├── 0049_chief_terrax.sql │ │ │ ├── 0050_dark_saracen.sql │ │ │ ├── 0051_young_senator_kelly.sql │ │ │ ├── 0052_cute_punisher.sql │ │ │ ├── 0053_lyrical_union_jack.sql │ │ │ └── meta/ │ │ │ ├── 0000_snapshot.json │ │ │ ├── 0001_snapshot.json │ │ │ ├── 0002_snapshot.json │ │ │ ├── 0003_snapshot.json │ │ │ ├── 0004_snapshot.json │ │ │ ├── 0005_snapshot.json │ │ │ ├── 0006_snapshot.json │ │ │ ├── 0007_snapshot.json │ │ │ ├── 0008_snapshot.json │ │ │ ├── 0009_snapshot.json │ │ │ ├── 0010_snapshot.json │ │ │ ├── 0011_snapshot.json │ │ │ ├── 0012_snapshot.json │ │ │ ├── 0013_snapshot.json │ │ │ ├── 0014_snapshot.json │ │ │ ├── 0015_snapshot.json │ │ │ ├── 0016_snapshot.json │ │ │ ├── 0017_snapshot.json │ │ │ ├── 0018_snapshot.json │ │ │ ├── 0019_snapshot.json │ │ │ ├── 0020_snapshot.json │ │ │ ├── 0021_snapshot.json │ │ │ ├── 0022_snapshot.json │ │ │ ├── 0023_snapshot.json │ │ │ ├── 0024_snapshot.json │ │ │ ├── 0025_snapshot.json │ │ │ ├── 0026_snapshot.json │ │ │ ├── 0027_snapshot.json │ │ │ ├── 0028_snapshot.json │ │ │ ├── 0029_snapshot.json │ │ │ ├── 0030_snapshot.json │ │ │ ├── 0031_snapshot.json │ │ │ ├── 0032_snapshot.json │ │ │ ├── 0033_snapshot.json │ │ │ ├── 0034_snapshot.json │ │ │ ├── 0035_snapshot.json │ │ │ ├── 0036_snapshot.json │ │ │ ├── 0037_snapshot.json │ │ │ ├── 0038_snapshot.json │ │ │ ├── 0039_snapshot.json │ │ │ ├── 0040_snapshot.json │ │ │ ├── 0041_snapshot.json │ │ │ ├── 0042_snapshot.json │ │ │ ├── 0043_snapshot.json │ │ │ ├── 0044_snapshot.json │ │ │ ├── 0045_snapshot.json │ │ │ ├── 0046_snapshot.json │ │ │ ├── 0047_snapshot.json │ │ │ ├── 0048_snapshot.json │ │ │ ├── 0049_snapshot.json │ │ │ ├── 0050_snapshot.json │ │ │ ├── 0051_snapshot.json │ │ │ ├── 0052_snapshot.json │ │ │ ├── 0053_snapshot.json │ │ │ └── _journal.json │ │ ├── schema/ │ │ │ ├── 00_common.ts │ │ │ ├── 01_setting.ts │ │ │ ├── 02_user.ts │ │ │ ├── 03_organization.ts │ │ │ ├── 04_member.ts │ │ │ ├── 05_invitation.ts │ │ │ ├── 06_project.ts │ │ │ ├── 07_database.ts │ │ │ ├── 08_agent.ts │ │ │ ├── 09_notification-channel.ts │ │ │ ├── 10_alert-policy.ts │ │ │ ├── 11_notification-log.ts │ │ │ ├── 12_storage-channel.ts │ │ │ ├── 13_storage-policy.ts │ │ │ ├── 14_storage-backup.ts │ │ │ ├── 15_healthcheck-log.ts │ │ │ └── types.ts │ │ ├── services/ │ │ │ ├── agent.ts │ │ │ ├── backup.ts │ │ │ ├── database.ts │ │ │ ├── healthcheck.ts │ │ │ ├── notification-channel.ts │ │ │ ├── notification-log.ts │ │ │ ├── storage-channel.ts │ │ │ └── user.ts │ │ └── utils/ │ │ └── index.ts │ ├── env.mjs │ ├── features/ │ │ ├── agents/ │ │ │ ├── agents.action.ts │ │ │ ├── agents.schema.ts │ │ │ ├── components/ │ │ │ │ ├── agent-organizations.action.ts │ │ │ │ ├── agent-organizations.form.tsx │ │ │ │ ├── agent-organizations.schema.ts │ │ │ │ ├── agent.dialog.tsx │ │ │ │ └── agent.form.tsx │ │ │ └── hooks/ │ │ │ └── use-agent-update-check.ts │ │ ├── browser/ │ │ │ ├── theme-meta-updater-root.tsx │ │ │ └── theme-meta-updater.tsx │ │ ├── dashboard/ │ │ │ ├── backup/ │ │ │ │ └── columns.tsx │ │ │ ├── organization-cookie.ts │ │ │ └── restore/ │ │ │ ├── columns.tsx │ │ │ └── restore.action.ts │ │ ├── keys/ │ │ │ └── keys.action.ts │ │ ├── layout/ │ │ │ ├── Header.tsx │ │ │ ├── card-auth.tsx │ │ │ └── page.tsx │ │ ├── notifications/ │ │ │ ├── dispatch.ts │ │ │ ├── helpers.ts │ │ │ ├── providers/ │ │ │ │ ├── discord.ts │ │ │ │ ├── gotify.ts │ │ │ │ ├── index.ts │ │ │ │ ├── ntfy.ts │ │ │ │ ├── slack.ts │ │ │ │ ├── smtp.ts │ │ │ │ ├── telegram.ts │ │ │ │ └── webhook.ts │ │ │ └── types.ts │ │ ├── organization/ │ │ │ ├── components/ │ │ │ │ ├── edit-organization.dialog.tsx │ │ │ │ └── organization.form.tsx │ │ │ ├── organization.action.ts │ │ │ └── organization.schema.ts │ │ ├── projects/ │ │ │ ├── components/ │ │ │ │ ├── project.dialog.tsx │ │ │ │ └── project.form.tsx │ │ │ ├── projects.action.ts │ │ │ └── projects.schema.ts │ │ ├── shared/ │ │ │ └── event.ts │ │ ├── storages/ │ │ │ ├── dispatch.ts │ │ │ ├── helpers.ts │ │ │ ├── providers/ │ │ │ │ ├── google-drive/ │ │ │ │ │ ├── helpers.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── index.ts │ │ │ │ ├── local.ts │ │ │ │ └── s3.ts │ │ │ └── types.ts │ │ ├── theme/ │ │ │ ├── mode-toggle.tsx │ │ │ └── theme-provider.tsx │ │ ├── updates/ │ │ │ ├── components/ │ │ │ │ └── update-notification.tsx │ │ │ ├── hooks/ │ │ │ │ └── use-update-check.ts │ │ │ └── services/ │ │ │ └── github.ts │ │ └── upload/ │ │ └── public/ │ │ └── upload.action.ts │ ├── fonts/ │ │ └── fonts.ts │ ├── hooks/ │ │ ├── use-mobile.ts │ │ ├── use-mobile.tsx │ │ └── use-organization-permissions.ts │ ├── lib/ │ │ ├── acl/ │ │ │ └── organization-acl.ts │ │ ├── auth/ │ │ │ ├── auth-client.ts │ │ │ ├── auth.ts │ │ │ ├── config.ts │ │ │ ├── current-user.ts │ │ │ ├── oauth.ts │ │ │ ├── oidc.ts │ │ │ └── permissions.ts │ │ ├── email/ │ │ │ ├── helpers.ts │ │ │ ├── index.ts │ │ │ └── types.ts │ │ ├── logger.ts │ │ ├── safe-actions/ │ │ │ └── actions.ts │ │ ├── services.ts │ │ ├── tasks/ │ │ │ ├── cleaning/ │ │ │ │ └── index.ts │ │ │ ├── database/ │ │ │ │ ├── index.ts │ │ │ │ ├── retention-count.ts │ │ │ │ ├── retention-days.ts │ │ │ │ ├── retention-gsf.ts │ │ │ │ └── utils/ │ │ │ │ └── delete.ts │ │ │ └── index.ts │ │ ├── twx.tsx │ │ ├── utils.ts │ │ └── zod.ts │ ├── middleware/ │ │ ├── errorHandler.ts │ │ └── loggingMiddleware.ts │ ├── types/ │ │ ├── action-type.ts │ │ ├── auth.ts │ │ ├── common.ts │ │ └── next.ts │ └── utils/ │ ├── common.ts │ ├── cron.ts │ ├── date-formatting.ts │ ├── detection.ts │ ├── edge_key.ts │ ├── get-server-url.ts │ ├── init.ts │ ├── mock-data.ts │ ├── name-from-email.ts │ ├── os-parser.ts │ ├── password.ts │ ├── rsa-keys.ts │ ├── slugify.ts │ ├── text.ts │ └── verify-uuid.ts └── tsconfig.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .dockerignore ================================================ # Ignore Docker-related configs .dockerignore docker-compose.yml docker-compose*.yml # Development/Editor config .idea .vscode # Node-related node_modules npm-debug.log yarn-debug.log yarn-error.log pnpm-debug.log # Next.js build output .next out # Docs and markdowns README.md *.md ================================================ FILE: .eslintrc.json ================================================ { "extends": [ "next/core-web-vitals", "plugin:tailwindcss/recommended" ], "rules": { "react/no-unescaped-entities": "off", "react-hooks/rules-of-hooks": "off" } } ================================================ FILE: .github/CODE_OF_CONDUCT.md ================================================ # Contributor Covenant Code of Conduct ## Our Pledge We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. ## Our Standards Examples of behavior that contributes to a positive environment for our community include: * Demonstrating empathy and kindness toward other people * Being respectful of differing opinions, viewpoints, and experiences * Giving and gracefully accepting constructive feedback * Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience * Focusing on what is best not just for us as individuals, but for the overall community Examples of unacceptable behavior include: * The use of sexualized language or imagery, and sexual attention or advances of any kind * Trolling, insulting or derogatory comments, and personal or political attacks * Public or private harassment * Publishing others' private information, such as a physical or email address, without their explicit permission * Other conduct which could reasonably be considered inappropriate in a professional setting ## Enforcement Responsibilities Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. ## Scope This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at contact@portabase.io. All complaints will be reviewed and investigated promptly and fairly. All community leaders are obligated to respect the privacy and security of the reporter of any incident. ## Enforcement Guidelines Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: ### 1. Correction **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. ### 2. Warning **Community Impact**: A violation through a single incident or series of actions. **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. ### 3. Temporary Ban **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. ### 4. Permanent Ban **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. **Consequence**: A permanent ban from any sort of public interaction within the community. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0, available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). [homepage]: https://www.contributor-covenant.org For answers to common questions about this code of conduct, see the FAQ at https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations. ================================================ FILE: .github/CONTRIBUTING.md ================================================ --- # Contributing to Portabase Thank you for considering contributing to **Portabase!** 🎉 Contributions help make this project better for everyone. Please take a moment to review this guide. It will help you understand how to contribute effectively. --- ## Table of Contents 1. [How to Get Started](#how-to-get-started) 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) --- ## How to Get Started 1. **Fork the repository** Click the "Fork" button at the top-right corner of this repository. 2. **Clone the repository** ```bash git clone https://github.com/Portabase/portabase ``` 3. **Set up the development environment** Follow the steps in the `README.md` to install dependencies and configure the project. 4. **Create a branch** Use the feature branch to work on changes. ```bash git checkout -b feature/ ``` --- ## Reporting Issues If you encounter a bug or have a suggestion for improvement, follow these steps: 1. **Check existing issues** to avoid duplicates. 2. **Open a new issue** if needed: - Provide a clear and descriptive title. - Describe the issue with steps to reproduce it (if applicable). - Include relevant logs, screenshots, or code snippets. --- ## Submitting Changes 1. **Ensure your branch is up to date** ```bash git pull origin main ``` 2. **Write meaningful commit messages** Follow this format: ``` [type] Summary of changes ``` Example: ``` feat: add user authentication fix: resolve crash on login page ``` 3. **Push your branch** ```bash git push origin feat/ ``` 4. **Open a Pull Request (PR)** Go to the repository on GitHub and click "New Pull Request." --- ## Code Style Guidelines - Follow the [specific coding style guide] (e.g., Prettier, ESLint, PEP8,Biome). - Use meaningful variable names and include comments where necessary. - Tests before submitting your changes. --- ## Pull Request Process 1. Ensure your code passes all tests and linters. 2. Provide a clear description of what your PR does. 3. Reference any related issues (e.g., `Closes #123`). 4. Wait for a review from a maintainer. --- ## Community Guidelines - Be respectful and inclusive to all contributors. - Follow the [Code of Conduct](CODE_OF_CONDUCT.md). - Feel free to ask questions if you’re unsure about something. --- Thank you for contributing! 🙌 --- ================================================ FILE: .github/FUNDING.yml ================================================ # These are supported funding model platforms github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] patreon: # Replace with a single Patreon username open_collective: # Replace with a single Open Collective username ko_fi: # Replace with a single Ko-fi username tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry liberapay: # Replace with a single Liberapay username issuehunt: # Replace with a single IssueHunt username lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry polar: # Replace with a single Polar username buy_me_a_coffee: portabase thanks_dev: # Replace with a single thanks.dev username custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ --- name: Bug report about: Create a report to help us improve title: '' labels: '' assignees: '' --- **Describe the bug** A clear and concise description of what the bug is. **To Reproduce** Steps to reproduce the behavior: 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' 4. See error **Expected behavior** A clear and concise description of what you expected to happen. **Screenshots** If applicable, add screenshots to help explain your problem. **Desktop (please complete the following information):** - OS: [e.g. iOS] - Browser [e.g. chrome, safari] - Version [e.g. 22] **Smartphone (please complete the following information):** - Device: [e.g. iPhone6] - OS: [e.g. iOS8.1] - Browser [e.g. stock browser, safari] - Version [e.g. 22] **Additional context** Add any other context about the problem here. ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.md ================================================ --- name: Feature request about: Suggest an idea for this project title: '' labels: '' assignees: '' --- **Is your feature request related to a problem? Please describe.** A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] **Describe the solution you'd like** A clear and concise description of what you want to happen. **Describe alternatives you've considered** A clear and concise description of any alternative solutions or features you've considered. **Additional context** Add any other context or screenshots about the feature request here. ================================================ FILE: .github/SECURITY.md ================================================ --- # Security Policy ## Supported Versions We take security seriously and aim to support the following versions of the project with security updates: | Version | Supported | |---------|--------------------| | Latest | ✅ Fully Supported | --- ## Reporting a Vulnerability If you discover a security vulnerability in this project, we appreciate your help in disclosing it responsibly. 1. **Contact Us** Please report the vulnerability by emailing **[contact@portabase.io](mailto:contact@portabase.io)**. Include the following details: - A detailed description of the issue. - Steps to reproduce the vulnerability (if applicable). - Any potential impacts or risks. 2. **Response Time** We aim to respond to security reports within **72 hours**. Once the issue is verified, we will: - Acknowledge receipt of your report. - Provide a timeline for addressing the issue. - Keep you informed throughout the process. 3. **Public Disclosure** We will coordinate with you before publicly disclosing the vulnerability. Credit will be given to the reporter unless otherwise requested. --- ## Security Best Practices We encourage all users to: - Use the latest stable version of the project. - Review the project’s dependencies and update them regularly. - Follow secure coding practices when using this project. --- ## Thanks We thank the security community for their vigilance and help in keeping this project secure! --- ================================================ FILE: .github/workflows/discord.yml ================================================ name: Discord Notification on: workflow_call: inputs: release_tag: required: true type: string discord_title: required: true type: string discord_color: required: true type: number discord_footer: required: true type: string secrets: DISCORD_WEBHOOK: required: true GH_TOKEN: required: true jobs: notify-discord: runs-on: ubuntu-latest steps: - name: Send Discord Notification env: DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} GH_TOKEN: ${{ secrets.GH_TOKEN }} run: | RELEASE_INFO=$(gh release view "${{ inputs.release_tag }}" -R ${{ github.repository }} --json name,url,body,author) RELEASE_TITLE=$(echo "$RELEASE_INFO" | jq -r .name) if [ -z "$RELEASE_TITLE" ] || [ "$RELEASE_TITLE" = "null" ]; then RELEASE_TITLE="${{ inputs.release_tag }}"; fi RELEASE_URL=$(echo "$RELEASE_INFO" | jq -r .url) RELEASE_BODY=$(echo "$RELEASE_INFO" | jq -r .body) AUTHOR_NAME="Portabase" AUTHOR_ICON="https://github.com/Portabase.png" PAYLOAD=$(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 }} \ '{ content: $discord_title, embeds: [{ title: $title, url: $url, description: $description, color: $discord_color, author: { name: $author, icon_url: $icon }, footer: { text: $discord_footer } }] }' ) curl -H "Content-Type: application/json" \ -d "$PAYLOAD" \ "$DISCORD_WEBHOOK" ================================================ FILE: .github/workflows/docker.yml ================================================ name: Docker Publish on: workflow_call: inputs: version: required: true type: string ref: required: true type: string image_name: required: false type: string default: "portabase/portabase" add_latest: required: false type: boolean default: false target: required: false type: string default: "prod" dockerfile: required: false type: string default: "./docker/dockerfile/Dockerfile" secrets: DOCKER_USERNAME: required: true DOCKER_PASSWORD: required: true jobs: build: name: Build architectures runs-on: ${{ matrix.platform == 'linux/amd64' && 'ubuntu-latest' || 'ubuntu-24.04-arm' }} strategy: fail-fast: false matrix: platform: [ linux/amd64, linux/arm64 ] steps: - name: Checkout uses: actions/checkout@v4 with: ref: ${{ inputs.ref }} fetch-depth: 0 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Login to Docker uses: docker/login-action@v3 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Prepare Image Tags id: prep run: | ARCH=${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }} echo "image=${{ inputs.image_name }}:${{inputs.version}}-$ARCH" >> $GITHUB_OUTPUT echo "safe_platform=${{ matrix.platform == 'linux/amd64' && 'linux-amd64' || 'linux-arm64' }}" >> $GITHUB_OUTPUT - name: Build and push image uses: docker/build-push-action@v6 with: context: . file: ${{ inputs.dockerfile }} platforms: ${{ matrix.platform }} push: true 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 }} - name: Save image name for manifest run: echo "${{ steps.prep.outputs.image }}" > image.txt - uses: actions/upload-artifact@v4 with: name: image-${{ steps.prep.outputs.safe_platform }} path: image.txt retention-days: 1 create-manifest: name: Create multi-arch manifest runs-on: ubuntu-latest needs: build steps: - uses: actions/download-artifact@v4 with: name: image-linux-amd64 path: /tmp/digests/amd64 - uses: actions/download-artifact@v4 with: name: image-linux-arm64 path: /tmp/digests/arm64 - name: Login to Docker uses: docker/login-action@v3 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Generate semantic tags id: tags run: | VERSION="${{ inputs.version }}" IFS='.' read -r MAJOR MINOR PATCH <<< "$VERSION" echo "VERSION_TAG=$VERSION" >> $GITHUB_OUTPUT if [ -n "$MINOR" ]; then echo "MINOR_TAG=$MAJOR.$MINOR" >> $GITHUB_OUTPUT fi if [ -n "$MAJOR" ]; then echo "MAJOR_TAG=$MAJOR" >> $GITHUB_OUTPUT fi if [ "${{ inputs.add_latest }}" = "true" ]; then echo "LATEST_TAG=latest" >> $GITHUB_OUTPUT fi - name: Extract Docker metadata id: meta uses: docker/metadata-action@v5 with: images: ${{ inputs.image_name }} tags: | type=raw,value=${{ steps.tags.outputs.VERSION_TAG }} type=raw,value=${{ steps.tags.outputs.MINOR_TAG }} type=raw,value=${{ steps.tags.outputs.MAJOR_TAG }} type=raw,value=${{ steps.tags.outputs.LATEST_TAG }} - name: Create and push manifest list working-directory: /tmp/digests run: | DOCKER_IMAGES="$(cat amd64/image.txt) $(cat arm64/image.txt)" TAG_ARGS=$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") echo $TAG_ARGS docker buildx imagetools create $TAG_ARGS $DOCKER_IMAGES ================================================ FILE: .github/workflows/e2e.yml ================================================ name: End-to-end testing on: pull_request: branches: [main] jobs: build-and-test: runs-on: ubuntu-latest if: github.event.pull_request.head.repo.full_name == github.repository steps: - uses: actions/checkout@v4 - name: Setup pnpm uses: pnpm/action-setup@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: 22 cache: 'pnpm' - name: Install dependencies run: pnpm install --frozen-lockfile - name: Build Docker image uses: docker/build-push-action@v6 with: context: . file: ./docker/dockerfile/Dockerfile push: false load: true tags: portabase/portabase:test - 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 ================================================ FILE: .github/workflows/helm.yml ================================================ name: Publish Helm Chart on: workflow_call: inputs: version: required: true type: string secrets: GH_TOKEN: required: true jobs: publish-helm: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Set up Helm uses: azure/setup-helm@v4 - name: Package Helm chart run: | mkdir -p ./helm-packages helm package helm \ --version ${{ inputs.version }} \ --app-version ${{ inputs.version }} \ --destination ./helm-packages - name: Authenticate to GitHub Packages run: | echo "${{ secrets.GH_TOKEN }}" | helm registry login ghcr.io -u ${{ github.actor }} --password-stdin - name: Push Helm chart to GitHub Packages (OCI) run: | helm push ./helm-packages/portabase-${{ inputs.version }}.tgz oci://ghcr.io/portabase/charts ================================================ FILE: .github/workflows/release-candidate.yml ================================================ name: Publish Docker image for release candidate on: push: tags: - "*.*.*-rc*" - "!*-*-rc*" permissions: contents: read packages: write jobs: docker_publish: uses: ./.github/workflows/docker.yml with: add_latest: true secrets: DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME_2 }} DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD_2 }} discord_notification: needs: docker_publish uses: ./.github/workflows/discord.yml with: discord_title: "New Release Candidate: ${{ github.ref_name }}" discord_color: 2044800 discord_footer: "Portabase" secrets: DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} ================================================ FILE: .github/workflows/release.yml ================================================ name: Auto Release & Publish on: pull_request_target: types: [ closed ] branches: - main permissions: contents: write packages: write jobs: check-skip: runs-on: ubuntu-latest outputs: skip: ${{ steps.set-skip.outputs.skip }} steps: - name: Determine if release should be skipped id: set-skip run: | TITLE="${{ github.event.pull_request.title }}" echo "PR title: $TITLE" if [[ "$TITLE" == *"[skip-release]"* ]]; then echo "PR title contains [skip-release], skipping release jobs." echo "skip=true" >> $GITHUB_OUTPUT else echo "skip=false" >> $GITHUB_OUTPUT fi create-release: needs: check-skip if: ${{ needs.check-skip.outputs.skip == 'false' }} runs-on: ubuntu-latest outputs: draft_tag: ${{ steps.release_step.outputs.draft_tag }} version: ${{ steps.release_step.outputs.version }} steps: - uses: actions/create-github-app-token@v1 id: app-token with: app-id: ${{ vars.APP_ID }} private-key: ${{ secrets.APP_PRIVATE_KEY }} - uses: actions/checkout@v4 with: fetch-depth: 0 token: ${{ steps.app-token.outputs.token }} ref: main - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: node-version: "lts/*" cache: "pnpm" - run: pnpm install --frozen-lockfile - run: | git config --global user.name 'github-actions[bot]' git config --global user.email 'github-actions[bot]@users.noreply.github.com' - name: Run release-it id: release_step run: | git pull origin main VERSION=$(pnpm exec release-it --ci --release-version) echo $VERSION echo "version=$VERSION" >> $GITHUB_OUTPUT OUTPUT=$(pnpm exec release-it --ci) echo "$OUTPUT" DRAFT_TAG=$(echo "$OUTPUT" | grep -oE 'untagged-[a-z0-9]+') echo $DRAFT_TAG echo "draft_tag=$DRAFT_TAG" >> $GITHUB_OUTPUT env: GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} publish-docker: needs: create-release if: ${{ needs.create-release.result == 'success' }} uses: ./.github/workflows/docker.yml with: version: ${{ needs.create-release.outputs.version }} ref: ${{ needs.create-release.outputs.version }} add_latest: true secrets: DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME_2 }} DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD_2 }} publish-helm: needs: create-release if: ${{ needs.create-release.result == 'success' }} uses: ./.github/workflows/helm.yml with: version: ${{ needs.create-release.outputs.version }} secrets: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} finalize-release: needs: - create-release - publish-docker - publish-helm runs-on: ubuntu-latest outputs: release_tag: ${{ steps.publish_release_step.outputs.release_tag }} steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Publish GitHub Release id: publish_release_step env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | OUTPUT=$(gh release edit ${{ needs.create-release.outputs.draft_tag }} --draft=false) echo "$OUTPUT" RELEASE_TAG=$(echo "$OUTPUT" | sed -E 's|.*/releases/tag/||') echo "release_tag=$RELEASE_TAG" >> $GITHUB_OUTPUT notify-discord: needs: - publish-docker - publish-helm - create-release - finalize-release uses: ./.github/workflows/discord.yml with: release_tag: ${{ needs.create-release.outputs.version }} discord_title: "New Release" discord_color: 3066993 discord_footer: "Portabase" secrets: DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} ================================================ FILE: .github/workflows/security.yml ================================================ name: Security Checks on: pull_request: push: branches: [ main ] jobs: sca-deps: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: aquasecurity/trivy-action@v0.35.0 with: scan-type: 'fs' format: 'table' severity: 'CRITICAL,HIGH' ignore-unfixed: true secrets-gitleaks: if: github.event.pull_request.head.repo.fork == false runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - uses: gitleaks/gitleaks-action@v2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }} with: config-path: .gitleaks.toml ================================================ FILE: .gitignore ================================================ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. .idea .vscode # dependencies /node_modules /.pnp .pnp.js #.yarn/install-state.gz # testing /coverage # next.js /.next/ /out/ # production /build # misc .DS_Store *.pem # debug npm-debug.log* yarn-debug.log* yarn-error.log* # local env files .env*.local # vercel .vercel # typescript *.tsbuildinfo next-env.d.ts public/uploads/* !public/uploads/ private/uploads/* private/keys/* /.env seeds/pocket-id/*.db certificates # Playwright /test-results/ /playwright-report/ /blob-report/ /playwright/.cache/ /playwright/.auth/ /e2e/local-storage.json ================================================ FILE: .gitleaks.toml ================================================ title = "Custom gitleaks config" [allowlist] # Global allowlist patterns (won’t be flagged) regexes = [] paths = [ "src/utils/init.ts" ] ================================================ FILE: .pre-commit-config.yaml ================================================ repos: - repo: https://github.com/gitleaks/gitleaks rev: v8.24.2 hooks: - id: gitleaks ================================================ FILE: .release-it.json ================================================ { "github": { "release": true, "draft": true, "tokenRef": "GITHUB_TOKEN" }, "git": { "commit": true, "commitMessage": "chore: release ${version}", "requireCleanWorkingDir": true, "tag": true, "tagName": "${version}", "push": true }, "plugins": { "@release-it/conventional-changelog": { "preset": { "name": "conventionalcommits", "types": [ { "type": "feat", "section": "✨ Features" }, { "type": "fix", "section": "🐛 Bug Fixes" }, { "type": "perf", "section": "⚡️ Performance Improvements" }, { "type": "revert", "section": "⏪️ Reverts" }, { "type": "docs", "section": "📝 Documentation", "hidden": true }, { "type": "chore", "section": "🔧 Chores", "hidden": true } ] } }, "@release-it/bumper": { "out": { "file": "CITATION.cff", "path": "version", "type": "text/yaml" } } } } ================================================ FILE: CITATION.cff ================================================ cff-version: 1.2.0 title: Portabase message: If you use this software, please cite it as below. type: software authors: - family-names: Gauthereau given-names: Charles - family-names: Larcher given-names: Killian repository-code: https://github.com/Portabase/portabase url: https://portabase.io abstract: >- Portabase is a free, open-source, self-hosted solution for database administration, providing backup and restore capabilities, scheduling, retention policies, notifications, and support for multiple storage backends. Its headless agent architecture enables connection to multiple database instances securely and efficiently. keywords: - docker - kubernetes - backups - postgresql - mysql - mariadb - devops - database - monitoring - s3 - self-hosted - system-administration - web-ui - agent license: Apache-2.0 version: 1.13.0 date-released: '2026-03-02' ================================================ FILE: LICENSE ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright 2024 Portabase Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: Makefile ================================================ CLUSTER_SCRIPT=docker/entrypoints/app-dev-entrypoint.sh up: @bash $(CLUSTER_SCRIPT) seed-keycloak: @[ $$(ls -1 seeds/keycloak/*.json 2>/dev/null | wc -l) -gt 0 ] || (echo "No realm export found in seeds/keycloak. Add an export from Keycloak first."; exit 1) @docker compose -f docker-compose.func.yml stop keycloak >/dev/null 2>&1 || true @docker compose -f docker-compose.func.yml rm -f -s keycloak >/dev/null 2>&1 || true @docker volume rm portabase-dev-func_keycloak-data >/dev/null 2>&1 || true @docker compose -f docker-compose.func.yml up -d keycloak @echo "Keycloak seed import triggered from seeds/keycloak/*.json" seed-pocket: @[ -f seeds/pocket-id/portabase.zip ] || (echo "No export found in seeds/pocket-id. Add an export from Pocket ID first."; exit 1) @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 $$(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 @echo "Pocket ID data restored from seeds/pocket-id/portabase.zip" seed-auth: seed-keycloak seed-pocket pocket-token: @docker compose -f docker-compose.func.yml exec pocket-id ./pocket-id one-time-access-token admin export-pocket: @echo "Exporting Pocket ID data to seeds/pocket-id/portabase.zip..." @mkdir -p ./seeds/pocket-id @docker compose -f docker-compose.func.yml exec pocket-id ./pocket-id export --path /tmp/portabase-export.zip @docker compose -f docker-compose.func.yml cp pocket-id:/tmp/portabase-export.zip ./seeds/pocket-id/portabase.zip @docker compose -f docker-compose.func.yml exec pocket-id rm /tmp/portabase-export.zip @echo "Pocket ID data exported to seeds/pocket-id/portabase.zip" export-keycloak: @echo "Exporting Keycloak configuration and users to seeds/keycloak/*.json..." @mkdir -p ./seeds/keycloak @rm -f ./seeds/keycloak/*.json @docker compose -f docker-compose.func.yml stop keycloak >/dev/null 2>&1 @docker rm -f kc-exporter >/dev/null 2>&1 || true @docker compose -f docker-compose.func.yml run --name kc-exporter keycloak export --dir /tmp/kc-export --users realm_file @docker cp kc-exporter:/tmp/kc-export/. ./seeds/keycloak/ @docker rm -f kc-exporter >/dev/null 2>&1 @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." 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." ================================================ FILE: README.md ================================================
Logo

Portabase

Portabase is a tool designed to simplify the backup and restoration of your database instances. It integrates seamlessly with Portabase agents for managing operations securely and efficiently.

[![License: Apache](https://img.shields.io/badge/License-apache-yellow.svg)](LICENSE) [![Docker Pulls](https://img.shields.io/docker/pulls/portabase/portabase?color=brightgreen)](https://hub.docker.com/r/portabase/portabase) [![Helm Chart](https://img.shields.io/badge/Helm-Kubernetes-326ce5?logo=helm&logoColor=white)](https://github.com/Portabase/portabase/pkgs/container/charts%2Fportabase) [![Platform](https://img.shields.io/badge/platform-linux%20%7C%20macos%20%7C%20windows-lightgrey)](https://github.com/Portabase/portabase) [![Support Portabase](https://img.shields.io/badge/Support-Portabase-orange)](https://www.buymeacoffee.com/portabase) [![PostgreSQL](https://img.shields.io/badge/PostgreSQL-336791?logo=postgresql&logoColor=white)](https://www.postgresql.org/) [![MySQL](https://img.shields.io/badge/MySQL-4479A1?logo=mysql&logoColor=white)](https://www.mysql.com/) [![MariaDB](https://img.shields.io/badge/MariaDB-003545?logo=mariadb&logoColor=white)](https://mariadb.org/) [![SQLite](https://img.shields.io/badge/-SQLite-blue?logo=sqlite&logoColor=white)](https://sqlite.org/) [![Redis](https://img.shields.io/badge/Redis-DC382D?style=flat&logo=Redis&logoColor=white)](https://redis.io/) [![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/) [![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) [![NextJS][NextJS]][NextJS-url] [![BetterAuth][BetterAuth]][BetterAuth-url] [![Drizzle][Drizzle]][Drizzle-url] [![ShadcnUI][ShadcnUI]][ShadcnUI-url] [![Docker][Docker]][Docker-url]

WebsiteDocumentationDemoInstallationReport BugRequest Feature

![portabase-dashboard](https://github.com/user-attachments/assets/8f2c69d6-f1f9-4b80-b51c-01f6f13b9b62)
## Installation You have 4 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) - 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 | See the [Database Servers documentation](https://portabase.io/docs/agent/db) for version-specific backup and restore details. ## Contributors [![Contributors](https://contrib.rocks/image?repo=Portabase/portabase)](https://github.com/Portabase/portabase/graphs/contributors) [!["Support Portabase"](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://www.buymeacoffee.com/portabase) ## License Distributed under the Apache License. See `LICENSE.txt` for more details. [Docker]: https://img.shields.io/badge/Docker-2496ED?logo=docker&logoColor=fff&style=for-the-badge [NextJS]: https://img.shields.io/badge/next.js-000000?style=for-the-badge&logo=nextdotjs&logoColor=white [BetterAuth]: https://img.shields.io/badge/Better%20Auth-FFF?logo=betterauth&logoColor=000&style=for-the-badge [Drizzle]: https://img.shields.io/badge/Drizzle-111?style=for-the-badge&logo=Drizzle&logoColor=c5f74f [ShadcnUI]: https://img.shields.io/badge/shadcn/ui-000000?style=for-the-badge&logo=shadcn/ui&logoColor=white [NextJS-url]: https://nextjs.org/ [BetterAuth-url]: https://www.better-auth.com/ [Drizzle-url]: https://orm.drizzle.team/ [ShadcnUI-url]: https://ui.shadcn.com/ [Docker-url]: https://www.docker.com/ ================================================ FILE: app/(auth)/forgot-password/page.tsx ================================================ 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 { env } from "@/env.mjs"; import { CardAuth } from "@/features/layout/card-auth"; import { redirect } from "next/navigation"; export default async function RoutePage(props: { searchParams: Promise<{ callbackUrl: string | undefined }> }) { if (env.AUTH_EMAIL_PASSWORD_ENABLED !== "true") { redirect("/login"); } return (

Reset password

Enter your email address and we'll send you a link to reset your password.

); } ================================================ FILE: app/(auth)/guard/page.tsx ================================================ import {CardContent, CardHeader} from "@/components/ui/card"; import {TooltipProvider} from "@/components/ui/tooltip"; import {GuardForm} from "@/components/wrappers/auth/guard/guard-form"; import {cookies} from "next/headers"; import {redirect} from "next/navigation"; import {CardAuth} from "@/features/layout/card-auth"; export default async function GuardPage() { const cookieStore = await cookies(); const token = cookieStore.get("better-auth.two_factor")?.value || cookieStore.get("__Secure-better-auth.two_factor")?.value; if (!token) { redirect("/login"); } return (

Two-factor verification

Please enter the verification code generated by your authentication app.

); } ================================================ FILE: app/(auth)/layout.tsx ================================================ 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 { Heart } from "lucide-react"; export default async function Layout({ children, }: { children: React.ReactNode; }) { const user = await currentUser(); if (user && !user.banned && user.role !== "pending") { redirect("/dashboard/home"); } return (
{children}

Made with{" "} {" "} by Portabase

); } ================================================ FILE: app/(auth)/login/page.tsx ================================================ import { LoginForm } from "@/components/wrappers/auth/login/login-form/login-form"; import { Metadata } from "next"; import { SUPPORTED_PROVIDERS } from "@/lib/auth/config"; import { SocialAuthButtons } from "@/components/wrappers/auth/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 { env } from "@/env.mjs"; export const metadata: Metadata = { title: "Login", }; export default async function SignInPage() { return (

Login

Fill your login informations

{env.AUTH_EMAIL_PASSWORD_ENABLED === "true" && ( )} {env.AUTH_EMAIL_PASSWORD_ENABLED === "true" && SUPPORTED_PROVIDERS.filter((p) => !p.isManual && p.isActive) .length > 0 && (
OR
)} {SUPPORTED_PROVIDERS.filter((p) => !p.isManual && p.isActive).length > 0 && } {env.AUTH_SIGNUP_ENABLED !== "true" || (env.AUTH_EMAIL_PASSWORD_ENABLED === "true" && (
Don't have an account ?{" "} Sign up
))}
); } ================================================ FILE: app/(auth)/register/page.tsx ================================================ import { PageParams } from "@/types/next"; import { RegisterForm } from "@/components/wrappers/auth/register/register-form/register-form"; import { Metadata } from "next"; import { redirect } from "next/navigation"; import { env } from "@/env.mjs"; export const metadata: Metadata = { title: "Register", }; export default async function RoutePage(props: PageParams<{}>) { if ( env.AUTH_SIGNUP_ENABLED !== "true" || env.AUTH_EMAIL_PASSWORD_ENABLED !== "true" ) { redirect("/login"); } return (
); } ================================================ FILE: app/(auth)/reset-password/page.tsx ================================================ 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 { 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 { env } from "@/env.mjs"; export default async function RoutePage(props: { searchParams: Promise<{ token: string | undefined }> }) { if (env.AUTH_EMAIL_PASSWORD_ENABLED !== "true") { return redirect("/login"); } const { token } = await props.searchParams; if (!token) { return redirect(`/login?error=invalid_or_expired_token`); } const verification = await (await auth.$context).internalAdapter.findVerificationValue(`reset-password:${token}`); if (!verification || verification.expiresAt < new Date()) { return redirect(`/login?error=invalid_or_expired_token`); } const user = await (await auth.$context).internalAdapter.findUserById(verification.value); return (

Set a new password

Please enter your new password below.

{user!.name .split(" ") .map((n) => n[0]) .join("") .toUpperCase() .slice(0, 2)}
{user!.name}
{user!.email}
); } ================================================ FILE: app/(customer)/dashboard/(admin)/admin/organizations/[organizationId]/page.tsx ================================================ import {notFound} from "next/navigation"; 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 {buildOrganizationWithMembers} from "@/utils/common"; import {isUUID} from "@/utils/text"; import {user} from "@/db/schema/02_user"; import {invitation} from "@/db/schema/05_invitation"; import {member} from "@/db/schema/04_member"; import {organization} from "@/db/schema/03_organization"; import {user as drizzleUser} from "@/db/schema/02_user"; export default async function RoutePage(props: PageParams<{ organizationId: string }>) { const {organizationId} = await props.params; if (!organizationId) { return notFound(); } if (!isUUID(organizationId)) { return notFound(); } const users = await db.select().from(drizzleUser); const organizationData = await db .select({organization, member, user, invitation}) .from(organization) .leftJoin(member, eq(drizzleDb.schemas.organization.id, member.organizationId)) .leftJoin(invitation, eq(drizzleDb.schemas.invitation.id, invitation.organizationId)) .leftJoin(user, eq(drizzleDb.schemas.member.userId, user.id)) .where(eq(organization.id, organizationId)); const formattedData = buildOrganizationWithMembers(organizationData); if (!formattedData) return notFound(); return ( ); } ================================================ FILE: app/(customer)/dashboard/(admin)/admin/organizations/page.tsx ================================================ import {PageParams} from "@/types/next"; import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/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"; import {isNull} from "drizzle-orm"; export default async function RoutePage(props: PageParams<{}>) { const organizations = await db.query.organization.findMany({ where: (fields) => isNull(fields.deletedAt), with: { members: true, }, }); return (
Active organizations
); } ================================================ FILE: app/(customer)/dashboard/(admin)/admin/settings/page.tsx ================================================ import {PageParams} from "@/types/next"; import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page"; import {db} from "@/db"; import {notFound} from "next/navigation"; import {SettingsTabs} from "@/components/wrappers/dashboard/admin/settings/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"; export default async function RoutePage(props: PageParams<{}>) { const settings = await db.query.setting.findFirst({ where: (fields, {eq}) => eq(fields.name, "system"), }); console.log(settings) const storageChannels = await db.query.storageChannel.findMany({ with: { organizations: true }, where: isNull(drizzleDb.schemas.storageChannel.organizationId), orderBy: desc(drizzleDb.schemas.storageChannel.createdAt) }) as StorageChannelWith[] const notificationChannels = await db.query.notificationChannel.findMany({ with: { organizations: true }, where: isNull(drizzleDb.schemas.notificationChannel.organizationId), orderBy: desc(drizzleDb.schemas.notificationChannel.createdAt) }) as NotificationChannelWith[] if (!settings || !storageChannels || !notificationChannels ) { notFound() } return (
System settings
); } ================================================ FILE: app/(customer)/dashboard/(admin)/admin/users/page.tsx ================================================ import {PageParams} from "@/types/next"; import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/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 {SUPPORTED_PROVIDERS} from "@/lib/auth/config"; 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 organizations = await db.query.organization.findMany({ with: { members: true, }, }); const credentialProvider = SUPPORTED_PROVIDERS.find(p => p.id === 'credential'); const isPasswordAuthEnabled = credentialProvider?.isActive || false; return (
Active users
); } ================================================ FILE: app/(customer)/dashboard/(admin)/agents/[agentId]/page.tsx ================================================ import { PageParams } from "@/types/next"; import { Page, PageContent, PageDescription, PageTitle, } from "@/features/layout/page"; import { db } from "@/db"; import * as drizzleDb from "@/db"; 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 { 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"; export default async function RoutePage( props: PageParams<{ agentId: string }>, ) { const { agentId } = await props.params; const agent = await db.query.agent.findFirst({ 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 (
{capitalizeFirstLetter(agent.name)}
{agent.description && ( {agent.description} )}
); } ================================================ FILE: app/(customer)/dashboard/(admin)/agents/page.tsx ================================================ 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 {notFound} from "next/navigation"; import {db} from "@/db"; import * as drizzleDb from "@/db"; import {and, desc, eq, isNull, not} from "drizzle-orm"; import {Metadata} from "next"; import {AgentDialog} from "@/features/agents/components/agent.dialog"; export const metadata: Metadata = { title: "Agents", }; export default async function RoutePage(props: PageParams<{}>) { const agents = await db.query.agent.findMany({ 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(); } return ( Agents {agents.length > 0 && ( )} {agents.length > 0 ? ( ) : ( )} ); } ================================================ FILE: app/(customer)/dashboard/(admin)/layout.tsx ================================================ import React from "react"; import { notFound } from "next/navigation"; import { currentUser } from "@/lib/auth/current-user"; export default async function Layout({ children }: { children: React.ReactNode }) { const user = await currentUser(); if (user!.role !== "superadmin" && user!.role !== "admin") { notFound(); } return <>{children}; } ================================================ FILE: app/(customer)/dashboard/(admin)/notifications/channels/page.tsx ================================================ import {PageParams} from "@/types/next"; import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/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 * as drizzleDb from "@/db"; export const metadata: Metadata = { title: "Notification Channels", }; export default async function RoutePage(props: PageParams<{}>) { const notificationChannels = await db.query.notificationChannel.findMany({ with: { organizations: true }, where: isNull(drizzleDb.schemas.notificationChannel.organizationId), orderBy: desc(notificationChannel.createdAt) }) as NotificationChannelWith[] const organizations = await db.query.organization.findMany({ where: (fields) => isNull(fields.deletedAt), with: { members: true, }, }); return ( Notification channels ); } ================================================ FILE: app/(customer)/dashboard/(admin)/notifications/logs/page.tsx ================================================ import {PageParams} from "@/types/next"; import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page"; import {Metadata} from "next"; import {NotificationLogsList} from "@/components/wrappers/dashboard/admin/notifications/logs/notification-logs-list"; import {notFound} from "next/navigation"; import {getNotificationHistory} from "@/db/services/notification-log"; export const metadata: Metadata = { title: "Activity logs", }; export default async function RoutePage(props: PageParams<{}>) { const notificationLogs = await getNotificationHistory() if (!notificationLogs) { notFound(); } return ( Activity logs ); } ================================================ FILE: app/(customer)/dashboard/(admin)/storages/channels/page.tsx ================================================ import {PageParams} from "@/types/next"; import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page"; import {Metadata} from "next"; import {ChannelsSection} from "@/components/wrappers/dashboard/admin/channels/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"; export const metadata: Metadata = { title: "Storage Channels", }; export default async function RoutePage(props: PageParams<{}>) { const storageChannels = await db.query.storageChannel.findMany({ with: { organizations: true }, where: isNull(drizzleDb.schemas.storageChannel.organizationId), orderBy: desc(drizzleDb.schemas.storageChannel.createdAt) }) as StorageChannelWith[] const organizations = await db.query.organization.findMany({ where: (fields) => isNull(fields.deletedAt), with: { members: true, }, }); const settings = await db.query.setting.findFirst({ where: eq(drizzleDb.schemas.setting.name, "system"), }); return ( Storage channels ); } ================================================ FILE: app/(customer)/dashboard/(organization)/migration/page.tsx ================================================ import {PageParams} from "@/types/next"; import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page"; import {notFound} from "next/navigation"; import {getOrganization} from "@/lib/auth/auth"; import {Metadata} from "next"; import {db} from "@/db"; import {MigrationTool} from "@/components/wrappers/dashboard/organization/migration/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 ( Database Migration

Import backups from a source project into your target database

); } ================================================ FILE: app/(customer)/dashboard/(organization)/projects/[projectId]/database/[databaseId]/page.tsx ================================================ 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 * 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"; export default async function RoutePage(props: PageParams<{ projectId: string; databaseId: string }>) { const {projectId, databaseId} = await props.params; const organization = await getOrganization({}); const activeMember = await getActiveMember() if (!organization || !activeMember) { notFound(); } 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)), 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 } } }, 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 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 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 [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 successRate = totalBackups > 0 ? (successfulBackups / totalBackups) * 100 : null; const isMember = activeMember?.role === "member"; return ( ); } ================================================ FILE: app/(customer)/dashboard/(organization)/projects/[projectId]/page.tsx ================================================ 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 * as drizzleDb from "@/db"; import {capitalizeFirstLetter} from "@/utils/text"; import {ProjectDialog} from "@/features/projects/components/project.dialog"; import {ProjectWith} from "@/db/schema/06_project"; import {isUuidv4} from "@/utils/verify-uuid"; import {getOrganizationAvailableDatabases} from "@/db/services/database"; export default async function RoutePage(props: PageParams<{ projectId: string }>) { const { projectId } = await props.params; if (!isUuidv4(projectId)) { notFound() } 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 (!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, }, }); if (!proj) { redirect("/dashboard/projects"); } const availableDatabases = await getOrganizationAvailableDatabases(organization.id, proj.id) const isMember = activeMember?.role === "member"; return (
{capitalizeFirstLetter(proj.name)}
{!isMember && (
)}
{proj.databases.length > 0 ? ( ) : (

No databases found

You haven’t added any databases to this project yet.

)}
); } ================================================ FILE: app/(customer)/dashboard/(organization)/projects/page.tsx ================================================ 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 {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 {Metadata} from "next"; 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", }; export default async function RoutePage(props: PageParams<{}>) { const organization = await getOrganization({}); const activeMember = await getActiveMember() 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: { databases: true, }, }); const isMember = activeMember?.role === "member"; const availableDatabases = await getOrganizationAvailableDatabases(organization.id) return ( Projects {(projects.length > 0 && !isMember) && ( )} {projects.length > 0 ? ( ) : isMember ? ( ) : ( )} ); } ================================================ FILE: app/(customer)/dashboard/(organization)/settings/agents/[agentId]/page.tsx ================================================ import {PageParams} from "@/types/next"; import { Page, PageContent, PageDescription, PageTitle, } from "@/features/layout/page"; import {db} from "@/db"; import * as drizzleDb from "@/db"; import {eq} from "drizzle-orm"; import {notFound} from "next/navigation"; import {ButtonDeleteAgent} from "@/components/wrappers/dashboard/agent/button-delete-agent/button-delete-agent"; 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 {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 (
{capitalizeFirstLetter(agent.name)}
{isOwned && (
)}
{agent.description && ( {agent.description} )}
); } ================================================ FILE: app/(customer)/dashboard/(organization)/settings/agents/page.tsx ================================================ import { redirect } from "next/navigation"; export default async function RoutePage() { redirect("/dashboard/settings?tab=agents"); } ================================================ FILE: app/(customer)/dashboard/(organization)/settings/page.tsx ================================================ import {PageParams} from "@/types/next"; import { Page, 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"; import * as drizzleDb from "@/db"; 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", }; export default async function RoutePage(props: PageParams<{ slug: string }>) { const organization = await getOrganization({}); const user = await currentUser(); const activeMember = await getActiveMember(); if (!organization || !activeMember || !user) { notFound(); } 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 organizationWithMembers = await db.query.organization.findFirst({ where: eq(drizzleDb.schemas.organization.id, organization.id), with: { projects: true, members: { with: { user: true, }, }, }, }); if (!organizationWithMembers) notFound(); return (
Organization settings
{permissions.canManageSettings && organization.slug !== "default" && ( )}
{permissions.canManageDangerZone && organization.slug !== "default" && (
0} organizationSlug={organization.slug} />
{organizationWithMembers.projects.length > 0 && (

Your organization has some projects associated with it. Please delete them before deleting the organization.

)}
)}
); } ================================================ FILE: app/(customer)/dashboard/(organization)/statistics/page.tsx ================================================ import {PageParams} from "@/types/next"; import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/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 {notFound} from "next/navigation"; import {db} from "@/db"; import {and, asc, count, eq, inArray} from "drizzle-orm"; import * as drizzleDb from "@/db"; import {getOrganization} from "@/lib/auth/auth"; import {Building2, DatabaseBackup, Folder, RefreshCcw} from "lucide-react"; import {Metadata} from "next"; export const metadata: Metadata = { title: "Statistics", }; export default async function RoutePage(props: PageParams<{}>) { const organization = await getOrganization({}); if (!organization) { notFound(); } const org = await db.query.organization.findFirst({ where: eq(drizzleDb.schemas.organization.slug, organization.slug), }); if (!org) notFound(); const projects = await db.query.project.findMany({ where: eq(drizzleDb.schemas.project.organizationId, org.id), }); const projectIds = projects.map(project => project.id); const databasesOfAllProjects = await db.query.database.findMany({ where: inArray(drizzleDb.schemas.database.projectId, projectIds), }) const databaseIds = databasesOfAllProjects.map((database) => database.id); const backupsEvolution = await db.query.backup.findMany({ columns: { id: true, createdAt: true, }, orderBy: [asc(drizzleDb.schemas.backup.id)], where: inArray(drizzleDb.schemas.backup.databaseId, databaseIds), }); const backupsRate = await db .select({ createdAt: drizzleDb.schemas.backup.createdAt, status: drizzleDb.schemas.backup.status, _count: count(), }) .from(drizzleDb.schemas.backup) .where(and(inArray(drizzleDb.schemas.backup.status, ["success", "failed"]), inArray(drizzleDb.schemas.backup.databaseId, databaseIds))) .groupBy(drizzleDb.schemas.backup.createdAt, drizzleDb.schemas.backup.status) .orderBy(drizzleDb.schemas.backup.createdAt); const restorationsCountResult = await db .select({ count: count(), }) .from(drizzleDb.schemas.restoration) .where(inArray(drizzleDb.schemas.restoration.databaseId, databaseIds)); const restorationsCount = restorationsCountResult[0]?.count ?? 0; const projectsCount = projects.length; const backupsEvolutionCount = backupsEvolution.length; const sortedBackupsEvolution = backupsEvolution.sort( (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() ); return ( Statistics Overview
Projects
{projectsCount}

Active projects in this organization

Backups
{backupsEvolutionCount}

Total backups executed across all databases

Restorations
{restorationsCount}

Total restoration operations performed

); } ================================================ FILE: app/(customer)/dashboard/home/page.tsx ================================================ import {PageParams} from "@/types/next"; import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/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 {and, asc, eq, inArray} from "drizzle-orm"; import * as drizzleDb from "@/db"; import {listOrganizations} from "@/lib/auth/auth"; import {Metadata} from "next"; export const metadata: Metadata = { title: "Home", }; export default async function RoutePage(props: PageParams<{}>) { const user = await currentUser(); const organizations = await listOrganizations() if (!user || !organizations) notFound(); const organizationIds = organizations.map(project => project.id); const agents = await db.query.agent.findMany({}); const projects = await db.query.project.findMany({ where: and(inArray(drizzleDb.schemas.project.organizationId, organizationIds), eq(drizzleDb.schemas.project.isArchived, false)), }); const projectIds = projects.map(project => project.id); const databasesOfAllProjects = await db.query.database.findMany({ where: inArray(drizzleDb.schemas.database.projectId, projectIds), }) const databaseIds = databasesOfAllProjects.map((database) => database.id); const backupsEvolution = await db.query.backup.findMany({ columns: { id: true, createdAt: true, deletedAt: true, }, orderBy: [asc(drizzleDb.schemas.backup.id)], where: inArray(drizzleDb.schemas.backup.databaseId, databaseIds), }); const availableBackups = backupsEvolution.filter(backup => backup.deletedAt == null); return ( Dashboard
Organizations
{organizations.length}

Number of organizations

Projects
{projects.length}

Number of projects

Databases
{databasesOfAllProjects.length}

Databases across all projects

Agents
{agents.length}

Registered agents

Total Backups
{backupsEvolution.length}

All backups recorded

Available Backups
{availableBackups.length}

Currently active backups

{/*Do not delete*/} {/*
*/} {/*
*/} {/*
*/} {/*
*/} {/*
*/} {/*
*/} {/*
*/} ); } ================================================ FILE: app/(customer)/dashboard/layout.tsx ================================================ 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 { currentUser } from "@/lib/auth/current-user"; import { ThemeMetaUpdater } from "@/features/browser/theme-meta-updater"; export default async function Layout({ children }: { children: ReactNode }) { const user = await currentUser(); if (!user) redirect("/login"); return (
{children}
); } ================================================ FILE: app/(customer)/dashboard/loading.tsx ================================================ import { LoadingSpinner } from "@/components/wrappers/common/loading/loading-spinner"; export default function Loading() { return (
); } ================================================ FILE: app/(landing)/home/page.tsx ================================================ export default function Home() { return (

Home landing page

); } ================================================ FILE: app/(landing)/page.tsx ================================================ import { redirect } from "next/navigation"; import { getCurrentOrganizationSlug } from "@/features/dashboard/organization-cookie"; import { currentUser } from "@/lib/auth/current-user"; export default async function Index() { const user = await currentUser(); if (user) { const currentOrganizationSlug = await getCurrentOrganizationSlug(); redirect(`/dashboard/home`); } redirect("/login"); //Do not delete // return ( // // ); } ================================================ FILE: app/api/agent/[agentId]/backup/helpers.ts ================================================ import {NextResponse} from "next/server"; import {and, eq} from "drizzle-orm"; import {db} from "@/db"; import * as drizzleDb from "@/db"; import {logger} from "@/lib/logger"; const log = logger.child({module: "api/agent/backup/helpers"}); export function withAgentCheck(handler: Function) { return async (request: Request, context: { params: Promise<{ agentId: string }> }) => { try { const agentId = (await context.params).agentId; 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} ); } return handler(request, {...context, agent}); } catch (err) { log.error({error: err, name: "withAgentCheck"}, "Error in agent middleware"); return NextResponse.json( {error: "Internal server error"}, {status: 500} ); } }; } export async function getDatabaseOrThrow(generatedId: string) { const database = await db.query.database.findFirst({ where: eq(drizzleDb.schemas.database.agentDatabaseId, generatedId), with: { project: true, alertPolicies: true, storagePolicies: true } }); if (!database) { throw NextResponse.json( {error: "Database associated with generatedId not found"}, {status: 404} ); } return database; } ================================================ FILE: app/api/agent/[agentId]/backup/route.ts ================================================ import {NextResponse} from "next/server"; import {and, eq} from "drizzle-orm"; import * as drizzleDb from "@/db"; 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 {EventKind} from "@/features/notifications/types"; import {logger} from "@/lib/logger"; const log = logger.child({module: "api/agent/backup/route"}); export type BodyPost = { method: "manual" | "automatic" generatedId: string } export type BodyPatch = { backupId: string status: "success" | "failed" size: number generatedId: string } export const POST = withAgentCheck(async (request: Request, {params, agent}: { params: Promise<{ agentId: string }>, agent: any }) => { try { const body: BodyPost = await request.json(); const method = body.method const database = await getDatabaseOrThrow(body.generatedId); let backup: Backup | null | undefined = null; if (method === "automatic") { const ongoingBackup = await db.query.backup.findFirst({ where: and( eq(drizzleDb.schemas.backup.status, 'ongoing'), eq(drizzleDb.schemas.backup.databaseId, database.id), ), }); if (!ongoingBackup) { [backup] = await db .insert(drizzleDb.schemas.backup) .values({ status: 'ongoing', databaseId: database.id, }) .returning(); if (!backup) { return NextResponse.json( {error: "Unable to create an automatic backup"}, {status: 500} ); } } else { return NextResponse.json( {error: "A backup is already ongoing"}, {status: 500} ); } } else { backup = await db.query.backup.findFirst({ where: and( eq(drizzleDb.schemas.backup.status, 'ongoing'), eq(drizzleDb.schemas.backup.databaseId, database.id), ), }); if (!backup) { return NextResponse.json( {error: "Unable to find the corresponding backup"}, {status: 404} ); } } eventEmitter.emit('modification', {update: true}); return NextResponse.json( { message: "Init backup success", backup: backup, }, {status: 200} ); } catch (error) { log.error({error: error}, "Error in POST for INIT backup"); return NextResponse.json( {error: "Internal server error"}, {status: 500} ); } }); export const PATCH = withAgentCheck(async (request: Request, {params, agent}: { params: Promise<{ agentId: string }>, agent: any }) => { 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 const backupSize = body.size const database = await getDatabaseOrThrow(body.generatedId); const backup = await db.query.backup.findFirst({ where: eq(drizzleDb.schemas.backup.id, backupId), }); if (!backup) { return NextResponse.json( {error: "No backup found"}, {status: 500} ); } const [backupUpdated] = await dbClient .update(drizzleDb.schemas.backup) .set(withUpdatedAt({ status: status, fileSize: backupSize })) .where(eq(drizzleDb.schemas.backup.id, backup.id)) .returning(); eventEmitter.emit('modification', {update: true}); await sendNotificationsBackupRestore(database, status == "failed" ? "error_backup" : "success_backup" as EventKind); return NextResponse.json( { message: "Backup successfully updated", backup: backupUpdated, }, {status: 200} ); } catch (error) { log.error({error: error}, "Error in PATCH backup") return NextResponse.json( {error: "Internal server error"}, {status: 500} ); } }); ================================================ FILE: app/api/agent/[agentId]/backup/upload/init/route.ts ================================================ 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"; 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 }) => { try { const body: Body = await request.json(); 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}); 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} ); } }); ================================================ FILE: app/api/agent/[agentId]/backup/upload/status/route.ts ================================================ import {NextResponse} from "next/server"; import {and, eq} from "drizzle-orm"; 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 {logger} from "@/lib/logger"; const log = logger.child({module: "api/agent/backup/upload/status"}); export type Body = { generatedId: string status: "success" | "failed" backupStorageId: string path: string size: number backupId: string } export const PATCH = withAgentCheck(async (request: Request, {params, agent}: { params: Promise<{ agentId: string }>, agent: any }) => { try { const body: Body = await request.json(); const generatedId = body.generatedId; const status = body.status; const filePath = body.path; const fileSize = body.size; const backupStorageId = body.backupStorageId; const backupId = body.backupId; log.info({data: body}, "Body for backup upload status"); 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), ), with: { storages: true } }); if (!backup) { return NextResponse.json( {error: "Unable to find the corresponding backup"}, {status: 404} ); } const [backupStorage] = await dbClient .update(drizzleDb.schemas.backupStorage) .set(withUpdatedAt({ status: status, path: filePath, size: fileSize })) .where(eq(drizzleDb.schemas.backupStorage.id, backupStorageId)) .returning(); if (backup.storages.length > 0) { const hasSuccessfulStorage = backup.storages.some( (storage) => storage.status === "success" ); if (hasSuccessfulStorage && backup.status !== "success") { await db .update(drizzleDb.schemas.backup) .set(withUpdatedAt({ status: "success", fileSize: fileSize, })) .where(eq(drizzleDb.schemas.backup.id, backup.id)); } } eventEmitter.emit('modification', {update: true}); return NextResponse.json({ message: "Backup status successfully updated", 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} ); } }); ================================================ FILE: app/api/agent/[agentId]/restore/route.ts ================================================ import {NextResponse} from "next/server"; import {isUuidv4} from "@/utils/verify-uuid"; 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 {withUpdatedAt} from "@/db/utils"; const log = logger.child({module: "api/agent/restore"}); export type BodyResultRestore = { generatedId: string status: string } type RestorationStatus = 'waiting' | 'ongoing' | 'failed' | 'success'; export async function POST( request: Request, {params}: { params: Promise<{ agentId: string }> } ) { 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: 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}) } await db .update(drizzleDb.schemas.restoration) .set(withUpdatedAt({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} ); } } ================================================ FILE: app/api/agent/[agentId]/status/helpers.ts ================================================ 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 {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"; 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) => ({ generatedId: database.agentDatabaseId, dbms: database.dbms, storages: storages, encrypt: settings.encryption, data: { backup: { action: backupAction, cron: database.backupPolicy, }, restore: { action: restoreAction, file: UrlBackup, metaFile: urlMeta }, }, }); for (const db of body.databases) { const existingDatabase = await dbClient.query.database.findFirst({ where: eq(drizzleDb.schemas.database.agentDatabaseId, db.generatedId), with: { project: true } }); let backupAction: boolean = false let restoreAction: boolean = false let urlBackup: string | null = null; let urlMeta: string | null = null if (!existingDatabase) { if (!isUuidv4(db.generatedId)) { return NextResponse.json( {error: "generatedId is not a valid uuid"}, {status: 500} ); } if (!dbmsEnumSchema.safeParse(db.dbms).success) { log.error({name: "handleDatabases"},`Database type not available: ${db.dbms}`); continue; } const [databaseCreated] = await dbClient .insert(drizzleDb.schemas.database) .values({ agentId: agent.id, name: db.name, dbms: db.dbms as EDbmsSchema, agentDatabaseId: db.generatedId, lastContact: db.pingStatus ? lastContact : null, healthErrorCount: null }) .returning(); if (databaseCreated) { await dbClient .insert(drizzleDb.schemas.healthcheckLog) .values({ kind: "database", status: db.pingStatus ? "success" : "failed", objectId: databaseCreated.id, date: lastContact }) const storages = await getDatabaseStorageChannels(databaseCreated.id) databasesResponse.push(formatDatabase(databaseCreated, backupAction, restoreAction, urlBackup, storages, null)); } } else { const [databaseUpdated] = await dbClient .update(drizzleDb.schemas.database) .set(withUpdatedAt({ name: db.name, agentId: agent.id, dbms: db.dbms as EDbmsSchema, lastContact: db.pingStatus ? lastContact : existingDatabase.lastContact, healthErrorCount: db.pingStatus ? null : existingDatabase.healthErrorCount, })) .where(eq(drizzleDb.schemas.database.id, existingDatabase.id)) .returning(); await dbClient .insert(drizzleDb.schemas.healthcheckLog) .values({ kind: "database", status: db.pingStatus ? "success" : "failed", objectId: databaseUpdated.id, date: lastContact }) const activeBackup = await dbClient.query.backup.findFirst({ where: and( eq(drizzleDb.schemas.backup.databaseId, databaseUpdated.id), inArray(drizzleDb.schemas.backup.status, ["waiting", "ongoing"]) ) }) const restoration = await dbClient.query.restoration.findFirst({ where: and(eq(drizzleDb.schemas.restoration.databaseId, databaseUpdated.id), eq(drizzleDb.schemas.restoration.status, "waiting")), with: { backupStorage: true } }) if (activeBackup && activeBackup.status == "waiting") { backupAction = true await dbClient .update(drizzleDb.schemas.backup) .set(withUpdatedAt({status: "ongoing"})) .where(eq(drizzleDb.schemas.backup.id, activeBackup.id)); } if (restoration) { restoreAction = true if (!restoration.backupStorage || restoration.backupStorage.status != "success" || !restoration.backupStorage.path) { restoreAction = false continue; } const input: StorageInput = { action: "get", data: { path: restoration.backupStorage.path, signedUrl: true, }, metadata: { storageId: restoration.backupStorage.storageChannelId, fileKind: "backups" } }; const inputMeta: StorageInput = { action: "get", data: { path: `${restoration.backupStorage.path}.meta`, signedUrl: true, }, metadata: { storageId: restoration.backupStorage.storageChannelId, fileKind: "backups" } }; try { const result = await dispatchStorage(input, undefined, restoration.backupStorage.storageChannelId); const resultMeta = await dispatchStorage(inputMeta, undefined, restoration.backupStorage.storageChannelId); if (result.success) { urlBackup = result.url ?? null; urlMeta = resultMeta.url ?? null } else { await dbClient .update(drizzleDb.schemas.restoration) .set(withUpdatedAt({status: "failed"})) .where(eq(drizzleDb.schemas.restoration.id, restoration.id)); const errorMessage = "Failed to get backup URL"; log.error({error: errorMessage, name: "handleDatabases"}, "Restoration failed"); continue; } } catch (err) { log.error({error: err, name: "handleDatabases"}, "Restoration crashed unexpectedly"); await dbClient .update(drizzleDb.schemas.restoration) .set(withUpdatedAt({status: "failed"})) .where(eq(drizzleDb.schemas.restoration.id, restoration.id)); continue; } await dbClient .update(drizzleDb.schemas.restoration) .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)); } } return databasesResponse; } type PingDatabaseStorageChannels = { id: string; config: any provider: string } async function getDatabaseStorageChannels(databaseId: string): Promise { const database = await db.query.database.findFirst({ where: eq(drizzleDb.schemas.database.id, databaseId), with: { project: true, retentionPolicy: true, alertPolicies: true, storagePolicies: true } }); if (!database) { return [] } const settings = await db.query.setting.findFirst({ where: eq(drizzleDb.schemas.setting.name, "system"), with: {storageChannel: true}, }); const defaultStorageChannel: PingDatabaseStorageChannels[] = settings?.storageChannel ? [{ id: settings.storageChannel.id, provider: settings.storageChannel.provider, config: settings.storageChannel.config, }] : []; const enabledDatabaseStorageChannels = await Promise.all( (database.storagePolicies ?? []) .filter(p => p.enabled) .map(async policy => { const storageChannel = await db.query.storageChannel.findFirst({ where: eq(drizzleDb.schemas.storageChannel.id, policy.storageChannelId), }); if (!storageChannel) return null; return { id: storageChannel.id, config: storageChannel.config, provider: storageChannel.provider, } as PingDatabaseStorageChannels; }) ); const filteredChannels: PingDatabaseStorageChannels[] = enabledDatabaseStorageChannels.filter( (c): c is PingDatabaseStorageChannels => c !== null ); return filteredChannels.length > 0 ? filteredChannels : defaultStorageChannel; } ================================================ FILE: app/api/agent/[agentId]/status/route.ts ================================================ 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 {and, 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"}); export type databaseAgent = { name: string, dbms: EDbmsSchema, generatedId: string pingStatus: boolean } export type Body = { version: string, databases: databaseAgent[] } export async function POST( 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 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: 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} ); } } ================================================ FILE: app/api/auth/[...all]/route.ts ================================================ import { auth } from "@/lib/auth/auth"; import { toNextJsHandler } from "better-auth/next-js"; export const { GET, POST } = toNextJsHandler(auth.handler); ================================================ FILE: app/api/config/route.ts ================================================ import { NextResponse } from "next/server"; 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, }); } ================================================ FILE: app/api/events/route.ts ================================================ import {auth} from "@/lib/auth/auth"; import {headers} from "next/headers"; import {NextResponse} from "next/server"; import {eventEmitter} from "@/features/shared/event"; import {logger} from "@/lib/logger"; const log = logger.child({module: "api/events"}); export async function GET(request: Request) { const session = await auth.api.getSession({ headers: await headers(), }); if (!session) { return NextResponse.json({error: "Unauthorized"}, {status: 403}); } return new Response( new ReadableStream({ start(controller) { log.info("Stream started"); const handleModification = (data: any) => { log.info({data: data},"Modification event triggered"); controller.enqueue(`event: modification\n`); controller.enqueue(`data: ${JSON.stringify(data)}\n\n`); }; eventEmitter.on('modification', handleModification); request.signal.addEventListener('abort', () => { log.info("Client disconnected"); controller.close(); eventEmitter.off('modification', handleModification); }); }, }), { status: 200, headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive', }, } ); } ================================================ FILE: app/api/files/backups/route.ts ================================================ import {NextResponse} from "next/server"; import path from "path"; import type {StorageInput} from "@/features/storages/types"; import {dispatchStorage} from "@/features/storages/dispatch"; import {Readable} from "node:stream"; import {logger} from "@/lib/logger"; const log = logger.child({module: "api/files/backups"}); export async function GET( request: Request, ) { const {searchParams} = new URL(request.url); const token = searchParams.get('token'); const expires = searchParams.get('expires'); const pathFromUrl = searchParams.get('path'); const storageId = searchParams.get('storageId'); if (!pathFromUrl || !storageId) { return NextResponse.json({error: "Missing search params"}, {status: 404}) } const input: StorageInput = { action: "get", data: { path: pathFromUrl, signedUrl: true, }, metadata: { storageId: storageId, fileKind: "backups", } }; log.info({input: input}, "Dispatch Storage"); const result = await dispatchStorage(input, undefined, storageId); if (!result.success) { return NextResponse.json({error: "Enable to get file from provided storage channel, an error occurred !"}) } const fileName = path.basename(pathFromUrl); const crypto = require('crypto'); const expectedToken = crypto.createHash('sha256').update(`${fileName}${expires}`).digest('hex'); if (token !== expectedToken) { return NextResponse.json( {error: 'Invalid signed token'}, {status: 403} ); } const expiresAt = parseInt(expires!, 10); if (Date.now() > expiresAt) { return NextResponse.json( {error: 'Signed token expired'}, {status: 403} ); } if (!result.file || !(result.file instanceof Readable)) { return NextResponse.json( {error: "Invalid file payload"}, {status: 500} ); } const fileStream = Readable.from(result.file as Readable); const stream = new ReadableStream({ start(controller) { fileStream.on('data', (chunk) => controller.enqueue(chunk)); fileStream.on('end', () => controller.close()); fileStream.on('error', (err) => controller.error(err)); }, }); return new NextResponse(stream, { headers: { 'Content-Disposition': `attachment; filename="${fileName}"`, 'Content-Type': 'application/octet-stream', }, }); } ================================================ FILE: app/api/files/images/[fileName]/route.ts ================================================ 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 {Readable} from "node:stream"; import {logger} from "@/lib/logger"; const log = logger.child({module: "api/files/images"}); export async function GET( req: Request, {params}: { params: Promise<{ fileName: string }> } ) { const {searchParams} = new URL(req.url); const fileName = (await params).fileName; const storageId = searchParams.get('storageId'); if (!fileName) return NextResponse.json({error: "Missing file parameter"}, {status: 400}); const session = await auth.api.getSession({headers: await headers()}); if (!session) return NextResponse.json({error: "Unauthorized"}, {status: 403}); if (!storageId) { return NextResponse.json({error: "Missing storageId in search params"}, {status: 404}) } const ext = fileName.split(".").pop()?.toLowerCase(); const contentType = ext === "png" ? "image/png" : ext === "jpg" || ext === "jpeg" ? "image/jpeg" : ext === "gif" ? "image/gif" : ext === "webp" ? "image/webp" : "application/octet-stream"; try { const path = `images/${fileName}`; const input: StorageInput = { action: "get", data: { path: path, }, metadata: { storageId: storageId, fileKind: "images" } } const result = await dispatchStorage(input, undefined, storageId); if (!result.file || !(result.file instanceof Readable)) { log.error({error: result}, `An error occurred while getting file`); return NextResponse.json( {error: "Invalid file payload"}, {status: 500} ); } const fileStream = Readable.from(result.file as Readable); const stream = new ReadableStream({ start(controller) { fileStream.on('data', (chunk) => controller.enqueue(chunk)); fileStream.on('end', () => controller.close()); fileStream.on('error', (err) => controller.error(err)); }, }); return new NextResponse(stream, { headers: { 'Content-Disposition': `inline; filename="${fileName}"`, "Cache-Control": "no-store", "Content-Type": contentType, }, }); } catch (err) { log.error({error: err}, `Error streaming image`); return NextResponse.json({error: "Error fetching file"}, {status: 500}); } } ================================================ FILE: app/api/google/drive/callback/route.ts ================================================ export async function GET(request: Request) { const url = new URL(request.url); const code = url.searchParams.get("code"); const success = Boolean(code); return new Response( `

${success ? "Success" : "Failed"}

`, { status: success ? 200 : 400, headers: { "Content-Type": "text/html; charset=utf-8", }, } ); } ================================================ FILE: app/api/tus/hooks/route.ts ================================================ import {NextResponse} from "next/server"; import fs from "fs"; import path from "path"; import {env} from "@/env.mjs"; import {logger} from "@/lib/logger"; const log = logger.child({module: "api/tus/hooks"}); export async function POST(request: Request) { try { const body = await request.json(); const event = body.Event const headers = event.HTTPRequest.Header const uploadLength = headers["X-File-Size"]?.[0]; const uploadOffset = headers["Upload-Offset"]?.[0]; const status = headers["X-Status"]?.[0]; log.info(`Upload ID : ${event.Upload.ID} (${uploadOffset}/${uploadLength})`); if (status === "success") { if ( body.Type === "post-receive" && event.Upload.SizeIsDeferred === false && 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}); } const uploadDir = path.join(env.PRIVATE_PATH!, "/uploads/"); const oldFilePath = path.join(uploadDir, "tmp", id); const newFilePath = path.join(uploadDir, filePath); fs.mkdirSync(path.dirname(newFilePath), {recursive: true}); let retries = 10; while (!fs.existsSync(oldFilePath)) { if (retries-- === 0) { return NextResponse.json({error: `Upload file not found: ${oldFilePath}`}, {status: 500}); } await new Promise(r => setTimeout(r, 200)); } fs.renameSync(oldFilePath, newFilePath); const infoFilePath = `${oldFilePath}.info`; if (fs.existsSync(infoFilePath)) { fs.unlinkSync(infoFilePath); } const metadataHeaderB64 = headers["Upload-Metadata"]?.[0]; if (metadataHeaderB64) { const metadataHeader = Buffer.from(metadataHeaderB64, "base64").toString("utf-8"); if (metadataHeader) { const tomlContent = metadataHeader .split(",") .map((pair) => { const [key, value] = pair.split(" "); const escapedValue = value.replace(/"/g, '\\"'); return `${key} = "${escapedValue}"`; }) .join("\n"); const metaFilePath = `${newFilePath}.meta`; fs.writeFileSync(metaFilePath, tomlContent, "utf-8"); } } } } return NextResponse.json({}); } catch (error) { log.error({error: error},"TUS Hook error"); return NextResponse.json({error: "Internal server error"}, {status: 500}); } } ================================================ FILE: app/error/page.tsx ================================================ "use client"; import { Loader2 } from "lucide-react"; import { useRouter, useSearchParams } from "next/navigation"; import { useEffect } from "react"; import { useSession } from "@/lib/auth/auth-client"; export default function ErrorPage() { const router = useRouter(); const searchParams = useSearchParams(); const { data: session, isPending } = useSession(); useEffect(() => { if (isPending) { return; } const error = searchParams.get("error"); const params = new URLSearchParams(); if (error) { params.set("error", error); } const destination = session ? "/dashboard/home" : "/login"; router.replace(`${destination}?${params.toString()}`); }, [isPending, session, router, searchParams]); return (
); } ================================================ FILE: app/globals.css ================================================ @import "tailwindcss"; @import "tw-animate-css"; @custom-variant dark (&:is(.dark *)); @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); --font-sans: var(--font-poppins), ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; --font-title: var(--font-poppins), var(--font-poppins), sans-serif; --font-mono: var(--font-geist-mono); --font-author: var(--font-author); --font-poppins: var(--font-poppins); --color-sidebar-ring: var(--sidebar-ring); --color-sidebar-border: var(--sidebar-border); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-accent: var(--sidebar-accent); --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); --color-sidebar-primary: var(--sidebar-primary); --color-sidebar-foreground: var(--sidebar-foreground); --color-sidebar: var(--sidebar); --color-chart-5: var(--chart-5); --color-chart-4: var(--chart-4); --color-chart-3: var(--chart-3); --color-chart-2: var(--chart-2); --color-chart-1: var(--chart-1); --color-ring: var(--ring); --color-input: var(--input); --color-border: var(--border); --color-destructive: var(--destructive); --color-accent-foreground: var(--accent-foreground); --color-accent: var(--accent); --color-muted-foreground: var(--muted-foreground); --color-muted: var(--muted); --color-secondary-foreground: var(--secondary-foreground); --color-secondary: var(--secondary); --color-primary-foreground: var(--primary-foreground); --color-primary: var(--primary); --color-popover-foreground: var(--popover-foreground); --color-popover: var(--popover); --color-card-foreground: var(--card-foreground); --color-card: var(--card); --radius-sm: calc(var(--radius) - 4px); --radius-md: calc(var(--radius) - 2px); --radius-lg: var(--radius); --radius-xl: calc(var(--radius) + 4px); --animate-accordion-down: accordion-down 0.2s ease-out; --animate-accordion-up: accordion-up 0.2s ease-out; @keyframes accordion-down { from { height: 0; } to { height: var(--radix-accordion-content-height); } } @keyframes accordion-up { from { height: var(--radix-accordion-content-height); } to { height: 0; } } } :root { --radius: 0.625rem; --background: oklch(1 0 0); --foreground: oklch(0.145 0 0); --card: oklch(1 0 0); --card-foreground: oklch(0.145 0 0); --popover: oklch(1 0 0); --popover-foreground: oklch(0.145 0 0); --primary: oklch(0.611 0.204 42.149); --primary-foreground: oklch(0.985 0 0); --secondary: oklch(0.97 0 0); --secondary-foreground: oklch(0.205 0 0); --muted: oklch(0.97 0 0); --muted-foreground: oklch(0.556 0 0); --accent: oklch(0.97 0 0); --accent-foreground: oklch(0.205 0 0); --destructive: oklch(0.577 0.245 27.325); --border: oklch(0.922 0 0); --input: oklch(0.922 0 0); --ring: oklch(0.611 0.204 42.149); --chart-1: oklch(0.646 0.222 41.116); --chart-2: oklch(0.6 0.118 184.704); --chart-3: oklch(0.398 0.07 227.392); --chart-4: oklch(0.828 0.189 84.429); --chart-5: oklch(0.769 0.188 70.08); --sidebar: oklch(0.985 0 0); --sidebar-foreground: oklch(0.145 0 0); --sidebar-primary: oklch(0.611 0.204 42.149); --sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-accent: oklch(0.97 0 0); --sidebar-accent-foreground: oklch(0.205 0 0); --sidebar-border: oklch(0.922 0 0); --sidebar-ring: oklch(0.611 0.204 42.149); } .dark { --background: oklch(0.145 0 0); --foreground: oklch(0.985 0 0); --card: oklch(0.205 0 0); --card-foreground: oklch(0.985 0 0); --popover: oklch(0.205 0 0); --popover-foreground: oklch(0.985 0 0); --primary: oklch(0.704 0.191 47.132); --primary-foreground: oklch(0.985 0 0); --secondary: oklch(0.269 0 0); --secondary-foreground: oklch(0.985 0 0); --muted: oklch(0.269 0 0); --muted-foreground: oklch(0.708 0 0); --accent: oklch(0.269 0 0); --accent-foreground: oklch(0.985 0 0); --destructive: oklch(0.704 0.191 22.216); --border: oklch(1 0 0 / 10%); --input: oklch(1 0 0 / 15%); --ring: oklch(0.704 0.191 47.132); --chart-1: oklch(0.488 0.243 264.376); --chart-2: oklch(0.696 0.17 162.48); --chart-3: oklch(0.769 0.188 70.08); --chart-4: oklch(0.627 0.265 303.9); --chart-5: oklch(0.645 0.246 16.439); --sidebar: oklch(0.205 0 0); --sidebar-foreground: oklch(0.985 0 0); --sidebar-primary: oklch(0.704 0.191 47.132); --sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-accent: oklch(0.269 0 0); --sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-border: oklch(1 0 0 / 10%); --sidebar-ring: oklch(0.704 0.191 47.132); } @layer base { * { @apply border-border outline-ring/50; } body { @apply bg-background text-foreground font-sans; } h1, h2, h3, h4, h5, h6 { @apply font-title; } .scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; } .scrollbar-hide::-webkit-scrollbar { display: none; } } ================================================ FILE: app/layout.tsx ================================================ import type { Metadata } from "next"; import type React from "react"; import "./globals.css"; import { ConsoleSilencer } from "@/components/wrappers/common/console-silencer"; import { author, geistMono, poppins } from "@/fonts/fonts"; import { cn } from "@/lib/utils"; import { Providers } from "./providers"; const title = process.env.PROJECT_NAME ?? "Portabase"; export const metadata: Metadata = { title: { default: title, template: `%s - ${title}`, }, description: process.env.PROJECT_DESCRIPTION ?? undefined, }; export default async function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { return ( {children} ); } ================================================ FILE: app/manifest.json ================================================ { "name": "Portabase", "short_name": "Portabase", "icons": [ { "src": "/web-app-manifest-192x192.png", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, { "src": "/web-app-manifest-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" } ], "theme_color": "#ffffff", "background_color": "#ffffff", "display": "standalone" } ================================================ FILE: app/not-found.tsx ================================================ import BackButton from "@/components/wrappers/common/button/back-button"; export default async function NotFound() { return (

Not found

The content you are trying to view is not available.

Go home
); } ================================================ FILE: app/providers.tsx ================================================ "use client"; 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"; export type ProviderProps = PropsWithChildren<{}>; const queryClient = new QueryClient(); export const Providers = (props: ProviderProps) => { return ( {props.children} ); }; ================================================ FILE: components.json ================================================ { "$schema": "https://ui.shadcn.com/schema.json", "style": "new-york", "rsc": true, "tsx": true, "tailwind": { "config": "tailwind.config.ts", "css": "app/globals.css", "baseColor": "neutral", "cssVariables": true, "prefix": "" }, "iconLibrary": "radix", "aliases": { "components": "@/components", "utils": "@/lib/utils", "ui": "@/components/ui", "lib": "@/lib", "hooks": "@/hooks" }, "registries": { "@reui": "https://reui.io/r/{name}.json" } } ================================================ FILE: docker/dockerfile/Dockerfile ================================================ FROM --platform=$BUILDPLATFORM node:22-bullseye AS base RUN apt-get update && apt-get install -y \ curl \ ca-certificates \ bash \ tzdata \ libc6 \ build-essential \ g++ \ make \ autoconf \ 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 RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates \ && rm -rf /var/lib/apt/lists/* WORKDIR /build RUN git clone https://github.com/tus/tusd.git . \ && git checkout v${TUSD_VERSION} RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} \ go build -ldflags="-s -w" -o /tusddist/tusd ./cmd/tusd FROM scratch AS tusd-dist COPY --from=tusd-base /tusddist/tusd /tusd FROM base AS build-env RUN corepack enable && corepack prepare pnpm@latest --activate FROM build-env AS deps WORKDIR /app COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ RUN pnpm i --frozen-lockfile FROM build-env AS dev COPY --from=deps /app/node_modules ./node_modules COPY --from=tusd-base /usr/local/bin/tusd /usr/local/bin/tusd WORKDIR /app COPY . . USER root RUN chmod +x /app/docker/entrypoints/app-dev-entrypoint.sh ENTRYPOINT ["sh","/app/docker/entrypoints/app-dev-entrypoint.sh"] FROM build-env AS builder WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . ENV NEXT_TELEMETRY_DISABLED=1 RUN pnpm run build FROM base AS prod WORKDIR /app ENV NODE_ENV=production ENV NEXT_TELEMETRY_DISABLED=1 ENV PORT=80 ENV HOSTNAME="0.0.0.0" ENV PGDATA=/data/postgres ENV PRIVATE_PATH=/data/private RUN addgroup --system --gid 1001 nodejs RUN adduser --system --uid 1001 nextjs COPY --from=builder /app/public ./public COPY --from=builder /app/next.config.ts ./ COPY --from=builder /app/portabase.config.ts ./ 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 COPY docker/nginx/nginx.conf /etc/nginx/nginx.conf RUN mkdir -p .next /data/private/uploads \ && chown -R nextjs:nodejs .next /data/private /app/public USER root COPY ./docker/entrypoints/app-prod-entrypoint.sh /app/app-prod-entrypoint.sh RUN chmod +x /app/app-prod-entrypoint.sh EXPOSE 80 ENTRYPOINT ["sh","/app/app-prod-entrypoint.sh"] ================================================ FILE: docker/entrypoints/app-dev-entrypoint.sh ================================================ #!/bin/bash set -euo pipefail echo "▶ Running Drizzle codegen..." pnpm drizzle-kit generate echo "▶ Applying migrations..." pnpm drizzle-kit migrate echo "▶ Starting Next.js dev server..." exec pnpm dev ================================================ FILE: docker/entrypoints/app-prod-entrypoint.sh ================================================ #!/bin/bash if [ -n "$TZ" ]; then echo "[INFO] Application timezone set to $TZ (environment only)" export TZ="$TZ" else echo "[WARN] No TZ provided, using default container timezone" fi POSTGRES_BIN=$(ls -d /usr/lib/postgresql/*/bin | head -n 1) if [ -z "$POSTGRES_BIN" ]; then echo "PostgreSQL binaries not found" exit 1 fi export PATH="$POSTGRES_BIN:$PATH" if [ -z "$DATABASE_URL" ]; then echo "[INFO] No DATABASE_URL provided, starting internal Postgres..." mkdir -p "$PGDATA" chown -R postgres:postgres "$PGDATA" if [ ! -f "$PGDATA/PG_VERSION" ]; then echo "[INFO] Initializing database cluster..." if ! su postgres -c "initdb -D '$PGDATA'" > /dev/null 2>&1; then echo "[ERROR] initdb failed" exit 1 fi fi if ! su postgres -c "pg_ctl -D '$PGDATA' \ -o \"-c listen_addresses='localhost' -c logging_collector=on\" \ -l $PGDATA/postgres.log -w start" > /dev/null 2>&1; then echo "[ERROR] PostgreSQL failed to start" exit 1 fi until su postgres -c "pg_isready -h 127.0.0.1 -p 5432" > /dev/null 2>&1; do sleep 1 done echo "[INFO] PostgreSQL server is up and accepting connections" DB_USER="${POSTGRES_USER:-portabase_user}" DB_PASS="${POSTGRES_PASSWORD:-JaB6b1SUtIWYvt7srnOt}" DB_NAME="${POSTGRES_DB:-portabase_db}" USER_EXISTS=$(su postgres -c "psql -tAc \"SELECT 1 FROM pg_roles WHERE rolname='$DB_USER'\"" 2>/dev/null) if [ "$USER_EXISTS" != "1" ]; then if ! su postgres -c "psql -c \"CREATE USER $DB_USER WITH PASSWORD '$DB_PASS';\"" > /dev/null 2>&1; then echo "[ERROR] Failed creating user" exit 1 fi fi DB_EXISTS=$(su postgres -c "psql -tAc \"SELECT 1 FROM pg_database WHERE datname='$DB_NAME'\"" 2>/dev/null) if [ "$DB_EXISTS" != "1" ]; then if ! su postgres -c "psql -c \"CREATE DATABASE $DB_NAME OWNER $DB_USER;\"" > /dev/null 2>&1; then echo "[ERROR] Failed creating database" exit 1 fi fi export DATABASE_URL="postgres://$DB_USER:$DB_PASS@127.0.0.1:5432/$DB_NAME" echo "[SUCCESS] Internal PostgreSQL started successfully" echo "[SUCCESS] Database: $DB_NAME | User: $DB_USER | Host: 127.0.0.1:5432" 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 & echo "▶ Starting Next.js server..." PORT=3000 node server.js & echo "▶ Starting nginx..." exec nginx -g "daemon off;" ================================================ FILE: docker/nginx/nginx.conf ================================================ events {} http { client_max_body_size 20G; ignore_invalid_headers off; server { listen 80; location /tus/ { proxy_pass http://127.0.0.1:1080/tus/; proxy_pass_request_headers on; proxy_request_buffering off; proxy_buffering off; proxy_http_version 1.1; 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-For $proxy_add_x_forwarded_for; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; } location / { proxy_pass http://127.0.0.1:3000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; 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-For $proxy_add_x_forwarded_for; } } } ================================================ FILE: docker-compose.e2e.yml ================================================ 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: ================================================ FILE: docker-compose.func.yml ================================================ name: portabase-dev-func services: keycloak: image: quay.io/keycloak/keycloak:latest command: start-dev --import-realm # environment: # KC_BOOTSTRAP_ADMIN_USERNAME: admin # KC_BOOTSTRAP_ADMIN_PASSWORD: admin ports: - "3056:8080" volumes: - keycloak-data:/opt/keycloak/data - ./seeds/keycloak:/opt/keycloak/data/import:ro - ./export:/tmp/export pocket-id: image: ghcr.io/pocket-id/pocket-id restart: unless-stopped environment: - APP_URL=http://localhost:3055 - ENCRYPTION_KEY=QwHyjbZvSsDUAcjpdmSPsuYxaH6vET6OeBaeLwXccCb43L6Om3W1AoU5pKIJTzYr ports: - 3055:1411 volumes: - pocket-id-data:/app/data - ./seeds/pocket-id:/seed:ro healthcheck: test: "curl -f http://localhost:1411/healthz" interval: 1m30s timeout: 5s retries: 2 start_period: 10s volumes: keycloak-data: pocket-id-data: ================================================ FILE: docker-compose.prod.yml ================================================ services: app: # build: # context: . # dockerfile: docker/dockerfile/Dockerfile # target: prod image: portabase/portabase:1.7.1 ports: - '8887:80' environment: TZ: "Europe/Paris" env_file: - .env volumes: - portabase-data:/data depends_on: db: condition: service_healthy container_name: portabase-app-prod db: image: postgres:16-alpine ports: - "5433:5432" volumes: - postgres-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-data: postgres-data: ================================================ FILE: docker-compose.yml ================================================ name: portabase-dev services: db: image: postgres:17-alpine ports: - "5433:5432" volumes: - postgres-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 tusd: image: tusproject/tusd:v2.8.0 ports: - "1080:8080" command: > -upload-dir /data/uploads/tmp -hooks-http http://localhost:8887/api/tus/hooks -max-size 21474836480 -base-path /tus/files/ extra_hosts: - "localhost:host-gateway" volumes: - ./private/uploads/tmp:/data/uploads/tmp user: "1000:1000" volumes: postgres-data: ================================================ FILE: drizzle.config.ts ================================================ import { defineConfig } from "drizzle-kit"; import dotenv from "dotenv"; dotenv.config({ path: ".env", }); export default defineConfig({ out: "./src/db/migrations", schema: ["./src/db/schema", "./src/db/schema/types.ts"], dialect: "postgresql", dbCredentials: { url: process.env.DATABASE_URL!, }, }); ================================================ FILE: e2e/access-management.spec.ts ================================================ 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(); }); }); ================================================ FILE: e2e/agent.spec.ts ================================================ 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; } }); }); ================================================ FILE: e2e/auth.spec.ts ================================================ 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}) }) }) ================================================ FILE: e2e/cleanup.spec.ts ================================================ 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(?:\?.*)?$/); }); }); }); ================================================ FILE: e2e/helpers/access-management.ts ================================================ 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(); } ================================================ FILE: e2e/helpers/agent-cli.ts ================================================ 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((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", }, ]); } ================================================ FILE: e2e/helpers/agent.ts ================================================ 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(); } ================================================ FILE: e2e/helpers/auth.ts ================================================ import {Page} from "@playwright/test"; export type UserCredentials = { username: string email: string password: string } export const users: Record = { 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(); } ================================================ FILE: e2e/helpers/env.ts ================================================ 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; } ================================================ FILE: e2e/helpers/notification.ts ================================================ 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, 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(); } ================================================ FILE: e2e/helpers/project.ts ================================================ 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(); } ================================================ FILE: e2e/helpers/session.ts ================================================ export const LOCAL_STORAGE_PATH = "./e2e/local-storage.json"; ================================================ FILE: e2e/helpers/storage.ts ================================================ 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, 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(); } ================================================ FILE: e2e/notification/discord.spec.ts ================================================ 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); }); }); ================================================ FILE: e2e/notification/gotify.spec.ts ================================================ 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); }); }); ================================================ FILE: e2e/notification/ntfy.spec.ts ================================================ 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); }); }); ================================================ FILE: e2e/notification/slack.spec.ts ================================================ 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); }); }); ================================================ FILE: e2e/notification/smtp.spec.ts ================================================ 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); }); }); ================================================ FILE: e2e/notification/teams.spec.ts ================================================ ================================================ FILE: e2e/notification/telegram.spec.ts ================================================ 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); }); }); ================================================ FILE: e2e/notification/webhook.spec.ts ================================================ 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); }); }); ================================================ FILE: e2e/project.spec.ts ================================================ 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); }); }); ================================================ FILE: e2e/setup.spec.ts ================================================ 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 () => { }); }); ================================================ FILE: e2e/storage/azure.spec.ts ================================================ ================================================ FILE: e2e/storage/gcs.spec.ts ================================================ ================================================ FILE: e2e/storage/google-drive.spec.ts ================================================ 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); }); }); ================================================ FILE: e2e/storage/s3.spec.ts ================================================ 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 providers = [ { title: "AWS S3", requiredChannelName: "AWS S3 E2E Required", optionalChannelName: "AWS S3 E2E Optional", invalidChannelName: "AWS S3 E2E Invalid", endpointUrl: getEnv("E2E_STORAGE_AWS_S3_ENDPOINT_URL"), region: getEnv("E2E_STORAGE_AWS_S3_REGION"), accessKey: getEnv("E2E_STORAGE_AWS_S3_ACCESS_KEY"), secretKey: getEnv("E2E_STORAGE_AWS_S3_SECRET_KEY"), bucketName: getEnv("E2E_STORAGE_AWS_S3_BUCKET_NAME"), port: getEnv("E2E_STORAGE_AWS_S3_PORT"), invalidAccessKey: "wrong-aws-access-key", invalidSecretKey: "wrong-aws-secret-key", }, { title: "Cloudflare R2", requiredChannelName: "Cloudflare R2 E2E Required", optionalChannelName: "Cloudflare R2 E2E Optional", invalidChannelName: "Cloudflare R2 E2E Invalid", endpointUrl: getEnv("E2E_STORAGE_R2_ENDPOINT_URL"), region: getEnv("E2E_STORAGE_R2_REGION"), accessKey: getEnv("E2E_STORAGE_R2_ACCESS_KEY"), secretKey: getEnv("E2E_STORAGE_R2_SECRET_KEY"), bucketName: getEnv("E2E_STORAGE_R2_BUCKET_NAME"), port: getEnv("E2E_STORAGE_R2_PORT"), invalidAccessKey: "wrong-r2-access-key", invalidSecretKey: "wrong-r2-secret-key", }, ] as const; for (const provider of providers) { test.describe(provider.title, () => { // test.describe.serial("Valid channels", () => { // test(`Create and test a valid ${provider.title} channel`, async ({page}) => { // await page.goto("/dashboard/storages/channels"); // await expect(page.getByRole("heading", {name: "Storage channels"})).toBeVisible(); // await create(page, "S3", provider.requiredChannelName, async (page) => { // await page.getByLabel(/Endpoint URL/).fill(provider.endpointUrl); // await page.getByLabel(/Access Key/).fill(provider.accessKey); // await page.getByLabel(/Secret Key/).fill(provider.secretKey); // await page.getByLabel(/Bucket name/).fill(provider.bucketName); // }); // 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, provider.requiredChannelName)).toBeVisible(); // // await testFromEdit(page, provider.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(`Create and test a valid ${provider.title} channel with optional region and port`, async ({page}) => { // await page.goto("/dashboard/storages/channels"); // await expect(page.getByRole("heading", {name: "Storage channels"})).toBeVisible(); // await create(page, "S3", provider.optionalChannelName, async (page) => { // await page.getByLabel(/Endpoint URL/).fill(provider.endpointUrl); // await page.getByLabel(/^Region$/).fill(provider.region); // await page.getByLabel(/Access Key/).fill(provider.accessKey); // await page.getByLabel(/Secret Key/).fill(provider.secretKey); // await page.getByLabel(/Bucket name/).fill(provider.bucketName); // await page.getByLabel(/^Port$/).fill(provider.port); // }); // 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, provider.optionalChannelName)).toBeVisible(); // // await testFromEdit(page, provider.optionalChannelName); // 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 ${provider.title} channel`, async ({page}) => { await page.goto("/dashboard/storages/channels"); await expect(page.getByRole("heading", {name: "Storage channels"})).toBeVisible(); await create(page, "S3", provider.invalidChannelName, async (page) => { await page.getByLabel(/Endpoint URL/).fill(provider.endpointUrl); await page.getByLabel(/^Region$/).fill(provider.region); await page.getByLabel(/Access Key/).fill(provider.invalidAccessKey); await page.getByLabel(/Secret Key/).fill(provider.invalidSecretKey); await page.getByLabel(/Bucket name/).fill(provider.bucketName); await page.getByLabel(/^Port$/).fill(provider.port); }); await expect(page.getByRole("heading", {name: "Add Storage Channel"})).toBeVisible(); 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, provider.invalidChannelName)).toBeVisible(); }); test(`Delete invalid ${provider.title} channel`, async ({page}) => { await page.goto("/dashboard/storages/channels"); await expect(page.getByRole("heading", {name: "Storage channels"})).toBeVisible(); await expect(get(page, provider.invalidChannelName)).toBeVisible(); await remove(page, provider.invalidChannelName); await expect(page.getByText("Storage channel has been successfully removed.")).toBeVisible(); await expect(page.getByText(provider.invalidChannelName)).toHaveCount(0); }); }); }); } ================================================ FILE: eslint.config.mjs ================================================ import {defineConfig, globalIgnores} from 'eslint/config' import nextVitals from 'eslint-config-next/core-web-vitals' const eslintConfig = defineConfig([ ...nextVitals, globalIgnores([ '.next/**', 'out/**', 'build/**', 'next-env.d.ts', ]), ]) export default eslintConfig ================================================ FILE: helm/.helmignore ================================================ .DS_Store .git/ .gitignore .bzr/ .bzrignore .hg/ .hgignore .svn/ *.swp *.bak *.tmp *.orig *~ .project .idea/ *.tmproj .vscode/ ================================================ FILE: helm/Chart.yaml ================================================ apiVersion: v2 name: portabase description: Helm chart for Portabase type: application version: 0.0.0 appVersion: "latest" keywords: - postgresql - mariadb - mongodb - mysql - sqlite - backup - database - restore home: https://github.com/Portabase/portabase sources: - https://github.com/Portabase/portabase - https://github.com/Portabase/portabase/tree/main/helm maintainers: - name: Charles Gauthereau url: https://github.com/RambokDev - name: Killian Larcher url: https://github.com/KillianLarcher icon: https://raw.githubusercontent.com/Portabase/portabase/main/.github/assets/logo.png ================================================ FILE: helm/README.md ================================================ # Development Notes ## Check that Kubernetes is reachable locally ```bash kubectl get nodes ``` ## Install the local Portabase Helm chart ```bash helm install portabase . \ --set project.secret=$(openssl rand -hex 32) ``` ## Check the pods ```bash kubectl get pods ``` ## Check the services ```bash kubectl get svc ``` ## If the service type is ClusterIP, expose it locally using port forwarding: ```bash kubectl port-forward svc/portabase 8887:80 ``` ================================================ FILE: helm/templates/deployment.yaml ================================================ apiVersion: apps/v1 kind: Deployment metadata: name: portabase spec: replicas: 1 selector: matchLabels: app: portabase template: metadata: labels: app: portabase spec: containers: - name: portabase image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" ports: - containerPort: 80 env: - name: TZ value: {{ .Values.timezone }} - name: PROJECT_URL value: {{ .Values.project.url }} - name: PROJECT_SECRET value: {{ .Values.project.secret }} volumeMounts: - name: data mountPath: /data volumes: - name: data persistentVolumeClaim: claimName: portabase-data ================================================ FILE: helm/templates/pvc.yaml ================================================ apiVersion: v1 kind: PersistentVolumeClaim metadata: name: portabase-data spec: accessModes: - ReadWriteOnce resources: requests: storage: {{ .Values.persistence.size }} ================================================ FILE: helm/templates/service.yaml ================================================ apiVersion: v1 kind: Service metadata: name: portabase spec: type: {{ .Values.service.type }} selector: app: portabase ports: - port: {{ .Values.service.port }} targetPort: 80 nodePort: {{ .Values.service.nodePort }} ================================================ FILE: helm/values.yaml ================================================ image: repository: portabase/portabase tag: latest pullPolicy: Always replicaCount: 1 service: type: ClusterIP port: 80 targetPort: 80 resources: requests: memory: "1Gi" cpu: "1" limits: memory: "1Gi" cpu: "1" timezone: Europe/Paris project: url: http://localhost:8887 secret: "change_me_generate_secure_secret" persistence: enabled: true storageClassName: "" accessMode: ReadWriteOnce size: 10Gi mountPath: /data ingress: enabled: false className: nginx hosts: - host: portabase.example.com paths: - path: / pathType: Prefix tls: [] updateStrategy: type: RollingUpdate rollingUpdate: partition: 0 ================================================ FILE: instrumentation.ts ================================================ export async function register() { if (process.env.NEXT_RUNTIME === "nodejs") { const init = await import("@/utils/init"); await init.init(); } } ================================================ FILE: next.config.ts ================================================ import type {NextConfig} from "next"; import {PORTABASE_DEFAULT_SETTINGS} from "./portabase.config"; const isDev = process.env.NODE_ENV === "development"; function buildCSPHeader(): string { const {CSP} = PORTABASE_DEFAULT_SETTINGS.SECURITY; const directives = [ `default-src ${CSP.DEFAULT_SRC.join(" ")}`, `script-src ${CSP.SCRIPT_SRC.join(" ")}`, `style-src ${CSP.STYLE_SRC.join(" ")}`, `img-src ${CSP.IMG_SRC.join(" ")}`, `font-src ${CSP.FONT_SRC.join(" ")}`, `object-src ${CSP.OBJECT_SRC.join(" ")}`, `connect-src ${CSP.CONNECT_SRC.join(" ")}`, `base-uri ${CSP.BASE_URI.join(" ")}`, `form-action ${CSP.FORM_ACTION.join(" ")}`, `frame-ancestors ${CSP.FRAME_ANCESTORS.join(" ")}`, ]; if (CSP.BLOCK_ALL_MIXED_CONTENT) { directives.push("block-all-mixed-content"); } if (CSP.UPGRADE_INSECURE_REQUESTS) { directives.push("upgrade-insecure-requests"); } return directives.join("; "); } function buildPermissionsPolicy(): string { return Object.entries(PORTABASE_DEFAULT_SETTINGS.SECURITY.PERMISSIONS_POLICY) .map(([feature, values]) => `${feature.toLowerCase()}=${values.join(", ")}`) .join(", "); } const nextConfig: NextConfig = { output: "standalone", typescript: { ignoreBuildErrors: true, }, logging: { browserToTerminal: false, }, experimental: { serverActions: { bodySizeLimit: "10gb", }, proxyClientMaxBodySize: '10gb', }, async rewrites() { if (!isDev) return []; return [ { source: "/tus/:path*", destination: "http://localhost:1080/tus/:path*", }, ]; }, async headers() { return [ { source: "/(.*)", headers: [ { key: "Content-Security-Policy", value: buildCSPHeader(), }, { key: "Permissions-Policy", value: buildPermissionsPolicy(), }, { key: 'X-Content-Type-Options', value: 'nosniff', }, { key: 'X-Frame-Options', value: 'DENY', }, { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin', }, { key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains; preload', } // ...other security headers ], }, ]; }, }; export default nextConfig; ================================================ FILE: package.json ================================================ { "name": "portabase", "version": "1.13.0", "private": true, "scripts": { "dev": "next dev --turbopack -p 8887", "build": "next build --experimental-build-mode compile", "start": "next start", "lint": "next lint", "email": "email dev --dir ./src/components/emails", "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", "db:drop": "drizzle-kit drop", "auth:generate": "npx @better-auth/cli generate --config ./src/lib/auth/auth.ts", "release": "release-it" }, "dependencies": { "@better-auth/core": "1.6.2", "@better-auth/passkey": "^1.6.2", "@better-auth/sso": "^1.6.2", "@hookform/resolvers": "^5.2.2", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-aspect-ratio": "^1.1.8", "@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-hover-card": "^1.1.15", "@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-menubar": "^1.1.16", "@radix-ui/react-navigation-menu": "^1.2.14", "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-progress": "^1.1.8", "@radix-ui/react-radio-group": "^1.3.8", "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slider": "^1.3.6", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-toast": "^1.2.15", "@radix-ui/react-toggle": "^1.1.10", "@radix-ui/react-toggle-group": "^1.1.11", "@radix-ui/react-tooltip": "^1.2.8", "@react-email/components": "^0.0.41", "@t3-oss/env-nextjs": "^0.13.11", "@tanstack/react-query": "^5.97.0", "@tanstack/react-table": "^8.21.3", "@types/nodemailer": "^6.4.23", "@types/ws": "^8.18.1", "@zenstackhq/runtime": "2.14.2", "argon2": "^0.43.1", "bcrypt": "^6.0.0", "better-auth": "1.6.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", "date-fns": "^4.1.0", "dockerode": "^4.0.10", "dotenv": "^16.6.1", "drizzle-orm": "0.45.2", "drizzle-zod": "0.8.3", "embla-carousel-react": "^8.6.0", "googleapis": "^170.1.0", "input-otp": "^1.4.2", "lucide-react": "^0.553.0", "minio": "^8.0.7", "motion": "^12.38.0", "next": "16.2.3", "next-safe-action": "^7.10.8", "next-themes": "^0.4.6", "node-cron": "^4.2.1", "node-forge": "^1.4.0", "nodemailer": "8.0.5", "npm-check-updates": "^18.3.1", "pg": "^8.20.0", "pino": "^10.3.1", "pino-pretty": "^13.1.3", "prettier": "^3.8.2", "react": "^19.2.5", "react-day-picker": "9.7.0", "react-dom": "^19.2.5", "react-dropzone": "^14.4.1", "react-email": "^4.3.2", "react-hook-form": "^7.72.1", "react-qr-code": "^2.0.18", "react-resizable-panels": "^3.0.6", "react-twc": "^1.5.1", "react-use-measure": "^2.1.7", "recharts": "^2.15.4", "sharp": "^0.34.5", "socket.io": "^4.8.3", "socket.io-client": "^4.8.3", "sonner": "^2.0.7", "swiper": "^12.1.3", "tailwind-merge": "^3.5.0", "uuid": "^11.1.0", "vaul": "^1.1.2", "ws": "^8.20.0", "zod": "4.3.6" }, "devDependencies": { "@iconify/react": "^6.0.2", "@playwright/test": "1.58.2", "@react-email/preview-server": "4.3.2", "@react-email/render": "^2.0.6", "@release-it/bumper": "^7.0.5", "@release-it/conventional-changelog": "^10.0.6", "@tailwindcss/postcss": "^4.2.2", "@types/eslint-plugin-tailwindcss": "^3.17.0", "@types/node": "^22.19.17", "@types/node-forge": "^1.3.14", "@types/pg": "^8.20.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@zenstackhq/openapi": "^2.22.1", "@zenstackhq/tanstack-query": "^2.22.2", "baseline-browser-mapping": "^2.10.17", "drizzle-kit": "^0.31.10", "esbuild": "^0.27.7", "eslint": "^9.39.4", "eslint-config-next": "^16.2.3", "eslint-plugin-tailwindcss": "^3.18.2", "framer-motion": "^12.38.0", "node-pty": "^1.1.0", "postcss": "^8.5.9", "release-it": "^19.2.4", "tailwindcss": "^4.2.2", "tsx": "^4.21.0", "tw-animate-css": "^1.4.0", "typescript": "^5.9.3", "zenstack": "2.14.2" }, "packageManager": "pnpm@10.33.0" } ================================================ FILE: playwright.config.ts ================================================ import { defineConfig, devices } from '@playwright/test'; import dotenv from 'dotenv'; import path from 'path'; /** * Read environment variables from file. * https://github.com/motdotla/dotenv */ dotenv.config({ path: path.resolve(__dirname, '.env') }); /** * See https://playwright.dev/docs/test-configuration. */ export default defineConfig({ testDir: './e2e', /* Run tests in files in parallel */ fullyParallel: true, /* Fail the build on CI if you accidentally left test.only in the source code. */ forbidOnly: !!process.env.CI, /* Retry on CI only */ retries: process.env.CI ? 2 : 0, /* Opt out of parallel tests on CI. */ workers: process.env.CI ? 1 : undefined, /* Reporter to use. See https://playwright.dev/docs/test-reporters */ reporter: 'html', /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ use: { ...devices['Desktop Chrome'], baseURL: process.env.PROJECT_URL, /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ trace: 'on-first-retry', }, /* Configure projects and dependency order */ projects: [ { name: 'setup', testMatch: '**/setup.spec.ts', }, { name: 'auth', testMatch: '**/auth.spec.ts', dependencies: ['setup'], }, { name: 'access-management', testMatch: '**/access-management.spec.ts', dependencies: ['auth'], }, { name: 'notification', testMatch: '**/notification/**/*.spec.ts', dependencies: ['access-management'], }, { name: 'storage', testMatch: '**/storage/**/*.spec.ts', dependencies: ['access-management'], }, { name: 'agent', testMatch: '**/agent.spec.ts', dependencies: ['access-management'], }, { name: 'project', testMatch: '**/project.spec.ts', dependencies: ['access-management'], }, { name: 'cleanup', testMatch: '**/cleanup.spec.ts', dependencies: ['agent', 'storage', 'notification', 'project'], }, ] }); ================================================ FILE: pnpm-workspace.yaml ================================================ ignoredBuiltDependencies: - node-pty onlyBuiltDependencies: - '@prisma/client' - '@prisma/engines' - argon2 - bcrypt - cpu-features - esbuild - prisma - protobufjs - sharp - ssh2 - unrs-resolver - zenstack ================================================ FILE: portabase.config.ts ================================================ export const PORTABASE_DEFAULT_SETTINGS = { SECURITY: { CSP: { DEFAULT_SRC: ["'self'"], SCRIPT_SRC: [ "'self'", "'unsafe-eval'", "'unsafe-inline'", "https://cdn.jsdelivr.net", "https://www.googletagmanager.com", "https://code.iconify.design", "https://code.iconify.com", "https://cdn.iconify.design", "https://api.iconify.design", ], STYLE_SRC: [ "'self'", "'unsafe-inline'", "https://fonts.googleapis.com", "https://code.iconify.design", "https://cdn.iconify.design", "https://code.iconify.com", ], IMG_SRC: [ "'self'", "blob:", "data:", "https:", "https://code.iconify.design", "https://cdn.iconify.design", "https://code.iconify.com", "https://api.iconify.design", "http://localhost:9000", ], FONT_SRC: [ "'self'", "https://fonts.gstatic.com", "https://cdn.iconify.design", ], CONNECT_SRC: [ "'self'", "https://api.iconify.design", "https://code.iconify.design", "https://api.github.com", ], OBJECT_SRC: ["'none'"], BASE_URI: ["'self'"], FORM_ACTION: ["'self'"], FRAME_ANCESTORS: ["'none'"], BLOCK_ALL_MIXED_CONTENT: false, UPGRADE_INSECURE_REQUESTS: process.env.PROJECT_URL?.startsWith("https://") || false, }, PERMISSIONS_POLICY: { CAMERA: ["()"], MICROPHONE: ["()"], GEOLOCATION: ["()"], FULLSCREEN: ["(self)"], // ...other features }, }, }; ================================================ FILE: postcss.config.mjs ================================================ /** @type {import('postcss-load-config').Config} */ const config = { plugins: { '@tailwindcss/postcss': {}, }, }; export default config; ================================================ FILE: proxy.ts ================================================ import { NextRequest, NextResponse } from "next/server"; import { loggingMiddleware } from "@/middleware/loggingMiddleware"; import { errorHandler } from "@/middleware/errorHandler"; import { auth } from "@/lib/auth/auth"; import { headers } from "next/headers"; export async function proxy(request: NextRequest) { const url = request.nextUrl.clone(); const redirectUrl = encodeURIComponent(request.nextUrl.pathname); if (url.pathname.startsWith("/dashboard")) { const session = await auth.api.getSession({ headers: await headers(), }); if (!session) { return NextResponse.redirect( new URL(`/login?redirect=${redirectUrl}`, request.url), ); } if (session.user.banned) { await auth.api.signOut({ headers: await headers() }); return NextResponse.redirect(new URL("/login?error=banned", request.url)); } if (session.user.role === "pending") { await auth.api.signOut({ headers: await headers() }); return NextResponse.redirect( new URL(`/login?error=pending?redirect=${redirectUrl}`, request.url), ); } if (url.pathname === "/dashboard") { return NextResponse.redirect(new URL(`/dashboard/home`, request.url)); } return NextResponse.next(); } if (url.pathname.startsWith("/api/auth")) { return NextResponse.next(); } if (url.pathname.startsWith("/api")) { const routeExists = checkRouteExists(url.pathname); if (!routeExists) { return new NextResponse( JSON.stringify({ message: "This API route does not exist.", status: 404, }), { status: 404, headers: { "Content-Type": "application/json" }, }, ); } } try { loggingMiddleware(request); } catch (err) { errorHandler(err); } } function checkRouteExists(pathname: string) { const routePatterns = [ /^\/api\/agent\/[^/]+\/status\/?$/, /^\/api\/agent\/[^/]+\/backup\/?$/, /^\/api\/agent\/[^/]+\/backup\/upload\/init\/?$/, /^\/api\/agent\/[^/]+\/backup\/upload\/status\/?$/, /^\/api\/agent\/[^/]+\/restore\/?$/, /^\/api\/files\/images\/[^/]+\/?$/, /^\/api\/files\/backups\/?$/, /^\/api\/tus\/hooks\/?$/, /^\/api\/events\/?$/, /^\/api\/config\/?$/, /^\/api\/google\/drive\/callback\/?$/, ]; return routePatterns.some((pattern) => pattern.test(pathname)); } export const config = { matcher: ["/api/:path*", "/dashboard/:path*", "/dashboard"], }; ================================================ FILE: seeds/keycloak/master-realm.json ================================================ { "id" : "fb9cf3e7-ba35-48d2-b40e-8a48a98f8acd", "realm" : "master", "displayName" : "Portabase", "displayNameHtml" : "
Portabase
", "notBefore" : 0, "defaultSignatureAlgorithm" : "RS256", "revokeRefreshToken" : false, "refreshTokenMaxReuse" : 0, "accessTokenLifespan" : 60, "accessTokenLifespanForImplicitFlow" : 900, "ssoSessionIdleTimeout" : 1800, "ssoSessionMaxLifespan" : 36000, "ssoSessionIdleTimeoutRememberMe" : 0, "ssoSessionMaxLifespanRememberMe" : 0, "offlineSessionIdleTimeout" : 2592000, "offlineSessionMaxLifespanEnabled" : false, "offlineSessionMaxLifespan" : 5184000, "clientSessionIdleTimeout" : 0, "clientSessionMaxLifespan" : 0, "clientOfflineSessionIdleTimeout" : 0, "clientOfflineSessionMaxLifespan" : 0, "accessCodeLifespan" : 60, "accessCodeLifespanUserAction" : 300, "accessCodeLifespanLogin" : 1800, "actionTokenGeneratedByAdminLifespan" : 43200, "actionTokenGeneratedByUserLifespan" : 300, "oauth2DeviceCodeLifespan" : 600, "oauth2DevicePollingInterval" : 5, "enabled" : true, "sslRequired" : "none", "registrationAllowed" : false, "registrationEmailAsUsername" : false, "rememberMe" : false, "verifyEmail" : false, "loginWithEmailAllowed" : true, "duplicateEmailsAllowed" : false, "resetPasswordAllowed" : false, "editUsernameAllowed" : false, "bruteForceProtected" : false, "permanentLockout" : false, "maxTemporaryLockouts" : 0, "bruteForceStrategy" : "MULTIPLE", "maxFailureWaitSeconds" : 900, "minimumQuickLoginWaitSeconds" : 60, "waitIncrementSeconds" : 60, "quickLoginCheckMilliSeconds" : 1000, "maxDeltaTimeSeconds" : 43200, "failureFactor" : 30, "roles" : { "realm" : [ { "id" : "e2068803-d2fb-4955-928d-35afa1b552cf", "name" : "admin", "description" : "${role_admin}", "composite" : true, "composites" : { "realm" : [ "create-realm" ], "client" : { "master-realm" : [ "create-client", "view-users", "query-realms", "view-authorization", "view-identity-providers", "query-users", "query-groups", "manage-users", "manage-events", "view-clients", "view-realm", "manage-authorization", "manage-realm", "view-events", "impersonation", "query-clients", "manage-identity-providers", "manage-clients" ] } }, "clientRole" : false, "containerId" : "fb9cf3e7-ba35-48d2-b40e-8a48a98f8acd", "attributes" : { } }, { "id" : "1c6d4bb7-a9f0-4bf8-9780-d97e99de3c3d", "name" : "default-roles-master", "description" : "${role_default-roles}", "composite" : true, "composites" : { "realm" : [ "offline_access", "uma_authorization" ], "client" : { "account" : [ "view-profile", "manage-account" ] } }, "clientRole" : false, "containerId" : "fb9cf3e7-ba35-48d2-b40e-8a48a98f8acd", "attributes" : { } }, { "id" : "26712408-59c8-463f-bce1-d8f961b4bf51", "name" : "create-realm", "description" : "${role_create-realm}", "composite" : false, "clientRole" : false, "containerId" : "fb9cf3e7-ba35-48d2-b40e-8a48a98f8acd", "attributes" : { } }, { "id" : "546e3598-8de5-4e05-8823-5abe2efebf43", "name" : "offline_access", "description" : "${role_offline-access}", "composite" : false, "clientRole" : false, "containerId" : "fb9cf3e7-ba35-48d2-b40e-8a48a98f8acd", "attributes" : { } }, { "id" : "b8bf1864-b840-4377-9f6f-373a7f645dcc", "name" : "uma_authorization", "description" : "${role_uma_authorization}", "composite" : false, "clientRole" : false, "containerId" : "fb9cf3e7-ba35-48d2-b40e-8a48a98f8acd", "attributes" : { } } ], "client" : { "portabase" : [ { "id" : "180dc40c-1c09-49a2-a86b-782f1a2182c4", "name" : "uma_protection", "composite" : false, "clientRole" : true, "containerId" : "7b8c7b35-8ffd-47b3-82b5-b152b521cd77", "attributes" : { } } ], "security-admin-console" : [ ], "admin-cli" : [ ], "account-console" : [ ], "broker" : [ { "id" : "695d4423-e83d-403f-baf1-35076a456366", "name" : "read-token", "description" : "${role_read-token}", "composite" : false, "clientRole" : true, "containerId" : "af9b43bb-1902-4ed8-b4f6-caa7ec27efd5", "attributes" : { } } ], "master-realm" : [ { "id" : "27fbd47b-0350-4f11-a83c-a0134f9079b1", "name" : "create-client", "description" : "${role_create-client}", "composite" : false, "clientRole" : true, "containerId" : "6022c206-5f1f-45b7-af07-74ce6804cbc7", "attributes" : { } }, { "id" : "672a49fe-69f9-4371-b945-8bb539a76fa7", "name" : "view-users", "description" : "${role_view-users}", "composite" : true, "composites" : { "client" : { "master-realm" : [ "query-users", "query-groups" ] } }, "clientRole" : true, "containerId" : "6022c206-5f1f-45b7-af07-74ce6804cbc7", "attributes" : { } }, { "id" : "3df2803b-1be3-4446-ad55-de4a1654f0f1", "name" : "query-realms", "description" : "${role_query-realms}", "composite" : false, "clientRole" : true, "containerId" : "6022c206-5f1f-45b7-af07-74ce6804cbc7", "attributes" : { } }, { "id" : "963cdf89-9884-493f-b553-d095a190db70", "name" : "view-authorization", "description" : "${role_view-authorization}", "composite" : false, "clientRole" : true, "containerId" : "6022c206-5f1f-45b7-af07-74ce6804cbc7", "attributes" : { } }, { "id" : "0c7db742-fb5a-41ca-816a-ade107446ee8", "name" : "view-identity-providers", "description" : "${role_view-identity-providers}", "composite" : false, "clientRole" : true, "containerId" : "6022c206-5f1f-45b7-af07-74ce6804cbc7", "attributes" : { } }, { "id" : "9741b23a-9efa-4fbc-bfae-683f84997e8b", "name" : "query-users", "description" : "${role_query-users}", "composite" : false, "clientRole" : true, "containerId" : "6022c206-5f1f-45b7-af07-74ce6804cbc7", "attributes" : { } }, { "id" : "65439bfe-de32-499f-9f68-44ecd96ff49c", "name" : "query-groups", "description" : "${role_query-groups}", "composite" : false, "clientRole" : true, "containerId" : "6022c206-5f1f-45b7-af07-74ce6804cbc7", "attributes" : { } }, { "id" : "ecaab7fc-da4b-4613-8b7c-959ed4e225cc", "name" : "manage-events", "description" : "${role_manage-events}", "composite" : false, "clientRole" : true, "containerId" : "6022c206-5f1f-45b7-af07-74ce6804cbc7", "attributes" : { } }, { "id" : "e260a5cc-91c5-41db-86f8-01b494c4e66b", "name" : "manage-users", "description" : "${role_manage-users}", "composite" : false, "clientRole" : true, "containerId" : "6022c206-5f1f-45b7-af07-74ce6804cbc7", "attributes" : { } }, { "id" : "db6d0e0a-4193-4f6a-9343-d1f182fdda15", "name" : "view-clients", "description" : "${role_view-clients}", "composite" : true, "composites" : { "client" : { "master-realm" : [ "query-clients" ] } }, "clientRole" : true, "containerId" : "6022c206-5f1f-45b7-af07-74ce6804cbc7", "attributes" : { } }, { "id" : "f8bc7291-d450-48f6-820e-67a8ab2a6b5b", "name" : "view-realm", "description" : "${role_view-realm}", "composite" : false, "clientRole" : true, "containerId" : "6022c206-5f1f-45b7-af07-74ce6804cbc7", "attributes" : { } }, { "id" : "3444137f-0938-4340-9d5b-ceef2f7b1e97", "name" : "manage-authorization", "description" : "${role_manage-authorization}", "composite" : false, "clientRole" : true, "containerId" : "6022c206-5f1f-45b7-af07-74ce6804cbc7", "attributes" : { } }, { "id" : "a39bf436-1fa4-42d2-80ed-a010b43853aa", "name" : "manage-realm", "description" : "${role_manage-realm}", "composite" : false, "clientRole" : true, "containerId" : "6022c206-5f1f-45b7-af07-74ce6804cbc7", "attributes" : { } }, { "id" : "baa18f46-7d1d-4298-b3b0-9b0ecb945557", "name" : "view-events", "description" : "${role_view-events}", "composite" : false, "clientRole" : true, "containerId" : "6022c206-5f1f-45b7-af07-74ce6804cbc7", "attributes" : { } }, { "id" : "91298b5d-5182-4056-8240-8fdb188f865e", "name" : "impersonation", "description" : "${role_impersonation}", "composite" : false, "clientRole" : true, "containerId" : "6022c206-5f1f-45b7-af07-74ce6804cbc7", "attributes" : { } }, { "id" : "a5a58b82-32eb-4c78-993a-cf9c7e37eabb", "name" : "query-clients", "description" : "${role_query-clients}", "composite" : false, "clientRole" : true, "containerId" : "6022c206-5f1f-45b7-af07-74ce6804cbc7", "attributes" : { } }, { "id" : "c8c85c54-7bfb-4d45-8ccc-6253e429f649", "name" : "manage-clients", "description" : "${role_manage-clients}", "composite" : false, "clientRole" : true, "containerId" : "6022c206-5f1f-45b7-af07-74ce6804cbc7", "attributes" : { } }, { "id" : "baed486e-dc69-44a9-9630-9a88987142ee", "name" : "manage-identity-providers", "description" : "${role_manage-identity-providers}", "composite" : false, "clientRole" : true, "containerId" : "6022c206-5f1f-45b7-af07-74ce6804cbc7", "attributes" : { } } ], "account" : [ { "id" : "8085c714-b840-4539-823d-a8df8bab3db3", "name" : "view-applications", "description" : "${role_view-applications}", "composite" : false, "clientRole" : true, "containerId" : "18b5dfb2-44d4-448e-98df-9652daef1a22", "attributes" : { } }, { "id" : "12089b7a-e685-4c87-bb81-997ecc04ed17", "name" : "view-profile", "description" : "${role_view-profile}", "composite" : false, "clientRole" : true, "containerId" : "18b5dfb2-44d4-448e-98df-9652daef1a22", "attributes" : { } }, { "id" : "ea9e72d8-1d23-4f50-86b3-1795067da29f", "name" : "view-groups", "description" : "${role_view-groups}", "composite" : false, "clientRole" : true, "containerId" : "18b5dfb2-44d4-448e-98df-9652daef1a22", "attributes" : { } }, { "id" : "2bd8114a-75cf-4d6b-8f42-229f6a5c66a0", "name" : "delete-account", "description" : "${role_delete-account}", "composite" : false, "clientRole" : true, "containerId" : "18b5dfb2-44d4-448e-98df-9652daef1a22", "attributes" : { } }, { "id" : "7303b334-e9e0-4236-89b8-e1c9d6562138", "name" : "view-consent", "description" : "${role_view-consent}", "composite" : false, "clientRole" : true, "containerId" : "18b5dfb2-44d4-448e-98df-9652daef1a22", "attributes" : { } }, { "id" : "12c6fe35-ec80-4ca2-b2a8-9d6eae4c3a92", "name" : "manage-account-links", "description" : "${role_manage-account-links}", "composite" : false, "clientRole" : true, "containerId" : "18b5dfb2-44d4-448e-98df-9652daef1a22", "attributes" : { } }, { "id" : "e56b828b-7d5d-49a2-b686-c1bd535900ac", "name" : "manage-consent", "description" : "${role_manage-consent}", "composite" : true, "composites" : { "client" : { "account" : [ "view-consent" ] } }, "clientRole" : true, "containerId" : "18b5dfb2-44d4-448e-98df-9652daef1a22", "attributes" : { } }, { "id" : "98e19002-4144-48fc-bcca-4ac6f19a6a37", "name" : "manage-account", "description" : "${role_manage-account}", "composite" : true, "composites" : { "client" : { "account" : [ "manage-account-links" ] } }, "clientRole" : true, "containerId" : "18b5dfb2-44d4-448e-98df-9652daef1a22", "attributes" : { } } ] } }, "groups" : [ { "id" : "f523647e-76b3-493f-bb1e-f2a3f7deebdb", "name" : "Admin", "description" : "", "path" : "/Admin", "subGroups" : [ ], "attributes" : { }, "realmRoles" : [ "admin" ], "clientRoles" : { } }, { "id" : "f3b7286d-8169-41eb-b3a2-6241bf58907d", "name" : "Default", "description" : "", "path" : "/Default", "subGroups" : [ ], "attributes" : { }, "realmRoles" : [ ], "clientRoles" : { } }, { "id" : "eda8d5c9-d29a-4b35-9372-2b088b9a9d8d", "name" : "Pending", "description" : "", "path" : "/Pending", "subGroups" : [ ], "attributes" : { }, "realmRoles" : [ ], "clientRoles" : { } } ], "defaultRole" : { "id" : "1c6d4bb7-a9f0-4bf8-9780-d97e99de3c3d", "name" : "default-roles-master", "description" : "${role_default-roles}", "composite" : true, "clientRole" : false, "containerId" : "fb9cf3e7-ba35-48d2-b40e-8a48a98f8acd" }, "requiredCredentials" : [ "password" ], "otpPolicyType" : "totp", "otpPolicyAlgorithm" : "HmacSHA1", "otpPolicyInitialCounter" : 0, "otpPolicyDigits" : 6, "otpPolicyLookAheadWindow" : 1, "otpPolicyPeriod" : 30, "otpPolicyCodeReusable" : false, "otpSupportedApplications" : [ "totpAppFreeOTPName", "totpAppGoogleName", "totpAppMicrosoftAuthenticatorName" ], "localizationTexts" : { }, "webAuthnPolicyRpEntityName" : "keycloak", "webAuthnPolicySignatureAlgorithms" : [ "ES256", "RS256" ], "webAuthnPolicyRpId" : "", "webAuthnPolicyAttestationConveyancePreference" : "not specified", "webAuthnPolicyAuthenticatorAttachment" : "not specified", "webAuthnPolicyRequireResidentKey" : "not specified", "webAuthnPolicyUserVerificationRequirement" : "not specified", "webAuthnPolicyCreateTimeout" : 0, "webAuthnPolicyAvoidSameAuthenticatorRegister" : false, "webAuthnPolicyAcceptableAaguids" : [ ], "webAuthnPolicyExtraOrigins" : [ ], "webAuthnPolicyPasswordlessRpEntityName" : "keycloak", "webAuthnPolicyPasswordlessSignatureAlgorithms" : [ "ES256", "RS256" ], "webAuthnPolicyPasswordlessRpId" : "", "webAuthnPolicyPasswordlessAttestationConveyancePreference" : "not specified", "webAuthnPolicyPasswordlessAuthenticatorAttachment" : "not specified", "webAuthnPolicyPasswordlessRequireResidentKey" : "Yes", "webAuthnPolicyPasswordlessUserVerificationRequirement" : "required", "webAuthnPolicyPasswordlessCreateTimeout" : 0, "webAuthnPolicyPasswordlessAvoidSameAuthenticatorRegister" : false, "webAuthnPolicyPasswordlessAcceptableAaguids" : [ ], "webAuthnPolicyPasswordlessExtraOrigins" : [ ], "users" : [ { "id" : "9bd127a6-a29f-4a3d-a530-9b7284cf9770", "username" : "admin", "emailVerified" : false, "attributes" : { "is_temporary_admin" : [ "true" ] }, "enabled" : true, "createdTimestamp" : 1772178626982, "totp" : false, "credentials" : [ { "id" : "731ce7c2-9141-4ff1-8856-3e8625cf1929", "type" : "password", "createdDate" : 1772178627073, "secretData" : "{\"value\":\"quuOeVW9VVoilChYLmvjJWCnAM23OA/gK54tYoJ6atw=\",\"salt\":\"khqIggSOfCNLqP1i9qiwvQ==\",\"additionalParameters\":{}}", "credentialData" : "{\"hashIterations\":5,\"algorithm\":\"argon2\",\"additionalParameters\":{\"hashLength\":[\"32\"],\"memory\":[\"7168\"],\"type\":[\"id\"],\"version\":[\"1.3\"],\"parallelism\":[\"1\"]}}" } ], "disableableCredentialTypes" : [ ], "requiredActions" : [ ], "realmRoles" : [ "admin", "default-roles-master" ], "notBefore" : 0, "groups" : [ ] }, { "id" : "c62607eb-fe31-4ea4-b8cb-56c37039c1b3", "username" : "development", "firstName" : "Tech", "lastName" : "Portabase", "email" : "keycloak@portabase.io", "emailVerified" : true, "enabled" : true, "createdTimestamp" : 1772179382148, "totp" : false, "credentials" : [ { "id" : "b1cedcac-5207-4479-bc29-065ba849e414", "type" : "password", "userLabel" : "My password", "createdDate" : 1772179391727, "secretData" : "{\"value\":\"IXr6L3pvO2BAkn/cH3rh3v2tg1R83u2f8SWEZK2fq5E=\",\"salt\":\"XWV8/yKm7Rw/YXiwXZZUig==\",\"additionalParameters\":{}}", "credentialData" : "{\"hashIterations\":5,\"algorithm\":\"argon2\",\"additionalParameters\":{\"hashLength\":[\"32\"],\"memory\":[\"7168\"],\"type\":[\"id\"],\"version\":[\"1.3\"],\"parallelism\":[\"1\"]}}" } ], "disableableCredentialTypes" : [ ], "requiredActions" : [ ], "realmRoles" : [ "admin", "default-roles-master" ], "notBefore" : 0, "groups" : [ "/Admin" ] }, { "id" : "718dfe46-dbef-41a3-9120-78aeac240431", "username" : "service-account-portabase", "emailVerified" : false, "enabled" : true, "createdTimestamp" : 1772177793553, "totp" : false, "serviceAccountClientId" : "portabase", "credentials" : [ ], "disableableCredentialTypes" : [ ], "requiredActions" : [ ], "realmRoles" : [ "default-roles-master" ], "clientRoles" : { "portabase" : [ "uma_protection" ] }, "notBefore" : 0, "groups" : [ ] } ], "scopeMappings" : [ { "clientScope" : "offline_access", "roles" : [ "offline_access" ] } ], "clientScopeMappings" : { "account" : [ { "client" : "account-console", "roles" : [ "manage-account", "view-groups" ] } ] }, "clients" : [ { "id" : "18b5dfb2-44d4-448e-98df-9652daef1a22", "clientId" : "account", "name" : "${client_account}", "rootUrl" : "${authBaseUrl}", "baseUrl" : "/realms/master/account/", "surrogateAuthRequired" : false, "enabled" : true, "alwaysDisplayInConsole" : false, "clientAuthenticatorType" : "client-secret", "redirectUris" : [ "/realms/master/account/*" ], "webOrigins" : [ ], "notBefore" : 0, "bearerOnly" : false, "consentRequired" : false, "standardFlowEnabled" : true, "implicitFlowEnabled" : false, "directAccessGrantsEnabled" : false, "serviceAccountsEnabled" : false, "publicClient" : true, "frontchannelLogout" : false, "protocol" : "openid-connect", "attributes" : { "realm_client" : "false", "post.logout.redirect.uris" : "+" }, "authenticationFlowBindingOverrides" : { }, "fullScopeAllowed" : false, "nodeReRegistrationTimeout" : 0, "defaultClientScopes" : [ "web-origins", "acr", "profile", "roles", "basic", "email" ], "optionalClientScopes" : [ "address", "phone", "organization", "offline_access", "microprofile-jwt" ] }, { "id" : "b07531b2-43f4-4487-8068-46001dd1abf7", "clientId" : "account-console", "name" : "${client_account-console}", "rootUrl" : "${authBaseUrl}", "baseUrl" : "/realms/master/account/", "surrogateAuthRequired" : false, "enabled" : true, "alwaysDisplayInConsole" : false, "clientAuthenticatorType" : "client-secret", "redirectUris" : [ "/realms/master/account/*" ], "webOrigins" : [ ], "notBefore" : 0, "bearerOnly" : false, "consentRequired" : false, "standardFlowEnabled" : true, "implicitFlowEnabled" : false, "directAccessGrantsEnabled" : false, "serviceAccountsEnabled" : false, "publicClient" : true, "frontchannelLogout" : false, "protocol" : "openid-connect", "attributes" : { "realm_client" : "false", "post.logout.redirect.uris" : "+", "pkce.code.challenge.method" : "S256" }, "authenticationFlowBindingOverrides" : { }, "fullScopeAllowed" : false, "nodeReRegistrationTimeout" : 0, "protocolMappers" : [ { "id" : "ad646174-e521-4bd5-8f78-eb8eed2befb8", "name" : "audience resolve", "protocol" : "openid-connect", "protocolMapper" : "oidc-audience-resolve-mapper", "consentRequired" : false, "config" : { } } ], "defaultClientScopes" : [ "web-origins", "acr", "profile", "roles", "basic", "email" ], "optionalClientScopes" : [ "address", "phone", "organization", "offline_access", "microprofile-jwt" ] }, { "id" : "8ad89892-6f85-4f07-b6b6-1fcb1eda58e2", "clientId" : "admin-cli", "name" : "${client_admin-cli}", "surrogateAuthRequired" : false, "enabled" : true, "alwaysDisplayInConsole" : false, "clientAuthenticatorType" : "client-secret", "redirectUris" : [ ], "webOrigins" : [ ], "notBefore" : 0, "bearerOnly" : false, "consentRequired" : false, "standardFlowEnabled" : false, "implicitFlowEnabled" : false, "directAccessGrantsEnabled" : true, "serviceAccountsEnabled" : false, "publicClient" : true, "frontchannelLogout" : false, "protocol" : "openid-connect", "attributes" : { "realm_client" : "false", "client.use.lightweight.access.token.enabled" : "true", "post.logout.redirect.uris" : "+" }, "authenticationFlowBindingOverrides" : { }, "fullScopeAllowed" : true, "nodeReRegistrationTimeout" : 0, "defaultClientScopes" : [ "web-origins", "acr", "profile", "roles", "basic", "email" ], "optionalClientScopes" : [ "address", "phone", "organization", "offline_access", "microprofile-jwt" ] }, { "id" : "af9b43bb-1902-4ed8-b4f6-caa7ec27efd5", "clientId" : "broker", "name" : "${client_broker}", "surrogateAuthRequired" : false, "enabled" : true, "alwaysDisplayInConsole" : false, "clientAuthenticatorType" : "client-secret", "redirectUris" : [ ], "webOrigins" : [ ], "notBefore" : 0, "bearerOnly" : true, "consentRequired" : false, "standardFlowEnabled" : true, "implicitFlowEnabled" : false, "directAccessGrantsEnabled" : false, "serviceAccountsEnabled" : false, "publicClient" : false, "frontchannelLogout" : false, "protocol" : "openid-connect", "attributes" : { "realm_client" : "true", "post.logout.redirect.uris" : "+" }, "authenticationFlowBindingOverrides" : { }, "fullScopeAllowed" : false, "nodeReRegistrationTimeout" : 0, "defaultClientScopes" : [ "web-origins", "acr", "profile", "roles", "basic", "email" ], "optionalClientScopes" : [ "address", "phone", "organization", "offline_access", "microprofile-jwt" ] }, { "id" : "6022c206-5f1f-45b7-af07-74ce6804cbc7", "clientId" : "master-realm", "name" : "master Realm", "surrogateAuthRequired" : false, "enabled" : true, "alwaysDisplayInConsole" : false, "clientAuthenticatorType" : "client-secret", "redirectUris" : [ ], "webOrigins" : [ ], "notBefore" : 0, "bearerOnly" : true, "consentRequired" : false, "standardFlowEnabled" : true, "implicitFlowEnabled" : false, "directAccessGrantsEnabled" : false, "serviceAccountsEnabled" : false, "publicClient" : false, "frontchannelLogout" : false, "protocol" : "openid-connect", "attributes" : { "realm_client" : "true", "post.logout.redirect.uris" : "+" }, "authenticationFlowBindingOverrides" : { }, "fullScopeAllowed" : false, "nodeReRegistrationTimeout" : 0, "defaultClientScopes" : [ "web-origins", "acr", "profile", "roles", "basic", "email" ], "optionalClientScopes" : [ "address", "phone", "organization", "offline_access", "microprofile-jwt" ] }, { "id" : "7b8c7b35-8ffd-47b3-82b5-b152b521cd77", "clientId" : "portabase", "name" : "Portabase", "description" : "", "rootUrl" : "http://localhost:8887", "adminUrl" : "http://localhost:8887", "baseUrl" : "http://localhost:8887", "surrogateAuthRequired" : false, "enabled" : true, "alwaysDisplayInConsole" : false, "clientAuthenticatorType" : "client-secret", "secret" : "**********", "redirectUris" : [ "http://localhost:8887/api/auth/sso/callback/keycloak" ], "webOrigins" : [ "http://localhost:8887" ], "notBefore" : 0, "bearerOnly" : false, "consentRequired" : false, "standardFlowEnabled" : true, "implicitFlowEnabled" : false, "directAccessGrantsEnabled" : false, "serviceAccountsEnabled" : true, "authorizationServicesEnabled" : true, "publicClient" : false, "frontchannelLogout" : true, "protocol" : "openid-connect", "attributes" : { "realm_client" : "false", "oidc.ciba.grant.enabled" : "false", "client.secret.creation.time" : "1772177793", "backchannel.logout.session.required" : "true", "standard.token.exchange.enabled" : "false", "post.logout.redirect.uris" : "http://localhost:8887", "oauth2.device.authorization.grant.enabled" : "true", "pkce.code.challenge.method" : "S256", "backchannel.logout.revoke.offline.tokens" : "false", "dpop.bound.access.tokens" : "false" }, "authenticationFlowBindingOverrides" : { }, "fullScopeAllowed" : true, "nodeReRegistrationTimeout" : -1, "protocolMappers" : [ { "id" : "3e8f8909-e718-4ebe-80b9-ed6501e94cbe", "name" : "groups", "protocol" : "openid-connect", "protocolMapper" : "oidc-group-membership-mapper", "consentRequired" : false, "config" : { "full.path" : "false", "introspection.token.claim" : "true", "userinfo.token.claim" : "true", "multivalued" : "true", "id.token.claim" : "true", "lightweight.claim" : "false", "access.token.claim" : "true", "claim.name" : "groups" } } ], "defaultClientScopes" : [ "web-origins", "service_account", "acr", "profile", "roles", "basic", "email" ], "optionalClientScopes" : [ "address", "phone", "organization", "offline_access", "microprofile-jwt" ], "authorizationSettings" : { "allowRemoteResourceManagement" : true, "policyEnforcementMode" : "ENFORCING", "resources" : [ ], "policies" : [ ], "scopes" : [ ], "decisionStrategy" : "UNANIMOUS" } }, { "id" : "320637e2-0aa2-4601-8862-fe465e887167", "clientId" : "security-admin-console", "name" : "${client_security-admin-console}", "rootUrl" : "${authAdminUrl}", "baseUrl" : "/admin/master/console/", "surrogateAuthRequired" : false, "enabled" : true, "alwaysDisplayInConsole" : false, "clientAuthenticatorType" : "client-secret", "redirectUris" : [ "/admin/master/console/*" ], "webOrigins" : [ "+" ], "notBefore" : 0, "bearerOnly" : false, "consentRequired" : false, "standardFlowEnabled" : true, "implicitFlowEnabled" : false, "directAccessGrantsEnabled" : false, "serviceAccountsEnabled" : false, "publicClient" : true, "frontchannelLogout" : false, "protocol" : "openid-connect", "attributes" : { "realm_client" : "false", "client.use.lightweight.access.token.enabled" : "true", "post.logout.redirect.uris" : "+", "pkce.code.challenge.method" : "S256" }, "authenticationFlowBindingOverrides" : { }, "fullScopeAllowed" : true, "nodeReRegistrationTimeout" : 0, "protocolMappers" : [ { "id" : "59aa4c07-4e3b-4abd-947c-94e4840aab0f", "name" : "locale", "protocol" : "openid-connect", "protocolMapper" : "oidc-usermodel-attribute-mapper", "consentRequired" : false, "config" : { "introspection.token.claim" : "true", "userinfo.token.claim" : "true", "user.attribute" : "locale", "id.token.claim" : "true", "access.token.claim" : "true", "claim.name" : "locale", "jsonType.label" : "String" } } ], "defaultClientScopes" : [ "web-origins", "acr", "profile", "roles", "basic", "email" ], "optionalClientScopes" : [ "address", "phone", "organization", "offline_access", "microprofile-jwt" ] } ], "clientScopes" : [ { "id" : "1c5c7377-5552-4f36-8073-5b7c50ba0b58", "name" : "microprofile-jwt", "description" : "Microprofile - JWT built-in scope", "protocol" : "openid-connect", "attributes" : { "include.in.token.scope" : "true", "display.on.consent.screen" : "false" }, "protocolMappers" : [ { "id" : "d26ac380-69ea-47c8-9342-f8108b8b1c7a", "name" : "upn", "protocol" : "openid-connect", "protocolMapper" : "oidc-usermodel-attribute-mapper", "consentRequired" : false, "config" : { "introspection.token.claim" : "true", "userinfo.token.claim" : "true", "user.attribute" : "username", "id.token.claim" : "true", "access.token.claim" : "true", "claim.name" : "upn", "jsonType.label" : "String" } }, { "id" : "47e583ab-95d7-4b9e-83c3-37689f6808ff", "name" : "groups", "protocol" : "openid-connect", "protocolMapper" : "oidc-usermodel-realm-role-mapper", "consentRequired" : false, "config" : { "introspection.token.claim" : "true", "multivalued" : "true", "userinfo.token.claim" : "true", "user.attribute" : "foo", "id.token.claim" : "true", "access.token.claim" : "true", "claim.name" : "groups", "jsonType.label" : "String" } } ] }, { "id" : "b92d26a9-7930-4f6c-add6-082374546966", "name" : "roles", "description" : "OpenID Connect scope for add user roles to the access token", "protocol" : "openid-connect", "attributes" : { "include.in.token.scope" : "false", "consent.screen.text" : "${rolesScopeConsentText}", "display.on.consent.screen" : "true" }, "protocolMappers" : [ { "id" : "0bf151da-a754-460b-bc53-1d78249c3ac1", "name" : "audience resolve", "protocol" : "openid-connect", "protocolMapper" : "oidc-audience-resolve-mapper", "consentRequired" : false, "config" : { "introspection.token.claim" : "true", "access.token.claim" : "true" } }, { "id" : "a806de62-0e08-456f-9e16-76e8b186c724", "name" : "realm roles", "protocol" : "openid-connect", "protocolMapper" : "oidc-usermodel-realm-role-mapper", "consentRequired" : false, "config" : { "user.attribute" : "foo", "introspection.token.claim" : "true", "access.token.claim" : "true", "claim.name" : "realm_access.roles", "jsonType.label" : "String", "multivalued" : "true" } }, { "id" : "9063017e-d52e-4624-acff-22833103a577", "name" : "client roles", "protocol" : "openid-connect", "protocolMapper" : "oidc-usermodel-client-role-mapper", "consentRequired" : false, "config" : { "user.attribute" : "foo", "introspection.token.claim" : "true", "access.token.claim" : "true", "claim.name" : "resource_access.${client_id}.roles", "jsonType.label" : "String", "multivalued" : "true" } } ] }, { "id" : "dce5baba-c90f-4347-8b84-3162868c7ffa", "name" : "basic", "description" : "OpenID Connect scope for add all basic claims to the token", "protocol" : "openid-connect", "attributes" : { "include.in.token.scope" : "false", "display.on.consent.screen" : "false" }, "protocolMappers" : [ { "id" : "1457d324-6006-4141-b8a7-c45d7ab8814c", "name" : "auth_time", "protocol" : "openid-connect", "protocolMapper" : "oidc-usersessionmodel-note-mapper", "consentRequired" : false, "config" : { "user.session.note" : "AUTH_TIME", "introspection.token.claim" : "true", "userinfo.token.claim" : "true", "id.token.claim" : "true", "access.token.claim" : "true", "claim.name" : "auth_time", "jsonType.label" : "long" } }, { "id" : "0d3b31e5-31f5-4778-adbd-36a06b301af6", "name" : "sub", "protocol" : "openid-connect", "protocolMapper" : "oidc-sub-mapper", "consentRequired" : false, "config" : { "introspection.token.claim" : "true", "access.token.claim" : "true" } } ] }, { "id" : "f65dc085-328e-41d8-a3f3-933eed32c804", "name" : "email", "description" : "OpenID Connect built-in scope: email", "protocol" : "openid-connect", "attributes" : { "include.in.token.scope" : "true", "consent.screen.text" : "${emailScopeConsentText}", "display.on.consent.screen" : "true" }, "protocolMappers" : [ { "id" : "084130d1-642b-4b44-8643-60962e0a01d0", "name" : "email", "protocol" : "openid-connect", "protocolMapper" : "oidc-usermodel-attribute-mapper", "consentRequired" : false, "config" : { "introspection.token.claim" : "true", "userinfo.token.claim" : "true", "user.attribute" : "email", "id.token.claim" : "true", "access.token.claim" : "true", "claim.name" : "email", "jsonType.label" : "String" } }, { "id" : "894569d9-bf66-425e-a07e-892e97d4ac3d", "name" : "email verified", "protocol" : "openid-connect", "protocolMapper" : "oidc-usermodel-property-mapper", "consentRequired" : false, "config" : { "introspection.token.claim" : "true", "userinfo.token.claim" : "true", "user.attribute" : "emailVerified", "id.token.claim" : "true", "access.token.claim" : "true", "claim.name" : "email_verified", "jsonType.label" : "boolean" } } ] }, { "id" : "ae961041-beb6-4d8e-b9f2-b0a1f88423f6", "name" : "address", "description" : "OpenID Connect built-in scope: address", "protocol" : "openid-connect", "attributes" : { "include.in.token.scope" : "true", "consent.screen.text" : "${addressScopeConsentText}", "display.on.consent.screen" : "true" }, "protocolMappers" : [ { "id" : "84d4f934-f761-4741-bca5-b48581c6b2de", "name" : "address", "protocol" : "openid-connect", "protocolMapper" : "oidc-address-mapper", "consentRequired" : false, "config" : { "user.attribute.formatted" : "formatted", "user.attribute.country" : "country", "introspection.token.claim" : "true", "user.attribute.postal_code" : "postal_code", "userinfo.token.claim" : "true", "user.attribute.street" : "street", "id.token.claim" : "true", "user.attribute.region" : "region", "access.token.claim" : "true", "user.attribute.locality" : "locality" } } ] }, { "id" : "5e1cf5ad-00e6-4092-b8aa-76bb293a3efa", "name" : "saml_organization", "description" : "Organization Membership", "protocol" : "saml", "attributes" : { "display.on.consent.screen" : "false" }, "protocolMappers" : [ { "id" : "10efbc9e-4f21-42b7-832b-f5de7b3a666c", "name" : "organization", "protocol" : "saml", "protocolMapper" : "saml-organization-membership-mapper", "consentRequired" : false, "config" : { } } ] }, { "id" : "a1c75182-21f3-4e70-83f4-67e42f28267c", "name" : "web-origins", "description" : "OpenID Connect scope for add allowed web origins to the access token", "protocol" : "openid-connect", "attributes" : { "include.in.token.scope" : "false", "consent.screen.text" : "", "display.on.consent.screen" : "false" }, "protocolMappers" : [ { "id" : "1a570c6f-6f1a-4503-8677-5762b7b5578a", "name" : "allowed web origins", "protocol" : "openid-connect", "protocolMapper" : "oidc-allowed-origins-mapper", "consentRequired" : false, "config" : { "introspection.token.claim" : "true", "access.token.claim" : "true" } } ] }, { "id" : "4e350025-c16f-4a34-b132-4c6c07d0d90f", "name" : "profile", "description" : "OpenID Connect built-in scope: profile", "protocol" : "openid-connect", "attributes" : { "include.in.token.scope" : "true", "consent.screen.text" : "${profileScopeConsentText}", "display.on.consent.screen" : "true" }, "protocolMappers" : [ { "id" : "7b1a561c-f0d0-4979-ab02-0e9d564cb5af", "name" : "middle name", "protocol" : "openid-connect", "protocolMapper" : "oidc-usermodel-attribute-mapper", "consentRequired" : false, "config" : { "introspection.token.claim" : "true", "userinfo.token.claim" : "true", "user.attribute" : "middleName", "id.token.claim" : "true", "access.token.claim" : "true", "claim.name" : "middle_name", "jsonType.label" : "String" } }, { "id" : "ed49a16e-928f-4882-a8b6-835b83445349", "name" : "zoneinfo", "protocol" : "openid-connect", "protocolMapper" : "oidc-usermodel-attribute-mapper", "consentRequired" : false, "config" : { "introspection.token.claim" : "true", "userinfo.token.claim" : "true", "user.attribute" : "zoneinfo", "id.token.claim" : "true", "access.token.claim" : "true", "claim.name" : "zoneinfo", "jsonType.label" : "String" } }, { "id" : "0fea1c8d-77a9-4325-a414-ca503a702ea1", "name" : "family name", "protocol" : "openid-connect", "protocolMapper" : "oidc-usermodel-attribute-mapper", "consentRequired" : false, "config" : { "introspection.token.claim" : "true", "userinfo.token.claim" : "true", "user.attribute" : "lastName", "id.token.claim" : "true", "access.token.claim" : "true", "claim.name" : "family_name", "jsonType.label" : "String" } }, { "id" : "4ec1df3c-803c-4b3d-a96d-054f17b3745c", "name" : "gender", "protocol" : "openid-connect", "protocolMapper" : "oidc-usermodel-attribute-mapper", "consentRequired" : false, "config" : { "introspection.token.claim" : "true", "userinfo.token.claim" : "true", "user.attribute" : "gender", "id.token.claim" : "true", "access.token.claim" : "true", "claim.name" : "gender", "jsonType.label" : "String" } }, { "id" : "b066efb7-76f4-4a42-97ae-39cb8883898a", "name" : "nickname", "protocol" : "openid-connect", "protocolMapper" : "oidc-usermodel-attribute-mapper", "consentRequired" : false, "config" : { "introspection.token.claim" : "true", "userinfo.token.claim" : "true", "user.attribute" : "nickname", "id.token.claim" : "true", "access.token.claim" : "true", "claim.name" : "nickname", "jsonType.label" : "String" } }, { "id" : "0ef7cb1a-75f4-4b57-8614-3d35d7f93fc5", "name" : "birthdate", "protocol" : "openid-connect", "protocolMapper" : "oidc-usermodel-attribute-mapper", "consentRequired" : false, "config" : { "introspection.token.claim" : "true", "userinfo.token.claim" : "true", "user.attribute" : "birthdate", "id.token.claim" : "true", "access.token.claim" : "true", "claim.name" : "birthdate", "jsonType.label" : "String" } }, { "id" : "dd8c2607-67ab-4f25-96cc-19c9751a0e03", "name" : "profile", "protocol" : "openid-connect", "protocolMapper" : "oidc-usermodel-attribute-mapper", "consentRequired" : false, "config" : { "introspection.token.claim" : "true", "userinfo.token.claim" : "true", "user.attribute" : "profile", "id.token.claim" : "true", "access.token.claim" : "true", "claim.name" : "profile", "jsonType.label" : "String" } }, { "id" : "f2fdb527-5bb8-4c41-a260-7b8f2653baa8", "name" : "updated at", "protocol" : "openid-connect", "protocolMapper" : "oidc-usermodel-attribute-mapper", "consentRequired" : false, "config" : { "introspection.token.claim" : "true", "userinfo.token.claim" : "true", "user.attribute" : "updatedAt", "id.token.claim" : "true", "access.token.claim" : "true", "claim.name" : "updated_at", "jsonType.label" : "long" } }, { "id" : "672a0701-67a0-4aa4-bbeb-93fa325d2921", "name" : "username", "protocol" : "openid-connect", "protocolMapper" : "oidc-usermodel-attribute-mapper", "consentRequired" : false, "config" : { "introspection.token.claim" : "true", "userinfo.token.claim" : "true", "user.attribute" : "username", "id.token.claim" : "true", "access.token.claim" : "true", "claim.name" : "preferred_username", "jsonType.label" : "String" } }, { "id" : "a3ca4541-9326-4b34-b59d-5f6f366e2ff9", "name" : "website", "protocol" : "openid-connect", "protocolMapper" : "oidc-usermodel-attribute-mapper", "consentRequired" : false, "config" : { "introspection.token.claim" : "true", "userinfo.token.claim" : "true", "user.attribute" : "website", "id.token.claim" : "true", "access.token.claim" : "true", "claim.name" : "website", "jsonType.label" : "String" } }, { "id" : "ccdc393d-6aee-4c88-b071-b708f4cf2b05", "name" : "locale", "protocol" : "openid-connect", "protocolMapper" : "oidc-usermodel-attribute-mapper", "consentRequired" : false, "config" : { "introspection.token.claim" : "true", "userinfo.token.claim" : "true", "user.attribute" : "locale", "id.token.claim" : "true", "access.token.claim" : "true", "claim.name" : "locale", "jsonType.label" : "String" } }, { "id" : "cbf3d8ee-c2d9-445d-8662-279fd1160c80", "name" : "full name", "protocol" : "openid-connect", "protocolMapper" : "oidc-full-name-mapper", "consentRequired" : false, "config" : { "id.token.claim" : "true", "introspection.token.claim" : "true", "access.token.claim" : "true", "userinfo.token.claim" : "true" } }, { "id" : "fc8b06a9-e4fc-4381-830a-2e03f8982ff6", "name" : "given name", "protocol" : "openid-connect", "protocolMapper" : "oidc-usermodel-attribute-mapper", "consentRequired" : false, "config" : { "introspection.token.claim" : "true", "userinfo.token.claim" : "true", "user.attribute" : "firstName", "id.token.claim" : "true", "access.token.claim" : "true", "claim.name" : "given_name", "jsonType.label" : "String" } }, { "id" : "a46cc2ac-d92c-4b61-b119-910b4e96e456", "name" : "picture", "protocol" : "openid-connect", "protocolMapper" : "oidc-usermodel-attribute-mapper", "consentRequired" : false, "config" : { "introspection.token.claim" : "true", "userinfo.token.claim" : "true", "user.attribute" : "picture", "id.token.claim" : "true", "access.token.claim" : "true", "claim.name" : "picture", "jsonType.label" : "String" } } ] }, { "id" : "2b7175c7-d59b-4dea-9759-405299298c21", "name" : "organization", "description" : "Additional claims about the organization a subject belongs to", "protocol" : "openid-connect", "attributes" : { "include.in.token.scope" : "true", "consent.screen.text" : "${organizationScopeConsentText}", "display.on.consent.screen" : "true" }, "protocolMappers" : [ { "id" : "cd6a70e3-ebb8-4892-975e-4bece535c387", "name" : "organization", "protocol" : "openid-connect", "protocolMapper" : "oidc-organization-membership-mapper", "consentRequired" : false, "config" : { "introspection.token.claim" : "true", "multivalued" : "true", "userinfo.token.claim" : "true", "id.token.claim" : "true", "access.token.claim" : "true", "claim.name" : "organization", "jsonType.label" : "String" } } ] }, { "id" : "074912fc-e8b4-4369-8842-d8e44479e5d7", "name" : "role_list", "description" : "SAML role list", "protocol" : "saml", "attributes" : { "consent.screen.text" : "${samlRoleListScopeConsentText}", "display.on.consent.screen" : "true" }, "protocolMappers" : [ { "id" : "8e4761c8-96b5-422b-b9ef-f18970f9a677", "name" : "role list", "protocol" : "saml", "protocolMapper" : "saml-role-list-mapper", "consentRequired" : false, "config" : { "single" : "false", "attribute.nameformat" : "Basic", "attribute.name" : "Role" } } ] }, { "id" : "8b3b526e-ea3d-497a-b346-5852ddd29d01", "name" : "service_account", "description" : "Specific scope for a client enabled for service accounts", "protocol" : "openid-connect", "attributes" : { "include.in.token.scope" : "false", "display.on.consent.screen" : "false" }, "protocolMappers" : [ { "id" : "7e347974-b093-4a66-8fe0-8bdbab526232", "name" : "Client Host", "protocol" : "openid-connect", "protocolMapper" : "oidc-usersessionmodel-note-mapper", "consentRequired" : false, "config" : { "user.session.note" : "clientHost", "introspection.token.claim" : "true", "userinfo.token.claim" : "true", "id.token.claim" : "true", "access.token.claim" : "true", "claim.name" : "clientHost", "jsonType.label" : "String" } }, { "id" : "0a4fe9bc-c5b1-45fc-80c1-7de78eda92ab", "name" : "Client IP Address", "protocol" : "openid-connect", "protocolMapper" : "oidc-usersessionmodel-note-mapper", "consentRequired" : false, "config" : { "user.session.note" : "clientAddress", "introspection.token.claim" : "true", "userinfo.token.claim" : "true", "id.token.claim" : "true", "access.token.claim" : "true", "claim.name" : "clientAddress", "jsonType.label" : "String" } }, { "id" : "9b86ae58-d68d-4be7-9e48-3113a22caf5f", "name" : "Client ID", "protocol" : "openid-connect", "protocolMapper" : "oidc-usersessionmodel-note-mapper", "consentRequired" : false, "config" : { "user.session.note" : "client_id", "introspection.token.claim" : "true", "userinfo.token.claim" : "true", "id.token.claim" : "true", "access.token.claim" : "true", "claim.name" : "client_id", "jsonType.label" : "String" } } ] }, { "id" : "09e32dbb-120d-44bc-89eb-803b2caf35fc", "name" : "phone", "description" : "OpenID Connect built-in scope: phone", "protocol" : "openid-connect", "attributes" : { "include.in.token.scope" : "true", "consent.screen.text" : "${phoneScopeConsentText}", "display.on.consent.screen" : "true" }, "protocolMappers" : [ { "id" : "83dc1340-1f91-47eb-8bb5-d324084687f2", "name" : "phone number verified", "protocol" : "openid-connect", "protocolMapper" : "oidc-usermodel-attribute-mapper", "consentRequired" : false, "config" : { "introspection.token.claim" : "true", "userinfo.token.claim" : "true", "user.attribute" : "phoneNumberVerified", "id.token.claim" : "true", "access.token.claim" : "true", "claim.name" : "phone_number_verified", "jsonType.label" : "boolean" } }, { "id" : "014dfa26-791d-414b-bfb6-0b08605bf30f", "name" : "phone number", "protocol" : "openid-connect", "protocolMapper" : "oidc-usermodel-attribute-mapper", "consentRequired" : false, "config" : { "introspection.token.claim" : "true", "userinfo.token.claim" : "true", "user.attribute" : "phoneNumber", "id.token.claim" : "true", "access.token.claim" : "true", "claim.name" : "phone_number", "jsonType.label" : "String" } } ] }, { "id" : "e582a1a0-4ad9-4bbd-b41e-a503b435da3a", "name" : "acr", "description" : "OpenID Connect scope for add acr (authentication context class reference) to the token", "protocol" : "openid-connect", "attributes" : { "include.in.token.scope" : "false", "display.on.consent.screen" : "false" }, "protocolMappers" : [ { "id" : "f762a161-89f2-47a7-83cb-686362280a14", "name" : "acr loa level", "protocol" : "openid-connect", "protocolMapper" : "oidc-acr-mapper", "consentRequired" : false, "config" : { "id.token.claim" : "true", "introspection.token.claim" : "true", "access.token.claim" : "true", "userinfo.token.claim" : "true" } } ] }, { "id" : "b288b9a7-e8a8-4b0a-8972-af6083835858", "name" : "offline_access", "description" : "OpenID Connect built-in scope: offline_access", "protocol" : "openid-connect", "attributes" : { "consent.screen.text" : "${offlineAccessScopeConsentText}", "display.on.consent.screen" : "true" } } ], "defaultDefaultClientScopes" : [ "role_list", "saml_organization", "profile", "email", "roles", "web-origins", "acr", "basic" ], "defaultOptionalClientScopes" : [ "offline_access", "address", "phone", "microprofile-jwt", "organization" ], "browserSecurityHeaders" : { "contentSecurityPolicyReportOnly" : "", "xContentTypeOptions" : "nosniff", "referrerPolicy" : "no-referrer", "xRobotsTag" : "none", "xFrameOptions" : "SAMEORIGIN", "contentSecurityPolicy" : "frame-src 'self'; frame-ancestors 'self'; object-src 'none';", "strictTransportSecurity" : "max-age=31536000; includeSubDomains" }, "smtpServer" : { }, "eventsEnabled" : false, "eventsListeners" : [ "jboss-logging" ], "enabledEventTypes" : [ ], "adminEventsEnabled" : false, "adminEventsDetailsEnabled" : false, "identityProviders" : [ ], "identityProviderMappers" : [ ], "components" : { "org.keycloak.services.clientregistration.policy.ClientRegistrationPolicy" : [ { "id" : "c7eaaf0f-7650-4fc1-968a-889667759858", "name" : "Allowed Client Scopes", "providerId" : "allowed-client-templates", "subType" : "anonymous", "subComponents" : { }, "config" : { "allow-default-scopes" : [ "true" ] } }, { "id" : "1f0661c3-25de-4ff6-92bf-dbb36264a71b", "name" : "Max Clients Limit", "providerId" : "max-clients", "subType" : "anonymous", "subComponents" : { }, "config" : { "max-clients" : [ "200" ] } }, { "id" : "a889dafe-f9d0-4728-bdf5-9421d1077d1f", "name" : "Consent Required", "providerId" : "consent-required", "subType" : "anonymous", "subComponents" : { }, "config" : { } }, { "id" : "4790f0a4-3450-4dbf-9153-0a1805e02d98", "name" : "Allowed Registration Web Origins", "providerId" : "registration-web-origins", "subType" : "authenticated", "subComponents" : { }, "config" : { } }, { "id" : "4084bb89-71f9-4363-a5dd-dfa74e638d80", "name" : "Allowed Protocol Mapper Types", "providerId" : "allowed-protocol-mappers", "subType" : "anonymous", "subComponents" : { }, "config" : { "allowed-protocol-mapper-types" : [ "saml-user-attribute-mapper", "oidc-sha256-pairwise-sub-mapper", "oidc-address-mapper", "saml-role-list-mapper", "oidc-usermodel-attribute-mapper", "oidc-usermodel-property-mapper", "saml-user-property-mapper", "oidc-full-name-mapper" ] } }, { "id" : "dba96a52-5743-47d2-b5f5-42452892b0bd", "name" : "Allowed Client Scopes", "providerId" : "allowed-client-templates", "subType" : "authenticated", "subComponents" : { }, "config" : { "allow-default-scopes" : [ "true" ] } }, { "id" : "85b9458c-d890-48b8-87b7-7d3d5f331d5f", "name" : "Allowed Protocol Mapper Types", "providerId" : "allowed-protocol-mappers", "subType" : "authenticated", "subComponents" : { }, "config" : { "allowed-protocol-mapper-types" : [ "saml-user-attribute-mapper", "oidc-full-name-mapper", "saml-user-property-mapper", "oidc-usermodel-property-mapper", "oidc-sha256-pairwise-sub-mapper", "saml-role-list-mapper", "oidc-usermodel-attribute-mapper", "oidc-address-mapper" ] } }, { "id" : "181cd220-28a9-4a5e-b2d7-c9092c26ef67", "name" : "Full Scope Disabled", "providerId" : "scope", "subType" : "anonymous", "subComponents" : { }, "config" : { } }, { "id" : "be0c3922-b432-408c-935b-81923b0e7018", "name" : "Trusted Hosts", "providerId" : "trusted-hosts", "subType" : "anonymous", "subComponents" : { }, "config" : { "host-sending-registration-request-must-match" : [ "true" ], "client-uris-must-match" : [ "true" ] } }, { "id" : "81fe5bf6-4e18-40b8-8cf6-46028fb6704c", "name" : "Allowed Registration Web Origins", "providerId" : "registration-web-origins", "subType" : "anonymous", "subComponents" : { }, "config" : { } } ], "org.keycloak.userprofile.UserProfileProvider" : [ { "id" : "bd8f87c6-ffc2-401d-baab-cccb228e9b50", "providerId" : "declarative-user-profile", "subComponents" : { }, "config" : { "kc.user.profile.config" : [ "{\"attributes\":[{\"name\":\"username\",\"displayName\":\"${username}\",\"validations\":{\"length\":{\"min\":3,\"max\":255},\"username-prohibited-characters\":{},\"up-username-not-idn-homograph\":{}},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"multivalued\":false},{\"name\":\"email\",\"displayName\":\"${email}\",\"validations\":{\"email\":{},\"length\":{\"max\":255}},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"multivalued\":false},{\"name\":\"firstName\",\"displayName\":\"${firstName}\",\"validations\":{\"length\":{\"max\":255},\"person-name-prohibited-characters\":{}},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"multivalued\":false},{\"name\":\"lastName\",\"displayName\":\"${lastName}\",\"validations\":{\"length\":{\"max\":255},\"person-name-prohibited-characters\":{}},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"multivalued\":false}],\"groups\":[{\"name\":\"user-metadata\",\"displayHeader\":\"User metadata\",\"displayDescription\":\"Attributes, which refer to user metadata\"}]}" ] } } ], "org.keycloak.keys.KeyProvider" : [ { "id" : "2068dd28-1706-4fc6-8751-ead7dc26cf90", "name" : "aes-generated", "providerId" : "aes-generated", "subComponents" : { }, "config" : { "kid" : [ "81bfaacd-ca1f-4b68-a1b2-80102555f5d6" ], "secret" : [ "XEHl3aG9cESD4wedDu3uAg" ], "priority" : [ "100" ] } }, { "id" : "4bc97a67-b5fd-4bd5-9df9-7366efc0e091", "name" : "hmac-generated-hs512", "providerId" : "hmac-generated", "subComponents" : { }, "config" : { "kid" : [ "31342681-e4fb-428d-bd54-07a77581ec75" ], "secret" : [ "UhZlVtujVRkaFsvdVAOGPTEsNIwAJIOfmJDjQeciaQuWIGKWd-RBdKcS0II053kVswbJRGitqc-e0iqBfhZrUBrQyUmmcrCJT09BiaKCicdgRwYrb8fhYagiIg-70jk7fc3j3VINu5eeKjkTiDyfAf_0HS62-8eO4e98A5IW6QE" ], "priority" : [ "100" ], "algorithm" : [ "HS512" ] } }, { "id" : "1cf333a1-b495-47b5-94f4-138e34e92ec7", "name" : "rsa-generated", "providerId" : "rsa-generated", "subComponents" : { }, "config" : { "privateKey" : [ "MIIEpAIBAAKCAQEAvQ5kJj9mHLGm5zMvW5kYgSbEVvZYWcmMJKMQRWni4RdvbrKpkgsLv6gkH0xiwKU5aaUmk/e6hAZO2UvZ8Tp180VBhk2BXtS21Jm7KbZSVtQBFf33zVqjEXB2d89E1R6RlopzF4+Y6X1xxPnJ1UGxM0yT8uSrGB4CbJaNO4YH6/vUsJWoNXS9MJKFIvm0HP6t+o6DvWGn5HjLK7jhhF6iATZpp0JwqkG5Y+xo6TObx3m11aCR1HrFqlVg6zC3Ht4lhlQh3VhKcoO80gRAURU7WjOOAwauJViYrOF6eAsbMbpTD8EAyXL/4KCFWoITwRpHYNXwSzdR/BvgFlsJwLxJGwIDAQABAoIBABTzpleyyP8/FO8kdgghtFyDzliQ3oO82WIqDCCVNoaZkUjkVfSQctHfsXkifwM4jF9P5TGaz7nX9R8Rz+py/yVhtHxM1JyM2GJBU0OxJ/jlb+VfCvpgJBhHrWljuA4iYCId39lpmZmuxE//GsYejKPRxceyXd/DW6NrH+XL2c7mHe0AznAEfDeIsKWRDCoOjgm2xfpjU204HGLGmU+qTzH4EeZPWvpCnXWZiNBrxSiBUdauCMz6jWBZXW173rfd430t9JMu7zuPqFpcZ/RsLw3blHoPWpbpeULloxfSgxcYD+KP+oCk8t2oubTDGX2ES7OVfSbgy5yMYPbg7g+G97kCgYEA7Jj4d4tdTCOfk5kdqU2R7LB5IX3gsCohmp6FH3yPA2JBQ95Mk6IK15mIvvCXF4zyDvESsevjjQSlEhmYOQ1Us5MdvKZVtM/EgIZmI+3Z+r5M39QWQ8XYkOJr1DT990ed+QVF7H0khT7nmvmVzDYjPE1qtTtzadhwxY8r0Kcmew0CgYEAzI9bpm5rvpf3jfTPZeKb8pc1/4zlUqV0kH+P5BHnssXBX8nVJNgUxcv5xFZrL3MVr5BIYrXSh5qzYduGXkjsjCvo0zdhPmGvRYeL13xJi8EijbK78eORpdx5dN1Oht36+THtWSYnOyFU1gYZEdb3JC0Sc9TwjDPh5RWWLp2MqscCgYEAq85xLy3uc+myaVXYqiZ//qYvb9ieno4ZlNPjy3eBym1BA22boeEbinAdUroWna1l4N/COZ0XwkFLNReM6HD7vuLnxyPqPMBa6xGtfg2sKl6iKC80c8ZpetxQfOp2OWiyGDByFEbTjEafnHP9CSuO7q0w/aXMK2JWkb9ji8K1OMkCgYBmFPB2uMn5/hpi9BV/0btjL4SZ9/UE7l6iMZZcCXdn5noe1DkSvuZ24tjM2xd4QWVEDKui3vumAlScdBG1AY1SUiNJLGzR1avC9eaabYLCRGp67gQOrTMk40aVRE+IzEQPkZPRllGjl3mfqMFf/resjPWVamF0hfun98LPln35RwKBgQCrD+b/1kcSdHRUsaG04vEK8Qpm86E9GV8eLorMJv+gaOAAQXAgxQTp1QXlK/1A11Fj5CYTYg5vQtI16K3KYuF2tdhRR5LRlp0NDK7wf4QNhuK9OQfroHoETngOXGnA2p/RMaDKkAgh49GLT7vMo98ztjsrcRQqNOMGya8L1k48Jg==" ], "certificate" : [ "MIICmzCCAYMCBgGcnhPYNDANBgkqhkiG9w0BAQsFADARMQ8wDQYDVQQDDAZtYXN0ZXIwHhcNMjYwMjI3MDc0ODQ2WhcNMzYwMjI3MDc1MDI2WjARMQ8wDQYDVQQDDAZtYXN0ZXIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC9DmQmP2YcsabnMy9bmRiBJsRW9lhZyYwkoxBFaeLhF29usqmSCwu/qCQfTGLApTlppSaT97qEBk7ZS9nxOnXzRUGGTYFe1LbUmbsptlJW1AEV/ffNWqMRcHZ3z0TVHpGWinMXj5jpfXHE+cnVQbEzTJPy5KsYHgJslo07hgfr+9Swlag1dL0wkoUi+bQc/q36joO9YafkeMsruOGEXqIBNmmnQnCqQblj7GjpM5vHebXVoJHUesWqVWDrMLce3iWGVCHdWEpyg7zSBEBRFTtaM44DBq4lWJis4Xp4CxsxulMPwQDJcv/goIVaghPBGkdg1fBLN1H8G+AWWwnAvEkbAgMBAAEwDQYJKoZIhvcNAQELBQADggEBABWqPOyc4ReF3EnjSaD3n6Fk0ZVGDJZ6/du2cqlFm9rFEVlnJ0GS7Cz8an88PTge098gxM3+NSCjfgDN3B2QO//H3SdwjmGvKm1hghQVYvfFafW/Cat/nNqm68K6MCGNGP67OeNMLyl3OAL68SRNM1hoNbCplDt6HhX5s1Cn/iZPEQ7t4o6tt/aO/07E+3WfS3+PNqvt7jPrf0y5OlZ7oCAN0cnegSkbanvZehBlRpLkWhxphlAR90PomrW59Ok60EITscXr1jD1vlOgrvnw41y1L5eSvO3g19rKgqB5NrniavRdonP605109SQacZ31g7vmH/2qvV3MUuT9UBoRRFI=" ], "priority" : [ "100" ] } }, { "id" : "b68cba6a-7e0b-42be-a3aa-25d5c342801a", "name" : "rsa-enc-generated", "providerId" : "rsa-enc-generated", "subComponents" : { }, "config" : { "privateKey" : [ "MIIEowIBAAKCAQEApFfSvqZKE30r0gu77DbLv5P6NBiEsCC30KlQp4gbyyHgQuHFWhPyUNJKi0NJGjyFzkw7cztmB7uJOa9oc54QQITdlzLEQ1Z8zvsv6hG5aNDwSLgCNiKHaIAxe6XmCYwhQX3LNGaUidf/a9JS+Kr/WMjQb9Y2NGbKqM5V2vAskq/p7CR2TLVShX2iGlPVO34qYbJSA6nF98n0x2ZxAdj8P77HqWJs0z/wRGxEFPmoWP4QR+b9q9f2ewr7EmIXOi7ZsxNF4g0CCo4zqUD4VGnGABl158zx+9ONedgBTnZNwnb9iM++bFt5exuvjbQ86jpnPrMR1yhIVKGSAddUlbV9IwIDAQABAoIBAE569BSAM7K/3SvG17yTZFQ99cdoCAFV9oHKQU5nxKIKMN6vkz+Tc+2dpuR/QsspKNrd86vxKyW5LGxkNBS2YFt3N5yrLSddB2gOcxCr2ydPU9feK4wvjAte4IKENGjNxtnQGTiSXg+/muWiAGZovlznNQabPLJkfhYDxuMxO4/mdcNSX3riJ/u6MtUS2BZMxOZWvZZ+t1boakJsn7LUycXngpMlZBrivZ9+bckD75iEAsTlwAzj2Tm1VgycmtwcOwvNAL/Eb35yqemBggYNqjxpdi+3Mm2OgHdWmWIdXpHbuYcw/FaKUKIFJ9JiBZYeDYK2Adb8pHQh050assU6m+ECgYEA5Xe0M6a/yL+j+0lpM9xrz4R21c67uy9Tg70l8/8N1rtam+M7hqtHYMwvtWZ1pSFSoxZ+6iVlI2xg5BqSX1tkV5QNtHcOlrrgysBWPXzz6oyrG+j2pAJ2bvNd+haCu2WOANPbs03bIc4/PUiPevXGKBf0ZuJ22P78GCp+/VZt4YMCgYEAt1hq2ufPSlbYJ3uItmBBPoIHvHDm4MELqjCSPqTo4BKrPdmlDv7p3pAG8+D/8K2jZEDRMyOatEnUH64RqQBuCs9YgcrRqkrzxhnqLGKllH+Z3QOwT+xCntkAnxI8fULeAbOJvw8Nb1dAtTODY+wrvmUEhvbyYQ76guzqtSTfQ+ECgYEA09acf4qTRurUoel1u7DjvqIVavD9kqLwQJBf908hIXm4/mzayUpaDNyzto2uUhHfTjw4UkTPh9JH6I03T9z5V3iQ5md7Cl/foo3Jj95I9+GBHbUF7Qdw+qClw3kAm6v3WoA7NN7NS/oxm4vfGa7Hjr/+mvS6rz1G0bB5p7sgma0CgYAtXwoCZf8cLGWNT7rDNwquR0tWzLG1yM/0K9Tk/7ZJTRVnVubL0TVayFWQIIv1qWKXupqKhzMPjn2Z4V+pbNvOfQUwCVrdQ3MUAPG8TiUfnHwc/36wKI1L9fN7ae3iKZv6280opLb0aKkwrjDDl3wzv5fhNldAwY4ovCxQ63D8wQKBgE6ZR/2bPYAa0O5pA7lPGSKfKYZY4U4zNwhgTAgzm2b7ONyJSP00yVwXoBX4j8qHbotVQY+8VMfFo49t3JCJ61g/Q/T3Uyd43fz1LzoQKFInDe1B7yXfPkEL4R7rllVmEvhoszgivYtevIoE//ZeHiMAn53nBJFgGuQyk7BMx7z0" ], "certificate" : [ "MIICmzCCAYMCBgGcnhPYrjANBgkqhkiG9w0BAQsFADARMQ8wDQYDVQQDDAZtYXN0ZXIwHhcNMjYwMjI3MDc0ODQ2WhcNMzYwMjI3MDc1MDI2WjARMQ8wDQYDVQQDDAZtYXN0ZXIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCkV9K+pkoTfSvSC7vsNsu/k/o0GISwILfQqVCniBvLIeBC4cVaE/JQ0kqLQ0kaPIXOTDtzO2YHu4k5r2hznhBAhN2XMsRDVnzO+y/qEblo0PBIuAI2IodogDF7peYJjCFBfcs0ZpSJ1/9r0lL4qv9YyNBv1jY0ZsqozlXa8CySr+nsJHZMtVKFfaIaU9U7fiphslIDqcX3yfTHZnEB2Pw/vsepYmzTP/BEbEQU+ahY/hBH5v2r1/Z7CvsSYhc6LtmzE0XiDQIKjjOpQPhUacYAGXXnzPH704152AFOdk3Cdv2Iz75sW3l7G6+NtDzqOmc+sxHXKEhUoZIB11SVtX0jAgMBAAEwDQYJKoZIhvcNAQELBQADggEBAKNZLkRF4xSD7KltDoHn3ND+ouDaK5AQH4uZtbp5Rl+3beqwOpnPEJmCzhuhvij6eVngviL0Ga8pFIBzX7zQu2v8oxTFIbGvAYBH9NNkA8R/qsaosC4C/mEmY/H9UDGUXp1VSFI8e08n2GlnVoll9t1wCvhyaMqpFqglmD8SccGTKiBKeSD4v+SWN19+Wi4KOvI5wqR0KKrvlmNWDxbsYzsUbU/gII7Y5nsUB3L2RvugXssX8+zgTYuyBoygEgdXcOKsyWRQ/vmGr9ZE+8RuBsWTS0at8m3SN/nCkarboVFLhnqbRVpV5uLrx4LVRkGNmVCXKccf3Poh+DbxgLD/kNU=" ], "priority" : [ "100" ], "algorithm" : [ "RSA-OAEP" ] } } ] }, "internationalizationEnabled" : false, "authenticationFlows" : [ { "id" : "f1ec49bd-55dd-4584-9438-bedf328b356d", "alias" : "Account verification options", "description" : "Method with which to verify the existing account", "providerId" : "basic-flow", "topLevel" : false, "builtIn" : true, "authenticationExecutions" : [ { "authenticator" : "idp-email-verification", "authenticatorFlow" : false, "requirement" : "ALTERNATIVE", "priority" : 10, "autheticatorFlow" : false, "userSetupAllowed" : false }, { "authenticatorFlow" : true, "requirement" : "ALTERNATIVE", "priority" : 20, "autheticatorFlow" : true, "flowAlias" : "Verify Existing Account by Re-authentication", "userSetupAllowed" : false } ] }, { "id" : "72f9d538-d665-468f-8c06-cc6ef863d699", "alias" : "Browser - Conditional 2FA", "description" : "Flow to determine if any 2FA is required for the authentication", "providerId" : "basic-flow", "topLevel" : false, "builtIn" : true, "authenticationExecutions" : [ { "authenticator" : "conditional-user-configured", "authenticatorFlow" : false, "requirement" : "REQUIRED", "priority" : 10, "autheticatorFlow" : false, "userSetupAllowed" : false }, { "authenticatorConfig" : "browser-conditional-credential", "authenticator" : "conditional-credential", "authenticatorFlow" : false, "requirement" : "REQUIRED", "priority" : 20, "autheticatorFlow" : false, "userSetupAllowed" : false }, { "authenticator" : "auth-otp-form", "authenticatorFlow" : false, "requirement" : "ALTERNATIVE", "priority" : 30, "autheticatorFlow" : false, "userSetupAllowed" : false }, { "authenticator" : "webauthn-authenticator", "authenticatorFlow" : false, "requirement" : "DISABLED", "priority" : 40, "autheticatorFlow" : false, "userSetupAllowed" : false }, { "authenticator" : "auth-recovery-authn-code-form", "authenticatorFlow" : false, "requirement" : "DISABLED", "priority" : 50, "autheticatorFlow" : false, "userSetupAllowed" : false } ] }, { "id" : "b7e3c8f7-17bf-4a76-8921-2afb316272dd", "alias" : "Direct Grant - Conditional OTP", "description" : "Flow to determine if the OTP is required for the authentication", "providerId" : "basic-flow", "topLevel" : false, "builtIn" : true, "authenticationExecutions" : [ { "authenticator" : "conditional-user-configured", "authenticatorFlow" : false, "requirement" : "REQUIRED", "priority" : 10, "autheticatorFlow" : false, "userSetupAllowed" : false }, { "authenticator" : "direct-grant-validate-otp", "authenticatorFlow" : false, "requirement" : "REQUIRED", "priority" : 20, "autheticatorFlow" : false, "userSetupAllowed" : false } ] }, { "id" : "8bb954d6-800e-4ff4-bea8-d5fe51a101e6", "alias" : "First broker login - Conditional 2FA", "description" : "Flow to determine if any 2FA is required for the authentication", "providerId" : "basic-flow", "topLevel" : false, "builtIn" : true, "authenticationExecutions" : [ { "authenticator" : "conditional-user-configured", "authenticatorFlow" : false, "requirement" : "REQUIRED", "priority" : 10, "autheticatorFlow" : false, "userSetupAllowed" : false }, { "authenticatorConfig" : "first-broker-login-conditional-credential", "authenticator" : "conditional-credential", "authenticatorFlow" : false, "requirement" : "REQUIRED", "priority" : 20, "autheticatorFlow" : false, "userSetupAllowed" : false }, { "authenticator" : "auth-otp-form", "authenticatorFlow" : false, "requirement" : "ALTERNATIVE", "priority" : 30, "autheticatorFlow" : false, "userSetupAllowed" : false }, { "authenticator" : "webauthn-authenticator", "authenticatorFlow" : false, "requirement" : "DISABLED", "priority" : 40, "autheticatorFlow" : false, "userSetupAllowed" : false }, { "authenticator" : "auth-recovery-authn-code-form", "authenticatorFlow" : false, "requirement" : "DISABLED", "priority" : 50, "autheticatorFlow" : false, "userSetupAllowed" : false } ] }, { "id" : "9459afd7-9f72-4702-9f60-989664925fd3", "alias" : "Handle Existing Account", "description" : "Handle what to do if there is existing account with same email/username like authenticated identity provider", "providerId" : "basic-flow", "topLevel" : false, "builtIn" : true, "authenticationExecutions" : [ { "authenticator" : "idp-confirm-link", "authenticatorFlow" : false, "requirement" : "REQUIRED", "priority" : 10, "autheticatorFlow" : false, "userSetupAllowed" : false }, { "authenticatorFlow" : true, "requirement" : "REQUIRED", "priority" : 20, "autheticatorFlow" : true, "flowAlias" : "Account verification options", "userSetupAllowed" : false } ] }, { "id" : "f78ffd37-a52e-4c1a-b524-e7e30e8b59b0", "alias" : "Reset - Conditional OTP", "description" : "Flow to determine if the OTP should be reset or not. Set to REQUIRED to force.", "providerId" : "basic-flow", "topLevel" : false, "builtIn" : true, "authenticationExecutions" : [ { "authenticator" : "conditional-user-configured", "authenticatorFlow" : false, "requirement" : "REQUIRED", "priority" : 10, "autheticatorFlow" : false, "userSetupAllowed" : false }, { "authenticator" : "reset-otp", "authenticatorFlow" : false, "requirement" : "REQUIRED", "priority" : 20, "autheticatorFlow" : false, "userSetupAllowed" : false } ] }, { "id" : "221d25d8-15e0-44f0-ab87-890091819de2", "alias" : "User creation or linking", "description" : "Flow for the existing/non-existing user alternatives", "providerId" : "basic-flow", "topLevel" : false, "builtIn" : true, "authenticationExecutions" : [ { "authenticatorConfig" : "create unique user config", "authenticator" : "idp-create-user-if-unique", "authenticatorFlow" : false, "requirement" : "ALTERNATIVE", "priority" : 10, "autheticatorFlow" : false, "userSetupAllowed" : false }, { "authenticatorFlow" : true, "requirement" : "ALTERNATIVE", "priority" : 20, "autheticatorFlow" : true, "flowAlias" : "Handle Existing Account", "userSetupAllowed" : false } ] }, { "id" : "3abe4382-9087-4654-9f6a-13a882af038c", "alias" : "Verify Existing Account by Re-authentication", "description" : "Reauthentication of existing account", "providerId" : "basic-flow", "topLevel" : false, "builtIn" : true, "authenticationExecutions" : [ { "authenticator" : "idp-username-password-form", "authenticatorFlow" : false, "requirement" : "REQUIRED", "priority" : 10, "autheticatorFlow" : false, "userSetupAllowed" : false }, { "authenticatorFlow" : true, "requirement" : "CONDITIONAL", "priority" : 20, "autheticatorFlow" : true, "flowAlias" : "First broker login - Conditional 2FA", "userSetupAllowed" : false } ] }, { "id" : "bb681a9f-3bfa-4fa8-a8b6-74463a6cf493", "alias" : "browser", "description" : "Browser based authentication", "providerId" : "basic-flow", "topLevel" : true, "builtIn" : true, "authenticationExecutions" : [ { "authenticator" : "auth-cookie", "authenticatorFlow" : false, "requirement" : "ALTERNATIVE", "priority" : 10, "autheticatorFlow" : false, "userSetupAllowed" : false }, { "authenticator" : "auth-spnego", "authenticatorFlow" : false, "requirement" : "DISABLED", "priority" : 20, "autheticatorFlow" : false, "userSetupAllowed" : false }, { "authenticator" : "identity-provider-redirector", "authenticatorFlow" : false, "requirement" : "ALTERNATIVE", "priority" : 25, "autheticatorFlow" : false, "userSetupAllowed" : false }, { "authenticatorFlow" : true, "requirement" : "ALTERNATIVE", "priority" : 30, "autheticatorFlow" : true, "flowAlias" : "forms", "userSetupAllowed" : false } ] }, { "id" : "f02d0da2-4f63-4ec1-ad1e-1bf5b9388e44", "alias" : "clients", "description" : "Base authentication for clients", "providerId" : "client-flow", "topLevel" : true, "builtIn" : true, "authenticationExecutions" : [ { "authenticator" : "client-secret", "authenticatorFlow" : false, "requirement" : "ALTERNATIVE", "priority" : 10, "autheticatorFlow" : false, "userSetupAllowed" : false }, { "authenticator" : "client-jwt", "authenticatorFlow" : false, "requirement" : "ALTERNATIVE", "priority" : 20, "autheticatorFlow" : false, "userSetupAllowed" : false }, { "authenticator" : "client-secret-jwt", "authenticatorFlow" : false, "requirement" : "ALTERNATIVE", "priority" : 30, "autheticatorFlow" : false, "userSetupAllowed" : false }, { "authenticator" : "client-x509", "authenticatorFlow" : false, "requirement" : "ALTERNATIVE", "priority" : 40, "autheticatorFlow" : false, "userSetupAllowed" : false } ] }, { "id" : "0326d889-4cc3-43fd-94eb-fc05ae1277eb", "alias" : "direct grant", "description" : "OpenID Connect Resource Owner Grant", "providerId" : "basic-flow", "topLevel" : true, "builtIn" : true, "authenticationExecutions" : [ { "authenticator" : "direct-grant-validate-username", "authenticatorFlow" : false, "requirement" : "REQUIRED", "priority" : 10, "autheticatorFlow" : false, "userSetupAllowed" : false }, { "authenticator" : "direct-grant-validate-password", "authenticatorFlow" : false, "requirement" : "REQUIRED", "priority" : 20, "autheticatorFlow" : false, "userSetupAllowed" : false }, { "authenticatorFlow" : true, "requirement" : "CONDITIONAL", "priority" : 30, "autheticatorFlow" : true, "flowAlias" : "Direct Grant - Conditional OTP", "userSetupAllowed" : false } ] }, { "id" : "5cf04370-c12d-484f-befb-47fec450afc7", "alias" : "docker auth", "description" : "Used by Docker clients to authenticate against the IDP", "providerId" : "basic-flow", "topLevel" : true, "builtIn" : true, "authenticationExecutions" : [ { "authenticator" : "docker-http-basic-authenticator", "authenticatorFlow" : false, "requirement" : "REQUIRED", "priority" : 10, "autheticatorFlow" : false, "userSetupAllowed" : false } ] }, { "id" : "e9abe54d-edc6-4c80-b72a-e6603fb03a02", "alias" : "first broker login", "description" : "Actions taken after first broker login with identity provider account, which is not yet linked to any Keycloak account", "providerId" : "basic-flow", "topLevel" : true, "builtIn" : true, "authenticationExecutions" : [ { "authenticatorConfig" : "review profile config", "authenticator" : "idp-review-profile", "authenticatorFlow" : false, "requirement" : "REQUIRED", "priority" : 10, "autheticatorFlow" : false, "userSetupAllowed" : false }, { "authenticatorFlow" : true, "requirement" : "REQUIRED", "priority" : 20, "autheticatorFlow" : true, "flowAlias" : "User creation or linking", "userSetupAllowed" : false } ] }, { "id" : "39318842-fb7e-4356-a97e-69db12d00280", "alias" : "forms", "description" : "Username, password, otp and other auth forms.", "providerId" : "basic-flow", "topLevel" : false, "builtIn" : true, "authenticationExecutions" : [ { "authenticator" : "auth-username-password-form", "authenticatorFlow" : false, "requirement" : "REQUIRED", "priority" : 10, "autheticatorFlow" : false, "userSetupAllowed" : false }, { "authenticatorFlow" : true, "requirement" : "CONDITIONAL", "priority" : 20, "autheticatorFlow" : true, "flowAlias" : "Browser - Conditional 2FA", "userSetupAllowed" : false } ] }, { "id" : "725e0d82-e0b9-428b-88ed-d11ddab6159a", "alias" : "registration", "description" : "Registration flow", "providerId" : "basic-flow", "topLevel" : true, "builtIn" : true, "authenticationExecutions" : [ { "authenticator" : "registration-page-form", "authenticatorFlow" : true, "requirement" : "REQUIRED", "priority" : 10, "autheticatorFlow" : true, "flowAlias" : "registration form", "userSetupAllowed" : false } ] }, { "id" : "ba6f9d02-2a96-4220-9cbf-d3f64718923d", "alias" : "registration form", "description" : "Registration form", "providerId" : "form-flow", "topLevel" : false, "builtIn" : true, "authenticationExecutions" : [ { "authenticator" : "registration-user-creation", "authenticatorFlow" : false, "requirement" : "REQUIRED", "priority" : 20, "autheticatorFlow" : false, "userSetupAllowed" : false }, { "authenticator" : "registration-password-action", "authenticatorFlow" : false, "requirement" : "REQUIRED", "priority" : 50, "autheticatorFlow" : false, "userSetupAllowed" : false }, { "authenticator" : "registration-recaptcha-action", "authenticatorFlow" : false, "requirement" : "DISABLED", "priority" : 60, "autheticatorFlow" : false, "userSetupAllowed" : false }, { "authenticator" : "registration-terms-and-conditions", "authenticatorFlow" : false, "requirement" : "DISABLED", "priority" : 70, "autheticatorFlow" : false, "userSetupAllowed" : false } ] }, { "id" : "3a53ff54-b0cb-465f-86c8-e243d4ec0004", "alias" : "reset credentials", "description" : "Reset credentials for a user if they forgot their password or something", "providerId" : "basic-flow", "topLevel" : true, "builtIn" : true, "authenticationExecutions" : [ { "authenticator" : "reset-credentials-choose-user", "authenticatorFlow" : false, "requirement" : "REQUIRED", "priority" : 10, "autheticatorFlow" : false, "userSetupAllowed" : false }, { "authenticator" : "reset-credential-email", "authenticatorFlow" : false, "requirement" : "REQUIRED", "priority" : 20, "autheticatorFlow" : false, "userSetupAllowed" : false }, { "authenticator" : "reset-password", "authenticatorFlow" : false, "requirement" : "REQUIRED", "priority" : 30, "autheticatorFlow" : false, "userSetupAllowed" : false }, { "authenticatorFlow" : true, "requirement" : "CONDITIONAL", "priority" : 40, "autheticatorFlow" : true, "flowAlias" : "Reset - Conditional OTP", "userSetupAllowed" : false } ] }, { "id" : "b977c3de-2d43-4759-ba01-3fc5af17b0dd", "alias" : "saml ecp", "description" : "SAML ECP Profile Authentication Flow", "providerId" : "basic-flow", "topLevel" : true, "builtIn" : true, "authenticationExecutions" : [ { "authenticator" : "http-basic-authenticator", "authenticatorFlow" : false, "requirement" : "REQUIRED", "priority" : 10, "autheticatorFlow" : false, "userSetupAllowed" : false } ] } ], "authenticatorConfig" : [ { "id" : "c10561b1-ae3f-4cb4-9608-72fb3d83a98a", "alias" : "browser-conditional-credential", "config" : { "credentials" : "webauthn-passwordless" } }, { "id" : "d22338ae-5ecc-4b20-b781-fbcd610ffcb6", "alias" : "create unique user config", "config" : { "require.password.update.after.registration" : "false" } }, { "id" : "4338fbaa-8922-4274-9b3b-f6c9c00002a3", "alias" : "first-broker-login-conditional-credential", "config" : { "credentials" : "webauthn-passwordless" } }, { "id" : "48dfd05d-b3be-4d5d-a704-e1a1ac93b8eb", "alias" : "review profile config", "config" : { "update.profile.on.first.login" : "missing" } } ], "requiredActions" : [ { "alias" : "CONFIGURE_TOTP", "name" : "Configure OTP", "providerId" : "CONFIGURE_TOTP", "enabled" : true, "defaultAction" : false, "priority" : 10, "config" : { } }, { "alias" : "TERMS_AND_CONDITIONS", "name" : "Terms and Conditions", "providerId" : "TERMS_AND_CONDITIONS", "enabled" : false, "defaultAction" : false, "priority" : 20, "config" : { } }, { "alias" : "UPDATE_PASSWORD", "name" : "Update Password", "providerId" : "UPDATE_PASSWORD", "enabled" : true, "defaultAction" : false, "priority" : 30, "config" : { } }, { "alias" : "UPDATE_PROFILE", "name" : "Update Profile", "providerId" : "UPDATE_PROFILE", "enabled" : true, "defaultAction" : false, "priority" : 40, "config" : { } }, { "alias" : "VERIFY_EMAIL", "name" : "Verify Email", "providerId" : "VERIFY_EMAIL", "enabled" : true, "defaultAction" : false, "priority" : 50, "config" : { } }, { "alias" : "delete_account", "name" : "Delete Account", "providerId" : "delete_account", "enabled" : false, "defaultAction" : false, "priority" : 60, "config" : { } }, { "alias" : "UPDATE_EMAIL", "name" : "Update Email", "providerId" : "UPDATE_EMAIL", "enabled" : false, "defaultAction" : false, "priority" : 70, "config" : { } }, { "alias" : "webauthn-register", "name" : "Webauthn Register", "providerId" : "webauthn-register", "enabled" : true, "defaultAction" : false, "priority" : 80, "config" : { } }, { "alias" : "webauthn-register-passwordless", "name" : "Webauthn Register Passwordless", "providerId" : "webauthn-register-passwordless", "enabled" : true, "defaultAction" : false, "priority" : 90, "config" : { } }, { "alias" : "VERIFY_PROFILE", "name" : "Verify Profile", "providerId" : "VERIFY_PROFILE", "enabled" : true, "defaultAction" : false, "priority" : 100, "config" : { } }, { "alias" : "delete_credential", "name" : "Delete Credential", "providerId" : "delete_credential", "enabled" : true, "defaultAction" : false, "priority" : 110, "config" : { } }, { "alias" : "idp_link", "name" : "Linking Identity Provider", "providerId" : "idp_link", "enabled" : true, "defaultAction" : false, "priority" : 120, "config" : { } }, { "alias" : "CONFIGURE_RECOVERY_AUTHN_CODES", "name" : "Recovery Authentication Codes", "providerId" : "CONFIGURE_RECOVERY_AUTHN_CODES", "enabled" : true, "defaultAction" : false, "priority" : 130, "config" : { } }, { "alias" : "update_user_locale", "name" : "Update User Locale", "providerId" : "update_user_locale", "enabled" : true, "defaultAction" : false, "priority" : 1000, "config" : { } } ], "browserFlow" : "browser", "registrationFlow" : "registration", "directGrantFlow" : "direct grant", "resetCredentialsFlow" : "reset credentials", "clientAuthenticationFlow" : "clients", "dockerAuthenticationFlow" : "docker auth", "firstBrokerLoginFlow" : "first broker login", "attributes" : { "cibaBackchannelTokenDeliveryMode" : "poll", "cibaAuthRequestedUserHint" : "login_hint", "clientOfflineSessionMaxLifespan" : "0", "oauth2DevicePollingInterval" : "5", "clientSessionIdleTimeout" : "0", "clientOfflineSessionIdleTimeout" : "0", "cibaInterval" : "5", "realmReusableOtpCode" : "false", "cibaExpiresIn" : "120", "oauth2DeviceCodeLifespan" : "600", "saml.signature.algorithm" : "", "parRequestUriLifespan" : "60", "clientSessionMaxLifespan" : "0", "frontendUrl" : "", "acr.loa.map" : "{}" }, "keycloakVersion" : "26.5.4", "userManagedAccessAllowed" : false, "organizationsEnabled" : false, "verifiableCredentialsEnabled" : false, "adminPermissionsEnabled" : false, "clientProfiles" : { "profiles" : [ ] }, "clientPolicies" : { "policies" : [ ] } } ================================================ FILE: src/components/emails/auth/email-forgot-password.tsx ================================================ import * as React from "react"; import EmailLayout from "../email-layout"; import {Heading, Text, Section, Button} from "@react-email/components"; import {getServerUrl} from "@/utils/get-server-url"; interface EmailCreateUserProps { firstname?: string; token: string; } export const EmailForgotPassword = ({firstname, token}: EmailCreateUserProps) => { const baseUrl = getServerUrl(); return ( Hello {firstname}, We received a request to reset the password for your account associated with this email. If you did not request this password reset, please ignore this email. Your current password will remain unchanged. If this seems suspicious, please contact your administrator.
); }; export default EmailForgotPassword; ================================================ FILE: src/components/emails/auth/email-new-login.tsx ================================================ import * as React from "react"; import EmailLayout from "../email-layout"; import {Heading, Text} from "@react-email/components"; interface EmailCreateUserProps { firstname?: string; os: string; browser: string; ipAddress?: string; } export const EmailNewLogin = ({firstname, ipAddress, os, browser}: EmailCreateUserProps) => { return ( Hello {firstname}, We detected a new login to your account. Login details:
Device: {os} - {browser}
IP Address: {ipAddress}
); }; export default EmailNewLogin; ================================================ FILE: src/components/emails/auth/email-verification.tsx ================================================ import {Heading, Text, Section, Button} from "@react-email/components"; import {getServerUrl} from "@/utils/get-server-url"; import EmailLayout from "@/components/emails/email-layout"; interface EmailCreateUserProps { firstname?: string; oldEmail?: string; newEmail?: string; url: string; } export const EmailVerification = ({firstname, oldEmail, newEmail, url: urlVerification}: EmailCreateUserProps) => { const serverUrl = new URL(getServerUrl()); const url = new URL(urlVerification); url.hostname = serverUrl.hostname; url.port = serverUrl.port == "80" ? "" : serverUrl.port; const newUrl = url.toString(); return ( Hello {firstname}, We received a request to change the email address associated with your account. If you did not request this change, please ignore this email. Your current email address will remain unchanged. If this seems suspicious, contact your administrator. {oldEmail && newEmail ? ( Old email address: {oldEmail}
New email address: {newEmail}
) : null}
); }; export default EmailVerification; ================================================ FILE: src/components/emails/email-create-user.tsx ================================================ import * as React from "react"; import EmailLayout from "./email-layout"; import {Heading, Text, Section, Button} from "@react-email/components"; import {getServerUrl} from "@/utils/get-server-url"; import {env} from "@/env.mjs"; interface EmailCreateUserProps { email: string; password?: string; } export const EmailCreateUser = ({email, password}: EmailCreateUserProps) => { const baseUrl = getServerUrl(); return ( Your account on {env.PROJECT_NAME} has just been created! Email: {email} {password ? ( Default password: {password} ) : ( You can log in using one of the single sign-on (SSO) providers configured by your administrator. )}
); }; export default EmailCreateUser; ================================================ FILE: src/components/emails/email-layout.tsx ================================================ import { Body, Container, Head, Html, Img, Preview, Section, Tailwind } from "@react-email/components"; import * as React from "react"; import { PropsWithChildren } from "react"; import { getServerUrl } from "@/utils/get-server-url"; const baseUrl = getServerUrl(); export const EmailLayout = ({ children, preview }: PropsWithChildren<{ preview?: string }>) => { return ( {preview ? {preview} : Please check your mails}
Logo
{children}
); }; export default EmailLayout; ================================================ FILE: src/components/emails/email-notification.tsx ================================================ // import * as React from "react"; // import EmailLayout from "./email-layout"; // import {Text, Section, Button} from "@react-email/components"; // import type {EventPayload} from "@/features/notifications/types"; // // export interface EmailNotificationProps { // payload: EventPayload // } // // export const EmailNotification = ({payload}: EmailNotificationProps) => { // return ( // // ${payload.title} // Level: ${payload.level} // // // // ${payload.message.replace(/\n/g, '
')} //
{" "} // // //
// //
// // {payload.data && ( //
//
{JSON.stringify(payload.data, null, 2)}
//
// )} // // Regards,
Portabase
//
// ); // }; // // export default EmailNotification; // const html = ` //

${payload.title}

//

Level: ${payload.level}

//

${payload.message.replace(/\n/g, '
')}

// ${payload.data ? `
${JSON.stringify(payload.data, null, 2)}
` : ''} // `; // import * as React from "react"; import EmailLayout from "./email-layout"; import {Text, Section, Button} from "@react-email/components"; import type {EventPayload} from "@/features/notifications/types"; export interface EmailNotificationProps { payload: EventPayload; } export const EmailNotification = ({payload}: EmailNotificationProps) => { return (
{payload.title} Level: {payload.level}
{payload.message && (
")}} />
)} {payload.data && (
{JSON.stringify(payload.data, null, 2)}
)}
Regards,
Portabase
); }; export default EmailNotification; ================================================ FILE: src/components/emails/email-settings-test.tsx ================================================ import { Text } from "@react-email/components"; import * as React from "react"; import EmailLayout from "./email-layout"; export const EmailSettingsTest = () => { return ( Hi, your email settings are setup ! Best regard,{" "} Portabase ) }; export default EmailSettingsTest; ================================================ FILE: src/components/emails/email-text.tsx ================================================ import {Text as ReactEmailText} from "@react-email/components"; import {ComponentPropsWithoutRef} from "react"; export const EmailText = (props: ComponentPropsWithoutRef) => { return ; }; ================================================ FILE: src/components/layout.tsx ================================================ import { twx } from "@/lib/twx"; import { cn } from "@/lib/utils"; export const LayoutAdmin = twx.div((props) => [`w-full h-screen flex flex-col gap-4 mx-auto `]); export const Layout = twx.div((props) => [`max-w-5xl w-full flex-col flex gap-4 mx-auto px-4 `]); export const LayoutTitle = twx.h1((props) => [cn(`text-4xl font-bold mt-5 `, props.className)]); export const LayoutDescription = twx.p((props) => [`text-lg text-muted-foreground`]); ================================================ FILE: src/components/ui/accordion.tsx ================================================ "use client" import * as React from "react" import * as AccordionPrimitive from "@radix-ui/react-accordion" import { ChevronDownIcon } from "lucide-react" import { cn } from "@/lib/utils" function Accordion({ ...props }: React.ComponentProps) { return } function AccordionItem({ className, ...props }: React.ComponentProps) { return ( ) } function AccordionTrigger({ className, children, ...props }: React.ComponentProps) { return ( svg]:rotate-180", className )} {...props} > {children} ) } function AccordionContent({ className, children, ...props }: React.ComponentProps) { return (
{children}
) } export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } ================================================ FILE: src/components/ui/alert-dialog.tsx ================================================ "use client" import * as React from "react" import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" import { cn } from "@/lib/utils" import { buttonVariants } from "@/components/ui/button" function AlertDialog({ ...props }: React.ComponentProps) { return } function AlertDialogTrigger({ ...props }: React.ComponentProps) { return ( ) } function AlertDialogPortal({ ...props }: React.ComponentProps) { return ( ) } function AlertDialogOverlay({ className, ...props }: React.ComponentProps) { return ( ) } function AlertDialogContent({ className, ...props }: React.ComponentProps) { return ( ) } function AlertDialogHeader({ className, ...props }: React.ComponentProps<"div">) { return (
) } function AlertDialogFooter({ className, ...props }: React.ComponentProps<"div">) { return (
) } function AlertDialogTitle({ className, ...props }: React.ComponentProps) { return ( ) } function AlertDialogDescription({ className, ...props }: React.ComponentProps) { return ( ) } function AlertDialogAction({ className, ...props }: React.ComponentProps) { return ( ) } function AlertDialogCancel({ className, ...props }: React.ComponentProps) { return ( ) } export { AlertDialog, AlertDialogPortal, AlertDialogOverlay, AlertDialogTrigger, AlertDialogContent, AlertDialogHeader, AlertDialogFooter, AlertDialogTitle, AlertDialogDescription, AlertDialogAction, AlertDialogCancel, } ================================================ FILE: src/components/ui/alert.tsx ================================================ import * as React from "react" import { cva, type VariantProps } from "class-variance-authority" import { cn } from "@/lib/utils" const alertVariants = cva( "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current", { variants: { variant: { default: "bg-card text-card-foreground", destructive: "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90", }, }, defaultVariants: { variant: "default", }, } ) function Alert({ className, variant, ...props }: React.ComponentProps<"div"> & VariantProps) { return (
) } function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { return (
) } function AlertDescription({ className, ...props }: React.ComponentProps<"div">) { return (
) } export { Alert, AlertTitle, AlertDescription } ================================================ FILE: src/components/ui/aspect-ratio.tsx ================================================ "use client" import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio" function AspectRatio({ ...props }: React.ComponentProps) { return } export { AspectRatio } ================================================ FILE: src/components/ui/avatar.tsx ================================================ "use client" import * as React from "react" import * as AvatarPrimitive from "@radix-ui/react-avatar" import { cn } from "@/lib/utils" function Avatar({ className, ...props }: React.ComponentProps) { return ( ) } function AvatarImage({ className, ...props }: React.ComponentProps) { return ( ) } function AvatarFallback({ className, ...props }: React.ComponentProps) { return ( ) } export { Avatar, AvatarImage, AvatarFallback } ================================================ FILE: src/components/ui/badge.tsx ================================================ import * as React from "react" import { Slot } from "@radix-ui/react-slot" import { cva, type VariantProps } from "class-variance-authority" import { cn } from "@/lib/utils" const badgeVariants = cva( "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", { variants: { variant: { default: "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", secondary: "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", destructive: "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", }, }, defaultVariants: { variant: "default", }, } ) function Badge({ className, variant, asChild = false, ...props }: React.ComponentProps<"span"> & VariantProps & { asChild?: boolean }) { const Comp = asChild ? Slot : "span" return ( ) } export { Badge, badgeVariants } ================================================ FILE: src/components/ui/breadcrumb.tsx ================================================ import * as React from "react" import { Slot } from "@radix-ui/react-slot" import { cn } from "@/lib/utils" import { ChevronRightIcon, DotsHorizontalIcon } from "@radix-ui/react-icons" function Breadcrumb({ ...props }: React.ComponentProps<"nav">) { return