Compare commits

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

* rmv my dashboard

* Stop tracking my-dashboard

* gitignore: ignore all .env files

* working UI, not yet tested

* Update credential validation

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

* tested with fake-gcs-server

* fix: migration

* fix: migration, init for gcs and refactoring

* fix: get object gcs

---------

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

* filtered agents

---------

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

* fix: migration

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

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

* feat(onboarding): add sso-gate step

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

* feat(onboarding): add security step

* feat(onboarding): add preferences step

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

* feat(onboarding): add org-create step

* feat(onboarding): add invite-members step

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

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

* feat(onboarding): add defaults step

* feat(onboarding): add agent-create step

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

* feat(onboarding): add project-create step

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

* feat(onboarding): add finish step with confetti

* feat(onboarding): wire full step graph

* feat(onboarding): add /welcome route

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

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

* docs: add onboarding channel config + style refactor spec

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

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

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

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

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

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

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

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

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

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

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

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

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

* feat(onboarding): add resolveOnboardingState server function

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* docs: add onboarding refactor design spec

* docs: add onboarding refactor implementation plan

* refactor(onboarding): extract useCreateAgent and useDeleteAgent hooks

* refactor(onboarding): extract useCreateOrg and useCreateProject hooks

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

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

* refactor(onboarding): extract useUpdateAccount hook

* refactor(onboarding): extract useAddNotifier and useRemoveNotifier hooks

* refactor(onboarding): extract useAddStorage and useRemoveStorage hooks

* refactor(onboarding): extract useAgentStatus and useGenerateEdgeKey hooks

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

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

* docs: add onboarding theme & progress bar implementation plan

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* feat(onboarding): add applyOnboardingDbSettingsAction server action

* feat(onboarding): add BackupScheduleSelector component

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

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

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

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

* add: onboarding

* fix(migration): merge

* fix(migration): apply for onboarding

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

* chore: checkpoint before features reorganization

* Task 2: Add migration script for features directory reorganization

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

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

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

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

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

* refactor(logs): reorganize into components/types

* refactor(migration): reorganize into actions/components

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

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

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

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

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

* refactor(statistics): reorganize into components/utils

* refactor(storages): reorganize into types/utils

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

* refactor(updates): reorganize into components/hooks

* refactor(upload): move action to actions/

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

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

* fix: .gitignore

* fix: onboarding

* fix: removed dead files

* feat: backfill onboarding flag for instances with existing data

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

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

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

* feat: allow avatar upload regardless of mode

* feat: remove avatarMode from layout and profile prop chains

* feat: remove avatar mode admin tab and supporting files

* feat: remove avatar mode selector from onboarding defaults step

* fix: remove preferences onboarding step and fix routing

* fix: remove stale preferences entry from onboarding checklist

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

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

* fix: use small destructive button for avatar removal

* fix

* Update step-defaults.tsx

* fix: default dev password for keycloak refer to env

* fix

* fix(migration): refactoring

* fix: api agent helpers.ts

* Update .env.example

---------

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

* feat: Add Microsoft Teams as notification provider

* fix: Update database migration

* fix: Remove duplicate file 0058 db migration

* add Microsoft Teams notification provider E2E tests

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

* fix: externalize e2e testings

* fix: externalize e2e tests.

* refactor: improve e2e workflow.

* chore: upgrade workflow action version.

* fix: createEnv by adding emptyStringAsUndefined (#317)

* chore: release 1.18.2

---------

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

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

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

* fix: AUTH_DEFAULT_USER as zod email

---------

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

* fix: profile name trim

---------

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

* chore: release 1.18.3

* chore: Update README.md (#323)

* chore: Update README.md

added unraid link

* chore: Update README.md

fix markdown

* chore: release 1.18.4

* fix: teams.ts with timeout

---------

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

added unraid link

* chore: Update README.md

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

* fix: externalize e2e tests.

* refactor: improve e2e workflow.

* chore: upgrade workflow action version.

* fix: createEnv by adding emptyStringAsUndefined (#317)

* chore: release 1.18.2

---------

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

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

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

* fix: AUTH_DEFAULT_USER as zod email

---------

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

* fix: profile name trim

---------

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

* fix: AUTH_DEFAULT_USER as zod email

---------

Co-authored-by: charles-gauthereau <charles.gauthereau@soluce-technologies.com>
2026-06-11 15:20:06 +02:00
508 changed files with 34000 additions and 8404 deletions
+3
View File
@@ -81,3 +81,6 @@ TRUSTED_DOMAINS="http://localhost:8887, http://localhost:3055, http://localhost:
# Default to false
#TUSD_BEHIND_PROXY=true
#Skip the onboarding on fresh instance
#SKIP_ONBOARDING=true
+1 -5
View File
@@ -1,11 +1,7 @@
{
"extends": [
"next/core-web-vitals",
"plugin:tailwindcss/recommended"
],
"extends": ["next/core-web-vitals", "plugin:tailwindcss/recommended"],
"rules": {
"react/no-unescaped-entities": "off",
"react-hooks/rules-of-hooks": "off"
}
}
+11 -2
View File
@@ -14,8 +14,9 @@ Please take a moment to review this guide. It will help you understand how to co
2. [Reporting Issues](#reporting-issues)
3. [Submitting Changes](#submitting-changes)
4. [Code Style Guidelines](#code-style-guidelines)
5. [Pull Request Process](#pull-request-process)
6. [Community Guidelines](#community-guidelines)
5. [E2E Tests](#e2e-tests)
6. [Pull Request Process](#pull-request-process)
7. [Community Guidelines](#community-guidelines)
---
@@ -88,6 +89,14 @@ If you encounter a bug or have a suggestion for improvement, follow these steps:
---
## E2E Tests
E2E tests now live in a dedicated repository: [Portabase/e2e-tests](https://github.com/Portabase/e2e-tests).
If your contribution needs corresponding end-to-end test coverage, add it there instead of this repo.
---
## Pull Request Process
1. Ensure your code passes all tests and linters.
+1 -1
View File
@@ -73,7 +73,7 @@ jobs:
tags: ${{ steps.prep.outputs.image }}
target: ${{ inputs.target }}
cache-from: type=gha,scope=build-${{ matrix.platform }}
cache-to: type=gha,mode=max,scope=build-${{ matrix.platform }}
cache-to: type=gha,mode=max,scope=build-${{ matrix.platform }},ignore-error=true
- name: Save image name for manifest
run: echo "${{ steps.prep.outputs.image }}" > image.txt
+5
View File
@@ -9,6 +9,8 @@
# testing
/coverage
gcs-data/
gcs-storage/
# next.js
/.next/
@@ -28,6 +30,8 @@ yarn-error.log*
# local env files
.env*.local
.env
**/.env
# vercel
.vercel
@@ -60,3 +64,4 @@ certificates
.codex
/docs
.worktrees
.superpowers
+1 -1
View File
@@ -33,5 +33,5 @@ keywords:
- web-ui
- agent
license: Apache-2.0
version: 1.18.2
version: 1.20.6
date-released: '2026-03-02'
+6 -12
View File
@@ -47,16 +47,10 @@ export-keycloak:
@docker compose -f docker-compose.func.yml start keycloak >/dev/null 2>&1
@echo "Keycloak configuration and users exported to seeds/keycloak/*.json"
end-to-end:
@echo "Starting E2E testing..."
@docker compose -f docker-compose.e2e.yml up -d
@CI=true PROJECT_URL=http://localhost:8887 pnpm playwright test --project=chromium || (docker compose -f docker-compose.e2e.yml down --volumes && exit 1)
@docker compose -f docker-compose.e2e.yml down --volumes
@echo "Finished E2E testing successfully."
seed-blob:
@chmod +x seeds/azurite/azurite-seed.sh
@bash seeds/azurite/azurite-seed.sh
e2e-manual:
@echo "Starting E2E testing..."
@docker compose -f docker-compose.e2e.yml up -d
@pnpm playwright test --ui || (docker compose -f docker-compose.e2e.yml down --volumes && exit 1)
@docker compose -f docker-compose.e2e.yml down --volumes
@echo "Finished E2E testing successfully."
list-blob:
@chmod +x seeds/azurite/azurite-list.sh
@bash seeds/azurite/azurite-list.sh
+5 -2
View File
@@ -54,12 +54,13 @@
## Installation
You have 4 ways to install Portabase:
You have 5 ways to install Portabase:
- Automated CLI (recommended) - [details](https://portabase.io/docs/dashboard/setup#cli)
- Docker Run - [details](https://portabase.io/docs/dashboard/setup#docker)
- Docker Compose setup - [details](https://portabase.io/docs/dashboard/setup#docker-compose)
- Kubernetes with Helm [details](https://portabase.io/docs/dashboard/setup#helm)
- Unraid Community Apps - [details](https://ca.unraid.net/apps/portabase-dashboard-1sdc97m05ufd7q) - [unraid support thread](https://forums.unraid.net/topic/199072-support-portabase-dashboard-agent-dockers/)
- Kubernetes with Helm - [details](https://portabase.io/docs/dashboard/setup#helm)
- Development setup - [details](https://portabase.io/docs/dashboard/setup#development)
**Ensure Docker is installed on your machine before getting started.**
@@ -110,3 +111,5 @@ Distributed under the Apache License. See `LICENSE.txt` for more details.
[ShadcnUI-url]: https://ui.shadcn.com/
[Docker-url]: https://www.docker.com/
+2 -2
View File
@@ -1,9 +1,9 @@
import { CardContent, CardHeader } from "@/components/ui/card";
import { TooltipProvider } from "@/components/ui/tooltip";
import { ForgotPasswordForm } from "@/features/auth/forgot-password-form";
import { ForgotPasswordForm } from "@/features/auth/components/forgot-password-form";
import { env } from "@/env.mjs";
import { CardAuth } from "@/features/layout/card-auth";
import { CardAuth } from "@/features/layout/components/card-auth";
import { redirect } from "next/navigation";
export default async function RoutePage(props: { searchParams: Promise<{ callbackUrl: string | undefined }> }) {
+2 -2
View File
@@ -1,9 +1,9 @@
import {CardContent, CardHeader} from "@/components/ui/card";
import {TooltipProvider} from "@/components/ui/tooltip";
import {GuardForm} from "@/features/auth/guard-form";
import {GuardForm} from "@/features/auth/components/guard-form";
import {cookies} from "next/headers";
import {redirect} from "next/navigation";
import {CardAuth} from "@/features/layout/card-auth";
import {CardAuth} from "@/features/layout/components/card-auth";
export default async function GuardPage() {
+7 -1
View File
@@ -1,7 +1,9 @@
import React from "react";
import { redirect } from "next/navigation";
import { currentUser } from "@/lib/auth/current-user";
import { AuthLogoSection } from "@/features/auth/auth-logo-section";
import { isOnboardingDone } from "@/db/services/setting";
import { AuthLogoSection } from "@/features/auth/components/auth-logo-section";
import { env } from "@/env.mjs";
import { Heart } from "lucide-react";
export default async function Layout({
@@ -9,6 +11,10 @@ export default async function Layout({
}: {
children: React.ReactNode;
}) {
if (env.SKIP_ONBOARDING !== "true" && !(await isOnboardingDone())) {
redirect("/welcome");
}
const user = await currentUser();
if (user && !user.banned && user.role !== "pending") {
+3 -3
View File
@@ -1,12 +1,12 @@
import { LoginForm } from "@/features/auth/login-form";
import { LoginForm } from "@/features/auth/components/login-form";
import { Metadata } from "next";
import { SUPPORTED_PROVIDERS } from "@/lib/auth/config";
import { SocialAuthButtons } from "@/features/auth/social-buttons";
import { SocialAuthButtons } from "@/features/auth/components/social-buttons";
import { TooltipProvider } from "@/components/ui/tooltip";
import { CardContent, CardHeader } from "@/components/ui/card";
import Link from "next/link";
import { Separator } from "@/components/ui/separator";
import { CardAuth } from "@/features/layout/card-auth";
import { CardAuth } from "@/features/layout/components/card-auth";
import { env } from "@/env.mjs";
export const metadata: Metadata = {
+1 -1
View File
@@ -1,5 +1,5 @@
import { PageParams } from "@/types/next";
import { RegisterForm } from "@/features/auth/register-form";
import { RegisterForm } from "@/features/auth/components/register-form";
import { Metadata } from "next";
import { redirect } from "next/navigation";
import { env } from "@/env.mjs";
+2 -2
View File
@@ -1,10 +1,10 @@
import { CardContent, CardHeader } from "@/components/ui/card";
import { TooltipProvider } from "@/components/ui/tooltip";
import { ResetPasswordForm } from "@/features/auth/reset-password-form";
import { ResetPasswordForm } from "@/features/auth/components/reset-password-form";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth/auth";
import { Avatar, AvatarImage, AvatarFallback } from "@radix-ui/react-avatar";
import { CardAuth } from "@/features/layout/card-auth";
import { CardAuth } from "@/features/layout/components/card-auth";
import { env } from "@/env.mjs";
export default async function RoutePage(props: { searchParams: Promise<{ token: string | undefined }> }) {
@@ -3,8 +3,8 @@ import {eq} from "drizzle-orm";
import {db} from "@/db";
import * as drizzleDb from "@/db";
import {PageParams} from "@/types/next";
import {Page} from "@/features/layout/page";
import {OrganizationManagement} from "@/features/organizations/admin-organization-management";
import {Page} from "@/features/layout/components/page";
import {OrganizationManagement} from "@/features/organizations/components/admin-organization-management";
import {buildOrganizationWithMembers} from "@/utils/common";
import {isUUID} from "@/utils/text";
import {user} from "@/db/schema/02_user";
@@ -1,10 +1,10 @@
import {PageParams} from "@/types/next";
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/components/page";
import {db} from "@/db";
import {
AdminOrganizationAddModal
} from "@/features/organizations/admin-organization-add-modal";
import {AdminOrganizationList} from "@/features/organizations/admin-organization-list";
} from "@/features/organizations/components/admin-organization-add-modal";
import {AdminOrganizationList} from "@/features/organizations/components/admin-organization-list";
import {isNull} from "drizzle-orm";
export default async function RoutePage(props: PageParams<{}>) {
@@ -1,9 +1,9 @@
import {PageParams} from "@/types/next";
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/components/page";
import {db} from "@/db";
import {logger} from "@/lib/logger";
import {notFound} from "next/navigation";
import {SettingsTabs} from "@/features/settings/settings-tabs";
import {SettingsTabs} from "@/features/settings/components/settings-tabs";
import {desc, isNull} from "drizzle-orm";
import * as drizzleDb from "@/db";
import {StorageChannelWith} from "@/db/schema/12_storage-channel";
@@ -1,21 +1,28 @@
import {PageParams} from "@/types/next";
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/components/page";
import {db} from "@/db";
import {desc, isNull} from "drizzle-orm";
import {AdminUserList} from "@/features/users/admin-user-list";
import {AdminUserAddModal} from "@/features/users/admin-user-add-modal";
import {AdminUserList} from "@/features/users/components/admin-user-list";
import {AdminUserAddModal} from "@/features/users/components/admin-user-add-modal";
import {SUPPORTED_PROVIDERS} from "@/lib/auth/config";
import {getSettings} from "@/db/services/setting";
import {resolveAvatarUrl} from "@/utils/resolve-avatar-url";
export default async function RoutePage(props: PageParams<{}>) {
const users = await db.query.user.findMany({
where: (fields) => isNull(fields.deletedAt),
with: {
accounts: true
},
orderBy: (fields) => desc(fields.createdAt),
const [settings, users] = await Promise.all([
getSettings(),
db.query.user.findMany({
where: (fields) => isNull(fields.deletedAt),
with: { accounts: true },
orderBy: (fields) => desc(fields.createdAt),
}),
]);
const avatarUrls = Object.fromEntries(
users.map((u) => [u.id, resolveAvatarUrl(u, settings)])
);
});
const organizations = await db.query.organization.findMany({
with: {
members: true,
@@ -38,7 +45,7 @@ export default async function RoutePage(props: PageParams<{}>) {
</div>
</PageHeader>
<PageContent className="flex flex-col gap-5">
<AdminUserList users={users} isPasswordAuthEnabled={isPasswordAuthEnabled}/>
<AdminUserList users={users} isPasswordAuthEnabled={isPasswordAuthEnabled} avatarUrls={avatarUrls}/>
</PageContent>
</Page>
);
@@ -4,17 +4,17 @@ import {
PageContent,
PageDescription,
PageTitle,
} from "@/features/layout/page";
} from "@/features/layout/components/page";
import { db } from "@/db";
import * as drizzleDb from "@/db";
import {eq, isNull} from "drizzle-orm";
import { notFound } from "next/navigation";
import { ButtonDeleteAgent } from "@/features/agents/agent-delete-button";
import { ButtonDeleteAgent } from "@/features/agents/components/agent-delete-button";
import { capitalizeFirstLetter } from "@/utils/text";
import { generateEdgeKey } from "@/utils/edge_key";
import { getServerUrl } from "@/utils/get-server-url";
import { AgentContentPage } from "@/features/agents/agent-content";
import { AgentDialog } from "@/features/agents/agent-dialog";
import { AgentContentPage } from "@/features/agents/components/agent-content";
import { AgentDialog } from "@/features/agents/components/agent-dialog";
export default async function RoutePage(
@@ -1,13 +1,13 @@
import {PageParams} from "@/types/next";
import {AgentCard} from "@/features/agents/agent-card";
import {AgentCard} from "@/features/agents/components/agent-card";
import {CardsWithPagination} from "@/components/common/cards-with-pagination";
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/components/page";
import {notFound} from "next/navigation";
import {db} from "@/db";
import * as drizzleDb from "@/db";
import {and, desc, eq, isNull, not} from "drizzle-orm";
import {Metadata} from "next";
import {AgentDialog} from "@/features/agents/agent-dialog";
import {AgentDialog} from "@/features/agents/components/agent-dialog";
export const metadata: Metadata = {
title: "Agents",
@@ -1,11 +1,11 @@
import {PageParams} from "@/types/next";
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/components/page";
import {Metadata} from "next";
import {db} from "@/db";
import {notificationChannel, NotificationChannelWith} from "@/db/schema/09_notification-channel";
import {desc, isNull} from "drizzle-orm";
import {ChannelsSection} from "@/features/channel/channels-section";
import {ChannelAddEditModal} from "@/features/channel/channel-add-edit-modal";
import {ChannelsSection} from "@/features/channel/components/channels-section";
import {ChannelAddEditModal} from "@/features/channel/components/channel-add-edit-modal";
import * as drizzleDb from "@/db";
export const metadata: Metadata = {
@@ -1,7 +1,7 @@
import {PageParams} from "@/types/next";
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/components/page";
import {Metadata} from "next";
import {NotificationLogsList} from "@/features/notifications/notification-logs-list";
import {NotificationLogsList} from "@/features/notifications/components/notification-logs-list";
import {notFound} from "next/navigation";
import {getNotificationHistory} from "@/db/services/notification-log";
@@ -1,12 +1,12 @@
import {PageParams} from "@/types/next";
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/components/page";
import {Metadata} from "next";
import {ChannelsSection} from "@/features/channel/channels-section";
import {ChannelsSection} from "@/features/channel/components/channels-section";
import {db} from "@/db";
import {desc, eq, isNull} from "drizzle-orm";
import * as drizzleDb from "@/db";
import {StorageChannelWith} from "@/db/schema/12_storage-channel";
import {ChannelAddEditModal} from "@/features/channel/channel-add-edit-modal";
import {ChannelAddEditModal} from "@/features/channel/components/channel-add-edit-modal";
export const metadata: Metadata = {
title: "Storage Channels",
@@ -1,10 +1,10 @@
import {PageParams} from "@/types/next";
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/components/page";
import {notFound} from "next/navigation";
import {getOrganization} from "@/lib/auth/auth";
import {Metadata} from "next";
import {db} from "@/db";
import {MigrationTool} from "@/features/migration/migration-tool";
import {MigrationTool} from "@/features/migration/components/migration-tool";
export const metadata: Metadata = {
title: "Projects",
@@ -1,122 +1,137 @@
import {PageParams} from "@/types/next";
import {notFound, redirect} from "next/navigation";
import {Page} from "@/features/layout/page";
import {db} from "@/db";
import {eq, and, inArray} from "drizzle-orm";
import { PageParams } from "@/types/next";
import { notFound, redirect } from "next/navigation";
import { Page } from "@/features/layout/components/page";
import { db } from "@/db";
import { eq, and, inArray } from "drizzle-orm";
import * as drizzleDb from "@/db";
import {getOrganizationProjectDatabases} from "@/db/services/project";
import {getActiveMember, getOrganization} from "@/lib/auth/auth";
import {BackupModalProvider} from "@/features/database/backup-modal-context";
import {DatabaseContent} from "@/features/database/database-content";
import {getHealthLast12hLogs} from "@/db/services/healthcheck";
import {LogsModalProvider} from "@/features/logs/logs-modal-context";
import { getOrganizationProjectDatabases } from "@/db/services/project";
import { getActiveMember, getOrganization } from "@/lib/auth/auth";
import { BackupModalProvider } from "@/features/database/components/backup-modal-context";
import { DatabaseContent } from "@/features/database/components/database-content";
import { getHealthLast12hLogs } from "@/db/services/healthcheck";
import { LogsModalProvider } from "@/features/logs/components/logs-modal-context";
export default async function RoutePage(props: PageParams<{
export default async function RoutePage(
props: PageParams<{
projectId: string;
databaseId: string
}>) {
const {projectId, databaseId} = await props.params;
databaseId: string;
}>,
) {
const { projectId, databaseId } = await props.params;
const organization = await getOrganization({});
const activeMember = await getActiveMember()
const organization = await getOrganization({});
const activeMember = await getActiveMember();
if (!organization || !activeMember) {
notFound();
}
if (!organization || !activeMember) {
notFound();
}
const databasesProject = await getOrganizationProjectDatabases({
organizationSlug: organization.slug,
projectId: projectId
})
const databasesProject = await getOrganizationProjectDatabases({
organizationSlug: organization.slug,
projectId: projectId,
});
const dbItem = await db.query.database.findFirst({
where: and(inArray(drizzleDb.schemas.backup.id, databasesProject.ids ?? []), eq(drizzleDb.schemas.database.id, databaseId), eq(drizzleDb.schemas.database.projectId, projectId)),
const dbItem = await db.query.database.findFirst({
where: and(
inArray(drizzleDb.schemas.backup.id, databasesProject.ids ?? []),
eq(drizzleDb.schemas.database.id, databaseId),
eq(drizzleDb.schemas.database.projectId, projectId),
),
with: {
project: true,
retentionPolicy: true,
alertPolicies: true,
storagePolicies: true,
},
});
if (!dbItem) {
redirect("/dashboard/projects");
}
const backups = await db.query.backup.findMany({
where: eq(drizzleDb.schemas.backup.databaseId, dbItem.id),
with: {
restorations: true,
storages: {
with: {
project: true,
retentionPolicy: true,
alertPolicies: true,
storagePolicies: true,
}
});
if (!dbItem) {
redirect("/dashboard/projects");
}
const backups = await db.query.backup.findMany({
where: eq(drizzleDb.schemas.backup.databaseId, dbItem.id),
with: {
restorations: true,
storages: {
with: {
storageChannel: true
}
},
logs: true
storageChannel: true,
},
orderBy: (b, {desc}) => [desc(b.createdAt)],
});
},
logs: true,
},
orderBy: (b, { desc }) => [desc(b.createdAt)],
});
const restorations = await db.query.restoration.findMany({
where: eq(drizzleDb.schemas.restoration.databaseId, dbItem.id),
with: {
logs: true
},
orderBy: (r, {desc}) => [desc(r.createdAt)],
});
const restorations = await db.query.restoration.findMany({
where: eq(drizzleDb.schemas.restoration.databaseId, dbItem.id),
with: {
logs: true,
},
orderBy: (r, { desc }) => [desc(r.createdAt)],
});
const isAlreadyBackup = backups.some((b) => b.status === "waiting" || b.status === "ongoing");
const isAlreadyRestore = restorations.some((r) => r.status === "waiting");
//const isAlreadyBackup = backups.some((b) => b.status === "waiting" || b.status === "ongoing");
const isAlreadyRestore = restorations.some((r) => r.status === "waiting");
const totalBackups = await db.select({count: drizzleDb.schemas.backup.id})
.from(drizzleDb.schemas.backup)
.where(eq(drizzleDb.schemas.backup.databaseId, dbItem.id))
.then(rows => rows.length);
const totalBackups = await db
.select({ count: drizzleDb.schemas.backup.id })
.from(drizzleDb.schemas.backup)
.where(eq(drizzleDb.schemas.backup.databaseId, dbItem.id))
.then((rows) => rows.length);
const availableBackups = backups.filter(b => !b.deletedAt).length;
const availableBackups = backups.filter((b) => !b.deletedAt).length;
const successfulBackups = await db.select({count: drizzleDb.schemas.backup.id})
.from(drizzleDb.schemas.backup)
.where(and(
eq(drizzleDb.schemas.backup.databaseId, dbItem.id),
eq(drizzleDb.schemas.backup.status, "success")
))
.then(rows => rows.length);
const successfulBackups = await db
.select({ count: drizzleDb.schemas.backup.id })
.from(drizzleDb.schemas.backup)
.where(
and(
eq(drizzleDb.schemas.backup.databaseId, dbItem.id),
eq(drizzleDb.schemas.backup.status, "success"),
),
)
.then((rows) => rows.length);
const [settings] = await db
.select()
.from(drizzleDb.schemas.setting)
.where(eq(drizzleDb.schemas.setting.name, "system"))
.limit(1);
if (!settings) {
notFound();
}
const [settings] = await db.select().from(drizzleDb.schemas.setting).where(eq(drizzleDb.schemas.setting.name, "system")).limit(1);
if (!settings) {
notFound();
}
const databaseHealthLogs = dbItem
? await getHealthLast12hLogs({ id: dbItem.id })
: [];
const databaseHealthLogs = dbItem ? await getHealthLast12hLogs({id: dbItem.id}) : []
const successRate =
totalBackups > 0 ? (successfulBackups / totalBackups) * 100 : null;
//const isMember = activeMember?.role === "member";
const successRate = totalBackups > 0 ? (successfulBackups / totalBackups) * 100 : null;
const isMember = activeMember?.role === "member";
return (
<Page>
<LogsModalProvider>
<BackupModalProvider>
<DatabaseContent
activeMember={activeMember}
settings={settings}
database={dbItem}
databaseHealthLogs={databaseHealthLogs}
isAlreadyRestore={isAlreadyRestore}
restorations={restorations}
backups={backups}
totalBackups={totalBackups}
availableBackups={availableBackups}
successRate={successRate}
organizationId={organization.id}
activeOrganizationChannels={[]}
activeOrganizationStorageChannels={[]}
/>
</BackupModalProvider>
</LogsModalProvider>
</Page>
);
}
return (
<Page>
<LogsModalProvider>
<BackupModalProvider>
<DatabaseContent
activeMember={activeMember}
settings={settings}
database={dbItem}
databaseHealthLogs={databaseHealthLogs}
isAlreadyRestore={isAlreadyRestore}
restorations={restorations}
backups={backups}
totalBackups={totalBackups}
availableBackups={availableBackups}
successRate={successRate}
organizationId={organization.id}
activeOrganizationChannels={[]}
activeOrganizationStorageChannels={[]}
/>
</BackupModalProvider>
</LogsModalProvider>
</Page>
);
}
@@ -1,108 +1,115 @@
import {PageParams} from "@/types/next";
import {Page, PageContent, PageTitle} from "@/features/layout/page";
import {
ButtonDeleteProject
} from "@/features/projects/project-delete-button";
import {CardsWithPagination} from "@/components/common/cards-with-pagination";
import {ProjectDatabaseCard} from "@/features/projects/project-database-card";
import {notFound, redirect} from "next/navigation";
import {db} from "@/db";
import {eq} from "drizzle-orm";
import {getActiveMember, getOrganization} from "@/lib/auth/auth";
import { PageParams } from "@/types/next";
import { Page, PageContent, PageTitle } from "@/features/layout/components/page";
import { ButtonDeleteProject } from "@/features/projects/components/project-delete-button";
import { CardsWithPagination } from "@/components/common/cards-with-pagination";
import { ProjectDatabaseCard } from "@/features/projects/components/project-database-card";
import { 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/project-dialog";
import {ProjectWith} from "@/db/schema/06_project";
import {isUuidv4} from "@/utils/verify-uuid";
import {getOrganizationAvailableDatabases} from "@/db/services/database";
import { capitalizeFirstLetter, isUUID } from "@/utils/text";
import { ProjectDialog } from "@/features/projects/components/project-dialog";
import { ProjectWith } from "@/db/schema/06_project";
import { getOrganizationAvailableDatabases } from "@/db/services/database";
export default async function RoutePage(
props: PageParams<{
projectId: string;
}>,
) {
const { projectId } = await props.params;
export default async function RoutePage(props: PageParams<{
projectId: string
}>) {
const {
projectId
} = await props.params;
if (!isUUID(projectId)) {
notFound();
}
if (!isUuidv4(projectId)) {
notFound()
}
const organization = await getOrganization({});
const activeMember = await getActiveMember();
const organization = await getOrganization({});
const activeMember = await getActiveMember()
if (!organization) {
notFound();
}
const org = await db.query.organization.findFirst({
where: eq(drizzleDb.schemas.organization.slug, organization.slug),
});
if (!organization) {
notFound();
}
const org = await db.query.organization.findFirst({
where: eq(drizzleDb.schemas.organization.slug, organization.slug),
});
if (!org) notFound();
if (!org) notFound();
const proj = await db.query.project.findFirst({
where: (proj, { and, eq, not }) =>
and(
eq(proj.id, projectId),
eq(proj.organizationId, org.id),
not(eq(proj.isArchived, true)),
),
with: {
databases: true,
},
});
const proj = await db.query.project.findFirst({
where: (proj, {
and,
eq,
not
}) => and(eq(proj.id, projectId), eq(proj.organizationId, org.id), not(eq(proj.isArchived, true))),
with: {
databases: true,
},
});
if (!proj) {
redirect("/dashboard/projects");
}
if (!proj) {
redirect("/dashboard/projects");
}
const availableDatabases = await getOrganizationAvailableDatabases(
organization.id,
proj.id,
);
const isMember = activeMember?.role === "member";
const availableDatabases = await getOrganizationAvailableDatabases(organization.id, proj.id)
const isMember = activeMember?.role === "member";
return (
<Page>
<div className="justify-between gap-2 sm:flex">
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full ">
<div className="min-w-full md:min-w-fit ">
{capitalizeFirstLetter(proj.name)}
</div>
{!isMember && (
<div className="flex items-center gap-2 md:justify-between w-full ">
<div className="flex items-center gap-2">
<ProjectDialog
databases={availableDatabases}
organization={org}
project={proj as ProjectWith}
isEdit={true}
/>
</div>
<div className="flex items-center gap-2">
<ButtonDeleteProject projectId={projectId} text={"Delete Project"}/>
</div>
</div>
)}
</PageTitle>
return (
<Page>
<div className="justify-between gap-2 sm:flex">
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full ">
<div className="min-w-full md:min-w-fit ">
{capitalizeFirstLetter(proj.name)}
</div>
{!isMember && (
<div className="flex items-center gap-2 md:justify-between w-full ">
<div className="flex items-center gap-2">
<ProjectDialog
databases={availableDatabases}
organization={org}
project={proj as ProjectWith}
isEdit={true}
/>
</div>
<div className="flex items-center gap-2">
<ButtonDeleteProject
projectId={projectId}
text={"Delete Project"}
/>
</div>
</div>
<PageContent className="flex flex-col w-full h-full">
{proj.databases.length > 0 ? (
<CardsWithPagination
data={[...proj.databases].sort((a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
)}
organizationSlug={organization.slug}
// @ts-ignore
cardItem={ProjectDatabaseCard}
cardsPerPage={20}
numberOfColumns={3}
pageSizeOptions={[10, 20, 50]}
extendedProps={proj}
/>
) : (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground py-20">
<p className="text-lg font-medium">No databases found</p>
<p className="text-sm mt-2">You havent added any databases to this project yet.</p>
</div>
)}
</PageContent>
</Page>
);
}
)}
</PageTitle>
</div>
<PageContent className="flex flex-col w-full h-full">
{proj.databases.length > 0 ? (
<CardsWithPagination
data={[...proj.databases].sort(
(a, b) =>
new Date(b.createdAt).getTime() -
new Date(a.createdAt).getTime(),
)}
organizationSlug={organization.slug}
// @ts-ignore
cardItem={ProjectDatabaseCard}
cardsPerPage={20}
numberOfColumns={3}
pageSizeOptions={[10, 20, 50]}
extendedProps={proj}
/>
) : (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground py-20">
<p className="text-lg font-medium">No databases found</p>
<p className="text-sm mt-2">
You havent added any databases to this project yet.
</p>
</div>
)}
</PageContent>
</Page>
);
}
@@ -1,13 +1,13 @@
import {PageParams} from "@/types/next";
import {CardsWithPagination} from "@/components/common/cards-with-pagination";
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {ProjectCard} from "@/features/projects/project-card";
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/components/page";
import {ProjectCard} from "@/features/projects/components/project-card";
import {db} from "@/db";
import {notFound} from "next/navigation";
import {getActiveMember, getOrganization} from "@/lib/auth/auth";
import {EmptyStatePlaceholder} from "@/components/common/empty-state-placeholder";
import {Metadata} from "next";
import {ProjectDialog} from "@/features/projects/project-dialog";
import {ProjectDialog} from "@/features/projects/components/project-dialog";
import {DatabaseWith} from "@/db/schema/07_database";
import {getOrganizationAvailableDatabases} from "@/db/services/database";
@@ -4,17 +4,17 @@ import {
PageContent,
PageDescription,
PageTitle,
} from "@/features/layout/page";
} from "@/features/layout/components/page";
import {db} from "@/db";
import * as drizzleDb from "@/db";
import {eq} from "drizzle-orm";
import {notFound} from "next/navigation";
import {ButtonDeleteAgent} from "@/features/agents/agent-delete-button";
import {ButtonDeleteAgent} from "@/features/agents/components/agent-delete-button";
import {capitalizeFirstLetter} from "@/utils/text";
import {generateEdgeKey} from "@/utils/edge_key";
import {getServerUrl} from "@/utils/get-server-url";
import {AgentContentPage} from "@/features/agents/agent-content";
import {AgentDialog} from "@/features/agents/agent-dialog";
import {AgentContentPage} from "@/features/agents/components/agent-content";
import {AgentDialog} from "@/features/agents/components/agent-dialog";
import {getActiveMember, getOrganization} from "@/lib/auth/auth";
import {currentUser} from "@/lib/auth/current-user";
import {computeOrganizationPermissions} from "@/lib/acl/organization-acl";
@@ -4,17 +4,17 @@ import {
PageContent,
PageHeader,
PageTitle,
} from "@/features/layout/page";
} from "@/features/layout/components/page";
import {currentUser} from "@/lib/auth/current-user";
import {getActiveMember, getOrganization} from "@/lib/auth/auth";
import {notFound} from "next/navigation";
import {Metadata} from "next";
import {OrganizationTabs} from "@/features/organizations/organization-tabs";
import {OrganizationTabs} from "@/features/organizations/components/organization-tabs";
import {getOrganizationChannels} from "@/db/services/notification-channel";
import {computeOrganizationPermissions} from "@/lib/acl/organization-acl";
import {getOrganizationStorageChannels} from "@/db/services/storage-channel";
import {DeleteOrganizationButton} from "@/features/organizations/organization-delete-button";
import {EditOrganizationDialog} from "@/features/organizations/organization-edit-dialog";
import {DeleteOrganizationButton} from "@/features/organizations/components/organization-delete-button";
import {EditOrganizationDialog} from "@/features/organizations/components/organization-edit-dialog";
import {db} from "@/db";
import {isNull} from "drizzle-orm";
import * as drizzleDb from "@/db";
@@ -1,8 +1,8 @@
import {PageParams} from "@/types/next";
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/components/page";
import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card";
import {EvolutionLineChart} from "@/features/statistics/evolution-line-chart";
import {PercentageLineChart} from "@/features/statistics/percentage-line-chart";
import {EvolutionLineChart} from "@/features/statistics/components/evolution-line-chart";
import {PercentageLineChart} from "@/features/statistics/components/percentage-line-chart";
import {notFound} from "next/navigation";
import {db} from "@/db";
import {and, asc, count, eq, inArray} from "drizzle-orm";
+6 -3
View File
@@ -1,11 +1,12 @@
import {PageParams} from "@/types/next";
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/components/page";
import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card";
import {Building2, Database, DatabaseBackup, Folder, RefreshCcw, Server, Workflow} from "lucide-react";
import {currentUser} from "@/lib/auth/current-user";
import {notFound} from "next/navigation";
import {db} from "@/db";
import {and, asc, eq, inArray} from "drizzle-orm";
import {and, asc, eq, inArray, not} from "drizzle-orm";
import * as drizzleDb from "@/db";
import {listOrganizations} from "@/lib/auth/auth";
import {Metadata} from "next";
@@ -23,7 +24,9 @@ export default async function RoutePage(props: PageParams<{}>) {
const organizationIds = organizations.map(project => project.id);
const agents = await db.query.agent.findMany({});
const agents = await db.query.agent.findMany({
where: not(eq(drizzleDb.schemas.agent.isArchived, true)),
});
const projects = await db.query.project.findMany({
where: and(inArray(drizzleDb.schemas.project.organizationId, organizationIds), eq(drizzleDb.schemas.project.isArchived, false)),
+17 -19
View File
@@ -2,33 +2,31 @@ import { ReactNode } from "react";
import { redirect } from "next/navigation";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { AppSidebar } from "@/features/layout/app-sidebar";
import { Header } from "@/features/layout/header";
import { AppSidebar } from "@/features/layout/components/app-sidebar";
import { Header } from "@/features/layout/components/header";
import { currentUser } from "@/lib/auth/current-user";
import { ThemeMetaUpdater } from "@/features/theme/theme-meta-updater";
import { ModeToggle } from "@/features/theme/mode-toggle";
import { UpdateNotification } from "@/features/updates/update-notification";
import {AclProvider} from "@/lib/acl/acl-context";
import {env} from "@/env.mjs";
import { isOnboardingDone } from "@/db/services/setting";
import { env } from "@/env.mjs";
import { ModeToggle } from "@/features/theme/components/mode-toggle";
import { UpdateNotification } from "@/features/updates/components/update-notification";
export default async function Layout({ children }: { children: ReactNode }) {
if (env.SKIP_ONBOARDING !== "true" && !(await isOnboardingDone())) {
redirect("/welcome");
}
const user = await currentUser();
if (!user) redirect("/login");
const isDemoEnabled = env.DEMO_ENABLED
return (
<SidebarProvider>
<AclProvider demo={isDemoEnabled} user={user}>
<div className="flex flex-col lg:flex-row w-full">
<ThemeMetaUpdater />
<AppSidebar updateNotification={<UpdateNotification />} />
<SidebarInset>
<Header actions={<ModeToggle />} />
<main className="h-full">{children}</main>
</SidebarInset>
</div>
</AclProvider>
<div className="flex flex-col lg:flex-row w-full">
<AppSidebar updateNotification={<UpdateNotification />} />
<SidebarInset>
<Header actions={<ModeToggle />} />
<main className="h-full">{children}</main>
</SidebarInset>
</div>
</SidebarProvider>
);
}
+7 -1
View File
@@ -1,8 +1,14 @@
import { redirect } from "next/navigation";
import { getCurrentOrganizationSlug } from "@/features/organizations/organization-cookie";
import { getCurrentOrganizationSlug } from "@/features/organizations/utils/organization-cookie";
import { currentUser } from "@/lib/auth/current-user";
import { isOnboardingDone } from "@/db/services/setting";
import { env } from "@/env.mjs";
export default async function Index() {
if (env.SKIP_ONBOARDING !== "true" && !(await isOnboardingDone())) {
redirect("/welcome");
}
const user = await currentUser();
if (user) {
const currentOrganizationSlug = await getCurrentOrganizationSlug();
@@ -0,0 +1,29 @@
"use client";
import { useRouter } from "next/navigation";
import { OnboardingProvider } from "@onboardjs/react";
import { onboardingSteps } from "@/features/onboarding/onboarding-steps";
import { OnboardingShell } from "@/features/onboarding/onboarding-shell";
import type { OnboardingFlowData } from "@/features/onboarding/types";
type Props = {
initialStepId: string;
initialFlowData: Partial<OnboardingFlowData>;
};
export const OnboardingClient = ({ initialStepId, initialFlowData }: Props) => {
const router = useRouter();
return (
<OnboardingProvider
steps={onboardingSteps}
initialStepId={initialStepId}
initialContext={{ flowData: initialFlowData }}
onFlowComplete={async () => {
router.push("/dashboard/home");
}}
>
<OnboardingShell />
</OnboardingProvider>
);
};
+19
View File
@@ -0,0 +1,19 @@
import { redirect } from "next/navigation";
import { resolveOnboardingState } from "@/features/onboarding/onboarding-state";
import { OnboardingClient } from "./onboarding-client";
export default async function WelcomePage() {
const result = await resolveOnboardingState();
if ("redirect" in result) {
redirect(result.redirect);
}
return (
<OnboardingClient
key={result.stepId}
initialStepId={result.stepId}
initialFlowData={result.flowData}
/>
);
}
+2 -3
View File
@@ -6,8 +6,8 @@ import {getDatabaseOrThrow, withAgentCheck} from "./helpers";
import {Backup} from "@/db/schema/07_database";
import {withUpdatedAt} from "@/db/utils";
import {eventEmitter} from "@/lib/event";
import {sendNotificationsBackupRestore} from "@/features/notifications/notifications.helpers";
import {EventKind} from "@/features/notifications/notifications.types";
import {sendNotificationsBackupRestore} from "@/features/notifications/utils/notifications.helpers";
import {EventKind} from "@/features/notifications/types";
import {logger} from "@/lib/logger";
import {JobLogEntry} from "@/features/logs/types";
@@ -108,7 +108,6 @@ export const PATCH = withAgentCheck(async (request: Request, {params, agent}: {
}) => {
try {
const body: BodyPatch = await request.json();
log.info({data: body}, "Body from PATH in backup route");
const status = body.status
const backupId = body.backupId
@@ -1,79 +1,86 @@
import {NextResponse} from "next/server";
import {and, eq} from "drizzle-orm";
import { NextResponse } from "next/server";
import { and, eq } from "drizzle-orm";
import * as drizzleDb from "@/db";
import {db} from "@/db";
import {getDatabaseOrThrow, withAgentCheck} from "../../helpers";
import {isUuidv4} from "@/utils/verify-uuid";
import {eventEmitter} from "@/lib/event";
import {logger} from "@/lib/logger";
import { db } from "@/db";
import { getDatabaseOrThrow, withAgentCheck } from "../../helpers";
import { eventEmitter } from "@/lib/event";
import { logger } from "@/lib/logger";
import { isUUID } from "@/utils/text";
const log = logger.child({module: "api/agent/backup/upload/init"});
const log = logger.child({ module: "api/agent/backup/upload/init" });
export type Body = {
generatedId: string
storageChannelId: string
backupId: string
}
export const POST = withAgentCheck(async (request: Request, {params, agent}: {
params: Promise<{ agentId: string }>,
agent: any
}) => {
generatedId: string;
storageChannelId: string;
backupId: string;
};
export const POST = withAgentCheck(
async (
request: Request,
{
params,
agent,
}: {
params: Promise<{ agentId: string }>;
agent: any;
},
) => {
try {
const body: Body = await request.json();
const body: Body = await request.json();
log.info({data: body}, "Body for backup upload init");
log.info({ data: body }, "Body for backup upload init");
const generatedId = body.generatedId;
const storageChannelId = body.storageChannelId;
const backupId = body.backupId;
if (!generatedId || !isUuidv4(generatedId)) {
return NextResponse.json(
{error: "generatedId is not a valid UUID"},
{status: 400}
);
}
const database = await getDatabaseOrThrow(generatedId);
const backup = await db.query.backup.findFirst({
where: and(
eq(drizzleDb.schemas.backup.id, backupId),
eq(drizzleDb.schemas.backup.databaseId, database.id),
),
});
if (!backup) {
return NextResponse.json(
{error: "Unable to find the corresponding backup"},
{status: 404}
);
}
const [backupStorage] = await db
.insert(drizzleDb.schemas.backupStorage)
.values({
backupId: backup.id,
storageChannelId: storageChannelId,
status: "pending",
})
.returning();
eventEmitter.emit('modification', {update: true});
const generatedId = body.generatedId;
const storageChannelId = body.storageChannelId;
const backupId = body.backupId;
if (!generatedId || !isUUID(generatedId)) {
return NextResponse.json(
{
message: "Backup storage successfully created",
backupStorage: backupStorage
},
{status: 200}
{ error: "generatedId is not a valid UUID" },
{ status: 400 },
);
}
const database = await getDatabaseOrThrow(generatedId);
const backup = await db.query.backup.findFirst({
where: and(
eq(drizzleDb.schemas.backup.id, backupId),
eq(drizzleDb.schemas.backup.databaseId, database.id),
),
});
if (!backup) {
return NextResponse.json(
{ error: "Unable to find the corresponding backup" },
{ status: 404 },
);
}
const [backupStorage] = await db
.insert(drizzleDb.schemas.backupStorage)
.values({
backupId: backup.id,
storageChannelId: storageChannelId,
status: "pending",
})
.returning();
eventEmitter.emit("modification", { update: true });
return NextResponse.json(
{
message: "Backup storage successfully created",
backupStorage: backupStorage,
},
{ status: 200 },
);
} catch (error) {
log.error({error: error}, "Error in POST for INIT backup");
return NextResponse.json(
{error: "Internal server error"},
{status: 500}
);
log.error({ error: error }, "Error in POST for INIT backup");
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
});
},
);
+115 -101
View File
@@ -1,110 +1,124 @@
import {NextResponse} from "next/server";
import {isUuidv4} from "@/utils/verify-uuid";
import { NextResponse } from "next/server";
import * as drizzleDb from "@/db";
import {db as dbClient, db} from "@/db";
import {and, eq} from "drizzle-orm";
import {sendNotificationsBackupRestore} from "@/features/notifications/notifications.helpers";
import {logger} from "@/lib/logger";
import {withUpdatedAt} from "@/db/utils";
import {JobLogEntry} from "@/features/logs/types";
import { db as dbClient, db } from "@/db";
import { and, eq } from "drizzle-orm";
import { sendNotificationsBackupRestore } from "@/features/notifications/utils/notifications.helpers";
import { logger } from "@/lib/logger";
import { withUpdatedAt } from "@/db/utils";
import { JobLogEntry } from "@/features/logs/types";
import { isUUID } from "@/utils/text";
const log = logger.child({module: "api/agent/restore"});
const log = logger.child({ module: "api/agent/restore" });
export type BodyResultRestore = {
generatedId: string
status: string
logs: JobLogEntry[]
durationMs: number
}
type RestorationStatus = 'waiting' | 'ongoing' | 'failed' | 'success';
generatedId: string;
status: string;
logs: JobLogEntry[];
durationMs: number;
};
type RestorationStatus = "waiting" | "ongoing" | "failed" | "success";
export async function POST(
request: Request,
{params}: { params: Promise<{ agentId: string }> }
request: Request,
{ params }: { params: Promise<{ agentId: string }> },
) {
try {
const agentId = (await params).agentId;
const body: BodyResultRestore = await request.json();
try {
const agentId = (await params).agentId
const body: BodyResultRestore = await request.json();
if (!isUuidv4(body.generatedId)) {
return NextResponse.json(
{error: "generatedId is not a valid uuid"},
{status: 500}
);
}
const agent = await db.query.agent.findFirst({
where: and(eq(drizzleDb.schemas.agent.id, agentId), eq(drizzleDb.schemas.agent.isArchived, false)),
})
if (!agent) {
return NextResponse.json({error: "Agent not found"}, {status: 404})
}
const database = await db.query.database.findFirst({
where: eq(drizzleDb.schemas.database.agentDatabaseId, body.generatedId),
with: {
alertPolicies: true
}
})
if (!database) {
return NextResponse.json({error: "Database associated with generatedId provided not found"}, {status: 404})
}
const restoration = await db.query.restoration.findFirst({
where: and(eq(drizzleDb.schemas.restoration.status, "ongoing"), eq(drizzleDb.schemas.restoration.databaseId, database.id),)
})
if (!restoration) {
return NextResponse.json({error: "Unable to fin the corresponding restoration"}, {status: 404})
}
const [restorationUpdated] = await db
.update(drizzleDb.schemas.restoration)
.set(withUpdatedAt({status: body.status as RestorationStatus, durationMs: body.durationMs}))
.where(eq(drizzleDb.schemas.restoration.id, restoration.id)).returning();
const logsToInsert = body.logs.map((entry) => ({
backupId: null,
restorationId: restorationUpdated.id,
loggedAt: new Date(entry.timestamp),
entryType: entry.type,
level: entry.level,
message: entry.message,
command: entry.command ?? null,
output: entry.output ?? null,
exitCode: entry.exit_code ?? null,
durationMs: entry.duration_ms ?? null,
}));
if (logsToInsert.length > 0) {
await dbClient
.insert(drizzleDb.schemas.jobLog)
.values(logsToInsert);
}
await sendNotificationsBackupRestore(database, body.status == "failed" ? "error_restore" : "success_restore");
const response = {
status: true,
message: "Restoration successfully updated"
}
return Response.json(response, {status: 200})
} catch (error) {
log.error({error: error}, "Error in POST handler")
return NextResponse.json(
{error: 'Internal server error'},
{status: 500}
);
if (!isUUID(body.generatedId)) {
return NextResponse.json(
{ error: "generatedId is not a valid uuid" },
{ status: 500 },
);
}
}
const agent = await db.query.agent.findFirst({
where: and(
eq(drizzleDb.schemas.agent.id, agentId),
eq(drizzleDb.schemas.agent.isArchived, false),
),
});
if (!agent) {
return NextResponse.json({ error: "Agent not found" }, { status: 404 });
}
const database = await db.query.database.findFirst({
where: eq(drizzleDb.schemas.database.agentDatabaseId, body.generatedId),
with: {
alertPolicies: true,
},
});
if (!database) {
return NextResponse.json(
{ error: "Database associated with generatedId provided not found" },
{ status: 404 },
);
}
const restoration = await db.query.restoration.findFirst({
where: and(
eq(drizzleDb.schemas.restoration.status, "ongoing"),
eq(drizzleDb.schemas.restoration.databaseId, database.id),
),
});
if (!restoration) {
return NextResponse.json(
{ error: "Unable to fin the corresponding restoration" },
{ status: 404 },
);
}
const [restorationUpdated] = await db
.update(drizzleDb.schemas.restoration)
.set(
withUpdatedAt({
status: body.status as RestorationStatus,
durationMs: body.durationMs,
}),
)
.where(eq(drizzleDb.schemas.restoration.id, restoration.id))
.returning();
const logsToInsert = body.logs.map((entry) => ({
backupId: null,
restorationId: restorationUpdated.id,
loggedAt: new Date(entry.timestamp),
entryType: entry.type,
level: entry.level,
message: entry.message,
command: entry.command ?? null,
output: entry.output ?? null,
exitCode: entry.exit_code ?? null,
durationMs: entry.duration_ms ?? null,
}));
if (logsToInsert.length > 0) {
await dbClient.insert(drizzleDb.schemas.jobLog).values(logsToInsert);
}
await sendNotificationsBackupRestore(
database,
body.status == "failed" ? "error_restore" : "success_restore",
);
const response = {
status: true,
message: "Restoration successfully updated",
};
return Response.json(response, { status: 200 });
} catch (error) {
log.error({ error: error }, "Error in POST handler");
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
+11 -8
View File
@@ -1,6 +1,5 @@
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";
@@ -8,17 +7,18 @@ 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/storages.types";
import {dispatchStorage} from "@/features/storages/storages.dispatch";
import {Setting} from "@/db/schema/01_setting";
import {logger} from "@/lib/logger";
import {isUUID} from "@/utils/text";
import {StorageInput} from "@/features/storages/types";
import {dispatchStorage} from "@/features/storages/utils/storages.dispatch";
const log = logger.child({module: "api/agent/status/helpers"});
export async function handleDatabases(body: Body, agent: Agent, lastContact: Date, settings: Setting) {
const databasesResponse = [];
const formatDatabase = (database: DatabaseWith, backupAction: boolean, restoreAction: boolean, UrlBackup: string | null, storages: PingDatabaseStorageChannels[], urlMeta: string | null) => ({
const formatDatabase = (database: DatabaseWith, backupAction: boolean, restoreAction: boolean, UrlBackup: string | null, storages: PingDatabaseStorageChannels[], urlMeta: string | null, backupSize: number | null) => ({
generatedId: database.agentDatabaseId,
dbms: database.dbms,
storages: storages,
@@ -31,7 +31,8 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
restore: {
action: restoreAction,
file: UrlBackup,
metaFile: urlMeta
metaFile: urlMeta,
size: backupSize
},
},
});
@@ -49,9 +50,10 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
let restoreAction: boolean = false
let urlBackup: string | null = null;
let urlMeta: string | null = null
let backupSize: number | null = null
if (!existingDatabase) {
if (!isUuidv4(db.generatedId)) {
if (!isUUID(db.generatedId)) {
return NextResponse.json(
{error: "generatedId is not a valid uuid"},
{status: 500}
@@ -90,7 +92,7 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
const storages = await getDatabaseStorageChannels(databaseCreated.id)
databasesResponse.push(formatDatabase(databaseCreated, backupAction, restoreAction, urlBackup, storages, null));
databasesResponse.push(formatDatabase(databaseCreated, backupAction, restoreAction, urlBackup, storages, null, null));
}
} else {
@@ -180,6 +182,7 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
if (result.success) {
urlBackup = result.url ?? null;
urlMeta = resultMeta.url ?? null
backupSize = restoration.backupStorage.size
} else {
await dbClient
.update(drizzleDb.schemas.restoration)
@@ -205,7 +208,7 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
.where(eq(drizzleDb.schemas.restoration.id, restoration.id));
}
const storages = await getDatabaseStorageChannels(databaseUpdated.id)
databasesResponse.push(formatDatabase(databaseUpdated, backupAction, restoreAction, urlBackup, storages, urlMeta));
databasesResponse.push(formatDatabase(databaseUpdated, backupAction, restoreAction, urlBackup, storages, urlMeta, backupSize));
}
}
return databasesResponse;
+95 -87
View File
@@ -1,99 +1,107 @@
import {NextResponse} from "next/server";
import {handleDatabases} from "./helpers";
import { NextResponse } from "next/server";
import { handleDatabases } from "./helpers";
import * as drizzleDb from "@/db";
import {db} from "@/db";
import {EDbmsSchema} from "@/db/schema/types";
import {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"});
import { db } from "@/db";
import { EDbmsSchema } from "@/db/schema/types";
import { and, eq } from "drizzle-orm";
import { withUpdatedAt } from "@/db/utils";
import { logger } from "@/lib/logger";
import { isUUID } from "@/utils/text";
const log = logger.child({ module: "api/agent/status/route" });
export type databaseAgent = {
name: string,
dbms: EDbmsSchema,
generatedId: string
pingStatus: boolean
}
name: string;
dbms: EDbmsSchema;
generatedId: string;
pingStatus: boolean;
};
export type Body = {
version: string,
databases: databaseAgent[]
}
version: string;
databases: databaseAgent[];
};
export async function POST(
request: Request,
{params}: { params: Promise<{ agentId: string }> }
request: Request,
{ params }: { params: Promise<{ agentId: string }> },
) {
try {
const agentId = (await params).agentId
log.debug(`Agent ID: ${agentId}`)
const body: Body = await request.json();
const lastContact = new Date();
let message: string
try {
const agentId = (await params).agentId;
log.debug(`Agent ID: ${agentId}`);
const body: Body = await request.json();
const lastContact = new Date();
let message: string;
if (!isUuidv4(agentId)) {
message = "agentId is not a valid uuid"
log.error({error: message}, "An error occurred")
return NextResponse.json(
{error: "agentId is not a valid uuid"},
{status: 500}
);
}
const agent = await db.query.agent.findFirst({
where: 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}
);
if (!isUUID(agentId)) {
message = "agentId is not a valid uuid";
log.error({ error: message }, "An error occurred");
return NextResponse.json(
{ error: "agentId is not a valid uuid" },
{ status: 500 },
);
}
}
const agent = await db.query.agent.findFirst({
where: and(
eq(drizzleDb.schemas.agent.id, agentId),
eq(drizzleDb.schemas.agent.isArchived, false),
),
});
if (!agent) {
message = "Agent not found";
return NextResponse.json({ error: message }, { status: 404 });
}
const [settings] = await db
.select()
.from(drizzleDb.schemas.setting)
.where(eq(drizzleDb.schemas.setting.name, "system"))
.limit(1);
if (!settings) {
return NextResponse.json({ error: "An error occured" }, { status: 404 });
}
const databasesResponse = await handleDatabases(
body,
agent,
lastContact,
settings,
);
await db
.update(drizzleDb.schemas.agent)
.set(
withUpdatedAt({
version: body.version,
lastContact: lastContact,
healthErrorCount: null,
}),
)
.where(eq(drizzleDb.schemas.agent.id, agentId));
await db.insert(drizzleDb.schemas.healthcheckLog).values({
kind: "agent",
status: "success",
objectId: agentId,
date: lastContact,
});
const response = {
agent: {
id: agentId,
lastContact: lastContact,
},
databases: databasesResponse,
};
return Response.json(response);
} catch (error) {
log.error({ error: error }, "Error in POST handler");
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
+22 -20
View File
@@ -5,31 +5,33 @@ import { headers } from "next/headers";
const authHandler = toNextJsHandler(auth.handler);
async function blockApiKeyCreateForRestrictedUsers(req: NextRequest): Promise<NextResponse | null> {
const url = req.nextUrl;
if (req.method !== "POST" || !url.pathname.endsWith("/api-key/create")) {
return null;
}
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user) {
return null;
}
// @ts-ignore
if (session.user.banned || (session.user.role as string) === "pending") {
return NextResponse.json(
{ error: "Account not eligible to create API keys" },
{ status: 403 }
);
}
async function blockApiKeyCreateForRestrictedUsers(
req: NextRequest,
): Promise<NextResponse | null> {
const url = req.nextUrl;
if (req.method !== "POST" || !url.pathname.endsWith("/api-key/create")) {
return null;
}
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user) {
return null;
}
// @ts-ignore
if (session.user.banned || (session.user.role as string) === "pending") {
return NextResponse.json(
{ error: "Account not eligible to create API keys" },
{ status: 403 },
);
}
return null;
}
export async function GET(req: NextRequest) {
return authHandler.GET(req);
return authHandler.GET(req);
}
export async function POST(req: NextRequest) {
const guard = await blockApiKeyCreateForRestrictedUsers(req);
if (guard) return guard;
return authHandler.POST(req);
const guard = await blockApiKeyCreateForRestrictedUsers(req);
if (guard) return guard;
return authHandler.POST(req);
}
+2 -2
View File
@@ -1,7 +1,7 @@
import {NextResponse} from "next/server";
import path from "path";
import type {StorageInput} from "@/features/storages/storages.types";
import {dispatchStorage} from "@/features/storages/storages.dispatch";
import type {StorageInput} from "@/features/storages/types";
import {dispatchStorage} from "@/features/storages/utils/storages.dispatch";
import {Readable} from "node:stream";
import {logger} from "@/lib/logger";
+2 -2
View File
@@ -1,8 +1,8 @@
import {NextResponse} from "next/server";
import {auth} from "@/lib/auth/auth";
import {headers} from "next/headers";
import {StorageInput} from "@/features/storages/storages.types";
import {dispatchStorage} from "@/features/storages/storages.dispatch";
import {StorageInput} from "@/features/storages/types";
import {dispatchStorage} from "@/features/storages/utils/storages.dispatch";
import {Readable} from "node:stream";
import {logger} from "@/lib/logger";
+1 -1
View File
@@ -3,7 +3,7 @@ import {withApiKey} from "@/lib/api-v1/middleware";
import {logger} from "@/lib/logger";
import {ApiKeyContext} from "@/lib/api-v1/types";
import {getAgent, resolveAgentAccess} from "@/lib/api-v1/services/agents";
import {deleteAgentService} from "@/features/agents/agent-delete.action";
import {deleteAgentService} from "@/features/agents/actions/agent-delete.action";
const log = logger.child({module: "api/v1/agents/[id]"});
+1 -1
View File
@@ -5,7 +5,7 @@ import * as drizzleDb from "@/db";
import { inArray, eq, and, or, isNull } from "drizzle-orm";
import { z } from "zod";
import { logger } from "@/lib/logger";
import {createAgentService} from "@/features/agents/agents.action";
import {createAgentService} from "@/features/agents/actions/agents.action";
import { ActionError } from "@/lib/safe-actions/actions";
import {getAccessibleAgentIds} from "@/lib/api-v1/services/agents";
import {ApiKeyContext} from "@/lib/api-v1/types";
+5 -2
View File
@@ -1,4 +1,5 @@
import BackButton from "@/components/common/back-button";
import Link from "next/link";
import { Button } from "@/components/ui/button";
export default async function NotFound() {
return (
@@ -12,7 +13,9 @@ export default async function NotFound() {
The content you are trying to view is not available.
</p>
</div>
<BackButton>Go home</BackButton>
<Button asChild>
<Link href="/">Go home</Link>
</Button>
</div>
</div>
);
+3 -3
View File
@@ -5,8 +5,8 @@ import { type PropsWithChildren, Suspense } from "react";
import { Toaster } from "@/components/ui/sonner";
import { ErrorLayout } from "@/components/common/error-layout";
import { ThemeMetaUpdaterRoot } from "@/features/theme/theme-meta-updater-root";
import { ThemeProvider } from "@/features/theme/theme-provider";
import { ThemeMetaUpdater } from "@/features/theme/components/theme-meta-updater";
import { ThemeProvider } from "@/features/theme/components/theme-provider";
export type ProviderProps = PropsWithChildren<{}>;
const queryClient = new QueryClient();
@@ -15,7 +15,7 @@ export const Providers = (props: ProviderProps) => {
return (
<Suspense fallback={null}>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<ThemeMetaUpdaterRoot />
<ThemeMetaUpdater />
<QueryClientProvider client={queryClient}>
<ErrorLayout>
<Toaster />
-37
View File
@@ -1,37 +0,0 @@
services:
app:
build:
context: .
dockerfile: docker/dockerfile/Dockerfile
target: prod
ports:
- '8887:80'
environment:
TZ: "Europe/Paris"
env_file:
- .env
volumes:
- portabase-e2e-data:/data
depends_on:
db:
condition: service_healthy
db:
image: postgres:18-alpine
ports:
- "5433:5432"
volumes:
- postgres-e2e-data:/var/lib/postgresql/data
environment:
- POSTGRES_DB=devdb
- POSTGRES_USER=devuser
- POSTGRES_PASSWORD=changeme
healthcheck:
test: ["CMD-SHELL", "pg_isready -U devuser -d devdb"]
interval: 10s
timeout: 5s
retries: 5
volumes:
portabase-e2e-data:
postgres-e2e-data:
+6 -6
View File
@@ -1,12 +1,12 @@
services:
app:
# build:
# context: .
# dockerfile: docker/dockerfile/Dockerfile
# target: prod
# build:
# context: .
# dockerfile: docker/dockerfile/Dockerfile
# target: prod
image: portabase/portabase:1
ports:
- '8887:80'
- "8887:80"
environment:
TZ: "Europe/Paris"
env_file:
@@ -35,7 +35,7 @@ services:
- POSTGRES_USER=devuser
- POSTGRES_PASSWORD=changeme
healthcheck:
test: [ "CMD-SHELL", "pg_isready -U devuser -d devdb" ]
test: ["CMD-SHELL", "pg_isready -U devuser -d devdb"]
interval: 10s
timeout: 5s
retries: 5
+67
View File
@@ -41,5 +41,72 @@ services:
- "8025:8025"
- "1025:1025"
azurite:
image: mcr.microsoft.com/azure-storage/azurite
command: azurite --blobHost 0.0.0.0 --skipApiVersionCheck
ports:
- "10000:10000"
volumes:
- azurite-data:/data
extra_hosts:
- "localhost:host-gateway"
networks:
- portabase
# DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://127.0.0.1:10000/devstoreaccount1;QueueEndpoint=http://127.0.0.1:10001/devstoreaccount1;TableEndpoint=http://127.0.0.1:10002/devstoreaccount1;
gcs:
image: fsouza/fake-gcs-server:latest
container_name: fake-gcs
ports:
- "4443:4443"
command:
- "-scheme"
- "http"
- "-port"
- "4443"
- "-public-host"
- "localhost:4443"
- "-external-url"
- "http://localhost:4443"
- "-backend"
- "filesystem"
- "-filesystem-root"
- "/storage"
volumes:
- gcs-data:/data
- gcs-storage:/storage
networks:
- portabase
gcs-init:
image: curlimages/curl:latest
depends_on:
- gcs
entrypoint: ["/bin/sh", "-c"]
command: >
'until curl -fsS http://gcs:4443/storage/v1/b >/dev/null; do sleep 1; done;
curl -fsS http://gcs:4443/storage/v1/b/portabase >/dev/null
|| curl -fsS -X POST "http://gcs:4443/storage/v1/b?project=local"
-H "Content-Type: application/json"
-d "{\"name\":\"portabase\"}"'
networks:
- portabase
# curl http://localhost:4443/storage/v1/b
# projectId: "test-project",
# bucketName: "portabase",
# clientEmail: "fake@test.iam.gserviceaccount.com",
# privateKey: "unused-when-emulated",
volumes:
postgres-data:
azurite-data:
gcs-data:
gcs-storage:
networks:
portabase:
name: portabase_network
external: true
-74
View File
@@ -1,74 +0,0 @@
import {expect, test} from "@playwright/test";
import {users} from "./helpers/auth";
import {changeUserRole, create, switchToDefault} from "./helpers/access-management";
import {LOCAL_STORAGE_PATH} from "./helpers/session";
test.use({storageState: LOCAL_STORAGE_PATH});
const firstOrganization = "Organization A";
const secondOrganization = "Organization B";
test.describe.serial(() => {
test("Create an organization from organizations page", async ({page}) => {
await page.goto("/dashboard/admin/organizations");
await expect(page.getByRole("heading", {name: "Active organizations"})).toBeVisible();
await create(page, "button", firstOrganization);
await expect(page.getByText(/Organization has been successfully created\./i)).toBeVisible();
await expect(page).toHaveURL("/dashboard/admin/organizations");
await expect(page.getByRole("button", {name: firstOrganization, exact: true})).toBeVisible();
await switchToDefault(page);
});
test("Create an organization from sidebar button", async ({page}) => {
await page.goto("/dashboard/home");
await expect(page.getByRole("heading", {name: "Dashboard"})).toBeVisible();
await create(page, "sidebar", secondOrganization);
await expect(page.getByText(/Organization has been successfully created\./i)).toBeVisible();
await expect(page).toHaveURL("/dashboard/home");
await expect(page.getByRole("button", {name: secondOrganization, exact: true})).toBeVisible();
await switchToDefault(page);
});
test("Change John Doe's role from pending to user", async ({page}) => {
await page.goto("/dashboard/admin/users");
await expect(page.getByText(users.normal.email, {exact: true})).toBeVisible();
const userRow = page.locator("tr").filter({hasText: users.normal.email}).first();
await expect(userRow).toBeVisible();
await userRow.locator("button").last().click();
await page.getByRole('menuitem', {name: 'Role'}).click();
await expect(page.getByRole("heading", {name: "Change the user's role"})).toBeVisible();
await changeUserRole(page, "user")
await expect(page.getByText("User role changed successfully.")).toBeVisible();
await expect(page.locator("tr").filter({hasText: users.normal.email}).getByText("user", {exact: true})).toBeVisible();
});
test("Add John Doe to Organization A", async ({page}) => {
await page.goto("/dashboard/admin/organizations");
await expect(page.getByRole("heading", {name: "Active organizations"})).toBeVisible();
const organizationRow = page.locator("tr").filter({hasText: firstOrganization}).first();
await expect(organizationRow).toBeVisible();
await organizationRow.locator('a[href*="/dashboard/admin/organizations/"], a[href*="organizations/"]').click();
await expect(page.getByText(firstOrganization, {exact: true})).toBeVisible();
await page.getByRole("button", {name: /Add member/i}).click();
await expect(page.getByRole("heading", {name: "Add member to your organization"})).toBeVisible();
await page.getByPlaceholder("Enter a user email").fill(users.normal.email);
await page.getByRole("option", {name: new RegExp(users.normal.email, "i")}).click();
await page.getByRole("button", {name: "Confirm"}).click();
await expect(page.getByText("Member successfully added!")).toBeVisible();
await expect(page.getByText(users.normal.email, {exact: true})).toBeVisible();
});
});
-116
View File
@@ -1,116 +0,0 @@
import {expect, test} from "@playwright/test";
import {execSync} from "child_process";
import fs from "fs";
import os from "os";
import path from "path";
import {createAgentWithDockerDatabases} from "./helpers/agent-cli";
import {create, edit, get, remove} from "./helpers/agent";
import {LOCAL_STORAGE_PATH} from "./helpers/session";
const agent = {
aName: "Agent A",
aUpdatedName: "Agent A Updated",
bName: "Agent B",
description: "Agent created by Playwright E2E",
updatedDescription: "Agent updated by Playwright E2E",
};
test.use({storageState: LOCAL_STORAGE_PATH});
test.describe.serial(() => {
let agentWorkspace: string | null = null;
test("Create agent A from empty state", async ({page}) => {
await page.goto("/dashboard/agents");
await expect(page.getByRole("heading", {name: "Agents"})).toBeVisible();
await expect(page.getByText("Create new Agent", {exact: true})).toBeVisible();
await create(page, "emptyState", agent.aName, agent.description);
await expect(page.getByText("Success creating agent")).toBeVisible();
await expect(get(page, agent.aName)).toBeVisible();
await expect(page.getByText("Create new Agent", {exact: true})).toHaveCount(0);
});
test("Edit agent A", async ({page}) => {
await page.goto("/dashboard/agents");
await expect(page.getByRole("heading", {name: "Agents"})).toBeVisible();
await expect(get(page, agent.aName)).toBeVisible();
await edit(page, agent.aName, agent.aUpdatedName, agent.updatedDescription);
await expect(page.getByText("Success updating agent")).toBeVisible();
await expect(page.getByText(agent.aUpdatedName, {exact: true})).toBeVisible();
await expect(page.getByText(agent.updatedDescription, {exact: true})).toBeVisible();
});
test("Create agent B from classic button", async ({page}) => {
await page.goto("/dashboard/agents");
await expect(page.getByRole("heading", {name: "Agents"})).toBeVisible();
await expect(page.getByRole("button", {name: /Create Agent/i})).toBeVisible();
await create(page, "button", agent.bName, agent.description);
await expect(page.getByText("Success creating agent")).toBeVisible();
await expect(get(page, agent.bName)).toBeVisible();
});
test("Delete Agent B", async ({page}) => {
await page.goto("/dashboard/agents");
await expect(page.getByRole("heading", {name: "Agents"})).toBeVisible();
await expect(get(page, agent.bName)).toBeVisible();
await remove(page, agent.bName);
await expect(page.getByText("Agent has been successfully deleted.")).toBeVisible();
await expect(page).toHaveURL("/dashboard/agents");
await expect(page.getByText(agent.bName)).toHaveCount(0);
});
// test("Launch the updated agent", async ({page}) => {
// await page.goto("/dashboard/agents");
// await expect(page.getByRole("heading", {name: "Agents"})).toBeVisible();
// await get(page, agent.aName).click();
//
// await expect(page).toHaveURL(/\/dashboard\/agents\/.+/);
// await expect(page.getByText(agent.aName, {exact: true})).toBeVisible();
// await expect(page.getByText("Registration & Setup")).toBeVisible();
//
// const commandInput = page.locator("input[readonly]").first();
// await page.locator("input[readonly]").first().locator("xpath=following-sibling::button[1]").click();
// const command = await commandInput.inputValue();
//
// agentWorkspace = fs.mkdtempSync(path.join(os.tmpdir(), "portabase-agent-"));
// await createAgentWithDockerDatabases(command, agentWorkspace);
// execSync(`portabase start "${agent.aName}"`, {
// cwd: agentWorkspace,
// stdio: "pipe",
// timeout: 120_000,
// });
//
// await expect(page.getByText("Never connected.")).toHaveCount(0, {timeout: 120_000});
// await expect(page.getByText("Action Required")).toHaveCount(0);
// });
test.afterAll(async () => {
if (agentWorkspace) {
try {
execSync(`portabase stop "${agent.aName}"`, {
cwd: agentWorkspace,
stdio: "pipe",
timeout: 30_000,
});
} catch {
}
try {
execSync(`portabase uninstall --force "${agent.aName}"`, {
cwd: agentWorkspace,
stdio: "pipe",
timeout: 30_000,
});
} catch {
}
fs.rmSync(agentWorkspace, {recursive: true, force: true});
agentWorkspace = null;
}
});
});
-104
View File
@@ -1,104 +0,0 @@
import {test, expect} from '@playwright/test';
import {login, register, users} from "./helpers/auth";
import {LOCAL_STORAGE_PATH} from "./helpers/session";
const TIMEOUT = undefined
// const TIMEOUT = 5000
test.use({storageState: LOCAL_STORAGE_PATH})
test.describe.serial( () => {
test('Redirect to login if not connected', async ({page}) => {
await page.goto('/dashboard/projects');
await expect(page).toHaveURL("login?redirect=%2Fdashboard%2Fprojects", {timeout: TIMEOUT});
});
test('Password too short', async ({page}) => {
await page.goto('')
await page.click('text=Sign up')
await expect(page).toHaveURL('/register')
let password = '123456'
await register(page, users["admin"].username, users["admin"].email, password, password)
const toast = page.locator('text=Must have at least 8 character')
await expect(toast).toBeVisible()
})
test('Password too simple', async ({page}) => {
await page.goto('')
await page.click('text=Sign up')
await expect(page).toHaveURL('/register')
let password = '12345678'
await register(page, users["admin"].username, users["admin"].email, password, password)
const toast = page.locator('text=Your password must contain at least one uppercase letter, one lowercase letter, one number, and one special character.')
await expect(toast).toBeVisible()
})
test('Password and confirm password mismatch', async ({page}) => {
await page.goto('/')
await page.click('text=Sign up')
await expect(page).toHaveURL('/register')
let password = 'testPASS123456!'
let confirmPassword = 'testPASS123456!!'
await register(page, users["admin"].username, users["admin"].email, password, confirmPassword)
const toast = page.locator('text=The passwords did not match')
await expect(toast).toBeVisible()
})
test('Successful register for admin', async ({page}) => {
await page.goto('/')
await page.click('text=Sign up')
await expect(page).toHaveURL('/register')
await register(page, users["admin"].username, users["admin"].email, users["admin"].password, users["admin"].password)
await expect(page).toHaveURL('/login', {timeout: TIMEOUT})
})
test('User already exists.', async ({page}) => {
await page.goto('/')
await page.click('text=Sign up')
await expect(page).toHaveURL('/register')
await register(page, users["admin"].username, users["admin"].email, users["admin"].password, users["admin"].password)
const toast = page.locator('text=User already exists. Use another email.')
await expect(toast).toBeVisible()
})
test('Successful register for normal', async ({page}) => {
await page.goto('/')
await page.click('text=Sign up')
await expect(page).toHaveURL('/register')
await register(page, users["normal"].username, users["normal"].email, users["normal"].password, users["normal"].password)
await expect(page).toHaveURL('/login', {timeout: TIMEOUT})
})
test('Failed login because account not active', async ({page}) => {
await page.goto('/login')
await login(page, users["normal"].email, users["normal"].password)
const toast = page.locator('text=Your account is not active.')
await expect(toast).toBeVisible()
})
test('Successful login', async ({page}) => {
await page.goto('/login')
await login(page, users["admin"].email, users["admin"].password)
await expect(page).toHaveURL('/dashboard/home', {timeout: TIMEOUT})
await expect(page.getByRole('link', {name: 'Logo Portabase'})).toBeVisible()
await page.context().storageState({path: LOCAL_STORAGE_PATH})
})
})
-32
View File
@@ -1,32 +0,0 @@
import {expect, test} from "@playwright/test";
import fs from "fs";
import {logout} from "./helpers/auth";
import {LOCAL_STORAGE_PATH} from "./helpers/session";
test.use({storageState: LOCAL_STORAGE_PATH});
test.describe( () => {
test.afterAll(async () => {
if (fs.existsSync(LOCAL_STORAGE_PATH)) {
fs.unlinkSync(LOCAL_STORAGE_PATH);
}
});
test("Remove shared storage state", async ({page}) => {
await test.step("Logout shared authenticated session", async () => {
if (!fs.existsSync(LOCAL_STORAGE_PATH)) {
return;
}
const content = fs.readFileSync(LOCAL_STORAGE_PATH, "utf-8").trim();
if (!content || content === "{}") {
return;
}
await page.goto('/dashboard/home');
await logout(page);
await expect(page).toHaveURL(/\/login(?:\?.*)?$/);
});
});
});
-70
View File
@@ -1,70 +0,0 @@
import {Page} from "@playwright/test";
export function getUserRow(page: Page, email: string) {
return page.locator("tr").filter({hasText: email}).first();
}
export function getOrganizationRow(page: Page, organizationName: string) {
return page.locator("tr").filter({hasText: organizationName}).first();
}
/**
* Create an organization from the selected entrypoint.
*
* Available entrypoints:
* - `button`: the organizations page create button.
* - `sidebar`: the sidebar organization switcher.
*
* Executes from:
* - `/dashboard/admin/organizations` for "button" entrypoint
* - any `/dashboard/**` page for "sidebar" entrypoint
*/
export async function create(page: Page, entrypoint: "button" | "sidebar", name: string) {
if (entrypoint === "sidebar") {
await page.getByRole("button", {name: "Default Organization"}).click();
await page.getByText("Create organization", {exact: true}).click();
} else {
await page.getByRole("button", {name: /Create a new organization/i}).click();
}
await page.getByLabel("Name").fill(name);
await page.getByRole("button", {name: "Create"}).click();
}
/**
* Switch the active organization from the sidebar switcher.
*
* Executes from: any `/dashboard/**` if the sidebar is visible.
*/
export async function switchTo(page: Page, name: string) {
await page.getByTestId('organization-dropdown').click();
await page.getByRole('menuitem', {name: name}).click();
}
/**
* Switch back to the default organization.
*
* Executes from: any `/dashboard/**` if the sidebar is visible.
*/
export async function switchToDefault(page: Page) {
await switchTo(page, "Default Organization");
}
const ROLE_LABELS = {
pending: "Pending",
user: "User",
admin: "Admin",
} as const;
/**
* Change the global role of a user from the admin users role modal.
*
* Executes from: the "Change the user's role" dialog opened from `/dashboard/admin/users`.
*/
export async function changeUserRole(page: Page, role: keyof typeof ROLE_LABELS) {
const roleOption = ROLE_LABELS[role];
await page.getByRole("combobox").click();
await page.getByRole("option", {name: roleOption}).click();
await page.getByRole("button", {name: "Validate"}).click();
}
-107
View File
@@ -1,107 +0,0 @@
import * as pty from "node-pty";
type InteractiveStep = {
match: RegExp;
reply: string;
};
function normalizeOutput(output: string) {
return output.replace(/\u001b\[[0-9;?]*[ -/]*[@-~]/g, "");
}
/**
* Run an interactive CLI command and answer prompts in sequence.
* Entry point:
* - `command` is the full CLI command executed in the PTY.
* - `cwd` is the local workspace where the command is launched.
* - `steps` defines which prompt text is matched and which reply is sent.
* - `timeoutMs` controls when the command is aborted if it stalls.
* Executes from: not page-bound; runs from a local test workspace on the Node.js side.
*/
export async function runInteractiveCommand(
command: string,
cwd: string,
steps: InteractiveStep[],
timeoutMs: number = 120_000,
) {
await new Promise<void>((resolve, reject) => {
const child = pty.spawn("sh", ["-lc", command], {
cwd,
env: {
...process.env,
TERM: "xterm-256color",
},
cols: 120,
rows: 30,
});
let currentStep = 0;
let output = "";
const timer = setTimeout(() => {
child.kill();
reject(new Error(`Interactive command timed out.\n\nCommand: ${command}\n\nOutput:\n${normalizeOutput(output)}`));
}, timeoutMs);
const handleChunk = (chunk: string) => {
output += chunk;
const normalizedOutput = normalizeOutput(output);
while (currentStep < steps.length && steps[currentStep].match.test(normalizedOutput)) {
child.write(steps[currentStep].reply);
currentStep += 1;
}
};
child.onData(handleChunk);
child.onExit(({exitCode}) => {
clearTimeout(timer);
if (exitCode === 0) {
resolve();
return;
}
reject(new Error(`Interactive command failed with exit code ${exitCode}.\n\nCommand: ${command}\n\nOutput:\n${normalizeOutput(output)}`));
});
});
}
/**
* Create and configure a Portabase agent with two Docker-backed databases.
* Entry point:
* - `command` is the CLI setup command copied from the agent details page.
* - `cwd` is the local workspace where the agent is installed.
* - this helper answers the wizard for PostgreSQL first, then MariaDB.
* Executes from: not page-bound; runs from a local test workspace on the Node.js side.
*/
export async function createAgentWithDockerDatabases(command: string, cwd: string) {
await runInteractiveCommand(command, cwd, [
{
match: /configure.*database|add.*database/i,
reply: "y\n",
},
{
match: /docker.*new local container|manual.*external|external.*existing/i,
reply: "\r",
},
{
match: /postgresql|mariadb/i,
reply: "\r",
},
{
match: /another.*database|add.*another|configure.*another/i,
reply: "y\n",
},
{
match: /docker.*new local container|manual.*external|external.*existing/i,
reply: "\r",
},
{
match: /postgresql|mariadb/i,
reply: "\u001B[B\r",
},
{
match: /another.*database|add.*another|configure.*another/i,
reply: "n\n",
},
]);
}
-66
View File
@@ -1,66 +0,0 @@
import {Page} from "@playwright/test";
/**
* Locate an agent card in the list.
* Executes from: `/dashboard/agents`.
*/
export function get(page: Page, name: string) {
return page.locator('a[href^="/dashboard/agents"]').filter({hasText: name}).first();
}
/**
* Create an agent from the selected entrypoint.
*
* Available entrypoints:
* - `button`: the classic create button.
* - `emptyState`: the empty-state CTA.
* - `auto`: uses the classic create button and falls back to the empty-state CTA.
*
* Executes from: `/dashboard/agents`.
*/
export async function create(page: Page, entrypoint: "auto" | "emptyState" | "button" = "auto", agentName: string, description: string) {
if (entrypoint === "auto") {
const createButton = page.getByRole("button", {name: /Create Agent/i});
if (await createButton.isVisible()) await page.getByRole("button", {name: /Create Agent/i}).click();
await page.getByText("Create new Agent", {exact: true}).click();
} else if (entrypoint === "button") {
await page.getByRole("button", {name: /Create Agent/i}).click();
} else if (entrypoint === "emptyState") {
await page.getByText("Create new Agent", {exact: true}).click();
}
await page.getByLabel("Name").fill(agentName);
await page.getByLabel("Description").fill(description);
await page.getByRole("button", {name: "Create"}).click();
}
/**
* Edit an existing agent (name and description) from its details page.
*
* Executes from: `/dashboard/agents/[agentId]`.
*/
export async function edit(page: Page, currentName: string, updatedName: string, updatedDescription: string) {
await get(page, currentName).click();
await page
.getByRole("button", {name: /Delete Agent/i})
.locator("xpath=ancestor::div[1]/preceding-sibling::div[1]/*[1]")
.click();
await page.getByLabel("Name").fill(updatedName);
await page.getByLabel("Description").fill(updatedDescription);
await page.getByRole("button", {name: "Update"}).click();
}
/**
* Delete an agent from its details page.
* Executes from: `/dashboard/agents/[agentId]`.
*/
export async function remove(page: Page, name: string) {
await get(page, name).click();
await page.getByRole("button", {name: /Delete Agent/i}).click();
await page.getByRole("button", {name: "Delete", exact: true}).click();
}
-50
View File
@@ -1,50 +0,0 @@
import {Page} from "@playwright/test";
export type UserCredentials = {
username: string
email: string
password: string
}
export const users: Record<string, UserCredentials> = {
admin: {username: "Admin", email: "admin@example.com", password: "testPASS123456!"},
normal: {username: "John Doe", email: "john.doe@example.com", password: "testPASS123456!"},
}
/**
* Fill and submit the registration form.
*
* Executes from: `/register`.
*/
export async function register(page: Page, name: string, email: string, password: string, confirmPassword: string) {
await page.locator('input[name="name"]').fill(name)
await page.locator('input[name="email"]').fill(email)
await page.locator('input[name="password"]').fill(password)
await page.locator('input[name="confirmPassword"]').fill(confirmPassword)
await page.click('button[type="submit"]')
}
/**
* Fill and submit the login form.
*
* Executes from: `/login`.
*/
export async function login(page: Page, email: string, password: string) {
await page.locator('input[name="email"]').fill(email)
await page.locator('input[name="password"]').fill(password)
await page.locator('button:has-text("Login")').click()
}
/**
* Log out the current authenticated user.
*
* Executes from: any authenticated `/dashboard/**` page.
*/
export async function logout(page: Page) {
const profileButton = page.getByTestId('profile-dropdown')
await profileButton.first().click();
await page.getByRole("menuitem").filter({hasText: /Logout/i}).click();
}
-62
View File
@@ -1,62 +0,0 @@
const REQUIRED_E2E_ENV_VARS = [
"E2E_NOTIFICATION_SMTP_HOST",
"E2E_NOTIFICATION_SMTP_PORT",
"E2E_NOTIFICATION_SMTP_USER",
"E2E_NOTIFICATION_SMTP_PASSWORD",
"E2E_NOTIFICATION_SMTP_FROM",
"E2E_NOTIFICATION_SMTP_TO",
"E2E_NOTIFICATION_SLACK_WEBHOOK",
"E2E_NOTIFICATION_DISCORD_WEBHOOK",
"E2E_NOTIFICATION_TELEGRAM_BOT_TOKEN",
"E2E_NOTIFICATION_TELEGRAM_CHAT_ID",
"E2E_NOTIFICATION_TELEGRAM_TOPIC_ID",
"E2E_NOTIFICATION_GOTIFY_SERVER_URL",
"E2E_NOTIFICATION_GOTIFY_APP_TOKEN",
"E2E_NOTIFICATION_NTFY_TOPIC",
"E2E_NOTIFICATION_NTFY_SERVER_URL",
"E2E_NOTIFICATION_NTFY_TOKEN",
"E2E_NOTIFICATION_NTFY_USERNAME",
"E2E_NOTIFICATION_NTFY_PASSWORD",
"E2E_NOTIFICATION_WEBHOOK_URL",
"E2E_NOTIFICATION_WEBHOOK_SECRET_HEADER",
"E2E_NOTIFICATION_WEBHOOK_SECRET",
"E2E_STORAGE_AWS_S3_ENDPOINT_URL",
"E2E_STORAGE_AWS_S3_REGION",
"E2E_STORAGE_AWS_S3_ACCESS_KEY",
"E2E_STORAGE_AWS_S3_SECRET_KEY",
"E2E_STORAGE_AWS_S3_BUCKET_NAME",
"E2E_STORAGE_AWS_S3_PORT",
"E2E_STORAGE_R2_ENDPOINT_URL",
"E2E_STORAGE_R2_REGION",
"E2E_STORAGE_R2_ACCESS_KEY",
"E2E_STORAGE_R2_SECRET_KEY",
"E2E_STORAGE_R2_BUCKET_NAME",
"E2E_STORAGE_R2_PORT",
"E2E_STORAGE_GOOGLE_DRIVE_CLIENT_ID",
"E2E_STORAGE_GOOGLE_DRIVE_CLIENT_SECRET",
"E2E_STORAGE_GOOGLE_DRIVE_FOLDER_ID",
] as const;
function assertRequiredEnvVars() {
const missingVars = REQUIRED_E2E_ENV_VARS.filter((name) => {
const value = process.env[name]?.trim();
return !value;
});
if (missingVars.length > 0) {
throw new Error(
`Missing required environment variables:\n${missingVars.map((name) => `- ${name}`).join("\n")}`,
);
}
}
assertRequiredEnvVars();
export function getEnv(name: string): string {
const value = process.env[name]?.trim();
if (!value) {
throw new Error(`Missing required environment variable: ${name}`);
}
return value;
}
-94
View File
@@ -1,94 +0,0 @@
import {Page} from "@playwright/test";
/**
* Locate a notification channel card in the list.
*
* Executes from: `/dashboard/notifications/channels`.
*/
export function get(page: Page, channelName: string) {
return page.locator('div.block.transition-all.duration-200.rounded-xl', {
has: page.locator('h3', {hasText: channelName}),
}).first();
}
/**
* Open the edit dialog for an existing notification channel.
*
* Executes from: `/dashboard/notifications/channels`.
*/
export async function edit(page: Page, channelName: string) {
const card = get(page, channelName);
await card.locator("button").nth(1).click();
}
/**
* Delete an existing notification channel.
*
* Executes from: `/dashboard/notifications/channels`.
*/
export async function remove(page: Page, channelName: string) {
const card = get(page, channelName);
await card.locator("button").nth(2).click();
await page.getByRole("button", {name: "Delete"}).click();
}
/**
* Fill the notification channel creation form.
*
* Available entrypoints:
* - `button`: the classic add button.
* - `emptyState`: the empty-state CTA.
* - `auto` use the classic add button and falls back to the empty-state CTA.
*
* Executes from: `/dashboard/notifications/channels`.
*/
export async function create(
page: Page,
provider: "Discord" | "Gotify" | "ntfy.sh" | "Slack" | "Email" | "Telegram" | "Webhook",
channelName: string,
fillConfig: (page: Page) => Promise<void>,
entrypoint: "auto" | "emptyState" | "button" = "auto",
) {
if (entrypoint === "auto") {
const addButton = page.getByRole("button", {name: /Add notification channel/i});
if (await addButton.isVisible()) await page.getByRole("button", {name: /Add notification channel/i}).click();
else await page.getByRole("button", {name: /No notification channels configured yet/i}).click();
} else if (entrypoint === "button") {
await page.getByRole("button", {name: /Add notification channel/i}).click();
} else if (entrypoint === "emptyState") {
await page.getByRole("button", {name: /No notification channels configured yet/i}).click();
}
await page.getByText(provider, {exact: true}).click();
await page.getByLabel(/Channel Name/).fill(channelName);
await fillConfig(page);
}
/**
* Submit the notification channel creation form.
*
* Executes from: the add notification channel dialog opened from `/dashboard/notifications/channels`.
*/
export async function submit(page: Page) {
await page.getByRole("button", {name: "Add Channel"}).click();
}
/**
* Open the edit dialog for a notification channel and trigger the test action.
*
* Executes from: `/dashboard/notifications/channels`.
*/
export async function testFromEdit(page: Page, channelName: string) {
await edit(page, channelName);
await page.getByRole("button", {name: /Test Channel/i}).click();
}
/**
* Close the current notification channel dialog without saving.
*
* Executes from: the add or edit notification channel dialog.
*/
export async function cancel(page: Page) {
await page.getByRole("button", {name: "Cancel"}).click();
}
-59
View File
@@ -1,59 +0,0 @@
import {Page} from "@playwright/test";
/**
* Locate a project card in the list.
*
* Executes from: `/dashboard/projects`.
*/
export function get(page: Page, projectName: string) {
return page.locator('a[href^="/dashboard/projects/"]').filter({hasText: projectName}).first();
}
/**
* Create a project from the selected entrypoint.
*
* Available entrypoints:
* - `emptyState`: the empty-state CTA.
* - `button`: the classic create button.
*
* * Executes from: `/dashboard/projects`.
*/
export async function create(page: Page, entrypoint: "emptyState" | "button", projectName: string) {
if (entrypoint === "emptyState") {
await page.getByText("Create new Project", {exact: true}).click();
} else {
await page.getByRole("button", {name: /Create Project/i}).click();
}
await page.getByLabel("Name").fill(projectName);
await page.getByRole("button", {name: "Create"}).click();
}
/**
* Edit an existing project from its details page.
*
* Executes from: `/dashboard/projects/[projectId]`.
*/
export async function edit(page: Page, currentName: string, updatedName: string) {
await get(page, currentName).click();
await page
.getByRole("button", {name: /Delete Project/i})
.locator("xpath=ancestor::div[1]/preceding-sibling::div[1]/*[1]")
.click();
await page.getByLabel("Name").fill(updatedName);
await page.getByRole("button", {name: "Update"}).click();
}
/**
* Delete an existing project from its details page.
*
* Executes from: `/dashboard/projects/[projectId]`.
* */
export async function remove(page: Page, projectName: string) {
await get(page, projectName).click();
await page.getByRole("button", {name: /Delete Project/i}).click();
await page.getByRole("button", {name: "Delete", exact: true}).click();
}
-1
View File
@@ -1 +0,0 @@
export const LOCAL_STORAGE_PATH = "./e2e/local-storage.json";
-103
View File
@@ -1,103 +0,0 @@
import {Page} from "@playwright/test";
/**
* Locate a storage channel card in the channels list.
*
* Executes from: `/dashboard/storages/channels`.
*/
export function get(page: Page, channelName: string) {
return page.locator('div.block.transition-all.duration-200.rounded-xl', {
has: page.locator('h3', {hasText: channelName}),
}).first();
}
/**
* Fill the storage channel creation form.
*
* Available entrypoints:
* - `button`: the classic add button.
* - `emptyState`: the empty-state CTA.
* - `auto`: uses the classic add button and falls back to the empty-state CTA.
*
* Executes from: `/dashboard/storages/channels`.
*/
export async function create(
page: Page,
provider: "S3" | "Google Drive",
channelName: string,
fillConfig: (page: Page) => Promise<void>,
entrypoint: "auto" | "emptyState" | "button" = "auto",
) {
if (entrypoint === "auto") {
const addButton = page.getByRole("button", {name: /Add storage channel/i});
if (await addButton.isVisible()) await page.getByRole("button", {name: /Add storage channel/i}).click();
else await page.getByText("No storage channels configured yet", {exact: true}).click();
} else if (entrypoint === "button") {
await page.getByRole("button", {name: /Add storage channel/i}).click();
} else if (entrypoint === "emptyState") {
await page.getByText("No storage channels configured yet", {exact: true}).click();
}
await page.getByText(provider, {exact: true}).click();
await page.getByLabel(/Channel Name/).fill(channelName);
await fillConfig(page);
}
/**
* Open the edit dialog for an existing storage channel.
*
* Executes from: `/dashboard/storages/channels`.
*/
export async function edit(page: Page, channelName: string) {
const card = get(page, channelName);
await card.locator("button").nth(1).click();
}
/**
* Delete an existing storage channel.
*
* Executes from: `/dashboard/storages/channels`.
*/
export async function remove(page: Page, channelName: string) {
const card = get(page, channelName);
await card.locator("button").nth(2).click();
await page.getByRole("button", {name: "Delete"}).click();
}
/**
* Trigger the storage connection test in the current dialog.
*
* Executes from: the add or edit storage channel dialog opened from `/dashboard/storages/channels`.
*/
export async function testConnection(page: Page) {
await page.getByRole("button", {name: /Test Storage/i}).click();
}
/**
* Submit the storage channel creation form.
*
* Executes from: the add storage channel dialog opened from `/dashboard/storages/channels`.
*/
export async function submit(page: Page) {
await page.getByRole("button", {name: "Add Channel"}).click();
}
/**
* Open the edit dialog for a storage channel and trigger the test action.
*
* Executes from: `/dashboard/storages/channels`.
*/
export async function testFromEdit(page: Page, channelName: string) {
await edit(page, channelName);
await testConnection(page);
}
/**
* Close the current storage channel dialog without saving.
*
* Executes from: the add or edit storage channel dialog.
*/
export async function cancel(page: Page) {
await page.getByRole("button", {name: "Cancel"}).click();
}
-71
View File
@@ -1,71 +0,0 @@
import {expect, test} from "@playwright/test";
import {
cancel, create, get, remove, submit, testFromEdit,
} from "../helpers/notification";
import {getEnv} from "../helpers/env";
import {LOCAL_STORAGE_PATH} from "../helpers/session";
test.use({storageState: LOCAL_STORAGE_PATH});
const validChannelName = "Discord E2E Required";
const invalidChannelName = "Discord E2E Invalid";
// test.describe.serial("Valid channel", () => {
// test("Create and test a valid Discord channel", async ({page}) => {
// await page.goto("/dashboard/notifications/channels");
// await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
// await create(page, "Discord", validChannelName, async (page) => {
// await page.getByLabel(/Discord Webhook URL/).fill(getEnv("E2E_NOTIFICATION_DISCORD_WEBHOOK"));
// });
// await expect(page.getByRole("heading", {name: "Add Notification Channel"})).toBeVisible();
// await submit(page);
// await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
// await expect(get(page, validChannelName)).toBeVisible();
//
// await testFromEdit(page, validChannelName);
// await expect(page.getByRole("heading", {name: "Edit Notification Channel"})).toBeVisible();
// await expect(page.getByText("Sent to Discord")).toBeVisible();
// await cancel(page);
// await expect(page.getByRole("heading", {name: "Edit Notification Channel"})).toHaveCount(0);
// });
//
// test("Edit and test a valid Discord channel", async ({page}) => {
// await page.goto("/dashboard/notifications/channels");
// await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
// await expect(get(page, validChannelName)).toBeVisible();
// await testFromEdit(page, validChannelName);
// await expect(page.getByRole("heading", {name: "Edit Notification Channel"})).toBeVisible();
// await expect(page.getByText("Sent to Discord")).toBeVisible();
// await cancel(page);
// await expect(page.getByRole("heading", {name: "Edit Notification Channel"})).toHaveCount(0);
// });
// });
test.describe.serial("Invalid channel", () => {
test("Create and test invalid Discord channel", async ({page}) => {
await page.goto("/dashboard/notifications/channels");
await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
await create(page, "Discord", invalidChannelName, async (page) => {
await page.getByLabel(/Discord Webhook URL/).fill("https://discord.com/api/webhooks/123456789012345678/wrong-discord-webhook-token");
});
await expect(page.getByRole("heading", {name: "Add Notification Channel"})).toBeVisible();
await submit(page);
await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
await expect(get(page, invalidChannelName)).toBeVisible();
await testFromEdit(page, invalidChannelName);
await expect(page.getByRole("heading", {name: "Edit Notification Channel"})).toBeVisible();
await expect(page.getByText("An error occurred while testing the notification channel, check your configuration")).toBeVisible();
await cancel(page);
await expect(page.getByRole("heading", {name: "Edit Notification Channel"})).toHaveCount(0);
});
test("Delete invalid Discord channel", async ({page}) => {
await page.goto("/dashboard/notifications/channels");
await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
await expect(get(page, invalidChannelName)).toBeVisible();
await remove(page, invalidChannelName);
await expect(page.getByText("Notification channel has been successfully removed.")).toBeVisible();
await expect(page.getByText(invalidChannelName)).toHaveCount(0);
});
});
-66
View File
@@ -1,66 +0,0 @@
import {expect, test} from "@playwright/test";
import {
cancel, create, get, remove, submit, testFromEdit,
} from "../helpers/notification";
import {getEnv} from "../helpers/env";
import {LOCAL_STORAGE_PATH} from "../helpers/session";
test.use({storageState: LOCAL_STORAGE_PATH});
const validChannelName = "Gotify E2E Required";
const invalidChannelName = "Gotify E2E Invalid";
// test.describe.serial("Valid channel", () => {
// test("Create and test a valid Gotify channel", async ({page}) => {
// await page.goto("/dashboard/notifications/channels");
// await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
// await create(page, "Gotify", validChannelName, async (page) => {
// await page.getByLabel(/Gotify Server URL/).fill(getEnv("E2E_NOTIFICATION_GOTIFY_SERVER_URL"));
// await page.getByLabel(/Application Token/).fill(getEnv("E2E_NOTIFICATION_GOTIFY_APP_TOKEN"));
// });
// await expect(page.getByRole("heading", {name: "Add Notification Channel"})).toBeVisible();
// await submit(page);
// await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
// await expect(get(page, validChannelName)).toBeVisible();
//
// await testFromEdit(page, validChannelName);
// await expect(page.getByText("Sent to Gotify")).toBeVisible();
// await cancel(page);
// });
//
// test("Edit and test a valid Gotify channel", async ({page}) => {
// await page.goto("/dashboard/notifications/channels");
// await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
// await expect(get(page, validChannelName)).toBeVisible();
// await testFromEdit(page, validChannelName);
// await expect(page.getByText("Sent to Gotify")).toBeVisible();
// await cancel(page);
// });
// });
test.describe.serial("Invalid channel", () => {
test("Create and test invalid Gotify channel", async ({page}) => {
await page.goto("/dashboard/notifications/channels");
await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
await create(page, "Gotify", invalidChannelName, async (page) => {
await page.getByLabel(/Gotify Server URL/).fill(getEnv("E2E_NOTIFICATION_GOTIFY_SERVER_URL"));
await page.getByLabel(/Application Token/).fill("wrong-gotify-app-token");
});
await expect(page.getByRole("heading", {name: "Add Notification Channel"})).toBeVisible();
await submit(page);
await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
await expect(get(page, invalidChannelName)).toBeVisible();
await testFromEdit(page, invalidChannelName);
await expect(page.getByText("An error occurred while testing the notification channel, check your configuration")).toBeVisible();
await cancel(page);
});
test("Delete invalid Gotify channel", async ({page}) => {
await page.goto("/dashboard/notifications/channels");
await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
await expect(get(page, invalidChannelName)).toBeVisible();
await remove(page, invalidChannelName);
await expect(page.getByText("Notification channel has been successfully removed.")).toBeVisible();
await expect(page.getByText(invalidChannelName)).toHaveCount(0);
});
});
-73
View File
@@ -1,73 +0,0 @@
import {expect, test} from "@playwright/test";
import {
cancel, create, get, remove, submit, testFromEdit,
} from "../helpers/notification";
import {getEnv} from "../helpers/env";
import {LOCAL_STORAGE_PATH} from "../helpers/session";
test.use({storageState: LOCAL_STORAGE_PATH});
const requiredChannelName = "Ntfy E2E Required";
const optionalChannelName = "Ntfy E2E Optional";
const invalidChannelName = "Ntfy E2E Invalid";
// test.describe.serial("Valid channels", () => {
// test("Create and test a valid Ntfy channel with only required fields", async ({page}) => {
// await page.goto("/dashboard/notifications/channels");
// await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
// await create(page, "ntfy.sh", requiredChannelName, async (page) => {
// await page.getByLabel(/Topic Name/).fill(getEnv("E2E_NOTIFICATION_NTFY_TOPIC"));
// });
// await submit(page);
// await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
// await expect(get(page, requiredChannelName)).toBeVisible();
// await testFromEdit(page, requiredChannelName);
// await expect(page.getByText("Sent to ntfy")).toBeVisible();
// await cancel(page);
// });
//
// test("Create and test a valid Ntfy channel with optional fields", async ({page}) => {
// await page.goto("/dashboard/notifications/channels");
// await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
// await create(page, "ntfy.sh", optionalChannelName, async (page) => {
// await page.getByLabel(/Topic Name/).fill(getEnv("E2E_NOTIFICATION_NTFY_TOPIC"));
// await page.getByLabel(/^Server URL$/).fill(getEnv("E2E_NOTIFICATION_NTFY_SERVER_URL"));
// await page.getByLabel(/^Access Token$/).fill(getEnv("E2E_NOTIFICATION_NTFY_TOKEN"));
// await page.getByLabel(/^Basic Auth Username$/).fill(getEnv("E2E_NOTIFICATION_NTFY_USERNAME"));
// await page.getByLabel(/^Basic Auth Password$/).fill(getEnv("E2E_NOTIFICATION_NTFY_PASSWORD"));
// });
// await submit(page);
// await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
// await expect(get(page, optionalChannelName)).toBeVisible();
// await testFromEdit(page, optionalChannelName);
// await expect(page.getByText("Sent to ntfy")).toBeVisible();
// await cancel(page);
// });
// });
test.describe.serial("Invalid channel", () => {
test("Create and test invalid Ntfy channel", async ({page}) => {
await page.goto("/dashboard/notifications/channels");
await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
await create(page, "ntfy.sh", invalidChannelName, async (page) => {
await page.getByLabel(/Topic Name/).fill("wrong-ntfy-topic");
await page.getByLabel(/^Server URL$/).fill(getEnv("E2E_NOTIFICATION_NTFY_SERVER_URL"));
await page.getByLabel(/^Access Token$/).fill("wrong-ntfy-token");
});
await submit(page);
await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
await expect(get(page, invalidChannelName)).toBeVisible();
await testFromEdit(page, invalidChannelName);
await expect(page.getByText("An error occurred while testing the notification channel, check your configuration")).toBeVisible();
await cancel(page);
});
test("Delete invalid Ntfy channel", async ({page}) => {
await page.goto("/dashboard/notifications/channels");
await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
await expect(get(page, invalidChannelName)).toBeVisible();
await remove(page, invalidChannelName);
await expect(page.getByText("Notification channel has been successfully removed.")).toBeVisible();
await expect(page.getByText(invalidChannelName)).toHaveCount(0);
});
});
-61
View File
@@ -1,61 +0,0 @@
import {expect, test} from "@playwright/test";
import {
cancel, create, get, remove, submit, testFromEdit,
} from "../helpers/notification";
import {getEnv} from "../helpers/env";
import {LOCAL_STORAGE_PATH} from "../helpers/session";
test.use({storageState: LOCAL_STORAGE_PATH});
const validChannelName = "Slack E2E Required";
const invalidChannelName = "Slack E2E Invalid";
// test.describe.serial("Valid channel", () => {
// test("Create and test a valid Slack channel", async ({page}) => {
// await page.goto("/dashboard/notifications/channels");
// await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
// await create(page, "Slack", validChannelName, async (page) => {
// await page.getByLabel(/Slack Webhook URL/).fill(getEnv("E2E_NOTIFICATION_SLACK_WEBHOOK"));
// });
// await submit(page);
// await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
// await expect(get(page, validChannelName)).toBeVisible();
// await testFromEdit(page, validChannelName);
// await expect(page.getByText("Sent to Slack")).toBeVisible();
// await cancel(page);
// });
//
// test("Edit and test a valid Slack E2E channel", async ({page}) => {
// await page.goto("/dashboard/notifications/channels");
// await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
// await expect(get(page, validChannelName)).toBeVisible();
// await testFromEdit(page, validChannelName);
// await expect(page.getByText("Sent to Slack")).toBeVisible();
// await cancel(page);
// });
// });
test.describe.serial("Invalid channel", () => {
test("Create and test invalid Slack channel", async ({page}) => {
await page.goto("/dashboard/notifications/channels");
await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
await create(page, "Slack", invalidChannelName, async (page) => {
await page.getByLabel(/Slack Webhook URL/).fill("https://WRONG_SLACK_WEBHOOK");
});
await submit(page);
await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
await expect(get(page, invalidChannelName)).toBeVisible();
await testFromEdit(page, invalidChannelName);
await expect(page.getByText("An error occurred while testing the notification channel, check your configuration")).toBeVisible();
await cancel(page);
});
test("Delete invalid Slack channel", async ({page}) => {
await page.goto("/dashboard/notifications/channels");
await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
await expect(get(page, invalidChannelName)).toBeVisible();
await remove(page, invalidChannelName);
await expect(page.getByText("Notification channel has been successfully removed.")).toBeVisible();
await expect(page.getByText(invalidChannelName)).toHaveCount(0);
});
});
-72
View File
@@ -1,72 +0,0 @@
import {expect, test} from "@playwright/test";
import {getEnv} from "../helpers/env";
import {
cancel, create, get, remove, submit, testFromEdit,
} from "../helpers/notification";
import {LOCAL_STORAGE_PATH} from "../helpers/session";
test.use({storageState: LOCAL_STORAGE_PATH});
const validChannelName = "SMTP E2E Required";
const invalidChannelName = "SMTP E2E Invalid";
const successMessage = `Email sent: ${getEnv("E2E_NOTIFICATION_SMTP_TO")}`;
// test.describe.serial("Valid channel", () => {
// test("Create and test a valid SMTP channel", async ({page}) => {
// await page.goto("/dashboard/notifications/channels");
// await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
// await create(page, "Email", validChannelName, async (page) => {
// await page.getByLabel(/SMTP Host/).fill(getEnv("E2E_NOTIFICATION_SMTP_HOST"));
// await page.getByLabel(/SMTP Port/).fill(getEnv("E2E_NOTIFICATION_SMTP_PORT"));
// await page.getByLabel(/Username/).fill(getEnv("E2E_NOTIFICATION_SMTP_USER"));
// await page.getByLabel(/Password/).fill(getEnv("E2E_NOTIFICATION_SMTP_PASSWORD"));
// await page.getByLabel(/From Email/).fill(getEnv("E2E_NOTIFICATION_SMTP_FROM"));
// await page.getByLabel(/To Email/).fill(getEnv("E2E_NOTIFICATION_SMTP_TO"));
// });
// await submit(page);
// await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
// await expect(get(page, validChannelName)).toBeVisible();
// await testFromEdit(page, validChannelName);
// await expect(page.getByText(successMessage)).toBeVisible();
// await cancel(page);
// });
//
// test("Edit and test a valid SMTP channel", async ({page}) => {
// await page.goto("/dashboard/notifications/channels");
// await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
// await expect(get(page, validChannelName)).toBeVisible();
// await testFromEdit(page, validChannelName);
// await expect(page.getByText(successMessage)).toBeVisible();
// await cancel(page);
// });
// });
test.describe.serial("Invalid channel", () => {
test("Create and test invalid SMTP E2E channel", async ({page}) => {
await page.goto("/dashboard/notifications/channels");
await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
await create(page, "Email", invalidChannelName, async (page) => {
await page.getByLabel(/SMTP Host/).fill("smtp.invalid");
await page.getByLabel(/SMTP Port/).fill(getEnv("E2E_NOTIFICATION_SMTP_PORT"));
await page.getByLabel(/Username/).fill(getEnv("E2E_NOTIFICATION_SMTP_USER"));
await page.getByLabel(/Password/).fill("wrong-password");
await page.getByLabel(/From Email/).fill(getEnv("E2E_NOTIFICATION_SMTP_FROM"));
await page.getByLabel(/To Email/).fill(getEnv("E2E_NOTIFICATION_SMTP_TO"));
});
await submit(page);
await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
await expect(get(page, invalidChannelName)).toBeVisible();
await testFromEdit(page, invalidChannelName);
await expect(page.getByText("An error occurred while testing the notification channel, check your configuration")).toBeVisible();
await cancel(page);
});
test("Delete invalid SMTP channel", async ({page}) => {
await page.goto("/dashboard/notifications/channels");
await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
await expect(get(page, invalidChannelName)).toBeVisible();
await remove(page, invalidChannelName);
await expect(page.getByText("Notification channel has been successfully removed.")).toBeVisible();
await expect(page.getByText(invalidChannelName)).toHaveCount(0);
});
});
-72
View File
@@ -1,72 +0,0 @@
import {expect, test} from "@playwright/test";
import {
cancel, create, get, remove, submit, testFromEdit,
} from "../helpers/notification";
import {getEnv} from "../helpers/env";
import {LOCAL_STORAGE_PATH} from "../helpers/session";
test.use({storageState: LOCAL_STORAGE_PATH});
const requiredChannelName = "Telegram E2E Required";
const optionalChannelName = "Telegram E2E Optional";
const invalidChannelName = "Telegram E2E Invalid";
// test.describe.serial("Valid channels", () => {
// test("Create and test a valid Telegram channel", async ({page}) => {
// await page.goto("/dashboard/notifications/channels");
// await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
// await create(page, "Telegram", requiredChannelName, async (page) => {
// await page.getByLabel(/Telegram Bot Token/).fill(getEnv("E2E_NOTIFICATION_TELEGRAM_BOT_TOKEN"));
// await page.getByLabel(/Telegram Chat ID/).fill(getEnv("E2E_NOTIFICATION_TELEGRAM_CHAT_ID"));
// });
// await submit(page);
// await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
// await expect(get(page, requiredChannelName)).toBeVisible();
// await testFromEdit(page, requiredChannelName);
// await expect(page.getByText("Sent to Telegram")).toBeVisible();
// await cancel(page);
// });
//
// test("Create and test a valid Telegram channel with Topic ID", async ({page}) => {
// await page.goto("/dashboard/notifications/channels");
// await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
// await create(page, "Telegram", optionalChannelName, async (page) => {
// await page.getByLabel(/Telegram Bot Token/).fill(getEnv("E2E_NOTIFICATION_TELEGRAM_BOT_TOKEN"));
// await page.getByLabel(/Telegram Chat ID/).fill(getEnv("E2E_NOTIFICATION_TELEGRAM_CHAT_ID"));
// await page.getByLabel(/Telegram Topic ID/).fill(getEnv("E2E_NOTIFICATION_TELEGRAM_TOPIC_ID"));
// });
// await submit(page);
// await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
// await expect(get(page, optionalChannelName)).toBeVisible();
// await testFromEdit(page, optionalChannelName);
// await expect(page.getByText("Sent to Telegram")).toBeVisible();
// await cancel(page);
// });
// });
test.describe.serial("Invalid channel", () => {
test("Create and test invalid Telegram channel", async ({page}) => {
await page.goto("/dashboard/notifications/channels");
await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
await create(page, "Telegram", invalidChannelName, async (page) => {
await page.getByLabel(/Telegram Bot Token/).fill("wrong-telegram-bot-token");
await page.getByLabel(/Telegram Chat ID/).fill(getEnv("E2E_NOTIFICATION_TELEGRAM_CHAT_ID"));
await page.getByLabel(/Telegram Topic ID/).fill(getEnv("E2E_NOTIFICATION_TELEGRAM_TOPIC_ID"));
});
await submit(page);
await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
await expect(get(page, invalidChannelName)).toBeVisible();
await testFromEdit(page, invalidChannelName);
await expect(page.getByText("An error occurred while testing the notification channel, check your configuration")).toBeVisible();
await cancel(page);
});
test("Delete invalid Telegram channel", async ({page}) => {
await page.goto("/dashboard/notifications/channels");
await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
await expect(get(page, invalidChannelName)).toBeVisible();
await remove(page, invalidChannelName);
await expect(page.getByText("Notification channel has been successfully removed.")).toBeVisible();
await expect(page.getByText(invalidChannelName)).toHaveCount(0);
});
});
-73
View File
@@ -1,73 +0,0 @@
import {expect, test} from "@playwright/test";
import {
cancel, create, get, remove, submit, testFromEdit,
} from "../helpers/notification";
import {getEnv} from "../helpers/env";
import {LOCAL_STORAGE_PATH} from "../helpers/session";
test.use({storageState: LOCAL_STORAGE_PATH});
const requiredChannelName = "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.getByRole("button", { name: "Add Header" }).click();
// await page.getByLabel(/^Header Name$/).fill(getEnv("E2E_NOTIFICATION_WEBHOOK_SECRET_HEADER"));
// await page.getByLabel(/^Header 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.getByRole("button", { name: "Add Header" }).click();
await page.getByLabel(/^Header Name$/).fill(getEnv("E2E_NOTIFICATION_WEBHOOK_SECRET_HEADER"));
await page.getByLabel(/^Header Value$/).fill("wrong-webhook-secret");
});
await submit(page);
await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
await expect(get(page, invalidChannelName)).toBeVisible();
await testFromEdit(page, invalidChannelName);
await expect(page.getByText("An error occurred while testing the notification channel, check your configuration")).toBeVisible();
await cancel(page);
});
test("Delete invalid Webhook E2E channel", async ({page}) => {
await page.goto("/dashboard/notifications/channels");
await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
await expect(get(page, invalidChannelName)).toBeVisible();
await remove(page, invalidChannelName);
await expect(page.getByText("Notification channel has been successfully removed.")).toBeVisible();
await expect(page.getByText(invalidChannelName)).toHaveCount(0);
});
});
-59
View File
@@ -1,59 +0,0 @@
import {expect, test} from "@playwright/test";
import {create, edit, get, remove} from "./helpers/project";
import {LOCAL_STORAGE_PATH} from "./helpers/session";
test.use({storageState: LOCAL_STORAGE_PATH});
const project = {
emptyStateName: "Project A",
buttonName: "Project B",
updatedButtonName: "Project B Updated",
};
test.describe.serial(() => {
test("Create a project from empty state", async ({page}) => {
await page.goto("/dashboard/projects");
await expect(page.getByRole("heading", {name: "Projects"})).toBeVisible();
await expect(page.getByText("Create new Project", {exact: true})).toBeVisible();
await create(page, "emptyState", project.emptyStateName);
await expect(page.getByText("Project has been successfully created.")).toBeVisible();
await expect(get(page, project.emptyStateName)).toBeVisible();
await expect(page.getByText("Create new Project", {exact: true})).toHaveCount(0);
});
test("Create a project from classic button", async ({page}) => {
await page.goto("/dashboard/projects");
await expect(page.getByRole("heading", {name: "Projects"})).toBeVisible();
await expect(get(page, project.emptyStateName)).toBeVisible();
await create(page, "button", project.buttonName);
await expect(page.getByText("Project has been successfully created.")).toBeVisible();
await expect(get(page, project.buttonName)).toBeVisible();
});
test("Edit the second created project", async ({page}) => {
await page.goto("/dashboard/projects");
await expect(page.getByRole("heading", {name: "Projects"})).toBeVisible();
await expect(get(page, project.buttonName)).toBeVisible();
await edit(page, project.buttonName, project.updatedButtonName);
await expect(page.getByText("Project has been successfully updated.")).toBeVisible();
await expect(page.getByText(project.updatedButtonName, {exact: true})).toBeVisible();
});
test("Delete the second created project", async ({page}) => {
await page.goto("/dashboard/projects");
await expect(page.getByRole("heading", {name: "Projects"})).toBeVisible();
await expect(get(page, project.updatedButtonName)).toBeVisible();
await remove(page, project.updatedButtonName);
await expect(page).toHaveURL("/dashboard/projects");
await expect(page.getByText("Projects has been successfully archived.")).toBeVisible();
await expect(page.getByText(project.updatedButtonName)).toHaveCount(0);
});
});
-13
View File
@@ -1,13 +0,0 @@
import {test} from "@playwright/test";
import fs from "fs";
import {LOCAL_STORAGE_PATH} from "./helpers/session";
test.describe(() => {
test.beforeAll(async () => {
if (fs.existsSync(LOCAL_STORAGE_PATH)) fs.unlinkSync(LOCAL_STORAGE_PATH);
fs.writeFileSync(LOCAL_STORAGE_PATH, JSON.stringify({}));
});
test("Prepare shared storage state", async () => {
});
});
View File
View File
-60
View File
@@ -1,60 +0,0 @@
import {expect, test} from "@playwright/test";
import {getEnv} from "../helpers/env";
import {LOCAL_STORAGE_PATH} from "../helpers/session";
import {
cancel, create, get, remove, submit, testConnection, testFromEdit,
} from "../helpers/storage";
test.use({storageState: LOCAL_STORAGE_PATH});
const requiredChannelName = "Google Drive E2E Required";
const invalidChannelName = "Google Drive E2E Invalid";
// test.describe.serial("Valid channel", () => {
// test("Create and test a valid Google Drive channel", async ({page}) => {
// await page.goto("/dashboard/storages/channels");
// await expect(page.getByRole("heading", {name: "Storage channels"})).toBeVisible();
// await create(page, "Google Drive", requiredChannelName, async (page) => {
// await page.getByLabel(/Client ID/).fill(getEnv("E2E_STORAGE_GOOGLE_DRIVE_CLIENT_ID"));
// await page.getByLabel(/Client Secret/).fill(getEnv("E2E_STORAGE_GOOGLE_DRIVE_CLIENT_SECRET"));
// await page.getByLabel(/Folder ID/).fill(getEnv("E2E_STORAGE_GOOGLE_DRIVE_FOLDER_ID"));
// });
// await expect(page.getByRole("heading", {name: "Add Storage Channel"})).toBeVisible();
// await testConnection(page);
// await expect(page.getByText("Successfully connected to storage channel")).toBeVisible();
// await submit(page);
// await expect(page.getByText("Storage channel has been successfully created.")).toBeVisible();
// await expect(get(page, requiredChannelName)).toBeVisible();
//
// await testFromEdit(page, requiredChannelName);
// await expect(page.getByRole("heading", {name: "Edit Storage Channel"})).toBeVisible();
// await expect(page.getByText("Successfully connected to storage channel")).toBeVisible();
// await cancel(page);
// });
// });
test.describe.serial("Invalid channel", () => {
test("Create and test invalid Google Drive channel", async ({page}) => {
await page.goto("/dashboard/storages/channels");
await expect(page.getByRole("heading", {name: "Storage channels"})).toBeVisible();
await create(page, "Google Drive", invalidChannelName, async (page) => {
await page.getByLabel(/Client ID/).fill(getEnv("E2E_STORAGE_GOOGLE_DRIVE_CLIENT_ID"));
await page.getByLabel(/Client Secret/).fill("wrong-google-drive-client-secret");
await page.getByLabel(/Folder ID/).fill(getEnv("E2E_STORAGE_GOOGLE_DRIVE_FOLDER_ID"));
});
await testConnection(page);
await expect(page.getByText("An error occurred while testing the storage channel")).toBeVisible();
await submit(page);
await expect(page.getByText("Storage channel has been successfully created.")).toBeVisible();
await expect(get(page, invalidChannelName)).toBeVisible();
});
test("Delete invalid Google Drive E2E channel", async ({page}) => {
await page.goto("/dashboard/storages/channels");
await expect(page.getByRole("heading", {name: "Storage channels"})).toBeVisible();
await expect(get(page, invalidChannelName)).toBeVisible();
await remove(page, invalidChannelName);
await expect(page.getByText("Storage channel has been successfully removed.")).toBeVisible();
await expect(page.getByText(invalidChannelName)).toHaveCount(0);
});
});
-121
View File
@@ -1,121 +0,0 @@
import {expect, test} from "@playwright/test";
import {getEnv} from "../helpers/env";
import {LOCAL_STORAGE_PATH} from "../helpers/session";
import {
cancel, create, get, remove, submit, testConnection, testFromEdit,
} from "../helpers/storage";
test.use({storageState: LOCAL_STORAGE_PATH});
const 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);
});
});
});
}
+80 -82
View File
@@ -1,102 +1,100 @@
import type {NextConfig} from "next";
import {PORTABASE_DEFAULT_SETTINGS} from "./portabase.config";
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 { 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(" ")}`,
];
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.BLOCK_ALL_MIXED_CONTENT) {
directives.push("block-all-mixed-content");
}
if (CSP.UPGRADE_INSECURE_REQUESTS) {
directives.push("upgrade-insecure-requests");
}
if (CSP.UPGRADE_INSECURE_REQUESTS) {
directives.push("upgrade-insecure-requests");
}
return directives.join("; ");
return directives.join("; ");
}
function buildPermissionsPolicy(): string {
return Object.entries(PORTABASE_DEFAULT_SETTINGS.SECURITY.PERMISSIONS_POLICY)
.map(([feature, values]) => `${feature.toLowerCase()}=${values.join(", ")}`)
.join(", ");
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,
output: "standalone",
devIndicators: false,
typescript: {
ignoreBuildErrors: true,
},
logging: {
browserToTerminal: false,
},
experimental: {
serverActions: {
bodySizeLimit: "10gb",
},
logging: {
browserToTerminal: false,
},
experimental: {
serverActions: {
bodySizeLimit: "10gb",
},
proxyClientMaxBodySize: '10gb',
},
async rewrites() {
if (!isDev) return [];
proxyClientMaxBodySize: "10gb",
},
async rewrites() {
if (!isDev) return [];
return [
{
source: "/tus/:path*",
destination: "http://localhost:1080/tus/:path*",
},
];
},
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
],
},
];
},
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",
},
],
},
];
},
};
export default nextConfig;
+7 -3
View File
@@ -1,6 +1,6 @@
{
"name": "portabase",
"version": "1.18.2",
"version": "1.20.6",
"private": true,
"scripts": {
"dev": "next dev --turbopack -p 8887",
@@ -16,12 +16,15 @@
},
"dependencies": {
"@asteasolutions/zod-to-openapi": "^8.5.0",
"@azure/storage-blob": "^12.32.0",
"@better-auth/api-key": "1.6.11",
"@better-auth/core": "1.6.11",
"@better-auth/passkey": "1.6.11",
"@better-auth/sso": "1.6.11",
"@google-cloud/storage": "^7.19.0",
"@hookform/resolvers": "^5.4.0",
"@modelcontextprotocol/sdk": "^1.29.0",
"@onboardjs/react": "1.0.0-rc.5",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-aspect-ratio": "^1.1.8",
@@ -60,6 +63,7 @@
"argon2": "^0.43.1",
"bcrypt": "^6.0.0",
"better-auth": "1.6.11",
"canvas-confetti": "^1.9.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
@@ -109,12 +113,12 @@
},
"devDependencies": {
"@iconify/react": "^6.0.2",
"@playwright/test": "1.58.2",
"@react-email/preview-server": "4.3.2",
"@react-email/render": "^2.0.8",
"@release-it/bumper": "^7.0.5",
"@release-it/conventional-changelog": "^10.0.6",
"@tailwindcss/postcss": "^4.3.0",
"@types/canvas-confetti": "^1.9.0",
"@types/eslint-plugin-tailwindcss": "^3.17.0",
"@types/node": "^22.19.19",
"@types/node-forge": "^1.3.14",
@@ -139,5 +143,5 @@
"typescript": "^5.9.3",
"zenstack": "2.14.2"
},
"packageManager": "pnpm@11.5.2+sha512.71c631e382066efc25625d5cf029075de07b61b37f6e27350fbd84b1bda5864c8c1967adc280776b45c30a715c0359a3be08fef42d5bb09e2b99029979692916"
"packageManager": "pnpm@11.8.0+sha512.c1f5e7c4cb241c8f174b743851d82f42b802324afc8b0f116b96adb15aa06664948dde36960a3ba1079ba5b4b29dd0140135b94b5b5f5263592249d68e555f26"
}
-79
View File
@@ -1,79 +0,0 @@
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'],
},
]
});
+1746 -1227
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -46,7 +46,7 @@ export const PORTABASE_DEFAULT_SETTINGS = {
"https://api.iconify.design",
"https://code.iconify.design",
"https://api.github.com",
"https://api.dicebear.com",
],
OBJECT_SRC: ["'none'"],
BASE_URI: ["'self'"],
+3 -2
View File
@@ -4,7 +4,7 @@ import { errorHandler } from "@/middleware/errorHandler";
import { auth } from "@/lib/auth/auth";
import { headers } from "next/headers";
import { env } from "@/env.mjs";
import {User} from "@/db/schema/02_user";
import { User } from "@/db/schema/02_user";
export async function proxy(request: NextRequest) {
const url = request.nextUrl.clone();
@@ -19,7 +19,7 @@ export async function proxy(request: NextRequest) {
new URL(`/login?redirect=${redirectUrl}`, request.url),
);
}
const user = session.user as User
const user = session.user as User;
if (user.banned) {
await auth.api.signOut({ headers: await headers() });
@@ -104,6 +104,7 @@ function checkRouteExists(pathname: string) {
/^\/api\/config\/?$/,
/^\/api\/health\/?$/,
/^\/api\/google\/drive\/callback\/?$/,
/^\/api\/avatar\/?$/,
// v1 external API
/^\/api\/v1\/mcp\/?$/,
/^\/api\/v1\/docs\/?$/,
Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env bash
#
# azurite-list.sh — enumerate every container and blob in a local Azurite emulator.
#
# Usage:
# ./azurite-list.sh # list containers + blobs
# ./azurite-list.sh -v # also print blob size, last-modified, content-type
#
set -euo pipefail
export AZURE_STORAGE_CONNECTION_STRING="DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://127.0.0.1:10000/devstoreaccount1;QueueEndpoint=http://127.0.0.1:10001/devstoreaccount1;TableEndpoint=http://127.0.0.1:10002/devstoreaccount1;"
VERBOSE=0
[[ "${1:-}" == "-v" || "${1:-}" == "--verbose" ]] && VERBOSE=1
command -v az >/dev/null 2>&1 || { echo "ERROR: 'az' CLI not found in PATH." >&2; exit 1; }
command -v jq >/dev/null 2>&1 || { echo "ERROR: 'jq' not found in PATH." >&2; exit 1; }
if ! az storage container list --num-results 1 >/dev/null 2>&1; then
echo "ERROR: Cannot reach Azurite at 127.0.0.1:10000. Is the container running?" >&2
echo " Start it with: docker start azurite" >&2
exit 1
fi
containers=$(az storage container list --query "[].name" -o tsv)
if [[ -z "$containers" ]]; then
echo "(no containers found in this emulator)"
exit 0
fi
while IFS= read -r container; do
echo "📦 container: $container"
if [[ "$VERBOSE" -eq 1 ]]; then
az storage blob list --container-name "$container" \
--query "[].{name:name, bytes:properties.contentLength, modified:properties.lastModified, type:properties.contentSettings.contentType}" \
-o json \
| jq -r '.[] | " • \(.name) [\(.bytes) bytes] \(.type // "?") \(.modified)"'
else
az storage blob list --container-name "$container" \
--query "[].{name:name, bytes:properties.contentLength}" \
-o json \
| jq -r '.[] | " • \(.name) [\(.bytes) bytes]"'
fi
count=$(az storage blob list --container-name "$container" --query "length(@)" -o tsv)
[[ "$count" -eq 0 ]] && echo " (empty)"
echo
done <<< "$containers"
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
#
# azurite-seed.sh — seed a local Azurite emulator with a container + sample blob.
#
# Usage:
# ./azurite-seed.sh # create container "portabase" + sample blob
# ./azurite-seed.sh my-container # override container name
#
set -euo pipefail
export AZURE_STORAGE_CONNECTION_STRING="DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://127.0.0.1:10000/devstoreaccount1;QueueEndpoint=http://127.0.0.1:10001/devstoreaccount1;TableEndpoint=http://127.0.0.1:10002/devstoreaccount1;"
CONTAINER="${1:-portabase}"
BLOB_NAME="hello.txt"
command -v az >/dev/null 2>&1 || { echo "ERROR: 'az' CLI not found in PATH." >&2; exit 1; }
# Reachability check — surface the real az error instead of guessing "container down".
if ! err=$(az storage container list --num-results 1 2>&1 >/dev/null); then
echo "ERROR: Azurite query failed:" >&2
printf '%s\n' "$err" | sed 's/^/ /' >&2
echo " Is Azurite up? Start it with: docker compose up -d azurite" >&2
exit 1
fi
# Create container (idempotent — az returns created:false if it already exists).
az storage container create --name "$CONTAINER" -o none
echo "📦 container ready: $CONTAINER"
# Upload a sample blob from a temp file.
tmp=$(mktemp)
trap 'rm -f "$tmp"' EXIT
printf 'hello from azurite seed — %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$tmp"
az storage blob upload \
--container-name "$CONTAINER" \
--name "$BLOB_NAME" \
--file "$tmp" \
--overwrite true \
--no-progress \
-o none
echo " • uploaded blob: $BLOB_NAME"
echo
echo "Seed complete. Verify with: make list-blob"
+78 -2
View File
@@ -351,6 +351,14 @@
"clientRole" : true,
"containerId" : "18b5dfb2-44d4-448e-98df-9652daef1a22",
"attributes" : { }
} ],
"exemple-client" : [ {
"id" : "67766209-57dd-45c7-940d-ed3313473101",
"name" : "uma_protection",
"composite" : false,
"clientRole" : true,
"containerId" : "248d3eaa-466f-4b85-8903-9242959e67ba",
"attributes" : { }
} ]
}
},
@@ -458,8 +466,8 @@
"id" : "b1cedcac-5207-4479-bc29-065ba849e414",
"type" : "password",
"userLabel" : "My password",
"createdDate" : 1772179391727,
"secretData" : "{\"value\":\"IXr6L3pvO2BAkn/cH3rh3v2tg1R83u2f8SWEZK2fq5E=\",\"salt\":\"XWV8/yKm7Rw/YXiwXZZUig==\",\"additionalParameters\":{}}",
"createdDate" : 1782393421785,
"secretData" : "{\"value\":\"L585pLqSeHaNW4HzOu+GzZm+VJWWBO3Df+rPs04gUGk=\",\"salt\":\"w3pmQ0hsMDJnCX+qJ3K+zQ==\",\"additionalParameters\":{}}",
"credentialData" : "{\"hashIterations\":5,\"algorithm\":\"argon2\",\"additionalParameters\":{\"hashLength\":[\"32\"],\"memory\":[\"7168\"],\"type\":[\"id\"],\"version\":[\"1.3\"],\"parallelism\":[\"1\"]}}"
} ],
"disableableCredentialTypes" : [ ],
@@ -467,6 +475,23 @@
"realmRoles" : [ "admin", "default-roles-master" ],
"notBefore" : 0,
"groups" : [ "/Admin" ]
}, {
"id" : "8c749a0c-f2ed-4bc7-9f54-4e0bcbc36626",
"username" : "service-account-exemple-client",
"emailVerified" : false,
"enabled" : true,
"createdTimestamp" : 1772190246795,
"totp" : false,
"serviceAccountClientId" : "exemple-client",
"credentials" : [ ],
"disableableCredentialTypes" : [ ],
"requiredActions" : [ ],
"realmRoles" : [ "default-roles-master" ],
"clientRoles" : {
"exemple-client" : [ "uma_protection" ]
},
"notBefore" : 0,
"groups" : [ ]
}, {
"id" : "718dfe46-dbef-41a3-9120-78aeac240431",
"username" : "service-account-portabase",
@@ -625,6 +650,57 @@
"nodeReRegistrationTimeout" : 0,
"defaultClientScopes" : [ "web-origins", "acr", "profile", "roles", "basic", "email" ],
"optionalClientScopes" : [ "address", "phone", "organization", "offline_access", "microprofile-jwt" ]
}, {
"id" : "248d3eaa-466f-4b85-8903-9242959e67ba",
"clientId" : "exemple-client",
"name" : "Exemple Client",
"description" : "",
"rootUrl" : "https://your-instance.portabase.io",
"adminUrl" : "https://your-instance.portabase.io",
"baseUrl" : "https://your-instance.portabase.io",
"surrogateAuthRequired" : false,
"enabled" : true,
"alwaysDisplayInConsole" : false,
"clientAuthenticatorType" : "client-secret",
"secret" : "lFZY6fXzh9uxCXwvucw9JtXGhNRKgnnj",
"redirectUris" : [ "https://your-instance.portabase.io/api/auth/sso/callback/exemple-client" ],
"webOrigins" : [ "https://your-instance.portabase.io" ],
"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" : "1772190246",
"backchannel.logout.session.required" : "true",
"standard.token.exchange.enabled" : "false",
"post.logout.redirect.uris" : "https://your-instance.portabase.io",
"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,
"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" : "6022c206-5f1f-45b7-af07-74ce6804cbc7",
"clientId" : "master-realm",
-18
View File
@@ -1,18 +0,0 @@
"use client";
import React from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
type BackButtonProps = React.ComponentProps<typeof Button> & {
children: React.ReactNode;
};
export default function BackButton({ children, ...props }: BackButtonProps) {
const router = useRouter();
return (
<Button onClick={() => router.back()} aria-label={children?.toString()} {...props}>
{children}
</Button>
);
}
+122
View File
@@ -0,0 +1,122 @@
"use client";
import { cn } from "@/lib/utils";
export type ThemeKey = "dark" | "light" | "system";
const themes: { value: ThemeKey }[] = [
{ value: "light" },
{ value: "dark" },
{ value: "system" },
];
const THEME_TEXT: Record<ThemeKey, string> = {
dark: "Dark",
light: "Light",
system: "System",
};
interface ThemeSelectorProps {
value?: string;
onSelect: (value: ThemeKey) => void;
className?: string;
}
export function ThemeSelector({
value,
onSelect,
className,
}: ThemeSelectorProps) {
return (
<div
className={cn("grid grid-cols-1 sm:grid-cols-3 gap-4 w-full", className)}
>
{themes.map((item) => {
const isDark = item.value === "dark";
const isSystem = item.value === "system";
const isActive = value === item.value;
return (
<div
key={item.value}
className={cn(
"border-2 rounded-xl p-1 cursor-pointer transition-all hover:bg-accent/50 space-y-2",
isActive ? "border-primary bg-primary/5" : "border-muted/40",
)}
onClick={() => onSelect(item.value)}
>
<div
className={cn(
"p-2 rounded-lg aspect-4/3 flex flex-col gap-2 relative overflow-hidden border",
isDark
? "bg-slate-950 border-slate-800"
: "bg-white border-slate-200",
isSystem && "bg-linear-to-br from-white to-slate-950",
)}
>
<div
className={cn(
"h-3 w-full rounded-sm shadow-sm opacity-80",
isDark ? "bg-slate-800" : "bg-slate-100",
)}
/>
<div className="flex gap-2 flex-1 relative">
<div
className={cn(
"w-1/4 h-full rounded-sm shadow-sm opacity-80",
isDark ? "bg-slate-800" : "bg-slate-100",
)}
/>
<div className="flex-1 flex flex-col gap-2">
<div
className={cn(
"h-3 w-full rounded-sm shadow-sm opacity-80",
isDark ? "bg-slate-800" : "bg-slate-100",
)}
/>
<div
className={cn(
"flex-1 rounded-sm shadow-sm p-1 space-y-2 opacity-50",
isDark ? "bg-slate-800" : "bg-slate-100",
)}
>
<div
className={cn(
"h-2 w-3/4 rounded-full",
isDark ? "bg-slate-700" : "bg-slate-300",
)}
/>
<div
className={cn(
"h-2 w-full rounded-full",
isDark ? "bg-slate-700" : "bg-slate-300",
)}
/>
</div>
</div>
</div>
</div>
<div className="flex items-center justify-between p-1 px-2">
<span className="font-medium text-sm">
{THEME_TEXT[item.value]}
</span>
<div
className={cn(
"w-4 h-4 rounded-full border flex items-center justify-center transition-all",
isActive
? "border-primary bg-primary"
: "border-muted-foreground/30",
)}
>
{isActive && (
<div className="w-1.5 h-1.5 rounded-full bg-primary-foreground" />
)}
</div>
</div>
</div>
);
})}
</div>
);
}
+2 -2
View File
@@ -1,7 +1,7 @@
// import * as React from "react";
// import EmailLayout from "./email-layout";
// import {Text, Section, Button} from "@react-email/components";
// import type {EventPayload} from "@/features/notifications/notifications.types";
// import type {EventPayload} from "@/features/notifications/types";
//
// export interface EmailNotificationProps {
// payload: EventPayload
@@ -48,7 +48,7 @@
import * as React from "react";
import EmailLayout from "./email-layout";
import {Text, Section, Button} from "@react-email/components";
import type {EventPayload} from "@/features/notifications/notifications.types";
import type {EventPayload} from "@/features/notifications/types";
export interface EmailNotificationProps {
payload: EventPayload;
@@ -0,0 +1 @@
ALTER TYPE "public"."provider_kind" ADD VALUE 'teams';
@@ -0,0 +1 @@
ALTER TYPE "public"."provider_kind" ADD VALUE 'pushover';
@@ -0,0 +1 @@
ALTER TYPE "public"."provider_storage_kind" ADD VALUE 'blob';
@@ -0,0 +1 @@
ALTER TABLE "settings" ADD COLUMN "onboarding" boolean DEFAULT false NOT NULL;
+11
View File
@@ -0,0 +1,11 @@
-- Custom SQL migration file, put your code below! --
UPDATE settings SET onboarding = true
WHERE onboarding = false
AND (
EXISTS (SELECT 1 FROM organization WHERE slug <> 'default')
OR EXISTS (SELECT 1 FROM agents)
OR EXISTS (SELECT 1 FROM projects)
OR EXISTS (SELECT 1 FROM databases)
OR EXISTS (SELECT 1 FROM notification_channel)
OR EXISTS (SELECT 1 FROM storage_channel WHERE provider <> 'local')
);
@@ -0,0 +1 @@
ALTER TYPE "public"."dbms_status" ADD VALUE 'postgresql-cluster' BEFORE 'mysql';
+1
View File
@@ -0,0 +1 @@
ALTER TYPE "public"."provider_storage_kind" ADD VALUE 'google-cloud-storage' BEFORE 'blob';
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff

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