Compare commits

..
418 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
killian-larcher b9924f511f Merge branch 'main' into dev 2026-06-10 18:21:21 +02:00
Killian LarcherGitHubkillian-larcherCharles GTEgithub-actions[bot] <github-actions[bot]@users.noreply.github.com>
32da8b5fd0 feat: externalize e2e testings [skip-release] (#312)
* feat: externalize e2e tests.

* refactor: improve e2e workflow.

* chore: upgrade workflow action version.

* fix: createEnv by adding emptyStringAsUndefined (#317)

* chore: release 1.18.2

---------

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

* feat: added the mcp server

* fix: api for database not linked to any projects

* fix: added variable to disable MCP

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

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

* docs: add features/channel/ to architecture conventions

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

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

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

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

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

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

* refactor: migrate wrappers/auth to features/auth

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

* refactor: remove dead auth files and fix zPassword schema

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: remove console.error from local storage provider

* fix: multi-select.tsx scroll issue

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

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

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

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

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

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

* feat(nextcloud): add nextcloud to provider_kind enum

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

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

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

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

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

* fix: multiple webhook headers and nextcloud talk notification provider

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

* fix: webhook.ts

---------

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

* fix: .env.example

---------

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

* fix: S3ChannelConfigSchema type port mismatch

* chore: package.json

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

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

* fix: logger.ts

---------

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

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

* feat: add migration sequences

* fix: responsive for migration tool

* fix: truncate database name in migration

* fix: filter on databases success for migration tool

---------

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

* feat: org agent management

* fix: adding migrations for legacy and refactoring.

* fix: refactoring

* fix: delete agent

* fix: refactoring

---------

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

* fix: home project counter
2026-04-06 21:08:29 +02:00
github-actions[bot] 4deaaa2c8e chore: release 1.11.3 2026-04-06 18:34:59 +00:00
Charles GTEandGitHub adaf0f89fb fix: pipeline ci (#255) 2026-04-06 20:34:24 +02:00
47de5373b1 fix: add page size selector to CardsWithPagination (#253)
* fix: add page size selector to CardsWithPagination

Add an optional pageSizeOptions prop to CardsWithPagination that renders
a "Cards per page" dropdown selector, consistent with the existing
TablePaginationSize pattern used in DataTable.

- New PaginationSize component (standalone, no TanStack Table dependency)
- CardsWithPagination: manage pageSize as state, reset to page 1 on change
- Project databases page: increase default from 6 to 20, add selector [10, 20, 50]
- Projects list page: increase default from 9 to 12, add selector [12, 24, 48]
- Fully backward compatible: without pageSizeOptions, behavior is unchanged

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address CodeRabbit review feedback

- Validate pageSize in handlePageSizeChange (reject 0/negative/NaN)
- Guard empty pageSizeOptions array from rendering empty selector
- Restore className on PaginationNavigation for backward compatibility
- Add aria-label to SelectTrigger for accessibility on small screens

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(pagination-size): fallback to pageSizeOptions[0] when pageSize not in options

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

* fix(pagination-size): fix pageSize is not defined (placeholder ref)

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 20:16:15 +02:00
github-actions[bot] db9d23b5ec chore: release 1.11.2 2026-04-02 17:47:39 +00:00
47c74931d4 fix: upload file database extension (#250)
* fix: upload file database

* fix: getFileHeadersBasedOnDbms for default if no dbType compatible

---------

Co-authored-by: charles-gauthereau <charles.gauthereau@soluce-technologies.com>
2026-04-02 19:47:05 +02:00
github-actions[bot] be01031501 chore: release 1.11.1 2026-04-02 12:43:10 +00:00
2e656d9c41 fix: storage broken url (#248)
Co-authored-by: charles-gauthereau <charles.gauthereau@soluce-technologies.com>
2026-04-02 14:42:30 +02:00
github-actions[bot] 9071193c4d chore: release 1.11.0 2026-04-02 09:08:40 +00:00
27e1da6363 fix: smtp crash due to SMTP_SECURE (#246)
Co-authored-by: charles-gauthereau <charles.gauthereau@soluce-technologies.com>
2026-04-02 11:07:48 +02:00
e02c5496ae chore: readme [skip-release] (#244)
* fix: set default notification channel

* fix: readme

* fix: README.md

---------

Co-authored-by: charles-gauthereau <charles.gauthereau@soluce-technologies.com>
2026-04-01 17:22:21 +02:00
3fee236fd0 feat: adding end-to-end testing for notification [skip-release] (#228)
* fix: release.yml

* fix: S3ChannelConfigSchema type port mismatch

* fix(csp): Conditionally sets UPGRADE_INSECURE_REQUESTS based on the PROJECT_URL scheme.

* chore: fix bug in github action.

* chore: fix bug in github action.

* chore: fix bug in github action.

* chore: fix bug in github action.

* chore: fix bug in github action.

* chore: fix bug in github action.

* chore: fix bug in github action.

* chore: fix bug in github action.

* chore: fix bug in github action.

* chore: fix bug in github action.

* chore: fix bug in github action.

* chore: fix bug in github action.

* chore: fix bug in github action.

* chore: fix bug in github action.

* chore: fix bug in github action.

* chore: fix bug in github action.

* chore: adding e2e tesing for notification.

* fix: updating trivy-action to the latest version, using new tagging pattern (vX.XX.XX instead of (X.XX.XX).

* fix: removing browser targeting in e2e workflow as it is already specify in playwright.config.ts

* fix: mapping secrets to environment variables in the end-to-end workflow.

* fix: add a condition to allow end-to-end workflow execution only on main repo (fork are excluded).

* fix: adding some data-testid to rely on more stable locator.

* fix: adding some data-testid to rely on more stable locator.

* fix: removing unusued browsers in end-to-end workflow.

* fix: removing unused browsers in end-to-end workflow.

* fix: removing unused browsers in end-to-end workflow.

---------

Co-authored-by: charlesgauthereau <charles.gauthereau@soluce-technologies.com>
Co-authored-by: killianlarcher <killian.larcher@soluce-technologies.com>
2026-03-31 20:25:15 +02:00
github-actions[bot] 78ba42239d chore: release 1.10.0 2026-03-31 13:09:08 +00:00
Charles GTEandGitHub 8ffb672e26 feat: add firebird (#242) 2026-03-31 15:08:31 +02:00
Charles GTE 150ab83f73 Merge branch 'main' into dev 2026-03-30 17:51:28 +02:00
Théo LAGACHEandGitHub 83ddc934ba fix: readme [skip-release] (#241)
* Update translations

* Add: firebird
2026-03-30 15:52:21 +02:00
github-actions[bot] 31cbd52703 chore: release 1.9.5 2026-03-29 12:11:43 +00:00
dd85f7a25d fix: trusted url missing, better auth crash due to update (#240)
* fix: release.yml

* fix: S3ChannelConfigSchema type port mismatch

* chore: package.json

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

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

---------

Co-authored-by: charlesgauthereau <charles.gauthereau@soluce-technologies.com>
Co-authored-by: killianlarcher <killian.larcher@soluce-technologies.com>
2026-03-29 14:10:58 +02:00
Charles GTE 35db7a4416 fix: trusted url error due to better auth update. 2026-03-29 14:08:20 +02:00
github-actions[bot] 203c25aabe chore: release 1.9.4 2026-03-29 09:43:29 +00:00
41b1c89283 fix: logs (#238)
* fix: release.yml

* fix: S3ChannelConfigSchema type port mismatch

* chore: package.json

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

---------

Co-authored-by: charlesgauthereau <charles.gauthereau@soluce-technologies.com>
Co-authored-by: killianlarcher <killian.larcher@soluce-technologies.com>
2026-03-29 11:42:48 +02:00
Charles GTE 1ce93af144 fix: refactoring logs and adding pino logger for production and dev. 2026-03-29 11:41:39 +02:00
Charles GTE f410a8fdd5 Merge branch 'main' into dev 2026-03-29 11:23:38 +02:00
github-actions[bot] 180ea21041 chore: release 1.9.3 2026-03-29 09:23:24 +00:00
Charles GTEandGitHub e1c0fc6917 fix: refactoring logs and adding pino logger for production and dev. (#236) 2026-03-29 11:22:28 +02:00
github-actions[bot] 057c192314 chore: release 1.9.2 2026-03-29 07:25:20 +00:00
Charles GTE 88914169bc Merge branch 'main' into dev 2026-03-29 09:25:00 +02:00
Charles GTEandGitHub aff782aa28 fix: mariadb and mysql separation (#234) 2026-03-29 09:24:31 +02:00
Charles GTE 8d51bcb74d Merge branch 'main' into dev 2026-03-28 19:01:31 +01:00
github-actions[bot] 2e729cb79c chore: release 1.9.1 2026-03-28 18:01:28 +00:00
Charles GTEandGitHub d8677d74a7 fix: default notification (#233) 2026-03-28 19:00:50 +01:00
Charles GTE 5a87529934 fix: refactoring 2026-03-28 16:55:31 +01:00
github-actions[bot] 2f77714994 chore: release 1.9.0 2026-03-28 15:37:58 +00:00
Charles GTEandGitHub bc8050c4df feat: add-healthcheck (#231)
* fix: refactoring

* fix: refactoring

* fix: refactoring
2026-03-28 16:37:03 +01:00
Charles GTEandGitHub a9c69b112d Merge pull request #232 from Portabase/revert-230-revert-229-feat/add-healthcheck
Revert "Revert "feat: add-healthcheck""
2026-03-28 16:35:18 +01:00
Charles GTEandGitHub 04785c323b Revert "Revert "feat: add-healthcheck"" 2026-03-28 16:35:02 +01:00
Charles GTEandGitHub 1d209ceb28 Merge pull request #230 from Portabase/revert-229-feat/add-healthcheck
Revert "feat: add-healthcheck"
2026-03-28 16:28:25 +01:00
Charles GTEandGitHub 941a9256f0 Revert "feat: add-healthcheck" 2026-03-28 16:27:57 +01:00
Charles GTEandGitHub 9098012426 feat: add-healthcheck (#229 )
feat: add-healthcheck
2026-03-28 16:23:11 +01:00
Charles GTE 8e12ff59a4 fix: refactoring 2026-03-28 16:22:04 +01:00
Charles GTE 0f8a267861 chore: update security.yml 2026-03-28 16:17:24 +01:00
Charles GTE da53a00fd8 chore: update security.yml 2026-03-28 16:13:36 +01:00
Charles GTE 47e3a93e42 fix: healthcheck system 2026-03-28 16:06:55 +01:00
Charles GTE 0262a1c176 fix: database health cron 2026-03-28 15:10:46 +01:00
Charles GTE 7aecd911c0 feat: database health logs 2026-03-27 23:06:19 +01:00
Charles GTE 595ccadfb3 feat: added heath_error_count and the cron for health check agent errors 2026-03-27 22:13:32 +01:00
charles-gauthereau bded273f94 fix: set default notification channel 2026-03-27 19:22:44 +01:00
charles-gauthereau c095d393cf fix: set default notification channel 2026-03-26 22:45:42 +01:00
charles-gauthereau c3ae82fde7 feat: set default notification channel 2026-03-26 20:17:23 +01:00
charles-gauthereau a8e11ebef7 feat: set default notification channel 2026-03-25 22:19:41 +01:00
charles-gauthereau 61b1a2e724 feat: set default notification channel 2026-03-25 22:07:20 +01:00
charles-gauthereau 7a4af4e1e0 feat: added cron job to remove healthcheck logs older than 12h 2026-03-25 21:11:09 +01:00
charles-gauthereau a9d6481193 fix: health chart labels and get logs for 12h instead of 24h 2026-03-25 20:45:20 +01:00
charles-gauthereau fb97963cc3 feat: front form for default notification settings page choice 2026-03-25 09:06:53 +01:00
charles-gauthereau 0cc0ba989c feat: add agent healthcheck 2026-03-24 20:06:43 +01:00
Charles GTE 0d7a41996e chore: package.json 2026-03-21 08:39:29 +01:00
github-actions[bot] 26eff02943 chore: release 1.8.0 2026-03-18 18:48:30 +00:00
67246efafc feat: adding basic end-to-end testing and ci workflow (#225)
* feat: add end-to-end testing

* chore: fix bug in github action.

---------

Co-authored-by: charlesgauthereau <charles.gauthereau@soluce-technologies.com>
Co-authored-by: killianlarcher <killian.larcher@soluce-technologies.com>
2026-03-18 19:47:41 +01:00
killianlarcher dc97de03d0 Merge branch 'main' into dev 2026-03-18 19:34:44 +01:00
github-actions[bot] 260b3ba1f9 chore: release 1.7.1 2026-03-17 14:03:35 +00:00
Charles GTEandGitHub 90f8885e73 fix: removed version number on auth layout (#223)
fix: removed version number on auth layout
2026-03-17 15:02:55 +01:00
charles-gauthereau 0bacd6a65c fix: removed version number on auth layout 2026-03-17 15:01:34 +01:00
github-actions[bot] 2846deb4d0 chore: release 1.7.0 2026-03-16 13:39:18 +00:00
ae6dbcab25 feat: add-valkey engine (#222)
* feat: add-valkey engine

* fix: README.md with Valkey content

---------

Co-authored-by: charles-gauthereau <charles.gauthereau@soluce-technologies.com>
2026-03-16 14:38:43 +01:00
charles-gauthereau d823ec2e82 fix: README.md with Valkey content 2026-03-16 14:26:25 +01:00
charles-gauthereau b0ce5273ce feat: add-valkey engine 2026-03-16 12:16:50 +01:00
github-actions[bot] b576862024 chore: release 1.6.1 2026-03-16 07:56:56 +00:00
dc2496ee18 fix: type mismatch for s3 (#221)
* fix: release.yml

* fix: S3ChannelConfigSchema type port mismatch

---------

Co-authored-by: charlesgauthereau <charles.gauthereau@soluce-technologies.com>
2026-03-16 08:56:19 +01:00
charlesgauthereau 5d4a1248e4 fix: S3ChannelConfigSchema type port mismatch 2026-03-16 08:54:13 +01:00
charlesgauthereau f876d8eb09 Merge branch 'main' into dev 2026-03-16 07:59:39 +01:00
87db207833 fix: add redis badge and Supported databases section in README.md (#219)
Co-authored-by: charlesgauthereau <charles.gauthereau@soluce-technologies.com>
2026-03-15 13:56:01 +01:00
github-actions[bot] b1ccf6ec5f chore: release 1.6.0 2026-03-15 11:10:32 +00:00
Charles GTEandGitHub ca3f63af81 feat: add-redis #217
feat: add-redis
2026-03-15 12:08:44 +01:00
Charles GTEGitHubCopilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
29b0ca5bf5 fix: 'Semicolon insertion'
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
2026-03-15 12:05:48 +01:00
charlesgauthereau da50193e8c fix: detabase details page in order to support backup only databases. 2026-03-15 12:02:00 +01:00
charlesgauthereau 1829c1fc1c feat: add redis database support for backup 2026-03-14 14:41:51 +01:00
github-actions[bot] 79a17407bd chore: release 1.5.4 2026-03-08 20:59:04 +00:00
dd1e3283d5 fix: release.yml (#214)
Co-authored-by: charlesgauthereau <charles.gauthereau@soluce-technologies.com>
2026-03-08 21:58:25 +01:00
charlesgauthereau 1d1d92a5cf fix: release.yml 2026-03-08 21:57:13 +01:00
d1baedd092 fix: pipeline [skip-release] (#213)
* fix: README.md

* fix: release.yml [skip-release]

* fix: release.yml [skip-release]

* fix: release.yml [skip-release]

* fix: release.yml [skip-release]

* fix: release.yml [skip-release]

* fix: release.yml [skip-release]

* fix: release.yml [skip-release]

---------

Co-authored-by: charlesgauthereau <charles.gauthereau@soluce-technologies.com>
2026-03-07 14:11:28 +01:00
9e504cefc1 fix: pipeline [skip-release] (#212)
* fix: README.md

* fix: release.yml [skip-release]

* fix: release.yml [skip-release]

* fix: release.yml [skip-release]

* fix: release.yml [skip-release]

* fix: release.yml [skip-release]

* fix: release.yml [skip-release]

---------

Co-authored-by: charlesgauthereau <charles.gauthereau@soluce-technologies.com>
2026-03-07 14:09:39 +01:00
3cc5ab70fa fix: release.yml [skip-release] (#211)
* fix: README.md

* fix: release.yml [skip-release]

* fix: release.yml [skip-release]

* fix: release.yml [skip-release]

* fix: release.yml [skip-release]

* fix: release.yml [skip-release]

---------

Co-authored-by: charlesgauthereau <charles.gauthereau@soluce-technologies.com>
2026-03-07 14:06:28 +01:00
0441af78a1 fix: readme [skip-release] (#210)
* fix: README.md

* fix: release.yml [skip-release]

* fix: release.yml [skip-release]

* fix: release.yml [skip-release]

---------

Co-authored-by: charlesgauthereau <charles.gauthereau@soluce-technologies.com>
2026-03-07 13:59:18 +01:00
3c615a4fd7 fix: workflow [skip-release]
* fix: README.md

* fix: release.yml [skip-release]

* fix: release.yml [skip-release]

---------

Co-authored-by: charlesgauthereau <charles.gauthereau@soluce-technologies.com>
2026-03-07 13:52:42 +01:00
8681e16fde fix: updated the README.md with helm and some bug fixes [skip-release]
* fix: README.md

* fix: release.yml [skip-release]

---------

Co-authored-by: charlesgauthereau <charles.gauthereau@soluce-technologies.com>
2026-03-07 13:46:47 +01:00
github-actions[bot] 918c6c448c chore: release 1.5.3 2026-03-07 11:57:16 +00:00
Charles GTEandGitHub 1e2b4f16b3 fix: helm-directory
fix: move helm directory to root
2026-03-07 12:56:48 +01:00
charlesgauthereau f0d02eb8b3 fix: Chart.yaml 2026-03-07 12:55:54 +01:00
charlesgauthereau 96274206c5 fix: move helm directory to root 2026-03-07 12:43:18 +01:00
github-actions[bot] 7ae7ea6f79 chore: release 1.5.2 2026-03-07 11:29:44 +00:00
Charles GTEandGitHub 487e7ad83d fix: helm.yml
fix: helm.yml
2026-03-07 12:29:07 +01:00
charlesgauthereau c9dabd39fe fix: helm.yml 2026-03-07 12:28:33 +01:00
github-actions[bot] 30ab689c6e chore: release 1.5.1 2026-03-07 11:06:41 +00:00
Charles GTEandGitHub 8143af18af fix: workflow for helm chart
feat/helm-chart
2026-03-07 12:06:12 +01:00
charlesgauthereau 471c65cc48 fix: release.yml 2026-03-07 12:05:21 +01:00
charlesgauthereau 9162d5afcc fix: release.yml 2026-03-07 12:04:22 +01:00
charlesgauthereau 9236c664c2 Merge branch 'main' into feat/helm-chart 2026-03-07 12:02:56 +01:00
charlesgauthereau 767ed69020 fix: release.yml 2026-03-07 12:02:25 +01:00
github-actions[bot] 93c706719f chore: release 1.6.0 2026-03-07 10:56:16 +00:00
Charles GTEandGitHub ed1ac1926b feat: helm-chart
feat/helm-chart
2026-03-07 11:55:50 +01:00
charlesgauthereau 3adb6d01c0 fix: release.yml 2026-03-07 11:54:43 +01:00
Charles GTEandGitHub 9c195c1f30 feat: helm-chart
feat/helm-chart
2026-03-07 11:52:24 +01:00
charlesgauthereau e27093404b feat: update the release pipeline with helm publish 2026-03-07 11:49:16 +01:00
charlesgauthereau 9602427ca0 feat: add config files for helm chart 2026-03-07 11:11:48 +01:00
charlesgauthereau 46812cb5ff Merge branch 'main' into dev 2026-03-07 10:36:29 +01:00
github-actions[bot] 6258d92464 chore: release 1.5.0 2026-03-06 21:02:09 +00:00
c57d913c46 feat: new pipeline for release process
* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix: workflow

---------

Co-authored-by: charlesgauthereau <charles.gauthereau@soluce-technologies.com>
2026-03-06 22:01:40 +01:00
charlesgauthereau 5d285b7ac6 Merge branch 'main' into dev 2026-03-06 22:01:05 +01:00
charlesgauthereau c96219f851 fix: workflow 2026-03-06 22:00:46 +01:00
github-actions[bot] c7d949b1d1 chore: release 1.4.56 2026-03-06 20:47:32 +00:00
6593a8e324 fix: workflow
* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

---------

Co-authored-by: charlesgauthereau <charles.gauthereau@soluce-technologies.com>
2026-03-06 21:47:00 +01:00
charlesgauthereau 0988fa0115 Merge branch 'main' into dev
# Conflicts:
#	.github/workflows/docker.yml
2026-03-06 21:46:22 +01:00
charlesgauthereau 9f0b1a172c fix 2026-03-06 21:45:43 +01:00
charlesgauthereau 9e22160b16 Merge branch 'main' into dev 2026-03-06 21:32:28 +01:00
charlesgauthereau e8e4b093d2 fix 2026-03-06 21:32:10 +01:00
charlesgauthereau 327b06d2a1 Merge branch 'main' into dev 2026-03-06 19:24:18 +01:00
charlesgauthereau 2c2cfc0b73 fix 2026-03-06 19:22:52 +01:00
charlesgauthereau f93bb6e33e fix 2026-03-06 18:59:01 +01:00
charlesgauthereau 73699bbda9 Merge branch 'main' into dev 2026-03-06 18:55:35 +01:00
charlesgauthereau 5f24ec2d86 fix 2026-03-06 18:55:19 +01:00
charlesgauthereau d01e705866 fix 2026-03-06 18:37:12 +01:00
charlesgauthereau 784a907384 fix 2026-03-06 18:36:19 +01:00
charlesgauthereau 467f9e2ffd fix 2026-03-06 18:33:36 +01:00
charlesgauthereau 1d25acde61 Merge branch 'main' into dev
# Conflicts:
#	.github/workflows/release.yml
2026-03-06 18:33:21 +01:00
charlesgauthereau cb6ac86bb0 fix 2026-03-06 18:32:10 +01:00
charlesgauthereau d7515b172c Merge branch 'main' into dev 2026-03-06 18:28:21 +01:00
charlesgauthereau e4de903cc9 fix 2026-03-06 18:27:54 +01:00
charlesgauthereau 9a3be8d5a5 fix 2026-03-06 18:13:49 +01:00
charlesgauthereau e20e7f016c fix 2026-03-06 18:12:01 +01:00
charlesgauthereau a0c1a24ba9 Merge branch 'main' into dev
# Conflicts:
#	.github/workflows/release.yml
2026-03-06 18:09:27 +01:00
charlesgauthereau 0475b3d01a fix 2026-03-06 18:08:35 +01:00
charlesgauthereau efd123ddfe fix 2026-03-06 18:07:06 +01:00
charlesgauthereau 413318886c Merge branch 'main' into dev
# Conflicts:
#	.github/workflows/release.yml
2026-03-06 18:06:51 +01:00
charlesgauthereau 187f9d2386 fix 2026-03-06 18:06:06 +01:00
charlesgauthereau fde52415cb fix 2026-03-06 18:02:51 +01:00
charlesgauthereau d92225d093 Merge branch 'main' into dev 2026-03-06 18:02:31 +01:00
charlesgauthereau e2a0932229 fix 2026-03-06 18:02:19 +01:00
charlesgauthereau fba714d679 fix 2026-03-06 17:42:32 +01:00
charlesgauthereau 8e15282405 Merge branch 'main' into dev
# Conflicts:
#	.github/workflows/release.yml
2026-03-06 17:42:24 +01:00
charlesgauthereau 72b5816451 fix 2026-03-06 17:42:09 +01:00
charlesgauthereau aa736b8b4a fix 2026-03-06 17:36:42 +01:00
charlesgauthereau 94cd937c56 Merge branch 'main' into dev
# Conflicts:
#	.github/workflows/release.yml
2026-03-06 17:36:14 +01:00
charlesgauthereau 9ccf3b7242 fix 2026-03-06 17:35:56 +01:00
charlesgauthereau fbd61cdf2f fix 2026-03-06 17:28:49 +01:00
charlesgauthereau e9534181da Merge branch 'main' into dev 2026-03-06 17:28:38 +01:00
charlesgauthereau fca09a84cb fix 2026-03-06 17:26:17 +01:00
charlesgauthereau 6ec6fb1f11 Merge branch 'main' into dev 2026-03-06 17:25:58 +01:00
charlesgauthereau 3d51d3548f fix 2026-03-06 17:25:33 +01:00
charlesgauthereau c7ca01c0fb Merge branch 'main' into dev
# Conflicts:
#	.github/workflows/release.yml
2026-03-06 17:06:47 +01:00
charlesgauthereau 5d2172f2bf fix 2026-03-06 17:06:00 +01:00
charlesgauthereau 0ebcf1729c Merge branch 'main' into dev
# Conflicts:
#	.github/workflows/release.yml
2026-03-06 16:59:04 +01:00
charlesgauthereau 51d101e8f0 fix 2026-03-06 16:58:35 +01:00
charlesgauthereau 2d9bf225a0 fix 2026-03-06 16:57:53 +01:00
charlesgauthereau 2499c7c426 Merge branch 'main' into dev
# Conflicts:
#	.github/workflows/release.yml
2026-03-06 16:06:18 +01:00
charlesgauthereau da530217ab fix 2026-03-06 16:05:44 +01:00
charlesgauthereau 0ad380cc81 fix 2026-03-06 16:00:40 +01:00
charlesgauthereau b77a2e374e Merge branch 'main' into dev
# Conflicts:
#	.github/workflows/release.yml
2026-03-06 16:00:00 +01:00
charlesgauthereau 647108e8ee fix 2026-03-06 15:59:33 +01:00
charlesgauthereau eb5ac5c8ea Merge branch 'main' into dev
# Conflicts:
#	.github/workflows/release.yml
2026-03-06 13:30:35 +01:00
charlesgauthereau 29344e0c48 fix 2026-03-06 13:29:27 +01:00
charlesgauthereau ff83ee9c62 fix 2026-03-06 13:24:18 +01:00
charlesgauthereau da3a8d7fbe Merge branch 'main' into dev 2026-03-06 13:23:18 +01:00
charlesgauthereau f2563c5e8d Merge branch 'main' into dev
# Conflicts:
#	.github/workflows/release.yml
2026-03-06 13:14:53 +01:00
charlesgauthereau 9326291780 fix 2026-03-06 13:12:37 +01:00
charlesgauthereau ab22680625 fix 2026-03-06 13:07:03 +01:00
charlesgauthereau a73e29efc9 Merge branch 'main' into dev 2026-03-06 13:05:51 +01:00
charlesgauthereau 26273b7240 fix 2026-03-06 13:04:58 +01:00
charlesgauthereau 0c4b7573cc Merge branch 'main' into dev 2026-03-06 11:49:58 +01:00
charlesgauthereau 401ce5c290 fix 2026-03-06 11:49:35 +01:00
charlesgauthereau e3e84e0d18 Merge branch 'main' into dev
# Conflicts:
#	.github/workflows/auto-release.yml
2026-03-06 11:47:26 +01:00
charlesgauthereau 55cb4a7144 fix 2026-03-06 11:46:33 +01:00
charlesgauthereau 9bc042e2cd Merge branch 'main' into dev
# Conflicts:
#	.github/workflows/auto-release.yml
2026-03-06 11:44:17 +01:00
charlesgauthereau 67302c6a17 fix 2026-03-06 11:43:48 +01:00
charlesgauthereau 3af5f89606 Merge branch 'main' into dev
# Conflicts:
#	.github/workflows/auto-release.yml
2026-03-06 11:24:39 +01:00
charlesgauthereau 3ccccfc5e5 fix 2026-03-06 11:24:12 +01:00
charlesgauthereau 9aecfb22a5 Merge branch 'main' into dev
# Conflicts:
#	.github/workflows/auto-release.yml
2026-03-06 11:21:31 +01:00
charlesgauthereau ba4476a3a7 fix 2026-03-06 11:21:11 +01:00
charlesgauthereau be58ebdb7d Merge branch 'main' into dev 2026-03-06 10:53:05 +01:00
charlesgauthereau 746a9f5eb6 fix 2026-03-06 10:49:04 +01:00
charlesgauthereau 483f9cf192 Merge branch 'main' into dev
# Conflicts:
#	.github/workflows/auto-release.yml
2026-03-06 10:39:45 +01:00
charlesgauthereau f1c7dcda45 fix 2026-03-06 10:39:28 +01:00
charlesgauthereau c840196a14 fix 2026-03-06 10:33:38 +01:00
charlesgauthereau 7d76037a43 Merge branch 'main' into dev 2026-03-06 10:33:18 +01:00
charlesgauthereau b063e5c276 fix 2026-03-06 10:29:27 +01:00
charlesgauthereau f2d98505f1 Merge branch 'main' into dev 2026-03-06 10:29:14 +01:00
charlesgauthereau 59dc181625 Merge branch 'main' into dev
# Conflicts:
#	.github/workflows/auto-release.yml
2026-03-06 10:25:16 +01:00
charlesgauthereau 79387b83f6 fix 2026-03-06 10:24:58 +01:00
charlesgauthereau baa5e300c8 Merge branch 'main' into dev
# Conflicts:
#	.github/workflows/auto-release.yml
2026-03-06 10:02:53 +01:00
charlesgauthereau 51747773ba fix 2026-03-06 10:02:32 +01:00
charlesgauthereau dd8663591e Merge branch 'main' into dev
# Conflicts:
#	.github/workflows/auto-release.yml
2026-03-06 09:58:42 +01:00
charlesgauthereau de4f31f255 fix 2026-03-06 09:58:11 +01:00
charlesgauthereau 53c1bc9e59 fix 2026-03-06 09:56:13 +01:00
charlesgauthereau c05469d704 Merge branch 'main' into dev
# Conflicts:
#	.github/workflows/auto-release.yml
2026-03-06 09:53:43 +01:00
charlesgauthereau 1e6bb542f9 fix 2026-03-06 09:53:23 +01:00
charlesgauthereau 7eb85d2a99 Merge branch 'main' into dev
# Conflicts:
#	.github/workflows/auto-release.yml
2026-03-06 09:47:23 +01:00
charlesgauthereau 7d4d6cd9df fix 2026-03-06 09:47:02 +01:00
charlesgauthereau ff48517b43 fix 2026-03-06 09:36:31 +01:00
charlesgauthereau cab7a7f8bb Merge branch 'main' into dev 2026-03-06 09:33:51 +01:00
charlesgauthereau f6da5961c3 fix 2026-03-06 09:33:23 +01:00
charlesgauthereau bfa3f7e04e Merge branch 'main' into dev
# Conflicts:
#	.github/workflows/auto-release.yml
2026-03-06 09:30:01 +01:00
charlesgauthereau 539b516e45 fix 2026-03-06 09:29:35 +01:00
charlesgauthereau 6002ad44e1 Merge branch 'main' into dev
# Conflicts:
#	.github/workflows/auto-release.yml
2026-03-06 09:24:36 +01:00
charlesgauthereau a431270718 fix 2026-03-06 09:24:09 +01:00
charlesgauthereau efb5318fc5 Merge branch 'main' into dev
# Conflicts:
#	.github/workflows/auto-release.yml
2026-03-06 09:21:11 +01:00
charlesgauthereau a5e83f3133 fix 2026-03-06 09:20:51 +01:00
charlesgauthereau 5d790231ea Merge branch 'main' into dev
# Conflicts:
#	.github/workflows/auto-release.yml
2026-03-06 09:17:28 +01:00
charlesgauthereau ad363ce38c fix 2026-03-06 09:16:58 +01:00
charlesgauthereau 13d3cfb290 Merge branch 'main' into dev 2026-03-06 09:11:05 +01:00
charlesgauthereau c657bdfb63 fix 2026-03-06 09:10:47 +01:00
659 changed files with 108914 additions and 9208 deletions
+27 -2
View File
@@ -1,15 +1,29 @@
# Environment # Environment
NODE_ENV=development NODE_ENV=development
# Logging
# Controls minimum log level. Options: debug | info | warn | error
# Set to "warn" or "error" in production to suppress health-check ping noise.
# Use "debug" to see all request logs including agent status pings.
LOG_LEVEL=info
# Database # Database
DATABASE_URL=postgresql://devuser:changeme@localhost:5433/devdb?schema=public DATABASE_URL=postgresql://devuser:changeme@localhost:5433/devdb?schema=public
# Project # Project
PROJECT_NAME="Portabase" PROJECT_NAME="Portabase"
PROJECT_DESCRIPTION="Portabase is a powerful database manager"
PROJECT_URL=http://localhost:8887 PROJECT_URL=http://localhost:8887
PROJECT_SECRET= PROJECT_SECRET=
AUTH_DEFAULT_USER_NAME="Portabase Admin"
AUTH_DEFAULT_USER=user@example.com
# Password must contain at least 8 characters
# Password must contain at least 1 number
# Password must contain at least 1 lowercase letter
# Password must contain at least 1 uppercase letter
# Password must contain at least 1 special character
AUTH_DEFAULT_PASSWORD=testPASS123456!
# SMTP (email) # SMTP (email)
SMTP_HOST= SMTP_HOST=
SMTP_PORT= SMTP_PORT=
@@ -40,7 +54,6 @@ AUTH_OIDC_ROLE_MAP=""
AUTH_ALLOW_LINKING=true AUTH_ALLOW_LINKING=true
AUTH_ALLOW_UNLINKING=true AUTH_ALLOW_UNLINKING=true
# Social OAuth2 Authentification # Social OAuth2 Authentification
AUTH_SOCIAL_ID="" AUTH_SOCIAL_ID=""
AUTH_SOCIAL_TITLE="" AUTH_SOCIAL_TITLE=""
@@ -59,3 +72,15 @@ AUTH_PASSKEY_ENABLED=true
# Retention # Retention
RETENTION_CRON="* * * * *" RETENTION_CRON="* * * * *"
TRUSTED_DOMAINS="http://localhost:8887, http://localhost:3055, http://localhost:3056"
#OPENAPI_ENABLED=true
#API_ENABLED=true
#MCP_ENABLED=true
# Default to false
#TUSD_BEHIND_PROXY=true
#Skip the onboarding on fresh instance
#SKIP_ONBOARDING=true
+1 -5
View File
@@ -1,11 +1,7 @@
{ {
"extends": [ "extends": ["next/core-web-vitals", "plugin:tailwindcss/recommended"],
"next/core-web-vitals",
"plugin:tailwindcss/recommended"
],
"rules": { "rules": {
"react/no-unescaped-entities": "off", "react/no-unescaped-entities": "off",
"react-hooks/rules-of-hooks": "off" "react-hooks/rules-of-hooks": "off"
} }
} }
+1 -1
View File
@@ -60,7 +60,7 @@ representative at an online or offline event.
Instances of abusive, harassing, or otherwise unacceptable behavior may be Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at reported to the community leaders responsible for enforcement at
contact@soluce-technologies.com. contact@portabase.io.
All complaints will be reviewed and investigated promptly and fairly. All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the All community leaders are obligated to respect the privacy and security of the
+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) 2. [Reporting Issues](#reporting-issues)
3. [Submitting Changes](#submitting-changes) 3. [Submitting Changes](#submitting-changes)
4. [Code Style Guidelines](#code-style-guidelines) 4. [Code Style Guidelines](#code-style-guidelines)
5. [Pull Request Process](#pull-request-process) 5. [E2E Tests](#e2e-tests)
6. [Community Guidelines](#community-guidelines) 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 ## Pull Request Process
1. Ensure your code passes all tests and linters. 1. Ensure your code passes all tests and linters.
+1 -1
View File
@@ -18,7 +18,7 @@ We take security seriously and aim to support the following versions of the proj
If you discover a security vulnerability in this project, we appreciate your help in disclosing it responsibly. If you discover a security vulnerability in this project, we appreciate your help in disclosing it responsibly.
1. **Contact Us** 1. **Contact Us**
Please report the vulnerability by emailing **[contact@soluce-technologies.com](mailto:contact@soluce-technologies.com)**. Include the following details: Please report the vulnerability by emailing **[contact@portabase.io](mailto:contact@portabase.io)**. Include the following details:
- A detailed description of the issue. - A detailed description of the issue.
- Steps to reproduce the vulnerability (if applicable). - Steps to reproduce the vulnerability (if applicable).
- Any potential impacts or risks. - Any potential impacts or risks.
+55 -19
View File
@@ -24,50 +24,86 @@ on:
jobs: jobs:
notify-discord: notify-discord:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Send Discord Notification - name: Send Discord Notification
env: env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
GH_TOKEN: ${{ secrets.GH_TOKEN }} GH_TOKEN: ${{ secrets.GH_TOKEN }}
RELEASE_TAG: ${{ inputs.release_tag }}
DISCORD_TITLE: ${{ inputs.discord_title }}
DISCORD_COLOR: ${{ inputs.discord_color }}
DISCORD_FOOTER: ${{ inputs.discord_footer }}
run: | run: |
RELEASE_INFO=$(gh release view "${{ inputs.release_tag }}" -R ${{ github.repository }} --json name,url,body,author) set -euo pipefail
RELEASE_TITLE=$(echo "$RELEASE_INFO" | jq -r .name) RELEASE_INFO=$(gh release view "$RELEASE_TAG" -R "${{ github.repository }}" --json name,url,body,author)
if [ -z "$RELEASE_TITLE" ] || [ "$RELEASE_TITLE" = "null" ]; then RELEASE_TITLE="${{ inputs.release_tag }}"; fi
RELEASE_URL=$(echo "$RELEASE_INFO" | jq -r .url) RELEASE_TITLE=$(echo "$RELEASE_INFO" | jq -r '.name // empty')
RELEASE_BODY=$(echo "$RELEASE_INFO" | jq -r .body) if [ -z "$RELEASE_TITLE" ]; then
RELEASE_TITLE="$RELEASE_TAG"
fi
RELEASE_URL=$(echo "$RELEASE_INFO" | jq -r '.url // empty')
RELEASE_BODY=$(echo "$RELEASE_INFO" | jq -r '.body // ""')
AUTHOR_NAME="Portabase" AUTHOR_NAME="Portabase"
AUTHOR_ICON="https://github.com/Portabase.png" AUTHOR_ICON="https://github.com/Portabase.png"
PAYLOAD=$(jq -n \ jq -n \
--arg title "$RELEASE_TITLE" \ --arg title "$RELEASE_TITLE" \
--arg description "$RELEASE_BODY" \ --arg description "$RELEASE_BODY" \
--arg url "$RELEASE_URL" \ --arg url "$RELEASE_URL" \
--arg author "$AUTHOR_NAME" \ --arg author "$AUTHOR_NAME" \
--arg icon "$AUTHOR_ICON" \ --arg icon "$AUTHOR_ICON" \
--arg discord_title "${{ inputs.discord_title }}" \ --arg discord_title "$DISCORD_TITLE" \
--arg discord_footer "${{ inputs.discord_footer }}" \ --arg discord_footer "$DISCORD_FOOTER" \
--argjson discord_color ${{ inputs.discord_color }} \ --argjson discord_color "$DISCORD_COLOR" \
'{ '{
content: $discord_title, content: (
if ($discord_title | length) > 1900
then ($discord_title[0:1900] + "...")
else $discord_title
end
),
embeds: [{ embeds: [{
title: $title, title: (
if ($title | length) > 256
then ($title[0:253] + "...")
else $title
end
),
url: $url, url: $url,
description: $description, description: (
if ($description | length) > 3800
then ($description[0:3800] + "\n\n...")
else $description
end
),
color: $discord_color, color: $discord_color,
author: { author: {
name: $author, name: (
if ($author | length) > 256
then ($author[0:253] + "...")
else $author
end
),
icon_url: $icon icon_url: $icon
}, },
footer: { footer: {
text: $discord_footer text: (
if ($discord_footer | length) > 512
then ($discord_footer[0:509] + "...")
else $discord_footer
end
)
} }
}] }]
}' }' > payload.json
)
curl -H "Content-Type: application/json" \ cat payload.json | jq .
-d "$PAYLOAD" \
"$DISCORD_WEBHOOK" curl --fail-with-body -sS \
-H "Content-Type: application/json" \
-d @payload.json \
"$DISCORD_WEBHOOK?wait=true"
+16 -37
View File
@@ -6,6 +6,9 @@ on:
version: version:
required: true required: true
type: string type: string
ref:
required: true
type: string
image_name: image_name:
required: false required: false
type: string type: string
@@ -39,6 +42,9 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
ref: ${{ inputs.ref }}
fetch-depth: 0
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
@@ -67,7 +73,7 @@ jobs:
tags: ${{ steps.prep.outputs.image }} tags: ${{ steps.prep.outputs.image }}
target: ${{ inputs.target }} target: ${{ inputs.target }}
cache-from: type=gha,scope=build-${{ matrix.platform }} 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 - name: Save image name for manifest
run: echo "${{ steps.prep.outputs.image }}" > image.txt run: echo "${{ steps.prep.outputs.image }}" > image.txt
@@ -99,60 +105,33 @@ jobs:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}
# - name: Extract Docker metadata
# id: meta
# uses: docker/metadata-action@v5
# with:
# images: ${{ inputs.image_name }}
# tags: |
# type=semver,pattern={{version}}
# type=semver,pattern={{major}}.{{minor}}
# type=semver,pattern={{major}}
# type=raw,value=latest,enable=${{ inputs.add_latest }}
#
# # - name: Create and push manifest list
# # working-directory: /tmp/digests
# # run: |
# # DOCKER_IMAGES="$(cat amd64/image.txt) $(cat arm64/image.txt)"
# # TAG_ARGS=$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON")
# # docker buildx imagetools create $DOCKER_IMAGES $TAG_ARGS
#
#
# - name: Create and push manifest list
# working-directory: /tmp/digests
# run: |
# DOCKER_IMAGES="$(cat amd64/image.txt) $(cat arm64/image.txt)"
# TAG_ARGS=$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON")
# echo $TAG_ARGS
# docker buildx imagetools create $TAG_ARGS $DOCKER_IMAGES
- name: Generate semantic tags - name: Generate semantic tags
id: tags id: tags
run: | run: |
VERSION="${{ inputs.version }}" VERSION="${{ inputs.version }}"
IFS='.' read -r MAJOR MINOR PATCH <<< "$VERSION" IFS='.' read -r MAJOR MINOR PATCH <<< "$VERSION"
TAGS="$VERSION" echo "VERSION_TAG=$VERSION" >> $GITHUB_OUTPUT
if [ -n "$MINOR" ]; then if [ -n "$MINOR" ]; then
TAGS="$TAGS $MAJOR.$MINOR" echo "MINOR_TAG=$MAJOR.$MINOR" >> $GITHUB_OUTPUT
fi fi
if [ -n "$MAJOR" ]; then if [ -n "$MAJOR" ]; then
TAGS="$TAGS $MAJOR" echo "MAJOR_TAG=$MAJOR" >> $GITHUB_OUTPUT
fi fi
if [ "${{ inputs.add_latest }}" = "true" ]; then if [ "${{ inputs.add_latest }}" = "true" ]; then
TAGS="$TAGS latest" echo "LATEST_TAG=latest" >> $GITHUB_OUTPUT
fi fi
echo "tags=$TAGS" >> $GITHUB_OUTPUT
- name: Extract Docker metadata - name: Extract Docker metadata
id: meta id: meta
uses: docker/metadata-action@v5 uses: docker/metadata-action@v5
with: with:
images: ${{ inputs.image_name }} images: ${{ inputs.image_name }}
tags: | tags: |
type=raw,value=${{ steps.tags.outputs.tags }} type=raw,value=${{ steps.tags.outputs.VERSION_TAG }}
type=raw,value=${{ steps.tags.outputs.MINOR_TAG }}
type=raw,value=${{ steps.tags.outputs.MAJOR_TAG }}
type=raw,value=${{ steps.tags.outputs.LATEST_TAG }}
- name: Create and push manifest list - name: Create and push manifest list
working-directory: /tmp/digests working-directory: /tmp/digests
+86
View File
@@ -0,0 +1,86 @@
name: End-to-end testing
on:
pull_request:
branches: [main]
permissions:
contents: read
packages: write
jobs:
build-image:
runs-on: ubuntu-latest
if: github.event.pull_request.head.repo.full_name == github.repository
outputs:
image: ${{ steps.image.outputs.ref }}
steps:
- uses: actions/checkout@v6
- name: Compute image reference
id: image
run: echo "ref=ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/portabase:${GITHUB_SHA}" >> "$GITHUB_OUTPUT"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
file: ./docker/dockerfile/Dockerfile
push: true
tags: ${{ steps.image.outputs.ref }}
target: prod
run-e2e:
if: github.event.pull_request.head.repo.full_name == github.repository
needs: build-image
uses: portabase/e2e-tests/.github/workflows/run-e2e.yml@main
with:
server_image: ${{ needs.build-image.outputs.image }}
secrets:
E2E_NOTIFICATION_SMTP_HOST: ${{ secrets.E2E_NOTIFICATION_SMTP_HOST }}
E2E_NOTIFICATION_SMTP_PORT: ${{ secrets.E2E_NOTIFICATION_SMTP_PORT }}
E2E_NOTIFICATION_SMTP_USER: ${{ secrets.E2E_NOTIFICATION_SMTP_USER }}
E2E_NOTIFICATION_SMTP_PASSWORD: ${{ secrets.E2E_NOTIFICATION_SMTP_PASSWORD }}
E2E_NOTIFICATION_SMTP_FROM: ${{ secrets.E2E_NOTIFICATION_SMTP_FROM }}
E2E_NOTIFICATION_SMTP_TO: ${{ secrets.E2E_NOTIFICATION_SMTP_TO }}
E2E_NOTIFICATION_SLACK_WEBHOOK: ${{ secrets.E2E_NOTIFICATION_SLACK_WEBHOOK }}
E2E_NOTIFICATION_DISCORD_WEBHOOK: ${{ secrets.E2E_NOTIFICATION_DISCORD_WEBHOOK }}
E2E_NOTIFICATION_TELEGRAM_BOT_TOKEN: ${{ secrets.E2E_NOTIFICATION_TELEGRAM_BOT_TOKEN }}
E2E_NOTIFICATION_TELEGRAM_CHAT_ID: ${{ secrets.E2E_NOTIFICATION_TELEGRAM_CHAT_ID }}
E2E_NOTIFICATION_TELEGRAM_TOPIC_ID: ${{ secrets.E2E_NOTIFICATION_TELEGRAM_TOPIC_ID }}
E2E_NOTIFICATION_GOTIFY_SERVER_URL: ${{ secrets.E2E_NOTIFICATION_GOTIFY_SERVER_URL }}
E2E_NOTIFICATION_GOTIFY_APP_TOKEN: ${{ secrets.E2E_NOTIFICATION_GOTIFY_APP_TOKEN }}
E2E_NOTIFICATION_NTFY_TOPIC: ${{ secrets.E2E_NOTIFICATION_NTFY_TOPIC }}
E2E_NOTIFICATION_NTFY_SERVER_URL: ${{ secrets.E2E_NOTIFICATION_NTFY_SERVER_URL }}
E2E_NOTIFICATION_NTFY_TOKEN: ${{ secrets.E2E_NOTIFICATION_NTFY_TOKEN }}
E2E_NOTIFICATION_NTFY_USERNAME: ${{ secrets.E2E_NOTIFICATION_NTFY_USERNAME }}
E2E_NOTIFICATION_NTFY_PASSWORD: ${{ secrets.E2E_NOTIFICATION_NTFY_PASSWORD }}
E2E_NOTIFICATION_WEBHOOK_URL: ${{ secrets.E2E_NOTIFICATION_WEBHOOK_URL }}
E2E_NOTIFICATION_WEBHOOK_SECRET_HEADER: ${{ secrets.E2E_NOTIFICATION_WEBHOOK_SECRET_HEADER }}
E2E_NOTIFICATION_WEBHOOK_SECRET: ${{ secrets.E2E_NOTIFICATION_WEBHOOK_SECRET }}
E2E_STORAGE_AWS_S3_ENDPOINT_URL: ${{ secrets.E2E_STORAGE_AWS_S3_ENDPOINT_URL }}
E2E_STORAGE_AWS_S3_REGION: ${{ secrets.E2E_STORAGE_AWS_S3_REGION }}
E2E_STORAGE_AWS_S3_ACCESS_KEY: ${{ secrets.E2E_STORAGE_AWS_S3_ACCESS_KEY }}
E2E_STORAGE_AWS_S3_SECRET_KEY: ${{ secrets.E2E_STORAGE_AWS_S3_SECRET_KEY }}
E2E_STORAGE_AWS_S3_BUCKET_NAME: ${{ secrets.E2E_STORAGE_AWS_S3_BUCKET_NAME }}
E2E_STORAGE_AWS_S3_PORT: ${{ secrets.E2E_STORAGE_AWS_S3_PORT }}
E2E_STORAGE_R2_ENDPOINT_URL: ${{ secrets.E2E_STORAGE_R2_ENDPOINT_URL }}
E2E_STORAGE_R2_REGION: ${{ secrets.E2E_STORAGE_R2_REGION }}
E2E_STORAGE_R2_ACCESS_KEY: ${{ secrets.E2E_STORAGE_R2_ACCESS_KEY }}
E2E_STORAGE_R2_SECRET_KEY: ${{ secrets.E2E_STORAGE_R2_SECRET_KEY }}
E2E_STORAGE_R2_BUCKET_NAME: ${{ secrets.E2E_STORAGE_R2_BUCKET_NAME }}
E2E_STORAGE_R2_PORT: ${{ secrets.E2E_STORAGE_R2_PORT }}
E2E_STORAGE_GOOGLE_DRIVE_CLIENT_ID: ${{ secrets.E2E_STORAGE_GOOGLE_DRIVE_CLIENT_ID }}
E2E_STORAGE_GOOGLE_DRIVE_CLIENT_SECRET: ${{ secrets.E2E_STORAGE_GOOGLE_DRIVE_CLIENT_SECRET }}
E2E_STORAGE_GOOGLE_DRIVE_FOLDER_ID: ${{ secrets.E2E_STORAGE_GOOGLE_DRIVE_FOLDER_ID }}
+37
View File
@@ -0,0 +1,37 @@
name: Publish Helm Chart
on:
workflow_call:
inputs:
version:
required: true
type: string
secrets:
GH_TOKEN:
required: true
jobs:
publish-helm:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Helm
uses: azure/setup-helm@v4
- name: Package Helm chart
run: |
mkdir -p ./helm-packages
helm package helm \
--version ${{ inputs.version }} \
--app-version ${{ inputs.version }} \
--destination ./helm-packages
- name: Authenticate to GitHub Packages
run: |
echo "${{ secrets.GH_TOKEN }}" | helm registry login ghcr.io -u ${{ github.actor }} --password-stdin
- name: Push Helm chart to GitHub Packages (OCI)
run: |
helm push ./helm-packages/portabase-${{ inputs.version }}.tgz oci://ghcr.io/portabase/charts
+36 -22
View File
@@ -1,19 +1,38 @@
name: Auto Release & Publish name: Auto Release & Publish
on: on:
pull_request: pull_request_target:
types: [ closed ] types: [ closed ]
branches: branches:
- main - main
permissions: permissions:
contents: write contents: write
packages: write packages: write
jobs: jobs:
check-skip:
runs-on: ubuntu-latest
outputs:
skip: ${{ steps.set-skip.outputs.skip }}
steps:
- name: Determine if release should be skipped
id: set-skip
run: |
TITLE="${{ github.event.pull_request.title }}"
echo "PR title: $TITLE"
if [[ "$TITLE" == *"[skip-release]"* ]]; then
echo "PR title contains [skip-release], skipping release jobs."
echo "skip=true" >> $GITHUB_OUTPUT
else
echo "skip=false" >> $GITHUB_OUTPUT
fi
create-release: create-release:
if: github.event.pull_request.merged == true needs: check-skip
if: ${{ needs.check-skip.outputs.skip == 'false' && github.event.pull_request.merged == true }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs: outputs:
draft_tag: ${{ steps.release_step.outputs.draft_tag }} draft_tag: ${{ steps.release_step.outputs.draft_tag }}
@@ -44,23 +63,6 @@ jobs:
git config --global user.email 'github-actions[bot]@users.noreply.github.com' git config --global user.email 'github-actions[bot]@users.noreply.github.com'
# - name: Run release-it
# id: release_step
# run: |
# git pull origin main
#
#
# VERSION=$(pnpm exec release-it --ci --release-version)
# echo "version=$VERSION"
# echo "version=$VERSION" >> $GITHUB_OUTPUT
#
# OUTPUT=$(pnpm exec release-it --ci)
# echo "$OUTPUT"
# DRAFT_TAG=$(echo "$OUTPUT" | grep -o 'releases/tag/[^ ]*' | sed 's|releases/tag/||')
# echo "draft_tag=$DRAFT_TAG" >> $GITHUB_OUTPUT
# env:
# GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
- name: Run release-it - name: Run release-it
id: release_step id: release_step
run: | run: |
@@ -80,21 +82,32 @@ jobs:
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
publish-docker: publish-docker:
needs: create-release needs: create-release
if: ${{ needs.create-release.result == 'success' }}
uses: ./.github/workflows/docker.yml uses: ./.github/workflows/docker.yml
with: with:
version: ${{ needs.create-release.outputs.version }} version: ${{ needs.create-release.outputs.version }}
ref: ${{ needs.create-release.outputs.version }}
add_latest: true add_latest: true
secrets: secrets:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME_2 }} DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME_2 }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD_2 }} DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD_2 }}
publish-helm:
needs: create-release
if: ${{ needs.create-release.result == 'success' }}
uses: ./.github/workflows/helm.yml
with:
version: ${{ needs.create-release.outputs.version }}
secrets:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
finalize-release: finalize-release:
needs: needs:
- create-release - create-release
- publish-docker - publish-docker
- publish-helm
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs: outputs:
release_tag: ${{ steps.publish_release_step.outputs.release_tag }} release_tag: ${{ steps.publish_release_step.outputs.release_tag }}
@@ -116,11 +129,12 @@ jobs:
notify-discord: notify-discord:
needs: needs:
- publish-docker - publish-docker
- publish-helm
- create-release - create-release
- finalize-release - finalize-release
uses: ./.github/workflows/discord.yml uses: ./.github/workflows/discord.yml
with: with:
release_tag: ${{ needs.create-release.outputs.draft_tag }} release_tag: ${{ needs.create-release.outputs.version }}
discord_title: "New Release" discord_title: "New Release"
discord_color: 3066993 discord_color: 3066993
discord_footer: "Portabase" discord_footer: "Portabase"
+1 -2
View File
@@ -9,14 +9,13 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: aquasecurity/trivy-action@0.20.0 - uses: aquasecurity/trivy-action@v0.35.0
with: with:
scan-type: 'fs' scan-type: 'fs'
format: 'table' format: 'table'
severity: 'CRITICAL,HIGH' severity: 'CRITICAL,HIGH'
ignore-unfixed: true ignore-unfixed: true
secrets-gitleaks: secrets-gitleaks:
if: github.event.pull_request.head.repo.fork == false if: github.event.pull_request.head.repo.fork == false
runs-on: ubuntu-latest runs-on: ubuntu-latest
+18
View File
@@ -9,6 +9,8 @@
# testing # testing
/coverage /coverage
gcs-data/
gcs-storage/
# next.js # next.js
/.next/ /.next/
@@ -28,6 +30,8 @@ yarn-error.log*
# local env files # local env files
.env*.local .env*.local
.env
**/.env
# vercel # vercel
.vercel .vercel
@@ -47,3 +51,17 @@ private/keys/*
seeds/pocket-id/*.db seeds/pocket-id/*.db
certificates certificates
# Playwright
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
/playwright/.auth/
/e2e/local-storage.json
.claude
.codex
/docs
.worktrees
.superpowers
+1 -2
View File
@@ -2,8 +2,7 @@
"github": { "github": {
"release": true, "release": true,
"draft": true, "draft": true,
"tokenRef": "GITHUB_TOKEN", "tokenRef": "GITHUB_TOKEN"
"update": true
}, },
"git": { "git": {
"commit": true, "commit": true,
+3 -1
View File
@@ -21,6 +21,8 @@ keywords:
- backups - backups
- postgresql - postgresql
- mysql - mysql
- firebird
- mssql
- mariadb - mariadb
- devops - devops
- database - database
@@ -31,5 +33,5 @@ keywords:
- web-ui - web-ui
- agent - agent
license: Apache-2.0 license: Apache-2.0
version: 1.4.55 version: 1.20.6
date-released: '2026-03-02' date-released: '2026-03-02'
+9 -1
View File
@@ -16,7 +16,7 @@ seed-pocket:
@docker compose -f docker-compose.func.yml stop pocket-id >/dev/null 2>&1 || true @docker compose -f docker-compose.func.yml stop pocket-id >/dev/null 2>&1 || true
@docker compose -f docker-compose.func.yml rm -f -s pocket-id >/dev/null 2>&1 || true @docker compose -f docker-compose.func.yml rm -f -s pocket-id >/dev/null 2>&1 || true
@docker volume rm portabase-dev-func_pocket-id-data >/dev/null 2>&1 || true @docker volume rm portabase-dev-func_pocket-id-data >/dev/null 2>&1 || true
@docker compose -f docker-compose.func.yml run --rm -v ./seeds/pocket-id/portabase.zip:/tmp/portabase.zip pocket-id ./pocket-id import --yes --path /tmp/portabase.zip >/dev/null @docker compose -f docker-compose.func.yml run --rm -v $$(pwd)/seeds/pocket-id/portabase.zip:/tmp/portabase.zip pocket-id ./pocket-id import --yes --path /tmp/portabase.zip >/dev/null
@docker compose -f docker-compose.func.yml up -d pocket-id @docker compose -f docker-compose.func.yml up -d pocket-id
@sleep 2 @sleep 2
@docker compose -f docker-compose.func.yml exec pocket-id ./pocket-id one-time-access-token admin @docker compose -f docker-compose.func.yml exec pocket-id ./pocket-id one-time-access-token admin
@@ -46,3 +46,11 @@ export-keycloak:
@docker rm -f kc-exporter >/dev/null 2>&1 @docker rm -f kc-exporter >/dev/null 2>&1
@docker compose -f docker-compose.func.yml start keycloak >/dev/null 2>&1 @docker compose -f docker-compose.func.yml start keycloak >/dev/null 2>&1
@echo "Keycloak configuration and users exported to seeds/keycloak/*.json" @echo "Keycloak configuration and users exported to seeds/keycloak/*.json"
seed-blob:
@chmod +x seeds/azurite/azurite-seed.sh
@bash seeds/azurite/azurite-seed.sh
list-blob:
@chmod +x seeds/azurite/azurite-list.sh
@bash seeds/azurite/azurite-list.sh
+28 -3
View File
@@ -12,7 +12,8 @@
[![License: Apache](https://img.shields.io/badge/License-apache-yellow.svg)](LICENSE) [![License: Apache](https://img.shields.io/badge/License-apache-yellow.svg)](LICENSE)
[![Docker Pulls](https://img.shields.io/docker/pulls/solucetechnologies/portabase?color=brightgreen)](https://hub.docker.com/r/solucetechnologies/portabase) [![Docker Pulls](https://img.shields.io/docker/pulls/portabase/portabase?color=brightgreen)](https://hub.docker.com/r/portabase/portabase)
[![Helm Chart](https://img.shields.io/badge/Helm-Kubernetes-326ce5?logo=helm&logoColor=white)](https://github.com/Portabase/portabase/pkgs/container/charts%2Fportabase)
[![Platform](https://img.shields.io/badge/platform-linux%20%7C%20macos%20%7C%20windows-lightgrey)](https://github.com/Portabase/portabase) [![Platform](https://img.shields.io/badge/platform-linux%20%7C%20macos%20%7C%20windows-lightgrey)](https://github.com/Portabase/portabase)
[![Support Portabase](https://img.shields.io/badge/Support-Portabase-orange)](https://www.buymeacoffee.com/portabase) [![Support Portabase](https://img.shields.io/badge/Support-Portabase-orange)](https://www.buymeacoffee.com/portabase)
@@ -20,7 +21,12 @@
[![MySQL](https://img.shields.io/badge/MySQL-4479A1?logo=mysql&logoColor=white)](https://www.mysql.com/) [![MySQL](https://img.shields.io/badge/MySQL-4479A1?logo=mysql&logoColor=white)](https://www.mysql.com/)
[![MariaDB](https://img.shields.io/badge/MariaDB-003545?logo=mariadb&logoColor=white)](https://mariadb.org/) [![MariaDB](https://img.shields.io/badge/MariaDB-003545?logo=mariadb&logoColor=white)](https://mariadb.org/)
[![SQLite](https://img.shields.io/badge/-SQLite-blue?logo=sqlite&logoColor=white)](https://sqlite.org/) [![SQLite](https://img.shields.io/badge/-SQLite-blue?logo=sqlite&logoColor=white)](https://sqlite.org/)
[![Redis](https://img.shields.io/badge/Redis-DC382D?style=flat&logo=Redis&logoColor=white)](https://redis.io/)
[![MongoDB](https://img.shields.io/badge/-MongoDB-13aa52?logo=mongodb&logoColor=white)](https://www.mongodb.com/) [![MongoDB](https://img.shields.io/badge/-MongoDB-13aa52?logo=mongodb&logoColor=white)](https://www.mongodb.com/)
[![Valkey](https://img.shields.io/badge/Valkey-6284fc?style=flat&logo=Valkey&logoColor=white)](https://valkey.io/)
[![Firebird](https://img.shields.io/badge/Firebird-f55b14?style=flat&logo=Firebird&logoColor=white)](https://firebirdsql.org/)
[![Microsoft SQL Server](https://img.shields.io/badge/Microsoft%20SQL%20Server-CC2927?style=flat&logo=microsoftsqlserver&logoColor=white)](https://www.microsoft.com/en-us/sql-server)
[![Self Hosted](https://img.shields.io/badge/self--hosted-yes-brightgreen)](https://github.com/Portabase/portabase) [![Self Hosted](https://img.shields.io/badge/self--hosted-yes-brightgreen)](https://github.com/Portabase/portabase)
[![Open Source](https://img.shields.io/badge/open%20source-❤️-red)](https://github.com/Portabase/portabase) [![Open Source](https://img.shields.io/badge/open%20source-❤️-red)](https://github.com/Portabase/portabase)
@@ -48,16 +54,33 @@
## Installation ## 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) - Automated CLI (recommended) - [details](https://portabase.io/docs/dashboard/setup#cli)
- Docker Run - [details](https://portabase.io/docs/dashboard/setup#docker) - Docker Run - [details](https://portabase.io/docs/dashboard/setup#docker)
- Docker Compose setup - [details](https://portabase.io/docs/dashboard/setup#docker-compose) - Docker Compose setup - [details](https://portabase.io/docs/dashboard/setup#docker-compose)
- Kubernetes with Helm (soon) - 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) - Development setup - [details](https://portabase.io/docs/dashboard/setup#development)
**Ensure Docker is installed on your machine before getting started.** **Ensure Docker is installed on your machine before getting started.**
## Supported databases
| Engine | Support | Supported Versions | Restore |
|:-------------------|:----------|:------------------------------|:--------|
| **PostgreSQL** | ✅ Stable | 12, 13, 14, 15, 16, 17 and 18 | Yes |
| **MySQL** | ✅ Stable | 5.7, 8 and 9 | Yes |
| **MariaDB** | ✅ Stable | 10 and 11 | Yes |
| **MongoDB** | ✅ Stable | 4, 5, 6, 7 and 8 | Yes |
| **SQLite** | ✅ Stable | 3.x | Yes |
| **Redis** | ✅ Stable | 2.8+ | No |
| **Valkey** | ✅ Stable | 7.2+ | No |
| **Firebird** | ✅ Stable | 3.0, 4.0, 5.0 | Yes |
| **MSSQL Server** | ✅ Stable | 2017, 2019, 2022, Azure SQL | Yes |
See the [Database Servers documentation](https://portabase.io/docs/agent/db) for version-specific backup and restore details.
## Contributors ## Contributors
[![Contributors](https://contrib.rocks/image?repo=Portabase/portabase)](https://github.com/Portabase/portabase/graphs/contributors) [![Contributors](https://contrib.rocks/image?repo=Portabase/portabase)](https://github.com/Portabase/portabase/graphs/contributors)
@@ -88,3 +111,5 @@ Distributed under the Apache License. See `LICENSE.txt` for more details.
[ShadcnUI-url]: https://ui.shadcn.com/ [ShadcnUI-url]: https://ui.shadcn.com/
[Docker-url]: https://www.docker.com/ [Docker-url]: https://www.docker.com/
+2 -2
View File
@@ -1,9 +1,9 @@
import { CardContent, CardHeader } from "@/components/ui/card"; import { CardContent, CardHeader } from "@/components/ui/card";
import { TooltipProvider } from "@/components/ui/tooltip"; import { TooltipProvider } from "@/components/ui/tooltip";
import { ForgotPasswordForm } from "@/components/wrappers/auth/login/forgot-password-form/forgot-password-form"; import { ForgotPasswordForm } from "@/features/auth/components/forgot-password-form";
import { env } from "@/env.mjs"; import { env } from "@/env.mjs";
import { CardAuth } from "@/features/layout/card-auth"; import { CardAuth } from "@/features/layout/components/card-auth";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
export default async function RoutePage(props: { searchParams: Promise<{ callbackUrl: string | undefined }> }) { 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 {CardContent, CardHeader} from "@/components/ui/card";
import {TooltipProvider} from "@/components/ui/tooltip"; import {TooltipProvider} from "@/components/ui/tooltip";
import {GuardForm} from "@/components/wrappers/auth/guard/guard-form"; import {GuardForm} from "@/features/auth/components/guard-form";
import {cookies} from "next/headers"; import {cookies} from "next/headers";
import {redirect} from "next/navigation"; 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() { export default async function GuardPage() {
+7 -4
View File
@@ -1,16 +1,20 @@
import React, { Suspense } from "react"; import React from "react";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { currentUser } from "@/lib/auth/current-user"; import { currentUser } from "@/lib/auth/current-user";
import { AuthLogoSection } from "@/components/wrappers/auth/auth-logo-section"; import { isOnboardingDone } from "@/db/services/setting";
import { AuthLogoSection } from "@/features/auth/components/auth-logo-section";
import { env } from "@/env.mjs"; import { env } from "@/env.mjs";
import { Heart } from "lucide-react"; import { Heart } from "lucide-react";
import { ErrorLayout } from "@/components/wrappers/common/error-layout";
export default async function Layout({ export default async function Layout({
children, children,
}: { }: {
children: React.ReactNode; children: React.ReactNode;
}) { }) {
if (env.SKIP_ONBOARDING !== "true" && !(await isOnboardingDone())) {
redirect("/welcome");
}
const user = await currentUser(); const user = await currentUser();
if (user && !user.banned && user.role !== "pending") { if (user && !user.banned && user.role !== "pending") {
@@ -31,7 +35,6 @@ export default async function Layout({
<Heart className="size-3 fill-red-500 text-red-500 animate-pulse" />{" "} <Heart className="size-3 fill-red-500 text-red-500 animate-pulse" />{" "}
by <span className="font-medium text-foreground">Portabase</span> by <span className="font-medium text-foreground">Portabase</span>
</p> </p>
<p>v{env.NEXT_PUBLIC_PROJECT_VERSION}</p>
</footer> </footer>
</div> </div>
); );
+3 -4
View File
@@ -1,14 +1,13 @@
import { LoginForm } from "@/components/wrappers/auth/login/login-form/login-form"; import { LoginForm } from "@/features/auth/components/login-form";
import { Metadata } from "next"; import { Metadata } from "next";
import { SUPPORTED_PROVIDERS } from "@/lib/auth/config"; import { SUPPORTED_PROVIDERS } from "@/lib/auth/config";
import { SocialAuthButtons } from "@/components/wrappers/auth/social-buttons"; import { SocialAuthButtons } from "@/features/auth/components/social-buttons";
import { TooltipProvider } from "@/components/ui/tooltip"; import { TooltipProvider } from "@/components/ui/tooltip";
import { CardContent, CardHeader } from "@/components/ui/card"; import { CardContent, CardHeader } from "@/components/ui/card";
import Link from "next/link"; import Link from "next/link";
import { Separator } from "@/components/ui/separator"; 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"; import { env } from "@/env.mjs";
import { en } from "zod/v4/locales";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Login", title: "Login",
+1 -1
View File
@@ -1,5 +1,5 @@
import { PageParams } from "@/types/next"; import { PageParams } from "@/types/next";
import { RegisterForm } from "@/components/wrappers/auth/register/register-form/register-form"; import { RegisterForm } from "@/features/auth/components/register-form";
import { Metadata } from "next"; import { Metadata } from "next";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { env } from "@/env.mjs"; import { env } from "@/env.mjs";
+2 -2
View File
@@ -1,10 +1,10 @@
import { CardContent, CardHeader } from "@/components/ui/card"; import { CardContent, CardHeader } from "@/components/ui/card";
import { TooltipProvider } from "@/components/ui/tooltip"; import { TooltipProvider } from "@/components/ui/tooltip";
import { ResetPasswordForm } from "@/components/wrappers/auth/login/reset-password-form/reset-password-form"; import { ResetPasswordForm } from "@/features/auth/components/reset-password-form";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { auth } from "@/lib/auth/auth"; import { auth } from "@/lib/auth/auth";
import { Avatar, AvatarImage, AvatarFallback } from "@radix-ui/react-avatar"; 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"; import { env } from "@/env.mjs";
export default async function RoutePage(props: { searchParams: Promise<{ token: string | undefined }> }) { 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 {db} from "@/db";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {PageParams} from "@/types/next"; import {PageParams} from "@/types/next";
import {Page} from "@/features/layout/page"; import {Page} from "@/features/layout/components/page";
import {OrganizationManagement} from "@/components/wrappers/dashboard/admin/organizations/organization/organization-management"; import {OrganizationManagement} from "@/features/organizations/components/admin-organization-management";
import {buildOrganizationWithMembers} from "@/utils/common"; import {buildOrganizationWithMembers} from "@/utils/common";
import {isUUID} from "@/utils/text"; import {isUUID} from "@/utils/text";
import {user} from "@/db/schema/02_user"; import {user} from "@/db/schema/02_user";
@@ -1,10 +1,10 @@
import {PageParams} from "@/types/next"; 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 {db} from "@/db";
import { import {
AdminOrganizationAddModal AdminOrganizationAddModal
} from "@/components/wrappers/dashboard/admin/organizations/organization/admin-organization-add-modal"; } from "@/features/organizations/components/admin-organization-add-modal";
import {AdminOrganizationList} from "@/components/wrappers/dashboard/admin/organizations/organization/admin-orgnization-list"; import {AdminOrganizationList} from "@/features/organizations/components/admin-organization-list";
import {isNull} from "drizzle-orm"; import {isNull} from "drizzle-orm";
export default async function RoutePage(props: PageParams<{}>) { export default async function RoutePage(props: PageParams<{}>) {
@@ -1,11 +1,15 @@
import {PageParams} from "@/types/next"; 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 {db} from "@/db";
import {logger} from "@/lib/logger";
import {notFound} from "next/navigation"; import {notFound} from "next/navigation";
import {SettingsTabs} from "@/components/wrappers/dashboard/admin/settings/settings-tabs"; import {SettingsTabs} from "@/features/settings/components/settings-tabs";
import {desc, isNull} from "drizzle-orm"; import {desc, isNull} from "drizzle-orm";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {StorageChannelWith} from "@/db/schema/12_storage-channel"; import {StorageChannelWith} from "@/db/schema/12_storage-channel";
import {NotificationChannelWith} from "@/db/schema/09_notification-channel";
const log = logger.child({module: "dashboard/admin/settings"});
export default async function RoutePage(props: PageParams<{}>) { export default async function RoutePage(props: PageParams<{}>) {
@@ -13,6 +17,8 @@ export default async function RoutePage(props: PageParams<{}>) {
where: (fields, {eq}) => eq(fields.name, "system"), where: (fields, {eq}) => eq(fields.name, "system"),
}); });
log.debug({settings}, "Settings loaded");
const storageChannels = await db.query.storageChannel.findMany({ const storageChannels = await db.query.storageChannel.findMany({
with: { with: {
organizations: true organizations: true
@@ -21,7 +27,16 @@ export default async function RoutePage(props: PageParams<{}>) {
orderBy: desc(drizzleDb.schemas.storageChannel.createdAt) orderBy: desc(drizzleDb.schemas.storageChannel.createdAt)
}) as StorageChannelWith[] }) as StorageChannelWith[]
if (!settings || !storageChannels ) { const notificationChannels = await db.query.notificationChannel.findMany({
with: {
organizations: true
},
where: isNull(drizzleDb.schemas.notificationChannel.organizationId),
orderBy: desc(drizzleDb.schemas.notificationChannel.createdAt)
}) as NotificationChannelWith[]
if (!settings || !storageChannels || !notificationChannels) {
notFound() notFound()
} }
@@ -33,7 +48,8 @@ export default async function RoutePage(props: PageParams<{}>) {
</div> </div>
</PageHeader> </PageHeader>
<PageContent className="flex flex-col gap-5"> <PageContent className="flex flex-col gap-5">
<SettingsTabs storageChannels={storageChannels} settings={settings} /> <SettingsTabs storageChannels={storageChannels} notificationChannels={notificationChannels}
settings={settings}/>
</PageContent> </PageContent>
</Page> </Page>
); );
@@ -1,21 +1,28 @@
import {PageParams} from "@/types/next"; 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 {db} from "@/db";
import {desc, isNull} from "drizzle-orm"; import {desc, isNull} from "drizzle-orm";
import {AdminUserList} from "@/components/wrappers/dashboard/admin/users/admin-user-list"; import {AdminUserList} from "@/features/users/components/admin-user-list";
import {AdminUserAddModal} from "@/components/wrappers/dashboard/admin/users/admin-user-add-modal"; import {AdminUserAddModal} from "@/features/users/components/admin-user-add-modal";
import {SUPPORTED_PROVIDERS} from "@/lib/auth/config"; 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<{}>) { export default async function RoutePage(props: PageParams<{}>) {
const users = await db.query.user.findMany({ const [settings, users] = await Promise.all([
where: (fields) => isNull(fields.deletedAt), getSettings(),
with: { db.query.user.findMany({
accounts: true where: (fields) => isNull(fields.deletedAt),
}, with: { accounts: true },
orderBy: (fields) => desc(fields.createdAt), orderBy: (fields) => desc(fields.createdAt),
}),
]);
const avatarUrls = Object.fromEntries(
users.map((u) => [u.id, resolveAvatarUrl(u, settings)])
);
});
const organizations = await db.query.organization.findMany({ const organizations = await db.query.organization.findMany({
with: { with: {
members: true, members: true,
@@ -30,13 +37,15 @@ export default async function RoutePage(props: PageParams<{}>) {
<PageHeader className="flex flex-col"> <PageHeader className="flex flex-col">
<div className="flex justify-between"> <div className="flex justify-between">
<PageTitle className="mb-3">Active users</PageTitle> <PageTitle className="mb-3">Active users</PageTitle>
<PageActions> {isPasswordAuthEnabled && (
<AdminUserAddModal organizations={organizations}/> <PageActions>
</PageActions> <AdminUserAddModal organizations={organizations}/>
</PageActions>
)}
</div> </div>
</PageHeader> </PageHeader>
<PageContent className="flex flex-col gap-5"> <PageContent className="flex flex-col gap-5">
<AdminUserList users={users} isPasswordAuthEnabled={isPasswordAuthEnabled}/> <AdminUserList users={users} isPasswordAuthEnabled={isPasswordAuthEnabled} avatarUrls={avatarUrls}/>
</PageContent> </PageContent>
</Page> </Page>
); );
@@ -4,18 +4,18 @@ import {
PageContent, PageContent,
PageDescription, PageDescription,
PageTitle, PageTitle,
} from "@/features/layout/page"; } from "@/features/layout/components/page";
import { db } from "@/db"; import { db } from "@/db";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import { eq } from "drizzle-orm"; import {eq, isNull} from "drizzle-orm";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { ButtonDeleteAgent } from "@/components/wrappers/dashboard/agent/button-delete-agent/button-delete-agent"; import { ButtonDeleteAgent } from "@/features/agents/components/agent-delete-button";
import { capitalizeFirstLetter } from "@/utils/text"; import { capitalizeFirstLetter } from "@/utils/text";
import { generateEdgeKey } from "@/utils/edge_key"; import { generateEdgeKey } from "@/utils/edge_key";
import { getServerUrl } from "@/utils/get-server-url"; import { getServerUrl } from "@/utils/get-server-url";
import { AgentContentPage } from "@/components/wrappers/dashboard/agent/agent-content"; import { AgentContentPage } from "@/features/agents/components/agent-content";
import { AgentDialog } from "@/features/agents/components/agent.dialog"; import { AgentDialog } from "@/features/agents/components/agent-dialog";
import { AgentType } from "@/features/agents/agents.schema";
export default async function RoutePage( export default async function RoutePage(
props: PageParams<{ agentId: string }>, props: PageParams<{ agentId: string }>,
@@ -26,13 +26,30 @@ export default async function RoutePage(
where: eq(drizzleDb.schemas.agent.id, agentId), where: eq(drizzleDb.schemas.agent.id, agentId),
with: { with: {
databases: true, databases: true,
organizations: true,
}, },
}); });
const organizations = await db.query.organization.findMany({
where: (fields) => isNull(fields.deletedAt),
with: {
members: true,
},
});
if (!agent) { if (!agent) {
notFound(); notFound();
} }
const isOwnerByAnOrganization = agent.organizationId
if (isOwnerByAnOrganization){
notFound();
}
const organizationIds = agent.organizations.map(org => org.organizationId)
const edgeKey = await generateEdgeKey(getServerUrl(), agent.id); const edgeKey = await generateEdgeKey(getServerUrl(), agent.id);
return ( return (
@@ -45,12 +62,14 @@ export default async function RoutePage(
<div className="flex items-center gap-2 md:justify-between w-full "> <div className="flex items-center gap-2 md:justify-between w-full ">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<AgentDialog <AgentDialog
agent={agent as AgentType & { id: string }} agent={agent}
typeTrigger={"edit"} typeTrigger={"edit"}
adminView={true}
organizations={organizations}
/> />
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ButtonDeleteAgent agentId={agentId} text={"Delete Agent"} /> <ButtonDeleteAgent organizationIds={organizationIds} agentId={agentId} text={"Delete Agent"} />
</div> </div>
</div> </div>
</PageTitle> </PageTitle>
@@ -1,13 +1,13 @@
import {PageParams} from "@/types/next"; import {PageParams} from "@/types/next";
import {AgentCard} from "@/components/wrappers/dashboard/agent/agent-card/agent-card"; import {AgentCard} from "@/features/agents/components/agent-card";
import {CardsWithPagination} from "@/components/wrappers/common/cards-with-pagination"; 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 {notFound} from "next/navigation";
import {db} from "@/db"; import {db} from "@/db";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {desc, eq, not} from "drizzle-orm"; import {and, desc, eq, isNull, not} from "drizzle-orm";
import {Metadata} from "next"; import {Metadata} from "next";
import {AgentDialog} from "@/features/agents/components/agent.dialog"; import {AgentDialog} from "@/features/agents/components/agent-dialog";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Agents", title: "Agents",
@@ -16,13 +16,13 @@ export const metadata: Metadata = {
export default async function RoutePage(props: PageParams<{}>) { export default async function RoutePage(props: PageParams<{}>) {
const agents = await db.query.agent.findMany({ const agents = await db.query.agent.findMany({
where: not(eq(drizzleDb.schemas.agent.isArchived, true)), where: and(not(eq(drizzleDb.schemas.agent.isArchived, true)),isNull(drizzleDb.schemas.agent.organizationId)),
with: { with: {
databases: true databases: true
}, },
orderBy: (fields) => desc(fields.lastContact), orderBy: (fields) => desc(fields.lastContact),
}); });
if (!agents) { if (!agents) {
notFound(); notFound();
} }
@@ -1,11 +1,11 @@
import {PageParams} from "@/types/next"; 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 {Metadata} from "next";
import {db} from "@/db"; import {db} from "@/db";
import {notificationChannel, NotificationChannelWith} from "@/db/schema/09_notification-channel"; import {notificationChannel, NotificationChannelWith} from "@/db/schema/09_notification-channel";
import {desc, isNull} from "drizzle-orm"; import {desc, isNull} from "drizzle-orm";
import {ChannelsSection} from "@/components/wrappers/dashboard/admin/channels/channels-section"; import {ChannelsSection} from "@/features/channel/components/channels-section";
import {ChannelAddEditModal} from "@/components/wrappers/dashboard/admin/channels/channel/channel-add-edit-modal"; import {ChannelAddEditModal} from "@/features/channel/components/channel-add-edit-modal";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
export const metadata: Metadata = { export const metadata: Metadata = {
@@ -1,7 +1,7 @@
import {PageParams} from "@/types/next"; 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 {Metadata} from "next";
import {NotificationLogsList} from "@/components/wrappers/dashboard/admin/notifications/logs/notification-logs-list"; import {NotificationLogsList} from "@/features/notifications/components/notification-logs-list";
import {notFound} from "next/navigation"; import {notFound} from "next/navigation";
import {getNotificationHistory} from "@/db/services/notification-log"; import {getNotificationHistory} from "@/db/services/notification-log";
@@ -1,12 +1,12 @@
import {PageParams} from "@/types/next"; 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 {Metadata} from "next";
import {ChannelsSection} from "@/components/wrappers/dashboard/admin/channels/channels-section"; import {ChannelsSection} from "@/features/channel/components/channels-section";
import {db} from "@/db"; import {db} from "@/db";
import {desc, eq, isNull} from "drizzle-orm"; import {desc, eq, isNull} from "drizzle-orm";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {StorageChannelWith} from "@/db/schema/12_storage-channel"; import {StorageChannelWith} from "@/db/schema/12_storage-channel";
import {ChannelAddEditModal} from "@/components/wrappers/dashboard/admin/channels/channel/channel-add-edit-modal"; import {ChannelAddEditModal} from "@/features/channel/components/channel-add-edit-modal";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Storage Channels", title: "Storage Channels",
@@ -0,0 +1,65 @@
import {PageParams} from "@/types/next";
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/components/page";
import {notFound} from "next/navigation";
import {getOrganization} from "@/lib/auth/auth";
import {Metadata} from "next";
import {db} from "@/db";
import {MigrationTool} from "@/features/migration/components/migration-tool";
export const metadata: Metadata = {
title: "Projects",
};
export default async function RoutePage(props: PageParams<{}>) {
const organization = await getOrganization({});
if (!organization) {
notFound();
}
const projects = await db.query.project.findMany({
where: (project, {eq, and, not}) =>
and(
eq(project.organizationId, organization.id),
not(eq(project.isArchived, true))
),
with: {
organization: true,
databases: {
where: (database, { isNull, not, inArray, and }) =>
and(
isNull(database.deletedAt),
not(inArray(database.dbms, ["valkey", "redis"]))
),
with: {
backups: {
where: (backup, { isNull, eq, and }) =>
and(
isNull(backup.deletedAt),
eq(backup.status, "success")
),
orderBy: (backup, {desc}) => [desc(backup.createdAt)],
limit: 15,
}
}
},
},
});
return (
<Page>
<PageHeader className="flex flex-col items-start justify-between mb-6">
<PageTitle className="mb-2">
Database Migration
</PageTitle>
<p className="text-sm text-muted-foreground">
Import backups from a source project into your target database
</p>
</PageHeader>
<PageContent>
<MigrationTool projects={projects}/>
</PageContent>
</Page>
);
}
@@ -1,111 +1,137 @@
import {PageParams} from "@/types/next"; import { PageParams } from "@/types/next";
import {notFound, redirect} from "next/navigation"; import { notFound, redirect } from "next/navigation";
import {Page} from "@/features/layout/page"; import { Page } from "@/features/layout/components/page";
import {db} from "@/db"; import { db } from "@/db";
import {eq, and, inArray} from "drizzle-orm"; import { eq, and, inArray } from "drizzle-orm";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {getOrganizationProjectDatabases} from "@/lib/services"; import { getOrganizationProjectDatabases } from "@/db/services/project";
import {getActiveMember, getOrganization} from "@/lib/auth/auth"; import { getActiveMember, getOrganization } from "@/lib/auth/auth";
import {BackupModalProvider} from "@/components/wrappers/dashboard/database/backup/backup-modal-context"; import { BackupModalProvider } from "@/features/database/components/backup-modal-context";
import {DatabaseContent} from "@/components/wrappers/dashboard/projects/database/database-content"; 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; projectId: string;
databaseId: string databaseId: string;
}>) { }>,
const {projectId, databaseId} = await props.params; ) {
const { projectId, databaseId } = await props.params;
const organization = await getOrganization({}); const organization = await getOrganization({});
const activeMember = await getActiveMember() const activeMember = await getActiveMember();
if (!organization || !activeMember) { if (!organization || !activeMember) {
notFound(); notFound();
} }
const databasesProject = await getOrganizationProjectDatabases({ const databasesProject = await getOrganizationProjectDatabases({
organizationSlug: organization.slug, organizationSlug: organization.slug,
projectId: projectId projectId: projectId,
}) });
const dbItem = await db.query.database.findFirst({ 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)), 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: { with: {
project: true, storageChannel: true,
retentionPolicy: true,
alertPolicies: true,
storagePolicies: true
}
});
if (!dbItem) {
redirect("/dashboard/projects");
}
const backups = await db.query.backup.findMany({
where: eq(drizzleDb.schemas.backup.databaseId, dbItem.id),
with: {
restorations: true,
storages: {
with: {
storageChannel: true
}
}
}, },
orderBy: (b, {desc}) => [desc(b.createdAt)], },
}); logs: true,
},
orderBy: (b, { desc }) => [desc(b.createdAt)],
});
const restorations = await db.query.restoration.findMany({ const restorations = await db.query.restoration.findMany({
where: eq(drizzleDb.schemas.restoration.databaseId, dbItem.id), where: eq(drizzleDb.schemas.restoration.databaseId, dbItem.id),
orderBy: (r, {desc}) => [desc(r.createdAt)], with: {
}); logs: true,
},
orderBy: (r, { desc }) => [desc(r.createdAt)],
});
const isAlreadyBackup = backups.some((b) => b.status === "waiting" || b.status === "ongoing"); //const isAlreadyBackup = backups.some((b) => b.status === "waiting" || b.status === "ongoing");
const isAlreadyRestore = restorations.some((r) => r.status === "waiting"); const isAlreadyRestore = restorations.some((r) => r.status === "waiting");
const totalBackups = await db.select({count: drizzleDb.schemas.backup.id}) const totalBackups = await db
.from(drizzleDb.schemas.backup) .select({ count: drizzleDb.schemas.backup.id })
.where(eq(drizzleDb.schemas.backup.databaseId, dbItem.id)) .from(drizzleDb.schemas.backup)
.then(rows => rows.length); .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}) const successfulBackups = await db
.from(drizzleDb.schemas.backup) .select({ count: drizzleDb.schemas.backup.id })
.where(and( .from(drizzleDb.schemas.backup)
eq(drizzleDb.schemas.backup.databaseId, dbItem.id), .where(
eq(drizzleDb.schemas.backup.status, "success") and(
)) eq(drizzleDb.schemas.backup.databaseId, dbItem.id),
.then(rows => rows.length); 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); const databaseHealthLogs = dbItem
if (!settings) { ? await getHealthLast12hLogs({ id: dbItem.id })
notFound(); : [];
}
const successRate =
totalBackups > 0 ? (successfulBackups / totalBackups) * 100 : null;
const successRate = totalBackups > 0 ? (successfulBackups / totalBackups) * 100 : null; //const isMember = activeMember?.role === "member";
const isMember = activeMember?.role === "member"; return (
<Page>
return ( <LogsModalProvider>
<Page> <BackupModalProvider>
<BackupModalProvider> <DatabaseContent
<DatabaseContent activeMember={activeMember}
activeMember={activeMember} settings={settings}
settings={settings} database={dbItem}
database={dbItem} databaseHealthLogs={databaseHealthLogs}
isAlreadyRestore={isAlreadyRestore} isAlreadyRestore={isAlreadyRestore}
restorations={restorations} restorations={restorations}
backups={backups} backups={backups}
totalBackups={totalBackups} totalBackups={totalBackups}
availableBackups={availableBackups} availableBackups={availableBackups}
successRate={successRate} successRate={successRate}
organizationId={organization.id} organizationId={organization.id}
activeOrganizationChannels={[]} activeOrganizationChannels={[]}
activeOrganizationStorageChannels={[]} activeOrganizationStorageChannels={[]}
/> />
</BackupModalProvider> </BackupModalProvider>
</Page> </LogsModalProvider>
); </Page>
} );
}
@@ -1,118 +1,115 @@
import {PageParams} from "@/types/next"; import { PageParams } from "@/types/next";
import {Page, PageContent, PageTitle} from "@/features/layout/page"; import { Page, PageContent, PageTitle } from "@/features/layout/components/page";
import { import { ButtonDeleteProject } from "@/features/projects/components/project-delete-button";
ButtonDeleteProject import { CardsWithPagination } from "@/components/common/cards-with-pagination";
} from "@/components/wrappers/dashboard/projects/button-delete-project/button-delete-project"; import { ProjectDatabaseCard } from "@/features/projects/components/project-database-card";
import {CardsWithPagination} from "@/components/wrappers/common/cards-with-pagination"; import { notFound, redirect } from "next/navigation";
import {ProjectDatabaseCard} from "@/components/wrappers/dashboard/projects/project-card/project-database-card"; import { db } from "@/db";
import {notFound, redirect} from "next/navigation"; import { eq } from "drizzle-orm";
import { getActiveMember, getOrganization } from "@/lib/auth/auth";
import {db} from "@/db";
import {eq} from "drizzle-orm";
import {getActiveMember, getOrganization} from "@/lib/auth/auth";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {capitalizeFirstLetter} from "@/utils/text"; import { capitalizeFirstLetter, isUUID } from "@/utils/text";
import {ProjectDialog} from "@/features/projects/components/project.dialog"; import { ProjectDialog } from "@/features/projects/components/project-dialog";
import {DatabaseWith} from "@/db/schema/07_database"; import { ProjectWith } from "@/db/schema/06_project";
import {ProjectWith} from "@/db/schema/06_project"; import { getOrganizationAvailableDatabases } from "@/db/services/database";
import {isUuidv4} from "@/utils/verify-uuid";
export default async function RoutePage(
props: PageParams<{
projectId: string;
}>,
) {
const { projectId } = await props.params;
export default async function RoutePage(props: PageParams<{ if (!isUUID(projectId)) {
projectId: string notFound();
}>) { }
const {
projectId
} = await props.params;
if (!isUuidv4(projectId)) { const organization = await getOrganization({});
notFound() const activeMember = await getActiveMember();
}
const organization = await getOrganization({}); if (!organization) {
const activeMember = await getActiveMember() notFound();
}
const org = await db.query.organization.findFirst({
where: eq(drizzleDb.schemas.organization.slug, organization.slug),
});
if (!organization) { if (!org) notFound();
notFound();
}
const org = await db.query.organization.findFirst({
where: eq(drizzleDb.schemas.organization.slug, organization.slug),
});
if (!org) notFound(); const proj = await db.query.project.findFirst({
where: (proj, { and, eq, not }) =>
and(
eq(proj.id, projectId),
eq(proj.organizationId, org.id),
not(eq(proj.isArchived, true)),
),
with: {
databases: true,
},
});
const proj = await db.query.project.findFirst({ if (!proj) {
where: (proj, { redirect("/dashboard/projects");
and, }
eq,
not
}) => and(eq(proj.id, projectId), eq(proj.organizationId, org.id), not(eq(proj.isArchived, true))),
with: {
databases: true,
},
});
if (!proj) { const availableDatabases = await getOrganizationAvailableDatabases(
redirect("/dashboard/projects"); organization.id,
} proj.id,
);
const isMember = activeMember?.role === "member";
const availableDatabases = ( return (
await db.query.database.findMany({ <Page>
where: (db, {or, eq, isNull}) => or(isNull(db.projectId), eq(db.projectId, proj.id)), <div className="justify-between gap-2 sm:flex">
with: { <PageTitle className="flex flex-col md:flex-row items-center justify-between w-full ">
agent: true, <div className="min-w-full md:min-w-fit ">
project: true, {capitalizeFirstLetter(proj.name)}
backups: true, </div>
restorations: true, {!isMember && (
}, <div className="flex items-center gap-2 md:justify-between w-full ">
orderBy: (db, {desc}) => [desc(db.createdAt)], <div className="flex items-center gap-2">
}) <ProjectDialog
) as DatabaseWith[]; databases={availableDatabases}
organization={org}
const isMember = activeMember?.role === "member"; project={proj as ProjectWith}
isEdit={true}
return ( />
<Page> </div>
<div className="justify-between gap-2 sm:flex"> <div className="flex items-center gap-2">
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full "> <ButtonDeleteProject
<div className="min-w-full md:min-w-fit "> projectId={projectId}
{capitalizeFirstLetter(proj.name)} text={"Delete Project"}
</div> />
{!isMember && ( </div>
<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>
</div> </div>
<PageContent className="flex flex-col w-full h-full"> )}
{proj.databases.length > 0 ? ( </PageTitle>
<CardsWithPagination </div>
data={proj.databases} <PageContent className="flex flex-col w-full h-full">
organizationSlug={organization.slug} {proj.databases.length > 0 ? (
// @ts-ignore <CardsWithPagination
cardItem={ProjectDatabaseCard} data={[...proj.databases].sort(
cardsPerPage={6} (a, b) =>
numberOfColumns={3} new Date(b.createdAt).getTime() -
extendedProps={proj} new Date(a.createdAt).getTime(),
/> )}
) : ( organizationSlug={organization.slug}
<div className="flex flex-col items-center justify-center h-full text-muted-foreground py-20"> // @ts-ignore
<p className="text-lg font-medium">No databases found</p> cardItem={ProjectDatabaseCard}
<p className="text-sm mt-2">You havent added any databases to this project yet.</p> cardsPerPage={20}
</div> numberOfColumns={3}
)} pageSizeOptions={[10, 20, 50]}
</PageContent> extendedProps={proj}
</Page> />
); ) : (
} <div className="flex flex-col items-center justify-center h-full text-muted-foreground py-20">
<p className="text-lg font-medium">No databases found</p>
<p className="text-sm mt-2">
You havent added any databases to this project yet.
</p>
</div>
)}
</PageContent>
</Page>
);
}
@@ -1,14 +1,15 @@
import {PageParams} from "@/types/next"; import {PageParams} from "@/types/next";
import {CardsWithPagination} from "@/components/wrappers/common/cards-with-pagination"; 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 {ProjectCard} from "@/components/wrappers/dashboard/projects/project-card/project-card"; import {ProjectCard} from "@/features/projects/components/project-card";
import {db} from "@/db"; import {db} from "@/db";
import {notFound} from "next/navigation"; import {notFound} from "next/navigation";
import {getActiveMember, getOrganization} from "@/lib/auth/auth"; import {getActiveMember, getOrganization} from "@/lib/auth/auth";
import {EmptyStatePlaceholder} from "@/components/wrappers/common/empty-state-placeholder"; import {EmptyStatePlaceholder} from "@/components/common/empty-state-placeholder";
import {Metadata} from "next"; import {Metadata} from "next";
import {ProjectDialog} from "@/features/projects/components/project.dialog"; import {ProjectDialog} from "@/features/projects/components/project-dialog";
import {DatabaseWith} from "@/db/schema/07_database"; import {DatabaseWith} from "@/db/schema/07_database";
import {getOrganizationAvailableDatabases} from "@/db/services/database";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Projects", title: "Projects",
@@ -35,18 +36,7 @@ export default async function RoutePage(props: PageParams<{}>) {
}); });
const isMember = activeMember?.role === "member"; const isMember = activeMember?.role === "member";
const availableDatabases = ( const availableDatabases = await getOrganizationAvailableDatabases(organization.id)
await db.query.database.findMany({
where: (db, {isNull}) => isNull(db.projectId),
with: {
agent: true,
project: true,
backups: true,
restorations: true,
},
orderBy: (db, {desc}) => [desc(db.createdAt)],
})
).filter((db) => db.project == null) as DatabaseWith[];
return ( return (
@@ -66,11 +56,12 @@ export default async function RoutePage(props: PageParams<{}>) {
organizationSlug={organization.slug} organizationSlug={organization.slug}
data={projects} data={projects}
cardItem={ProjectCard} cardItem={ProjectCard}
cardsPerPage={9} cardsPerPage={12}
numberOfColumns={3} numberOfColumns={3}
pageSizeOptions={[12, 24, 48]}
/> />
) : isMember ? ( ) : isMember ? (
<EmptyStatePlaceholder text="No project available"/> <EmptyStatePlaceholder state={"empty"} text="No project available"/>
) : ( ) : (
<ProjectDialog databases={availableDatabases} organization={organization} isEmpty={true}/> <ProjectDialog databases={availableDatabases} organization={organization} isEmpty={true}/>
)} )}
@@ -0,0 +1,101 @@
import {PageParams} from "@/types/next";
import {
Page,
PageContent,
PageDescription,
PageTitle,
} from "@/features/layout/components/page";
import {db} from "@/db";
import * as drizzleDb from "@/db";
import {eq} from "drizzle-orm";
import {notFound} from "next/navigation";
import {ButtonDeleteAgent} from "@/features/agents/components/agent-delete-button";
import {capitalizeFirstLetter} from "@/utils/text";
import {generateEdgeKey} from "@/utils/edge_key";
import {getServerUrl} from "@/utils/get-server-url";
import {AgentContentPage} from "@/features/agents/components/agent-content";
import {AgentDialog} from "@/features/agents/components/agent-dialog";
import {getActiveMember, getOrganization} from "@/lib/auth/auth";
import {currentUser} from "@/lib/auth/current-user";
import {computeOrganizationPermissions} from "@/lib/acl/organization-acl";
export default async function RoutePage(
props: PageParams<{ agentId: string }>,
) {
const {agentId} = await props.params;
const organization = await getOrganization({});
const user = await currentUser();
const activeMember = await getActiveMember();
if (!organization || !activeMember || !user) {
notFound();
}
const {canManageAgents} = computeOrganizationPermissions(activeMember);
if (!canManageAgents){
notFound();
}
const agent = await db.query.agent.findFirst({
where: eq(drizzleDb.schemas.agent.id, agentId),
with: {
databases: true,
organizations: true,
},
});
if (!agent) {
notFound();
}
const hasAccess =
agent.organizationId === organization.id ||
agent.organizations.some(org => org.organizationId === organization.id);
if (!hasAccess) {
notFound();
}
const isOwned = agent.organizationId
const edgeKey = await generateEdgeKey(getServerUrl(), agent.id);
return (
<Page>
<div className="justify-between gap-2 sm:flex">
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full ">
<div className="min-w-full md:min-w-fit ">
{capitalizeFirstLetter(agent.name)}
</div>
{isOwned && (
<div className="flex items-center gap-2 md:justify-between w-full ">
<div className="flex items-center gap-2">
<AgentDialog
agent={agent}
typeTrigger={"edit"}
/>
</div>
<div className="flex items-center gap-2">
<ButtonDeleteAgent organizationId={organization.id ?? null} agentId={agentId} text={"Delete Agent"}/>
</div>
</div>
)}
</PageTitle>
</div>
{agent.description && (
<PageDescription className="mt-5 sm:mt-0">
{agent.description}
</PageDescription>
)}
<PageContent className="flex flex-col w-full h-full justify-between gap-6">
<AgentContentPage agent={agent} edgeKey={edgeKey}/>
</PageContent>
</Page>
);
}
@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default async function RoutePage() {
redirect("/dashboard/settings?tab=agents");
}
@@ -1,95 +1,114 @@
import { PageParams } from "@/types/next"; import {PageParams} from "@/types/next";
import { import {
Page, Page,
PageActions, PageContent,
PageContent, PageHeader,
PageHeader, PageTitle,
PageTitle, } from "@/features/layout/components/page";
} from "@/features/layout/page"; import {currentUser} from "@/lib/auth/current-user";
import { currentUser } from "@/lib/auth/current-user"; import {getActiveMember, getOrganization} from "@/lib/auth/auth";
import { getActiveMember, getOrganization } from "@/lib/auth/auth"; import {notFound} from "next/navigation";
import { notFound } from "next/navigation"; import {Metadata} from "next";
import { Metadata } from "next"; import {OrganizationTabs} from "@/features/organizations/components/organization-tabs";
import { OrganizationTabs } from "@/components/wrappers/dashboard/organization/tabs/organization-tabs"; import {getOrganizationChannels} from "@/db/services/notification-channel";
import { getOrganizationChannels } from "@/db/services/notification-channel"; import {computeOrganizationPermissions} from "@/lib/acl/organization-acl";
import { computeOrganizationPermissions } from "@/lib/acl/organization-acl"; import {getOrganizationStorageChannels} from "@/db/services/storage-channel";
import { getOrganizationStorageChannels } from "@/db/services/storage-channel"; import {DeleteOrganizationButton} from "@/features/organizations/components/organization-delete-button";
import { DeleteOrganizationButton } from "@/components/wrappers/dashboard/organization/delete-organization-button"; import {EditOrganizationDialog} from "@/features/organizations/components/organization-edit-dialog";
import { EditOrganizationDialog } from "@/features/organization/components/edit-organization.dialog"; import {db} from "@/db";
import { db } from "@/db"; import {isNull} from "drizzle-orm";
import { isNull } from "drizzle-orm";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import { eq } from "drizzle-orm"; import {eq} from "drizzle-orm";
import {getOrganizationAgents} from "@/db/services/agent";
import {Tooltip, TooltipContent, TooltipTrigger} from "@/components/ui/tooltip";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Settings", title: "Settings",
}; };
export default async function RoutePage(props: PageParams<{ slug: string }>) { export default async function RoutePage(props: PageParams<{ slug: string }>) {
const organization = await getOrganization({}); const organization = await getOrganization({});
const user = await currentUser(); const user = await currentUser();
const activeMember = await getActiveMember(); const activeMember = await getActiveMember();
if (!organization || !activeMember || !user) { if (!organization || !activeMember || !user) {
notFound(); notFound();
} }
const notificationChannels = await getOrganizationChannels(organization.id); const notificationChannels = await getOrganizationChannels(organization.id);
const storageChannels = await getOrganizationStorageChannels(organization.id); const storageChannels = await getOrganizationStorageChannels(organization.id);
const permissions = computeOrganizationPermissions(activeMember); const agents = await getOrganizationAgents(organization.id);
const permissions = computeOrganizationPermissions(activeMember);
const users = await db.query.user.findMany({ const users = await db.query.user.findMany({
where: (fields) => isNull(fields.deletedAt), where: (fields) => isNull(fields.deletedAt),
}); });
const organizationWithMembers = await db.query.organization.findFirst({ const organizationWithMembers = await db.query.organization.findFirst({
where: eq(drizzleDb.schemas.organization.id, organization.id), where: eq(drizzleDb.schemas.organization.id, organization.id),
with: {
members: {
with: { with: {
user: true, projects: true,
members: {
with: {
user: true,
},
},
}, },
}, });
},
});
if (!organizationWithMembers) notFound(); if (!organizationWithMembers) notFound();
return ( return (
<Page> <Page>
<PageHeader> <PageHeader>
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full "> <PageTitle className="flex flex-col md:flex-row items-center justify-between w-full ">
<div className="min-w-full md:min-w-fit ">Organization settings</div> <div className="min-w-full md:min-w-fit ">Organization settings</div>
<div className="flex items-center gap-2 md:justify-between w-full "> <div className="flex items-center gap-2 md:justify-between w-full ">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{permissions.canManageSettings && {permissions.canManageSettings &&
organization.slug !== "default" && ( organization.slug !== "default" && (
<EditOrganizationDialog <EditOrganizationDialog
organization={organizationWithMembers} organization={organizationWithMembers}
users={users} users={users}
currentUser={user} currentUser={user}
/> />
)} )}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{permissions.canManageDangerZone && {permissions.canManageDangerZone &&
organization.slug !== "default" && ( organization.slug !== "default" && (
<DeleteOrganizationButton
organizationSlug={organization.slug} <Tooltip>
/> <TooltipTrigger asChild>
)} <div>
</div> <DeleteOrganizationButton
</div> disabled={organizationWithMembers.projects.length > 0}
</PageTitle> organizationSlug={organization.slug}
</PageHeader> />
<PageContent> </div>
<OrganizationTabs
activeMember={activeMember} </TooltipTrigger>
organization={organization} {organizationWithMembers.projects.length > 0 && (
notificationChannels={notificationChannels} <TooltipContent>
storageChannels={storageChannels} <p>Your organization has some projects associated with it. Please delete them before deleting the organization.</p>
/> </TooltipContent>
</PageContent> )}
</Page> </Tooltip>
);
)}
</div>
</div>
</PageTitle>
</PageHeader>
<PageContent>
<OrganizationTabs
activeMember={activeMember}
organization={organization}
notificationChannels={notificationChannels}
storageChannels={storageChannels}
agents={agents}
/>
</PageContent>
</Page>
);
} }
@@ -1,8 +1,8 @@
import {PageParams} from "@/types/next"; import {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 {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card";
import {EvolutionLineChart} from "@/components/wrappers/dashboard/statistics/charts/evolution-line-chart"; import {EvolutionLineChart} from "@/features/statistics/components/evolution-line-chart";
import {PercentageLineChart} from "@/components/wrappers/dashboard/statistics/charts/percentage-line-chart"; import {PercentageLineChart} from "@/features/statistics/components/percentage-line-chart";
import {notFound} from "next/navigation"; import {notFound} from "next/navigation";
import {db} from "@/db"; import {db} from "@/db";
import {and, asc, count, eq, inArray} from "drizzle-orm"; import {and, asc, count, eq, inArray} from "drizzle-orm";
+7 -4
View File
@@ -1,11 +1,12 @@
import {PageParams} from "@/types/next"; 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 {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card";
import {Building2, Database, DatabaseBackup, Folder, RefreshCcw, Server, Workflow} from "lucide-react"; import {Building2, Database, DatabaseBackup, Folder, RefreshCcw, Server, Workflow} from "lucide-react";
import {currentUser} from "@/lib/auth/current-user"; import {currentUser} from "@/lib/auth/current-user";
import {notFound} from "next/navigation"; import {notFound} from "next/navigation";
import {db} from "@/db"; import {db} from "@/db";
import {asc, inArray} from "drizzle-orm";
import {and, asc, eq, inArray, not} from "drizzle-orm";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {listOrganizations} from "@/lib/auth/auth"; import {listOrganizations} from "@/lib/auth/auth";
import {Metadata} from "next"; import {Metadata} from "next";
@@ -23,10 +24,12 @@ export default async function RoutePage(props: PageParams<{}>) {
const organizationIds = organizations.map(project => project.id); 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({ const projects = await db.query.project.findMany({
where: inArray(drizzleDb.schemas.project.organizationId, organizationIds), where: and(inArray(drizzleDb.schemas.project.organizationId, organizationIds), eq(drizzleDb.schemas.project.isArchived, false)),
}); });
const projectIds = projects.map(project => project.id); const projectIds = projects.map(project => project.id);
+12 -6
View File
@@ -2,22 +2,28 @@ import { ReactNode } from "react";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"; import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { AppSidebar } from "@/components/wrappers/dashboard/common/sidebar/app-sidebar"; import { AppSidebar } from "@/features/layout/components/app-sidebar";
import { Header } from "@/features/layout/Header"; import { Header } from "@/features/layout/components/header";
import { currentUser } from "@/lib/auth/current-user"; import { currentUser } from "@/lib/auth/current-user";
import { ThemeMetaUpdater } from "@/features/browser/theme-meta-updater"; import { isOnboardingDone } from "@/db/services/setting";
import { env } from "@/env.mjs";
import { ModeToggle } from "@/features/theme/components/mode-toggle";
import { UpdateNotification } from "@/features/updates/components/update-notification";
export default async function Layout({ children }: { children: ReactNode }) { export default async function Layout({ children }: { children: ReactNode }) {
if (env.SKIP_ONBOARDING !== "true" && !(await isOnboardingDone())) {
redirect("/welcome");
}
const user = await currentUser(); const user = await currentUser();
if (!user) redirect("/login"); if (!user) redirect("/login");
return ( return (
<SidebarProvider> <SidebarProvider>
<div className="flex flex-col lg:flex-row w-full"> <div className="flex flex-col lg:flex-row w-full">
<ThemeMetaUpdater /> <AppSidebar updateNotification={<UpdateNotification />} />
<AppSidebar />
<SidebarInset> <SidebarInset>
<Header /> <Header actions={<ModeToggle />} />
<main className="h-full">{children}</main> <main className="h-full">{children}</main>
</SidebarInset> </SidebarInset>
</div> </div>
+1 -1
View File
@@ -1,4 +1,4 @@
import { LoadingSpinner } from "@/components/wrappers/common/loading/loading-spinner"; import { LoadingSpinner } from "@/components/common/loading-spinner";
export default function Loading() { export default function Loading() {
return ( return (
+7 -5
View File
@@ -1,16 +1,18 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getCurrentOrganizationSlug } from "@/features/dashboard/organization-cookie"; import { getCurrentOrganizationSlug } from "@/features/organizations/utils/organization-cookie";
import { currentUser } from "@/lib/auth/current-user"; import { currentUser } from "@/lib/auth/current-user";
import { isOnboardingDone } from "@/db/services/setting";
import { env } from "@/env.mjs";
export default async function Index() { export default async function Index() {
if (env.SKIP_ONBOARDING !== "true" && !(await isOnboardingDone())) {
redirect("/welcome");
}
const user = await currentUser(); const user = await currentUser();
if (user) { if (user) {
const currentOrganizationSlug = await getCurrentOrganizationSlug(); const currentOrganizationSlug = await getCurrentOrganizationSlug();
redirect(`/dashboard/home`); redirect(`/dashboard/home`);
} }
redirect("/login"); redirect("/login");
//Do not delete
// return (
// <Home/>
// );
} }
@@ -0,0 +1,29 @@
"use client";
import { useRouter } from "next/navigation";
import { OnboardingProvider } from "@onboardjs/react";
import { onboardingSteps } from "@/features/onboarding/onboarding-steps";
import { OnboardingShell } from "@/features/onboarding/onboarding-shell";
import type { OnboardingFlowData } from "@/features/onboarding/types";
type Props = {
initialStepId: string;
initialFlowData: Partial<OnboardingFlowData>;
};
export const OnboardingClient = ({ initialStepId, initialFlowData }: Props) => {
const router = useRouter();
return (
<OnboardingProvider
steps={onboardingSteps}
initialStepId={initialStepId}
initialContext={{ flowData: initialFlowData }}
onFlowComplete={async () => {
router.push("/dashboard/home");
}}
>
<OnboardingShell />
</OnboardingProvider>
);
};
+19
View File
@@ -0,0 +1,19 @@
import { redirect } from "next/navigation";
import { resolveOnboardingState } from "@/features/onboarding/onboarding-state";
import { OnboardingClient } from "./onboarding-client";
export default async function WelcomePage() {
const result = await resolveOnboardingState();
if ("redirect" in result) {
redirect(result.redirect);
}
return (
<OnboardingClient
key={result.stepId}
initialStepId={result.stepId}
initialFlowData={result.flowData}
/>
);
}
+15 -13
View File
@@ -1,7 +1,10 @@
import { NextResponse } from "next/server"; import {NextResponse} from "next/server";
import { eq } from "drizzle-orm"; import {and, eq} from "drizzle-orm";
import { db } from "@/db"; import {db} from "@/db";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {logger} from "@/lib/logger";
const log = logger.child({module: "api/agent/backup/helpers"});
export function withAgentCheck(handler: Function) { export function withAgentCheck(handler: Function) {
return async (request: Request, context: { params: Promise<{ agentId: string }> }) => { return async (request: Request, context: { params: Promise<{ agentId: string }> }) => {
@@ -9,28 +12,27 @@ export function withAgentCheck(handler: Function) {
const agentId = (await context.params).agentId; const agentId = (await context.params).agentId;
const agent = await db.query.agent.findFirst({ const agent = await db.query.agent.findFirst({
where: eq(drizzleDb.schemas.agent.id, agentId), where: and(eq(drizzleDb.schemas.agent.id, agentId), eq(drizzleDb.schemas.agent.isArchived, false)),
}); });
if (!agent) { if (!agent) {
return NextResponse.json( return NextResponse.json(
{ error: "Agent not found" }, {error: "Agent not found"},
{ status: 404 } {status: 404}
); );
} }
return handler(request, { ...context, agent }); return handler(request, {...context, agent});
} catch (err) { } catch (err) {
console.error("Error in agent middleware:", err); log.error({error: err, name: "withAgentCheck"}, "Error in agent middleware");
return NextResponse.json( return NextResponse.json(
{ error: "Internal server error" }, {error: "Internal server error"},
{ status: 500 } {status: 500}
); );
} }
}; };
} }
export async function getDatabaseOrThrow(generatedId: string) { export async function getDatabaseOrThrow(generatedId: string) {
const database = await db.query.database.findFirst({ const database = await db.query.database.findFirst({
where: eq(drizzleDb.schemas.database.agentDatabaseId, generatedId), where: eq(drizzleDb.schemas.database.agentDatabaseId, generatedId),
@@ -43,8 +45,8 @@ export async function getDatabaseOrThrow(generatedId: string) {
if (!database) { if (!database) {
throw NextResponse.json( throw NextResponse.json(
{ error: "Database associated with generatedId not found" }, {error: "Database associated with generatedId not found"},
{ status: 404 } {status: 404}
); );
} }
+37 -9
View File
@@ -5,9 +5,13 @@ import {db as dbClient, db} from "@/db";
import {getDatabaseOrThrow, withAgentCheck} from "./helpers"; import {getDatabaseOrThrow, withAgentCheck} from "./helpers";
import {Backup} from "@/db/schema/07_database"; import {Backup} from "@/db/schema/07_database";
import {withUpdatedAt} from "@/db/utils"; import {withUpdatedAt} from "@/db/utils";
import {eventEmitter} from "@/features/shared/event"; import {eventEmitter} from "@/lib/event";
import {sendNotificationsBackupRestore} from "@/features/notifications/helpers"; import {sendNotificationsBackupRestore} from "@/features/notifications/utils/notifications.helpers";
import {EventKind} from "@/features/notifications/types"; import {EventKind} from "@/features/notifications/types";
import {logger} from "@/lib/logger";
import {JobLogEntry} from "@/features/logs/types";
const log = logger.child({module: "api/agent/backup/route"});
export type BodyPost = { export type BodyPost = {
method: "manual" | "automatic" method: "manual" | "automatic"
@@ -19,6 +23,8 @@ export type BodyPatch = {
status: "success" | "failed" status: "success" | "failed"
size: number size: number
generatedId: string generatedId: string
logs: JobLogEntry[]
durationMs: number
} }
export const POST = withAgentCheck(async (request: Request, {params, agent}: { export const POST = withAgentCheck(async (request: Request, {params, agent}: {
@@ -27,6 +33,7 @@ export const POST = withAgentCheck(async (request: Request, {params, agent}: {
}) => { }) => {
try { try {
const body: BodyPost = await request.json(); const body: BodyPost = await request.json();
const method = body.method const method = body.method
const database = await getDatabaseOrThrow(body.generatedId); const database = await getDatabaseOrThrow(body.generatedId);
@@ -77,7 +84,6 @@ export const POST = withAgentCheck(async (request: Request, {params, agent}: {
} }
} }
eventEmitter.emit('modification', {update: true}); eventEmitter.emit('modification', {update: true});
return NextResponse.json( return NextResponse.json(
@@ -88,7 +94,7 @@ export const POST = withAgentCheck(async (request: Request, {params, agent}: {
{status: 200} {status: 200}
); );
} catch (error) { } catch (error) {
console.error("Error in POST for INIT backup:", error); log.error({error: error}, "Error in POST for INIT backup");
return NextResponse.json( return NextResponse.json(
{error: "Internal server error"}, {error: "Internal server error"},
{status: 500} {status: 500}
@@ -102,7 +108,6 @@ export const PATCH = withAgentCheck(async (request: Request, {params, agent}: {
}) => { }) => {
try { try {
const body: BodyPatch = await request.json(); const body: BodyPatch = await request.json();
console.log("body", body);
const status = body.status const status = body.status
const backupId = body.backupId const backupId = body.backupId
@@ -125,12 +130,37 @@ export const PATCH = withAgentCheck(async (request: Request, {params, agent}: {
.update(drizzleDb.schemas.backup) .update(drizzleDb.schemas.backup)
.set(withUpdatedAt({ .set(withUpdatedAt({
status: status, status: status,
fileSize: backupSize fileSize: backupSize,
durationMs: body.durationMs
})) }))
.where(eq(drizzleDb.schemas.backup.id, backup.id)) .where(eq(drizzleDb.schemas.backup.id, backup.id))
.returning(); .returning();
const logsToInsert = body.logs.map((entry) => ({
backupId: backup.id,
restorationId: null,
loggedAt: new Date(entry.timestamp),
entryType: entry.type,
level: entry.level,
message: entry.message,
command: entry.command ?? null,
output: entry.output ?? null,
exitCode: entry.exit_code ?? null,
durationMs: entry.duration_ms ?? null,
}));
if (logsToInsert.length > 0) {
await dbClient
.insert(drizzleDb.schemas.jobLog)
.values(logsToInsert);
}
eventEmitter.emit('modification', {update: true}); eventEmitter.emit('modification', {update: true});
await sendNotificationsBackupRestore(database, status == "failed" ? "error_backup" : "success_backup" as EventKind); await sendNotificationsBackupRestore(database, status == "failed" ? "error_backup" : "success_backup" as EventKind);
@@ -142,9 +172,7 @@ export const PATCH = withAgentCheck(async (request: Request, {params, agent}: {
{status: 200} {status: 200}
); );
} catch (error) { } catch (error) {
log.error({error: error}, "Error in PATCH backup")
console.error("Error in PATCH backup:", error);
return NextResponse.json( return NextResponse.json(
{error: "Internal server error"}, {error: "Internal server error"},
{status: 500} {status: 500}
@@ -1,76 +1,86 @@
import {NextResponse} from "next/server"; import { NextResponse } from "next/server";
import {and, eq} from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {db} from "@/db"; import { db } from "@/db";
import {getDatabaseOrThrow, withAgentCheck} from "../../helpers"; import { getDatabaseOrThrow, withAgentCheck } from "../../helpers";
import {isUuidv4} from "@/utils/verify-uuid"; import { eventEmitter } from "@/lib/event";
import {eventEmitter} from "@/features/shared/event"; import { logger } from "@/lib/logger";
import { isUUID } from "@/utils/text";
const log = logger.child({ module: "api/agent/backup/upload/init" });
export type Body = { export type Body = {
generatedId: string generatedId: string;
storageChannelId: string storageChannelId: string;
backupId: string backupId: string;
} };
export const POST = withAgentCheck(async (request: Request, {params, agent}: { export const POST = withAgentCheck(
params: Promise<{ agentId: string }>, async (
agent: any request: Request,
}) => { {
params,
agent,
}: {
params: Promise<{ agentId: string }>;
agent: any;
},
) => {
try { try {
const body: Body = await request.json(); const body: Body = await request.json();
console.log("body", body); log.info({ data: body }, "Body for backup upload init");
const generatedId = body.generatedId; const generatedId = body.generatedId;
const storageChannelId = body.storageChannelId; const storageChannelId = body.storageChannelId;
const backupId = body.backupId; 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});
if (!generatedId || !isUUID(generatedId)) {
return NextResponse.json( return NextResponse.json(
{ { error: "generatedId is not a valid UUID" },
message: "Backup storage successfully created", { status: 400 },
backupStorage: backupStorage
},
{status: 200}
); );
}
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) { } catch (error) {
console.error("Error in POST for INIT backup:", error); log.error({ error: error }, "Error in POST for INIT backup");
return NextResponse.json( return NextResponse.json(
{error: "Internal server error"}, { error: "Internal server error" },
{status: 500} { status: 500 },
); );
} }
}); },
);
@@ -4,7 +4,10 @@ import * as drizzleDb from "@/db";
import {db as dbClient, db} from "@/db"; import {db as dbClient, db} from "@/db";
import {withUpdatedAt} from "@/db/utils"; import {withUpdatedAt} from "@/db/utils";
import {getDatabaseOrThrow, withAgentCheck} from "../../helpers"; import {getDatabaseOrThrow, withAgentCheck} from "../../helpers";
import {eventEmitter} from "@/features/shared/event"; import {eventEmitter} from "@/lib/event";
import {logger} from "@/lib/logger";
const log = logger.child({module: "api/agent/backup/upload/status"});
export type Body = { export type Body = {
generatedId: string generatedId: string
@@ -27,8 +30,7 @@ export const PATCH = withAgentCheck(async (request: Request, {params, agent}: {
const backupStorageId = body.backupStorageId; const backupStorageId = body.backupStorageId;
const backupId = body.backupId; const backupId = body.backupId;
log.info({data: body}, "Body for backup upload status");
console.log("body", body);
const database = await getDatabaseOrThrow(generatedId); const database = await getDatabaseOrThrow(generatedId);
@@ -68,10 +70,10 @@ export const PATCH = withAgentCheck(async (request: Request, {params, agent}: {
if (hasSuccessfulStorage && backup.status !== "success") { if (hasSuccessfulStorage && backup.status !== "success") {
await db await db
.update(drizzleDb.schemas.backup) .update(drizzleDb.schemas.backup)
.set({ .set(withUpdatedAt({
status: "success", status: "success",
fileSize: fileSize, fileSize: fileSize,
}) }))
.where(eq(drizzleDb.schemas.backup.id, backup.id)); .where(eq(drizzleDb.schemas.backup.id, backup.id));
} }
} }
@@ -85,7 +87,7 @@ export const PATCH = withAgentCheck(async (request: Request, {params, agent}: {
{status: 200} {status: 200}
); );
} catch (error) { } catch (error) {
console.error("Error in POST for INIT backup:", error); log.error({error: error},"Error in POST for INIT backup");
return NextResponse.json( return NextResponse.json(
{error: "Internal server error"}, {error: "Internal server error"},
{status: 500} {status: 500}
+116 -74
View File
@@ -1,82 +1,124 @@
import {NextResponse} from "next/server"; import { NextResponse } from "next/server";
import {isUuidv4} from "@/utils/verify-uuid";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {db} from "@/db"; import { db as dbClient, db } from "@/db";
import {and, eq} from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import {sendNotificationsBackupRestore} from "@/features/notifications/helpers"; import { sendNotificationsBackupRestore } from "@/features/notifications/utils/notifications.helpers";
import {eventEmitter} from "@/features/shared/event"; 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" });
export type BodyResultRestore = { export type BodyResultRestore = {
generatedId: string generatedId: string;
status: string status: string;
} logs: JobLogEntry[];
type RestorationStatus = 'waiting' | 'ongoing' | 'failed' | 'success'; durationMs: number;
};
type RestorationStatus = "waiting" | "ongoing" | "failed" | "success";
export async function POST( export async function POST(
request: Request, request: Request,
{params}: { params: Promise<{ agentId: string }> } { params }: { params: Promise<{ agentId: string }> },
) { ) {
try {
const agentId = (await params).agentId;
const body: BodyResultRestore = await request.json();
try { if (!isUUID(body.generatedId)) {
return NextResponse.json(
const agentId = (await params).agentId { error: "generatedId is not a valid uuid" },
const body: BodyResultRestore = await request.json(); { status: 500 },
);
if (!isUuidv4(body.generatedId)) {
return NextResponse.json(
{error: "generatedId is not a valid uuid"},
{status: 500}
);
}
const agent = await db.query.agent.findFirst({
where: eq(drizzleDb.schemas.agent.id, agentId)
})
if (!agent) {
return NextResponse.json({error: "Agent not found"}, {status: 404})
}
const database = await db.query.database.findFirst({
where: eq(drizzleDb.schemas.database.agentDatabaseId, body.generatedId),
with: {
alertPolicies: true
}
})
if (!database) {
return NextResponse.json({error: "Database associated with generatedId provided not found"}, {status: 404})
}
const restoration = await db.query.restoration.findFirst({
where: and(eq(drizzleDb.schemas.restoration.status, "ongoing"), eq(drizzleDb.schemas.restoration.databaseId, database.id),)
})
if (!restoration) {
return NextResponse.json({error: "Unable to fin the corresponding restoration"}, {status: 404})
}
await db
.update(drizzleDb.schemas.restoration)
.set({status: body.status as RestorationStatus})
.where(eq(drizzleDb.schemas.restoration.id, restoration.id));
await sendNotificationsBackupRestore(database, body.status == "failed" ? "error_restore" : "success_restore");
const response = {
message: true,
details: "Restoration successfully updated"
}
return Response.json(response, {status: 200})
} catch (error) {
console.error('Error in POST handler:', error);
return NextResponse.json(
{error: 'Internal server error'},
{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 },
);
}
}
+48 -18
View File
@@ -1,21 +1,24 @@
import {NextResponse} from "next/server"; import {NextResponse} from "next/server";
import {Body} from "./route"; import {Body} from "./route";
import {isUuidv4} from "@/utils/verify-uuid";
import {Agent} from "@/db/schema/08_agent"; import {Agent} from "@/db/schema/08_agent";
import {Database, DatabaseWith} from "@/db/schema/07_database"; import {DatabaseWith} from "@/db/schema/07_database";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {db, db as dbClient} from "@/db"; import {db, db as dbClient} from "@/db";
import {and, eq, inArray} from "drizzle-orm"; import {and, eq, inArray} from "drizzle-orm";
import {dbmsEnumSchema, EDbmsSchema} from "@/db/schema/types"; import {dbmsEnumSchema, EDbmsSchema} from "@/db/schema/types";
import {withUpdatedAt} from "@/db/utils"; import {withUpdatedAt} from "@/db/utils";
import type {StorageInput} from "@/features/storages/types";
import {dispatchStorage} from "@/features/storages/dispatch";
import {Setting} from "@/db/schema/01_setting"; import {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) { export async function handleDatabases(body: Body, agent: Agent, lastContact: Date, settings: Setting) {
const databasesResponse = []; 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, generatedId: database.agentDatabaseId,
dbms: database.dbms, dbms: database.dbms,
storages: storages, storages: storages,
@@ -28,7 +31,8 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
restore: { restore: {
action: restoreAction, action: restoreAction,
file: UrlBackup, file: UrlBackup,
metaFile: urlMeta metaFile: urlMeta,
size: backupSize
}, },
}, },
}); });
@@ -46,9 +50,10 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
let restoreAction: boolean = false let restoreAction: boolean = false
let urlBackup: string | null = null; let urlBackup: string | null = null;
let urlMeta: string | null = null let urlMeta: string | null = null
let backupSize: number | null = null
if (!existingDatabase) { if (!existingDatabase) {
if (!isUuidv4(db.generatedId)) { if (!isUUID(db.generatedId)) {
return NextResponse.json( return NextResponse.json(
{error: "generatedId is not a valid uuid"}, {error: "generatedId is not a valid uuid"},
{status: 500} {status: 500}
@@ -56,7 +61,7 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
} }
if (!dbmsEnumSchema.safeParse(db.dbms).success) { if (!dbmsEnumSchema.safeParse(db.dbms).success) {
console.log(`Database type not available: ${db.dbms}`); log.error({name: "handleDatabases"},`Database type not available: ${db.dbms}`);
continue; continue;
} }
@@ -67,15 +72,27 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
name: db.name, name: db.name,
dbms: db.dbms as EDbmsSchema, dbms: db.dbms as EDbmsSchema,
agentDatabaseId: db.generatedId, agentDatabaseId: db.generatedId,
lastContact: lastContact, lastContact: db.pingStatus ? lastContact : null,
healthErrorCount: null
}) })
.returning(); .returning();
if (databaseCreated) { if (databaseCreated) {
await dbClient
.insert(drizzleDb.schemas.healthcheckLog)
.values({
kind: "database",
status: db.pingStatus ? "success" : "failed",
objectId: databaseCreated.id,
date: lastContact
})
const storages = await getDatabaseStorageChannels(databaseCreated.id) 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 { } else {
@@ -84,11 +101,24 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
.set(withUpdatedAt({ .set(withUpdatedAt({
name: db.name, name: db.name,
agentId: agent.id, agentId: agent.id,
lastContact: lastContact dbms: db.dbms as EDbmsSchema,
lastContact: db.pingStatus ? lastContact : existingDatabase.lastContact,
healthErrorCount: db.pingStatus ? null : existingDatabase.healthErrorCount,
})) }))
.where(eq(drizzleDb.schemas.database.id, existingDatabase.id)) .where(eq(drizzleDb.schemas.database.id, existingDatabase.id))
.returning(); .returning();
await dbClient
.insert(drizzleDb.schemas.healthcheckLog)
.values({
kind: "database",
status: db.pingStatus ? "success" : "failed",
objectId: databaseUpdated.id,
date: lastContact
})
const activeBackup = await dbClient.query.backup.findFirst({ const activeBackup = await dbClient.query.backup.findFirst({
where: and( where: and(
eq(drizzleDb.schemas.backup.databaseId, databaseUpdated.id), eq(drizzleDb.schemas.backup.databaseId, databaseUpdated.id),
@@ -152,35 +182,35 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
if (result.success) { if (result.success) {
urlBackup = result.url ?? null; urlBackup = result.url ?? null;
urlMeta = resultMeta.url ?? null urlMeta = resultMeta.url ?? null
backupSize = restoration.backupStorage.size
} else { } else {
await dbClient await dbClient
.update(drizzleDb.schemas.restoration) .update(drizzleDb.schemas.restoration)
.set({status: "failed"}) .set(withUpdatedAt({status: "failed"}))
.where(eq(drizzleDb.schemas.restoration.id, restoration.id)); .where(eq(drizzleDb.schemas.restoration.id, restoration.id));
const errorMessage = "Failed to get backup URL"; const errorMessage = "Failed to get backup URL";
console.error("Restoration failed: ", errorMessage); log.error({error: errorMessage, name: "handleDatabases"}, "Restoration failed");
continue; continue;
} }
} catch (err) { } catch (err) {
console.error("Restoration crashed unexpectedly:", err); log.error({error: err, name: "handleDatabases"}, "Restoration crashed unexpectedly");
await dbClient await dbClient
.update(drizzleDb.schemas.restoration) .update(drizzleDb.schemas.restoration)
.set({status: "failed"}) .set(withUpdatedAt({status: "failed"}))
.where(eq(drizzleDb.schemas.restoration.id, restoration.id)); .where(eq(drizzleDb.schemas.restoration.id, restoration.id));
continue; continue;
} }
await dbClient await dbClient
.update(drizzleDb.schemas.restoration) .update(drizzleDb.schemas.restoration)
.set({status: "ongoing"}) .set(withUpdatedAt({status: "ongoing"}))
.where(eq(drizzleDb.schemas.restoration.id, restoration.id)); .where(eq(drizzleDb.schemas.restoration.id, restoration.id));
} }
const storages = await getDatabaseStorageChannels(databaseUpdated.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; return databasesResponse;
} }
+96 -77
View File
@@ -1,88 +1,107 @@
import {NextResponse} from "next/server"; import { NextResponse } from "next/server";
import {handleDatabases} from "./helpers"; import { handleDatabases } from "./helpers";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {db} from "@/db"; import { db } from "@/db";
import {EDbmsSchema} from "@/db/schema/types"; import { EDbmsSchema } from "@/db/schema/types";
import {eq} from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import {isUuidv4} from "@/utils/verify-uuid"; import { withUpdatedAt } from "@/db/utils";
import {withUpdatedAt} from "@/db/utils"; import { logger } from "@/lib/logger";
import {eventEmitter} from "@/features/shared/event"; import { isUUID } from "@/utils/text";
import {notFound} from "next/navigation";
const log = logger.child({ module: "api/agent/status/route" });
export type databaseAgent = { export type databaseAgent = {
name: string, name: string;
dbms: EDbmsSchema, dbms: EDbmsSchema;
generatedId: string generatedId: string;
} pingStatus: boolean;
};
export type Body = { export type Body = {
version: string, version: string;
databases: databaseAgent[] databases: databaseAgent[];
} };
export async function POST( export async function POST(
request: Request, request: Request,
{params}: { params: Promise<{ agentId: string }> } { params }: { params: Promise<{ agentId: string }> },
) { ) {
try { try {
const agentId = (await params).agentId const agentId = (await params).agentId;
console.log(agentId) log.debug(`Agent ID: ${agentId}`);
const body: Body = await request.json(); const body: Body = await request.json();
const lastContact = new Date(); const lastContact = new Date();
let message: string let message: string;
if (!isUuidv4(agentId)) { if (!isUUID(agentId)) {
message = "agentId is not a valid uuid" message = "agentId is not a valid uuid";
console.error(message) log.error({ error: message }, "An error occurred");
return NextResponse.json( return NextResponse.json(
{error: "agentId is not a valid uuid"}, { error: "agentId is not a valid uuid" },
{status: 500} { status: 500 },
); );
}
const agent = await db.query.agent.findFirst({
where: eq(drizzleDb.schemas.agent.id, agentId),
})
if (!agent) {
message = "Agent not found"
return NextResponse.json({error: message}, {status: 404})
}
const [settings] = await db.select().from(drizzleDb.schemas.setting).where(eq(drizzleDb.schemas.setting.name, "system")).limit(1);
if (!settings) {
return NextResponse.json({error: "An error occured"}, {status: 404})
}
const databasesResponse = await handleDatabases(body, agent, lastContact, settings)
await db
.update(drizzleDb.schemas.agent)
.set(withUpdatedAt({
version: body.version,
lastContact: lastContact
}))
.where(eq(drizzleDb.schemas.agent.id, agentId));
const response = {
agent: {
id: agentId,
lastContact: lastContact
},
databases: databasesResponse
}
return Response.json(response)
} catch (error) {
console.error('Error in POST handler:', error);
return NextResponse.json(
{error: 'Internal server error'},
{status: 500}
);
} }
}
const agent = await db.query.agent.findFirst({
where: and(
eq(drizzleDb.schemas.agent.id, agentId),
eq(drizzleDb.schemas.agent.isArchived, false),
),
});
if (!agent) {
message = "Agent not found";
return NextResponse.json({ error: message }, { status: 404 });
}
const [settings] = await db
.select()
.from(drizzleDb.schemas.setting)
.where(eq(drizzleDb.schemas.setting.name, "system"))
.limit(1);
if (!settings) {
return NextResponse.json({ error: "An error occured" }, { status: 404 });
}
const databasesResponse = await handleDatabases(
body,
agent,
lastContact,
settings,
);
await db
.update(drizzleDb.schemas.agent)
.set(
withUpdatedAt({
version: body.version,
lastContact: lastContact,
healthErrorCount: null,
}),
)
.where(eq(drizzleDb.schemas.agent.id, agentId));
await db.insert(drizzleDb.schemas.healthcheckLog).values({
kind: "agent",
status: "success",
objectId: agentId,
date: lastContact,
});
const response = {
agent: {
id: agentId,
lastContact: lastContact,
},
databases: databasesResponse,
};
return Response.json(response);
} catch (error) {
log.error({ error: error }, "Error in POST handler");
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
+34 -1
View File
@@ -1,4 +1,37 @@
import { auth } from "@/lib/auth/auth"; import { auth } from "@/lib/auth/auth";
import { toNextJsHandler } from "better-auth/next-js"; import { toNextJsHandler } from "better-auth/next-js";
import { NextRequest, NextResponse } from "next/server";
import { headers } from "next/headers";
export const { GET, POST } = toNextJsHandler(auth.handler); const authHandler = toNextJsHandler(auth.handler);
async function blockApiKeyCreateForRestrictedUsers(
req: NextRequest,
): Promise<NextResponse | null> {
const url = req.nextUrl;
if (req.method !== "POST" || !url.pathname.endsWith("/api-key/create")) {
return null;
}
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user) {
return null;
}
// @ts-ignore
if (session.user.banned || (session.user.role as string) === "pending") {
return NextResponse.json(
{ error: "Account not eligible to create API keys" },
{ status: 403 },
);
}
return null;
}
export async function GET(req: NextRequest) {
return authHandler.GET(req);
}
export async function POST(req: NextRequest) {
const guard = await blockApiKeyCreateForRestrictedUsers(req);
if (guard) return guard;
return authHandler.POST(req);
}
+1 -1
View File
@@ -4,6 +4,6 @@ export async function GET() {
return NextResponse.json({ return NextResponse.json({
PROJECT_URL: process.env.PROJECT_URL, PROJECT_URL: process.env.PROJECT_URL,
PROJECT_NAME: process.env.PROJECT_NAME, PROJECT_NAME: process.env.PROJECT_NAME,
PROJECT_DESCRIPTION: process.env.PROJECT_DESCRIPTION, PROJECT_DESCRIPTION: process.env.PROJECT_DESCRIPTION
}); });
} }
+7 -4
View File
@@ -1,7 +1,10 @@
import {auth} from "@/lib/auth/auth"; import {auth} from "@/lib/auth/auth";
import {headers} from "next/headers"; import {headers} from "next/headers";
import {NextResponse} from "next/server"; import {NextResponse} from "next/server";
import {eventEmitter} from "@/features/shared/event"; import {eventEmitter} from "@/lib/event";
import {logger} from "@/lib/logger";
const log = logger.child({module: "api/events"});
export async function GET(request: Request) { export async function GET(request: Request) {
@@ -16,9 +19,9 @@ export async function GET(request: Request) {
return new Response( return new Response(
new ReadableStream({ new ReadableStream({
start(controller) { start(controller) {
console.log('Stream started'); log.info("Stream started");
const handleModification = (data: any) => { const handleModification = (data: any) => {
console.log('Modification event triggered:', data); log.info({data: data},"Modification event triggered");
controller.enqueue(`event: modification\n`); controller.enqueue(`event: modification\n`);
controller.enqueue(`data: ${JSON.stringify(data)}\n\n`); controller.enqueue(`data: ${JSON.stringify(data)}\n\n`);
}; };
@@ -26,7 +29,7 @@ export async function GET(request: Request) {
eventEmitter.on('modification', handleModification); eventEmitter.on('modification', handleModification);
request.signal.addEventListener('abort', () => { request.signal.addEventListener('abort', () => {
console.log('Client disconnected'); log.info("Client disconnected");
controller.close(); controller.close();
eventEmitter.off('modification', handleModification); eventEmitter.off('modification', handleModification);
}); });
+5 -2
View File
@@ -1,8 +1,11 @@
import {NextResponse} from "next/server"; import {NextResponse} from "next/server";
import path from "path"; import path from "path";
import type {StorageInput} from "@/features/storages/types"; import type {StorageInput} from "@/features/storages/types";
import {dispatchStorage} from "@/features/storages/dispatch"; import {dispatchStorage} from "@/features/storages/utils/storages.dispatch";
import {Readable} from "node:stream"; import {Readable} from "node:stream";
import {logger} from "@/lib/logger";
const log = logger.child({module: "api/files/backups"});
export async function GET( export async function GET(
request: Request, request: Request,
@@ -30,7 +33,7 @@ export async function GET(
} }
}; };
console.debug(input); log.info({input: input}, "Dispatch Storage");
const result = await dispatchStorage(input, undefined, storageId); const result = await dispatchStorage(input, undefined, storageId);
+6 -6
View File
@@ -1,12 +1,12 @@
import {NextResponse} from "next/server"; import {NextResponse} from "next/server";
import {auth} from "@/lib/auth/auth"; import {auth} from "@/lib/auth/auth";
import {headers} from "next/headers"; import {headers} from "next/headers";
import {db} from "@/db";
import * as drizzleDb from "@/db";
import {eq} from "drizzle-orm";
import {StorageInput} from "@/features/storages/types"; import {StorageInput} from "@/features/storages/types";
import {dispatchStorage} from "@/features/storages/dispatch"; import {dispatchStorage} from "@/features/storages/utils/storages.dispatch";
import {Readable} from "node:stream"; import {Readable} from "node:stream";
import {logger} from "@/lib/logger";
const log = logger.child({module: "api/files/images"});
export async function GET( export async function GET(
req: Request, req: Request,
@@ -56,7 +56,7 @@ export async function GET(
const result = await dispatchStorage(input, undefined, storageId); const result = await dispatchStorage(input, undefined, storageId);
if (!result.file || !(result.file instanceof Readable)) { if (!result.file || !(result.file instanceof Readable)) {
console.error(`An error occurred while getting file :`, result); log.error({error: result}, `An error occurred while getting file`);
return NextResponse.json( return NextResponse.json(
{error: "Invalid file payload"}, {error: "Invalid file payload"},
{status: 500} {status: 500}
@@ -82,7 +82,7 @@ export async function GET(
}); });
} catch (err) { } catch (err) {
console.error("Error streaming image:", err); log.error({error: err}, `Error streaming image`);
return NextResponse.json({error: "Error fetching file"}, {status: 500}); return NextResponse.json({error: "Error fetching file"}, {status: 500});
} }
} }
+5
View File
@@ -0,0 +1,5 @@
import { NextResponse } from "next/server";
export async function GET() {
return NextResponse.json({ success: true });
}
+5 -5
View File
@@ -2,7 +2,9 @@ import {NextResponse} from "next/server";
import fs from "fs"; import fs from "fs";
import path from "path"; import path from "path";
import {env} from "@/env.mjs"; import {env} from "@/env.mjs";
import {logger} from "@/lib/logger";
const log = logger.child({module: "api/tus/hooks"});
export async function POST(request: Request) { export async function POST(request: Request) {
try { try {
@@ -13,7 +15,7 @@ export async function POST(request: Request) {
const uploadOffset = headers["Upload-Offset"]?.[0]; const uploadOffset = headers["Upload-Offset"]?.[0];
const status = headers["X-Status"]?.[0]; const status = headers["X-Status"]?.[0];
console.log(`Upload ID : ${event.Upload.ID} (${uploadOffset}/${uploadLength})`); log.info(`Upload ID : ${event.Upload.ID} (${uploadOffset}/${uploadLength})`);
if (status === "success") { if (status === "success") {
if ( if (
@@ -29,9 +31,7 @@ export async function POST(request: Request) {
return NextResponse.json({error: "Missing X-File-Path"}, {status: 500}); return NextResponse.json({error: "Missing X-File-Path"}, {status: 500});
} }
const uploadDir = path.join(env.PRIVATE_PATH!, "/uploads/");
// const uploadDir = path.join(process.cwd(), "/private/uploads/");
const uploadDir = path.join(env.PRIVATE_PATH, "/uploads/");
const oldFilePath = path.join(uploadDir, "tmp", id); const oldFilePath = path.join(uploadDir, "tmp", id);
const newFilePath = path.join(uploadDir, filePath); const newFilePath = path.join(uploadDir, filePath);
@@ -74,7 +74,7 @@ export async function POST(request: Request) {
} }
return NextResponse.json({}); return NextResponse.json({});
} catch (error) { } catch (error) {
console.error("Hook error:", error); log.error({error: error},"TUS Hook error");
return NextResponse.json({error: "Internal server error"}, {status: 500}); return NextResponse.json({error: "Internal server error"}, {status: 500});
} }
} }
+34
View File
@@ -0,0 +1,34 @@
import {withApiKey} from "@/lib/api-v1/middleware";
import {ApiKeyContext} from "@/lib/api-v1/types";
import {NextResponse} from "next/server";
import {getAgent, resolveAgentAccess} from "@/lib/api-v1/services/agents";
import {logger} from "@/lib/logger";
import {generateEdgeKey} from "@/utils/edge_key";
import {getServerUrl} from "@/utils/get-server-url";
const log = logger.child({ module: "api/v1/agents/[id]/key" });
export const GET = withApiKey(
async (_req: Request, ctx: ApiKeyContext, params?: Record<string, string>) => {
try {
const id = params?.id;
if (!id) return NextResponse.json({ error: "Not found" }, { status: 404 });
const access = await resolveAgentAccess(id, ctx.user);
if (access === "forbidden") return NextResponse.json({ error: "Forbidden" }, { status: 403 });
if (access === "not_found") return NextResponse.json({ error: "Not found" }, { status: 404 });
const agent = await getAgent(id);
if (!agent) return NextResponse.json({ error: "Agent not found" }, { status: 404 });
const edgeKey = await generateEdgeKey(getServerUrl(), agent.id);
return NextResponse.json({ data: edgeKey });
} catch (error) {
log.error({ error }, "Error in GET /api/v1/agents/[id]");
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}
);
+77
View File
@@ -0,0 +1,77 @@
import {NextResponse} from "next/server";
import {withApiKey} from "@/lib/api-v1/middleware";
import {logger} from "@/lib/logger";
import {ApiKeyContext} from "@/lib/api-v1/types";
import {getAgent, resolveAgentAccess} from "@/lib/api-v1/services/agents";
import {deleteAgentService} from "@/features/agents/actions/agent-delete.action";
const log = logger.child({module: "api/v1/agents/[id]"});
export const GET = withApiKey(
async (_req: Request, ctx: ApiKeyContext, params?: Record<string, string>) => {
try {
const id = params?.id;
if (!id) return NextResponse.json({error: "Not found"}, {status: 404});
const access = await resolveAgentAccess(id, ctx.user);
if (access === "forbidden") return NextResponse.json({error: "Forbidden"}, {status: 403});
if (access === "not_found") return NextResponse.json({error: "Not found"}, {status: 404});
const agent = await getAgent(id, {
includeDatabases: true,
includeOrganizations: false,
});
if (!agent) return NextResponse.json({error: "Not found"}, {status: 404});
return NextResponse.json({data: agent});
} catch (error) {
log.error({error}, "Error in GET /api/v1/agents/[id]");
return NextResponse.json({error: "Internal server error"}, {status: 500});
}
}
);
export const DELETE = withApiKey(
async (_req: Request, ctx: ApiKeyContext, params?: Record<string, string>) => {
try {
const id = params?.id;
if (!id) return NextResponse.json({error: "Not found"}, {status: 404});
const access = await resolveAgentAccess(id, ctx.user);
if (access === "forbidden") return NextResponse.json({error: "Forbidden"}, {status: 403});
if (access === "not_found") return NextResponse.json({error: "Not found"}, {status: 404});
const agent = await getAgent(id, {
includeDatabases: false,
includeOrganizations: true,
});
if (!agent) return NextResponse.json({error: "Agent no found"}, {status: 404});
const organizationIds = agent.organizations.map(org => org.organizationId)
const canDeleteGlobalAgent =
ctx.user.permissions.isAdmin || ctx.user.permissions.isSuperAdmin;
if (!agent.organizationId && !canDeleteGlobalAgent) {
return NextResponse.json(
{ error: "Forbidden" },
{ status: 403 }
);
}
await deleteAgentService({
agentId: agent.id,
organizationId: agent.organizationId ?? undefined,
organizationIds: organizationIds
});
return new Response(null, {status: 204});
} catch (error) {
log.error({error}, "Error in DELETE /api/v1/agents/[id]");
return NextResponse.json({error: "Internal server error"}, {status: 500});
}
}
);
+112
View File
@@ -0,0 +1,112 @@
import { NextResponse } from "next/server";
import { withApiKey } from "@/lib/api-v1/middleware";
import { db } from "@/db";
import * as drizzleDb from "@/db";
import { inArray, eq, and, or, isNull } from "drizzle-orm";
import { z } from "zod";
import { logger } from "@/lib/logger";
import {createAgentService} from "@/features/agents/actions/agents.action";
import { ActionError } from "@/lib/safe-actions/actions";
import {getAccessibleAgentIds} from "@/lib/api-v1/services/agents";
import {ApiKeyContext} from "@/lib/api-v1/types";
import {parseJsonBody} from "@/lib/api-v1/validation/json-body";
const log = logger.child({ module: "api/v1/agents" });
export const GET = withApiKey(async (_req: Request, ctx: ApiKeyContext) => {
try {
const agentIds = await getAccessibleAgentIds(ctx.user);
if (agentIds.length === 0) {
return NextResponse.json({ data: [] });
}
const agents = await db.query.agent.findMany({
where: and(
inArray(drizzleDb.schemas.agent.id, agentIds),
or(
eq(drizzleDb.schemas.agent.isArchived, false),
isNull(drizzleDb.schemas.agent.isArchived)
)
),
});
return NextResponse.json({ data: agents });
} catch (error) {
log.error({ error }, "Error in GET /api/v1/agents");
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
});
const CreateAgentSchema = z.object({
name: z.string().min(1, "name is required"),
organizationId: z.string().uuid("organizationId must be a valid UUID").optional(),
});
export const POST = withApiKey(
async (req: Request, ctx: ApiKeyContext) => {
try {
const body = await parseJsonBody(req, CreateAgentSchema);
if (!body.ok) {
return body.response;
}
const { name, organizationId } = body.data;
const org = ctx.organizations.find(
(org) => org.id === organizationId
);
if (
organizationId &&
(!org || !org.permissions.canManageAgents)
) {
return NextResponse.json(
{ error: "Forbidden" },
{ status: 403 }
);
}
const canCreateGlobalAgent =
ctx.user.permissions.isAdmin || ctx.user.permissions.isSuperAdmin;
if (!organizationId && !canCreateGlobalAgent) {
return NextResponse.json(
{ error: "Forbidden" },
{ status: 403 }
);
}
const createdAgent = await createAgentService({
organizationId,
data: {
name,
description: "",
},
});
return NextResponse.json(
{ data: createdAgent },
{ status: 201 }
);
} catch (error: unknown) {
if (error instanceof ActionError) {
return NextResponse.json(
{ error: error.message },
{ status: 400 }
);
}
log.error({error},
"Error in POST /api/v1/agents"
);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
);
@@ -0,0 +1,61 @@
import { NextResponse } from "next/server";
import { withApiKey } from "@/lib/api-v1/middleware";
import { db } from "@/db";
import * as drizzleDb from "@/db";
import { and, eq, isNull } from "drizzle-orm";
import { logger } from "@/lib/logger";
import { ApiKeyContext } from "@/lib/api-v1/types";
import {requireDatabaseAccess} from "@/lib/api-v1/services/databases";
const log = logger.child({
module: "api/v1/databases/[id]/backup/[backupId]",
});
export const GET = withApiKey(
async (_req: Request, ctx: ApiKeyContext, params?: Record<string, string>) => {
try {
const guard = await requireDatabaseAccess(params, ctx.user);
if (!guard.ok) {
return guard.response;
}
const { id } = guard.data;
const backupId = params?.backupId;
if (!backupId) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
const backup = await db.query.backup.findFirst({
where: and(
eq(drizzleDb.schemas.backup.id, backupId),
eq(drizzleDb.schemas.backup.databaseId, id),
isNull(drizzleDb.schemas.backup.deletedAt)
),
with: {
storages: {
where: (backupStorage, { isNull }) =>
isNull(backupStorage.deletedAt),
},
},
});
if (!backup) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
return NextResponse.json({ data: backup });
} catch (error) {
log.error(
{ error },
"Error in GET /api/v1/databases/[id]/backup/[backupId]"
);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
);
+96
View File
@@ -0,0 +1,96 @@
import { NextResponse } from "next/server";
import { withApiKey } from "@/lib/api-v1/middleware";
import { db } from "@/db";
import * as drizzleDb from "@/db";
import {and, desc, eq, inArray, isNull} from "drizzle-orm";
import { logger } from "@/lib/logger";
import { ApiKeyContext } from "@/lib/api-v1/types";
import {requireDatabaseAccess} from "@/lib/api-v1/services/databases";
const log = logger.child({ module: "api/v1/databases/[id]/backup" });
export const GET = withApiKey(
async (_req: Request, ctx: ApiKeyContext, params?: Record<string, string>) => {
try {
const guard = await requireDatabaseAccess(params, ctx.user);
if (!guard.ok) {
return guard.response;
}
const { id } = guard.data;
const backups = await db.query.backup.findMany({
where: and(
eq(drizzleDb.schemas.backup.databaseId, id),
isNull(drizzleDb.schemas.backup.deletedAt)
),
orderBy: [desc(drizzleDb.schemas.backup.createdAt)],
});
return NextResponse.json({ data: backups });
} catch (error) {
log.error({ error }, "Error in GET /api/v1/databases/[id]/backup");
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
);
export const POST = withApiKey(
async (_req: Request, ctx: ApiKeyContext, params?: Record<string, string>) => {
try {
const guard = await requireDatabaseAccess(params, ctx.user);
if (!guard.ok) {
return guard.response;
}
const { id } = guard.data;
const existingBackup = await db.query.backup.findFirst({
where: and(
eq(drizzleDb.schemas.backup.databaseId, id),
inArray(drizzleDb.schemas.backup.status, ["waiting", "ongoing"])
),
});
if (existingBackup) {
return NextResponse.json(
{
error:
"A backup is already waiting or ongoing for this database",
},
{ status: 409 }
);
}
const [createdBackup] = await db
.insert(drizzleDb.schemas.backup)
.values({
databaseId: id,
status: "waiting",
})
.returning();
if (!createdBackup) {
return NextResponse.json(
{ error: "Failed to create backup" },
{ status: 500 }
);
}
return NextResponse.json({ data: createdBackup }, { status: 201 });
} catch (error) {
log.error({ error }, "Error in POST /api/v1/databases/[id]/backup");
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
);
+122
View File
@@ -0,0 +1,122 @@
import { NextResponse } from "next/server";
import { withApiKey } from "@/lib/api-v1/middleware";
import { db } from "@/db";
import * as drizzleDb from "@/db";
import {and, eq, inArray, isNull} from "drizzle-orm";
import { z } from "zod";
import { logger } from "@/lib/logger";
import { ApiKeyContext } from "@/lib/api-v1/types";
import { parseJsonBody } from "@/lib/api-v1/validation/json-body";
import {requireDatabaseAccess} from "@/lib/api-v1/services/databases";
const log = logger.child({ module: "api/v1/databases/[id]/restore" });
const RestoreSchema = z.object({
backupId: z.string().uuid("backupId must be a valid UUID"),
backupStorageId: z.string().uuid("backupStorageId must be a valid UUID"),
});
export const POST = withApiKey(
async (req: Request, ctx: ApiKeyContext, params?: Record<string, string>) => {
try {
const guard = await requireDatabaseAccess(params, ctx.user);
if (!guard.ok) {
return guard.response;
}
const { id } = guard.data;
const body = await parseJsonBody(req, RestoreSchema);
if (!body.ok) {
return body.response;
}
const { backupId, backupStorageId } = body.data;
const backupRecord = await db.query.backup.findFirst({
where: and(
eq(drizzleDb.schemas.backup.id, backupId),
eq(drizzleDb.schemas.backup.databaseId, id),
isNull(drizzleDb.schemas.backup.deletedAt)
),
columns: {
id: true,
},
});
if (!backupRecord) {
return NextResponse.json(
{ error: "Backup not found for this database" },
{ status: 404 }
);
}
const backupStorage = await db.query.backupStorage.findFirst({
where: and(
eq(drizzleDb.schemas.backupStorage.id, backupStorageId),
eq(drizzleDb.schemas.backupStorage.backupId, backupId),
isNull(drizzleDb.schemas.backupStorage.deletedAt)
),
});
if (!backupStorage) {
return NextResponse.json(
{ error: "Backup storage not found" },
{ status: 404 }
);
}
if (backupStorage.status !== "success") {
return NextResponse.json(
{ error: "Backup storage is not in a successful state" },
{ status: 422 }
);
}
const existingRestorationBackup = await db.query.restoration.findFirst({
where: and(
eq(drizzleDb.schemas.restoration.databaseId, id),
inArray(drizzleDb.schemas.restoration.status, ["waiting", "ongoing"])
),
});
if (existingRestorationBackup) {
return NextResponse.json(
{
error:
"A restoration is already waiting or ongoing for this database",
},
{ status: 409 }
);
}
const [restoration] = await db
.insert(drizzleDb.schemas.restoration)
.values({
databaseId: id,
backupId,
backupStorageId,
status: "waiting",
})
.returning();
if (!restoration) {
return NextResponse.json(
{ error: "Failed to create restoration" },
{ status: 500 }
);
}
return NextResponse.json({ data: restoration }, { status: 201 });
} catch (error) {
log.error({ error }, "Error in POST /api/v1/databases/[id]/restore");
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
);
+44
View File
@@ -0,0 +1,44 @@
import { NextResponse } from "next/server";
import { withApiKey } from "@/lib/api-v1/middleware";
import { db } from "@/db";
import * as drizzleDb from "@/db";
import { and, eq, isNull } from "drizzle-orm";
import { logger } from "@/lib/logger";
import { ApiKeyContext } from "@/lib/api-v1/types";
import {requireDatabaseAccess} from "@/lib/api-v1/services/databases";
const log = logger.child({ module: "api/v1/databases/[id]" });
export const GET = withApiKey(
async (_req: Request, ctx: ApiKeyContext, params?: Record<string, string>) => {
try {
const guard = await requireDatabaseAccess(params, ctx.user);
if (!guard.ok) {
return guard.response;
}
const { id } = guard.data;
const database = await db.query.database.findFirst({
where: and(
eq(drizzleDb.schemas.database.id, id),
isNull(drizzleDb.schemas.database.deletedAt)
),
});
if (!database) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
return NextResponse.json({ data: database });
} catch (error) {
log.error({ error }, "Error in GET /api/v1/databases/[id]");
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
);
+72
View File
@@ -0,0 +1,72 @@
import { NextResponse } from "next/server";
import { withApiKey } from "@/lib/api-v1/middleware";
import { db } from "@/db";
import * as drizzleDb from "@/db";
import { and, desc, eq, isNull } from "drizzle-orm";
import { logger } from "@/lib/logger";
import { ApiKeyContext } from "@/lib/api-v1/types";
import {requireDatabaseAccess} from "@/lib/api-v1/services/databases";
const log = logger.child({ module: "api/v1/databases/[id]/status" });
export const GET = withApiKey(
async (_req: Request, ctx: ApiKeyContext, params?: Record<string, string>) => {
try {
const guard = await requireDatabaseAccess(params, ctx.user);
if (!guard.ok) {
return guard.response;
}
const { id } = guard.data;
const [database, latestBackup, latestRestoration] = await Promise.all([
db.query.database.findFirst({
where: and(
eq(drizzleDb.schemas.database.id, id),
isNull(drizzleDb.schemas.database.deletedAt)
),
}),
db.query.backup.findFirst({
where: and(
eq(drizzleDb.schemas.backup.databaseId, id),
isNull(drizzleDb.schemas.backup.deletedAt)
),
orderBy: [desc(drizzleDb.schemas.backup.createdAt)],
}),
db.query.restoration.findFirst({
where: and(
eq(drizzleDb.schemas.restoration.databaseId, id),
isNull(drizzleDb.schemas.restoration.deletedAt)
),
orderBy: [desc(drizzleDb.schemas.restoration.createdAt)],
}),
]);
if (!database) {
return NextResponse.json(
{ error: "Database not found" },
{ status: 404 }
);
}
return NextResponse.json({
data: {
isWaitingForBackup: database.isWaitingForBackup,
lastContact: database.lastContact,
latestBackup: latestBackup ?? null,
latestRestoration: latestRestoration ?? null,
},
});
} catch (error) {
log.error({ error }, "Error in GET /api/v1/databases/[id]/status");
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
);
+24
View File
@@ -0,0 +1,24 @@
import { NextResponse } from "next/server";
import { withApiKey } from "@/lib/api-v1/middleware";
import { logger } from "@/lib/logger";
import { ApiKeyContext } from "@/lib/api-v1/types";
import { getAccessibleDatabases } from "@/lib/api-v1/services/databases";
const log = logger.child({ module: "api/v1/databases" });
export const GET = withApiKey(async (_req: Request, ctx: ApiKeyContext) => {
try {
const databases = await getAccessibleDatabases(ctx.user);
return NextResponse.json({
data: databases,
});
} catch (error) {
log.error({ error }, "Error in GET /api/v1/databases");
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
});
+45
View File
@@ -0,0 +1,45 @@
export const dynamic = "force-dynamic";
const html = `<!DOCTYPE html>
<html>
<head>
<title>Portabase API Docs</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.32.6/swagger-ui.css"
integrity="sha384-9Q2fpS+xeS4ffJy6CagnwoUl+4ldAYhOs9pgZuEKxypVModhmZFzeMlvVsAjf7uT"
crossorigin="anonymous"
/>
<style>
body { margin: 0; }
</style>
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.32.6/swagger-ui-bundle.js" integrity="sha384-EYdOaiRwn44zNjrw+Tfs06qYz9BGQVo2f4/pLY5i7VorbjnZNhdplAbTBk8FXHUJ" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.32.6/swagger-ui-standalone-preset.js" integrity="sha384-49fpFaVrAWI/qdgl9Vv5E/4NXxRUiJX5vGuLws1NUpTWGtEqzWEx8gHTw2UTehFK" crossorigin="anonymous"></script>
<script>
window.onload = function () {
SwaggerUIBundle({
url: "/api/v1/openapi",
dom_id: "#swagger-ui",
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIStandalonePreset,
],
layout: "StandaloneLayout",
persistAuthorization: true,
filter: true,
});
};
</script>
</body>
</html>`;
export function GET() {
return new Response(html, {
headers: { "Content-Type": "text/html; charset=utf-8" },
});
}
+31
View File
@@ -0,0 +1,31 @@
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
import { NextResponse } from "next/server";
import { withApiKey } from "@/lib/api-v1/middleware";
import { createPortabaseMcpServer } from "@/lib/mcp/server";
import type { ApiKeyContext } from "@/lib/api-v1/types";
import { env } from "@/env.mjs";
/**
* MCP endpoint Streamable HTTP transport, stateless mode.
*
* Auth is handled by withApiKey before MCP is ever touched.
* The validated key is forwarded by MCP tools to downstream /api/v1 REST calls.
*/
const apiEnabled = env.API_ENABLED;
const mcpEnabled = env.MCP_ENABLED;
export const POST = apiEnabled && mcpEnabled
? withApiKey(async (req: Request, ctx: ApiKeyContext) => {
const apiKey = req.headers.get("x-api-key")!;
const server = createPortabaseMcpServer(ctx, apiKey);
const transport = new WebStandardStreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});
await server.connect(transport);
return transport.handleRequest(req);
})
: () => NextResponse.json({ error: "Not found" }, { status: 404 });
+8
View File
@@ -0,0 +1,8 @@
import { NextResponse } from "next/server";
import { buildSpec } from "@/lib/api-v1/openapi/spec";
export const dynamic = "force-dynamic";
export function GET() {
return NextResponse.json(buildSpec());
}
+2 -2
View File
@@ -1,12 +1,12 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import type React from "react"; import type React from "react";
import "./globals.css"; import "./globals.css";
import { ConsoleSilencer } from "@/components/wrappers/common/console-silencer"; import { ConsoleSilencer } from "@/components/common/console-silencer";
import { author, geistMono, poppins } from "@/fonts/fonts"; import { author, geistMono, poppins } from "@/fonts/fonts";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Providers } from "./providers"; import { Providers } from "./providers";
const title = process.env.PROJECT_NAME ?? "App Title"; const title = process.env.PROJECT_NAME ?? "Portabase";
export const metadata: Metadata = { export const metadata: Metadata = {
title: { title: {
+5 -2
View File
@@ -1,4 +1,5 @@
import BackButton from "@/components/wrappers/common/button/back-button"; import Link from "next/link";
import { Button } from "@/components/ui/button";
export default async function NotFound() { export default async function NotFound() {
return ( return (
@@ -12,7 +13,9 @@ export default async function NotFound() {
The content you are trying to view is not available. The content you are trying to view is not available.
</p> </p>
</div> </div>
<BackButton>Go home</BackButton> <Button asChild>
<Link href="/">Go home</Link>
</Button>
</div> </div>
</div> </div>
); );
+4 -4
View File
@@ -4,9 +4,9 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { type PropsWithChildren, Suspense } from "react"; import { type PropsWithChildren, Suspense } from "react";
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
import { ErrorLayout } from "@/components/wrappers/common/error-layout"; import { ErrorLayout } from "@/components/common/error-layout";
import { ThemeMetaUpdaterRoot } from "@/features/browser/theme-meta-updater-root"; import { ThemeMetaUpdater } from "@/features/theme/components/theme-meta-updater";
import { ThemeProvider } from "@/features/theme/theme-provider"; import { ThemeProvider } from "@/features/theme/components/theme-provider";
export type ProviderProps = PropsWithChildren<{}>; export type ProviderProps = PropsWithChildren<{}>;
const queryClient = new QueryClient(); const queryClient = new QueryClient();
@@ -15,7 +15,7 @@ export const Providers = (props: ProviderProps) => {
return ( return (
<Suspense fallback={null}> <Suspense fallback={null}>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem> <ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<ThemeMetaUpdaterRoot /> <ThemeMetaUpdater />
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<ErrorLayout> <ErrorLayout>
<Toaster /> <Toaster />
+13 -7
View File
@@ -1,12 +1,12 @@
services: services:
app: app:
# build: # build:
# context: . # context: .
# dockerfile: docker/dockerfile/Dockerfile # dockerfile: docker/dockerfile/Dockerfile
# target: prod # target: prod
image: portabase/portabase:1.4.0 image: portabase/portabase:1
ports: ports:
- '8887:80' - "8887:80"
environment: environment:
TZ: "Europe/Paris" TZ: "Europe/Paris"
env_file: env_file:
@@ -17,6 +17,12 @@ services:
db: db:
condition: service_healthy condition: service_healthy
container_name: portabase-app-prod container_name: portabase-app-prod
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost/api/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 60s
db: db:
image: postgres:16-alpine image: postgres:16-alpine
@@ -29,7 +35,7 @@ services:
- POSTGRES_USER=devuser - POSTGRES_USER=devuser
- POSTGRES_PASSWORD=changeme - POSTGRES_PASSWORD=changeme
healthcheck: healthcheck:
test: [ "CMD-SHELL", "pg_isready -U devuser -d devdb" ] test: ["CMD-SHELL", "pg_isready -U devuser -d devdb"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5
+77 -1
View File
@@ -12,7 +12,7 @@ services:
- POSTGRES_USER=devuser - POSTGRES_USER=devuser
- POSTGRES_PASSWORD=changeme - POSTGRES_PASSWORD=changeme
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U devuser -d devdb"] test: [ "CMD-SHELL", "pg_isready -U devuser -d devdb" ]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5
@@ -32,5 +32,81 @@ services:
- ./private/uploads/tmp:/data/uploads/tmp - ./private/uploads/tmp:/data/uploads/tmp
user: "1000:1000" user: "1000:1000"
mailpit:
image: axllent/mailpit:latest
container_name: portabase-mailpit-dev
hostname: mailpit
restart: unless-stopped
ports:
- "8025:8025"
- "1025:1025"
azurite:
image: mcr.microsoft.com/azure-storage/azurite
command: azurite --blobHost 0.0.0.0 --skipApiVersionCheck
ports:
- "10000:10000"
volumes:
- azurite-data:/data
extra_hosts:
- "localhost:host-gateway"
networks:
- portabase
# DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://127.0.0.1:10000/devstoreaccount1;QueueEndpoint=http://127.0.0.1:10001/devstoreaccount1;TableEndpoint=http://127.0.0.1:10002/devstoreaccount1;
gcs:
image: fsouza/fake-gcs-server:latest
container_name: fake-gcs
ports:
- "4443:4443"
command:
- "-scheme"
- "http"
- "-port"
- "4443"
- "-public-host"
- "localhost:4443"
- "-external-url"
- "http://localhost:4443"
- "-backend"
- "filesystem"
- "-filesystem-root"
- "/storage"
volumes:
- gcs-data:/data
- gcs-storage:/storage
networks:
- portabase
gcs-init:
image: curlimages/curl:latest
depends_on:
- gcs
entrypoint: ["/bin/sh", "-c"]
command: >
'until curl -fsS http://gcs:4443/storage/v1/b >/dev/null; do sleep 1; done;
curl -fsS http://gcs:4443/storage/v1/b/portabase >/dev/null
|| curl -fsS -X POST "http://gcs:4443/storage/v1/b?project=local"
-H "Content-Type: application/json"
-d "{\"name\":\"portabase\"}"'
networks:
- portabase
# curl http://localhost:4443/storage/v1/b
# projectId: "test-project",
# bucketName: "portabase",
# clientEmail: "fake@test.iam.gserviceaccount.com",
# privateKey: "unused-when-emulated",
volumes: volumes:
postgres-data: postgres-data:
azurite-data:
gcs-data:
gcs-storage:
networks:
portabase:
name: portabase_network
external: true
+1
View File
@@ -92,6 +92,7 @@ ENV PORT=80
ENV HOSTNAME="0.0.0.0" ENV HOSTNAME="0.0.0.0"
ENV PGDATA=/data/postgres ENV PGDATA=/data/postgres
ENV PRIVATE_PATH=/data/private ENV PRIVATE_PATH=/data/private
ENV TUSD_BEHIND_PROXY=false
RUN addgroup --system --gid 1001 nodejs RUN addgroup --system --gid 1001 nodejs
+12 -1
View File
@@ -72,7 +72,18 @@ fi
mkdir -p /data/private/uploads/tmp mkdir -p /data/private/uploads/tmp
echo "▶ Starting tusd server..." echo "▶ Starting tusd server..."
tusd --base-path /tus/files/ --upload-dir /data/private/uploads/tmp --hooks-http http://127.0.0.1:3000/api/tus/hooks --port 1080 --max-size 21474836480 & TUSD_BEHIND_PROXY_FLAG=""
if [ "${TUSD_BEHIND_PROXY:-false}" = "true" ]; then
TUSD_BEHIND_PROXY_FLAG="--behind-proxy"
echo "[INFO] TUSD_BEHIND_PROXY=true, enabling tusd proxy mode"
fi
tusd \
--base-path /tus/files/ \
--upload-dir /data/private/uploads/tmp \
--hooks-http http://127.0.0.1:3000/api/tus/hooks \
--port 1080 \
--max-size 21474836480 \
$TUSD_BEHIND_PROXY_FLAG &
echo "▶ Starting Next.js server..." echo "▶ Starting Next.js server..."
PORT=3000 node server.js & PORT=3000 node server.js &
+7 -2
View File
@@ -1,6 +1,11 @@
events {} events {}
http { http {
map $http_x_forwarded_proto $forwarded_proto {
default $scheme;
"~*^\s*(https?)\s*(?:,|$)" $1;
}
client_max_body_size 20G; client_max_body_size 20G;
ignore_invalid_headers off; ignore_invalid_headers off;
@@ -18,7 +23,7 @@ http {
proxy_set_header Host $http_host; proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-Host $http_host; proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $forwarded_proto;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
@@ -34,7 +39,7 @@ http {
proxy_set_header Host $http_host; proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-Host $http_host; proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $forwarded_proto;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
} }
} }
+17
View File
@@ -0,0 +1,17 @@
.DS_Store
.git/
.gitignore
.bzr/
.bzrignore
.hg/
.hgignore
.svn/
*.swp
*.bak
*.tmp
*.orig
*~
.project
.idea/
*.tmproj
.vscode/
+28
View File
@@ -0,0 +1,28 @@
apiVersion: v2
name: portabase
description: Helm chart for Portabase
type: application
version: 0.0.0
appVersion: "latest"
keywords:
- postgresql
- mariadb
- mongodb
- mysql
- sqlite
- backup
- database
- restore
home: https://github.com/Portabase/portabase
sources:
- https://github.com/Portabase/portabase
- https://github.com/Portabase/portabase/tree/main/helm
maintainers:
- name: Charles Gauthereau
url: https://github.com/RambokDev
- name: Killian Larcher
url: https://github.com/KillianLarcher
icon: https://raw.githubusercontent.com/Portabase/portabase/main/.github/assets/logo.png
+32
View File
@@ -0,0 +1,32 @@
# Development Notes
## Check that Kubernetes is reachable locally
```bash
kubectl get nodes
```
## Install the local Portabase Helm chart
```bash
helm install portabase . \
--set project.secret=$(openssl rand -hex 32)
```
## Check the pods
```bash
kubectl get pods
```
## Check the services
```bash
kubectl get svc
```
## If the service type is ClusterIP, expose it locally using port forwarding:
```bash
kubectl port-forward svc/portabase 8887:80
```
+59
View File
@@ -0,0 +1,59 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: portabase
spec:
replicas: 1
selector:
matchLabels:
app: portabase
template:
metadata:
labels:
app: portabase
spec:
containers:
- name: portabase
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
ports:
- containerPort: 80
env:
- name: TZ
value: {{ .Values.timezone }}
- name: PROJECT_URL
value: {{ .Values.project.url }}
- name: PROJECT_SECRET
value: {{ .Values.project.secret }}
- name: TUSD_BEHIND_PROXY
value: {{ .Values.project.behindProxy | quote }}
volumeMounts:
- name: data
mountPath: /data
startupProbe:
httpGet:
path: /api/health
port: 80
initialDelaySeconds: 10
periodSeconds: 10
failureThreshold: 12
timeoutSeconds: 5
livenessProbe:
httpGet:
path: /api/health
port: 80
initialDelaySeconds: 0
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /api/health
port: 80
initialDelaySeconds: 0
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
volumes:
- name: data
persistentVolumeClaim:
claimName: portabase-data
+10
View File
@@ -0,0 +1,10 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: portabase-data
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: {{ .Values.persistence.size }}
+12
View File
@@ -0,0 +1,12 @@
apiVersion: v1
kind: Service
metadata:
name: portabase
spec:
type: {{ .Values.service.type }}
selector:
app: portabase
ports:
- port: {{ .Values.service.port }}
targetPort: 80
nodePort: {{ .Values.service.nodePort }}
+48
View File
@@ -0,0 +1,48 @@
image:
repository: portabase/portabase
tag: latest
pullPolicy: Always
replicaCount: 1
service:
type: ClusterIP
port: 80
targetPort: 80
resources:
requests:
memory: "1Gi"
cpu: "1"
limits:
memory: "1Gi"
cpu: "1"
timezone: Europe/Paris
project:
url: http://localhost:8887
secret: "change_me_generate_secure_secret"
behindProxy: false
persistence:
enabled: true
storageClassName: ""
accessMode: ReadWriteOnce
size: 10Gi
mountPath: /data
ingress:
enabled: false
className: nginx
hosts:
- host: portabase.example.com
paths:
- path: /
pathType: Prefix
tls: []
updateStrategy:
type: RollingUpdate
rollingUpdate:
partition: 0
+80 -79
View File
@@ -1,99 +1,100 @@
import type {NextConfig} from "next"; import type { NextConfig } from "next";
import {PORTABASE_DEFAULT_SETTINGS} from "./portabase.config"; import { PORTABASE_DEFAULT_SETTINGS } from "./portabase.config";
const isDev = process.env.NODE_ENV === "development"; const isDev = process.env.NODE_ENV === "development";
function buildCSPHeader(): string { function buildCSPHeader(): string {
const {CSP} = PORTABASE_DEFAULT_SETTINGS.SECURITY; const { CSP } = PORTABASE_DEFAULT_SETTINGS.SECURITY;
const directives = [ const directives = [
`default-src ${CSP.DEFAULT_SRC.join(" ")}`, `default-src ${CSP.DEFAULT_SRC.join(" ")}`,
`script-src ${CSP.SCRIPT_SRC.join(" ")}`, `script-src ${CSP.SCRIPT_SRC.join(" ")}`,
`style-src ${CSP.STYLE_SRC.join(" ")}`, `style-src ${CSP.STYLE_SRC.join(" ")}`,
`img-src ${CSP.IMG_SRC.join(" ")}`, `img-src ${CSP.IMG_SRC.join(" ")}`,
`font-src ${CSP.FONT_SRC.join(" ")}`, `font-src ${CSP.FONT_SRC.join(" ")}`,
`object-src ${CSP.OBJECT_SRC.join(" ")}`, `object-src ${CSP.OBJECT_SRC.join(" ")}`,
`connect-src ${CSP.CONNECT_SRC.join(" ")}`, `connect-src ${CSP.CONNECT_SRC.join(" ")}`,
`base-uri ${CSP.BASE_URI.join(" ")}`, `base-uri ${CSP.BASE_URI.join(" ")}`,
`form-action ${CSP.FORM_ACTION.join(" ")}`, `form-action ${CSP.FORM_ACTION.join(" ")}`,
`frame-ancestors ${CSP.FRAME_ANCESTORS.join(" ")}`, `frame-ancestors ${CSP.FRAME_ANCESTORS.join(" ")}`,
]; ];
if (CSP.BLOCK_ALL_MIXED_CONTENT) { if (CSP.BLOCK_ALL_MIXED_CONTENT) {
directives.push("block-all-mixed-content"); directives.push("block-all-mixed-content");
} }
if (CSP.UPGRADE_INSECURE_REQUESTS) { if (CSP.UPGRADE_INSECURE_REQUESTS) {
directives.push("upgrade-insecure-requests"); directives.push("upgrade-insecure-requests");
} }
return directives.join("; "); return directives.join("; ");
} }
function buildPermissionsPolicy(): string { function buildPermissionsPolicy(): string {
return Object.entries(PORTABASE_DEFAULT_SETTINGS.SECURITY.PERMISSIONS_POLICY) return Object.entries(PORTABASE_DEFAULT_SETTINGS.SECURITY.PERMISSIONS_POLICY)
.map(([feature, values]) => `${feature.toLowerCase()}=${values.join(", ")}`) .map(([feature, values]) => `${feature.toLowerCase()}=${values.join(", ")}`)
.join(", "); .join(", ");
} }
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
output: "standalone", output: "standalone",
typescript: { devIndicators: false,
ignoreBuildErrors: true, typescript: {
ignoreBuildErrors: true,
},
logging: {
browserToTerminal: false,
},
experimental: {
serverActions: {
bodySizeLimit: "10gb",
}, },
experimental: { proxyClientMaxBodySize: "10gb",
serverActions: { },
bodySizeLimit: "10gb", async rewrites() {
}, if (!isDev) return [];
proxyClientMaxBodySize: '10gb',
},
async rewrites() {
if (!isDev) return [];
return [ return [
{ {
source: "/tus/:path*", source: "/tus/:path*",
destination: "http://localhost:1080/tus/:path*", destination: "http://localhost:1080/tus/:path*",
}, },
]; ];
}, },
async headers() { async headers() {
return [ return [
{ {
source: "/(.*)", source: "/(.*)",
headers: [ headers: [
{ {
key: "Content-Security-Policy", key: "Content-Security-Policy",
value: buildCSPHeader(), value: buildCSPHeader(),
}, },
{ {
key: "Permissions-Policy", key: "Permissions-Policy",
value: buildPermissionsPolicy(), value: buildPermissionsPolicy(),
}, },
{ {
key: 'X-Content-Type-Options', key: "X-Content-Type-Options",
value: 'nosniff', value: "nosniff",
}, },
{ {
key: 'X-Frame-Options', key: "X-Frame-Options",
value: 'DENY', value: "DENY",
}, },
{ {
key: 'Referrer-Policy', key: "Referrer-Policy",
value: 'strict-origin-when-cross-origin', value: "strict-origin-when-cross-origin",
}, },
{ {
key: 'Strict-Transport-Security', key: "Strict-Transport-Security",
value: 'max-age=63072000; includeSubDomains; preload', value: "max-age=63072000; includeSubDomains; preload",
} },
// ...other security headers ],
], },
}, ];
]; },
},
}; };
export default nextConfig; export default nextConfig;
+56 -44
View File
@@ -1,6 +1,6 @@
{ {
"name": "portabase", "name": "portabase",
"version": "1.4.55", "version": "1.20.6",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --turbopack -p 8887", "dev": "next dev --turbopack -p 8887",
@@ -15,9 +15,16 @@
"release": "release-it" "release": "release-it"
}, },
"dependencies": { "dependencies": {
"@better-auth/passkey": "^1.4.19", "@asteasolutions/zod-to-openapi": "^8.5.0",
"@better-auth/sso": "^1.4.19", "@azure/storage-blob": "^12.32.0",
"@hookform/resolvers": "^5.2.2", "@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-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-aspect-ratio": "^1.1.8", "@radix-ui/react-aspect-ratio": "^1.1.8",
@@ -47,45 +54,48 @@
"@radix-ui/react-toggle-group": "^1.1.11", "@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@react-email/components": "^0.0.41", "@react-email/components": "^0.0.41",
"@t3-oss/env-nextjs": "^0.13.10", "@t3-oss/env-nextjs": "^0.13.11",
"@tanstack/react-query": "^5.90.21", "@tanstack/react-query": "^5.100.14",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"@types/nodemailer": "^6.4.23", "@types/nodemailer": "^6.4.23",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"@zenstackhq/runtime": "2.14.2", "@zenstackhq/runtime": "2.14.2",
"argon2": "^0.43.1", "argon2": "^0.43.1",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"better-auth": "1.4.18", "better-auth": "1.6.11",
"canvas-confetti": "^1.9.4",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.3.0",
"dockerode": "^4.0.9", "dockerode": "^4.0.12",
"dotenv": "^16.6.1", "dotenv": "^16.6.1",
"drizzle-orm": "^0.43.1", "drizzle-orm": "0.45.2",
"drizzle-zod": "^0.7.1", "drizzle-zod": "0.8.3",
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
"googleapis": "^170.1.0", "googleapis": "^170.1.0",
"input-otp": "^1.4.2", "input-otp": "^1.4.2",
"lucide-react": "^0.553.0", "lucide-react": "^0.553.0",
"minio": "^8.0.6", "minio": "^8.0.7",
"motion": "^12.34.3", "motion": "^12.40.0",
"next": "16.1.5", "next": "16.2.3",
"next-safe-action": "^7.10.8", "next-safe-action": "^7.10.8",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"node-cron": "^4.2.1", "node-cron": "^4.2.1",
"node-forge": "^1.3.3", "node-forge": "^1.4.0",
"nodemailer": "^7.0.13", "nodemailer": "8.0.5",
"npm-check-updates": "^18.3.1", "npm-check-updates": "^18.3.1",
"pg": "^8.18.0", "pg": "^8.21.0",
"prettier": "^3.8.1", "pino": "^10.3.1",
"react": "^19.2.4", "pino-pretty": "^13.1.3",
"prettier": "^3.8.3",
"react": "^19.2.6",
"react-day-picker": "9.7.0", "react-day-picker": "9.7.0",
"react-dom": "^19.2.4", "react-dom": "^19.2.6",
"react-dropzone": "^14.4.1", "react-dropzone": "^14.4.1",
"react-email": "^4.3.2", "react-email": "^4.3.2",
"react-hook-form": "^7.71.2", "react-hook-form": "^7.76.1",
"react-qr-code": "^2.0.18", "react-qr-code": "^2.0.21",
"react-resizable-panels": "^3.0.6", "react-resizable-panels": "^3.0.6",
"react-twc": "^1.5.1", "react-twc": "^1.5.1",
"react-use-measure": "^2.1.7", "react-use-measure": "^2.1.7",
@@ -94,42 +104,44 @@
"socket.io": "^4.8.3", "socket.io": "^4.8.3",
"socket.io-client": "^4.8.3", "socket.io-client": "^4.8.3",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"swiper": "^12.1.2", "swiper": "^12.1.4",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.6.0",
"uuid": "^11.1.0", "uuid": "^11.1.1",
"vaul": "^1.1.2", "vaul": "^1.1.2",
"ws": "^8.19.0", "ws": "^8.21.0",
"zod": "^3.25.76" "zod": "4.3.6"
}, },
"devDependencies": { "devDependencies": {
"@iconify/react": "^6.0.2", "@iconify/react": "^6.0.2",
"@react-email/preview-server": "4.3.2", "@react-email/preview-server": "4.3.2",
"@react-email/render": "^2.0.4", "@react-email/render": "^2.0.8",
"@release-it/bumper": "^7.0.5", "@release-it/bumper": "^7.0.5",
"@release-it/conventional-changelog": "^10.0.5", "@release-it/conventional-changelog": "^10.0.6",
"@tailwindcss/postcss": "^4.2.1", "@tailwindcss/postcss": "^4.3.0",
"@types/canvas-confetti": "^1.9.0",
"@types/eslint-plugin-tailwindcss": "^3.17.0", "@types/eslint-plugin-tailwindcss": "^3.17.0",
"@types/node": "^22.19.11", "@types/node": "^22.19.19",
"@types/node-forge": "^1.3.14", "@types/node-forge": "^1.3.14",
"@types/pg": "^8.16.0", "@types/pg": "^8.20.0",
"@types/react": "^19.2.14", "@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@zenstackhq/openapi": "^2.22.1", "@zenstackhq/openapi": "^2.22.1",
"@zenstackhq/tanstack-query": "^2.22.2", "@zenstackhq/tanstack-query": "^2.22.2",
"baseline-browser-mapping": "^2.10.0", "baseline-browser-mapping": "^2.10.32",
"drizzle-kit": "^0.31.9", "drizzle-kit": "^0.31.10",
"esbuild": "^0.27.3", "esbuild": "^0.27.7",
"eslint": "^9.39.3", "eslint": "^9.39.4",
"eslint-config-next": "^16.1.6", "eslint-config-next": "^16.2.6",
"eslint-plugin-tailwindcss": "^3.18.2", "eslint-plugin-tailwindcss": "^3.18.3",
"framer-motion": "^12.34.3", "framer-motion": "^12.40.0",
"postcss": "^8.5.6", "node-pty": "^1.1.0",
"postcss": "8.5.10",
"release-it": "^19.2.4", "release-it": "^19.2.4",
"tailwindcss": "^4.2.1", "tailwindcss": "^4.3.0",
"tsx": "^4.21.0", "tsx": "^4.22.3",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"zenstack": "2.14.2" "zenstack": "2.14.2"
}, },
"packageManager": "pnpm@10.30.1+sha512.3590e550d5384caa39bd5c7c739f72270234b2f6059e13018f975c313b1eb9fefcc09714048765d4d9efe961382c312e624572c0420762bdc5d5940cdf9be73a" "packageManager": "pnpm@11.8.0+sha512.c1f5e7c4cb241c8f174b743851d82f42b802324afc8b0f116b96adb15aa06664948dde36960a3ba1079ba5b4b29dd0140135b94b5b5f5263592249d68e555f26"
} }
+5119 -2972
View File
File diff suppressed because it is too large Load Diff
+16
View File
@@ -1,3 +1,19 @@
allowBuilds:
'@prisma/client': true
'@prisma/engines': true
argon2: true
bcrypt: true
cpu-features: true
esbuild: true
node-pty: true
prisma: true
protobufjs: true
sharp: true
ssh2: true
unrs-resolver: true
zenstack: true
ignoredBuiltDependencies:
- node-pty
onlyBuiltDependencies: onlyBuiltDependencies:
- '@prisma/client' - '@prisma/client'
- '@prisma/engines' - '@prisma/engines'
+2 -1
View File
@@ -23,6 +23,7 @@ export const PORTABASE_DEFAULT_SETTINGS = {
"https://code.iconify.design", "https://code.iconify.design",
"https://cdn.iconify.design", "https://cdn.iconify.design",
"https://code.iconify.com", "https://code.iconify.com",
"https://cdn.jsdelivr.net",
], ],
IMG_SRC: [ IMG_SRC: [
"'self'", "'self'",
@@ -45,7 +46,7 @@ export const PORTABASE_DEFAULT_SETTINGS = {
"https://api.iconify.design", "https://api.iconify.design",
"https://code.iconify.design", "https://code.iconify.design",
"https://api.github.com", "https://api.github.com",
"https://api.dicebear.com",
], ],
OBJECT_SRC: ["'none'"], OBJECT_SRC: ["'none'"],
BASE_URI: ["'self'"], BASE_URI: ["'self'"],
+48 -2
View File
@@ -3,6 +3,8 @@ import { loggingMiddleware } from "@/middleware/loggingMiddleware";
import { errorHandler } from "@/middleware/errorHandler"; import { errorHandler } from "@/middleware/errorHandler";
import { auth } from "@/lib/auth/auth"; import { auth } from "@/lib/auth/auth";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { env } from "@/env.mjs";
import { User } from "@/db/schema/02_user";
export async function proxy(request: NextRequest) { export async function proxy(request: NextRequest) {
const url = request.nextUrl.clone(); const url = request.nextUrl.clone();
@@ -17,11 +19,13 @@ export async function proxy(request: NextRequest) {
new URL(`/login?redirect=${redirectUrl}`, request.url), new URL(`/login?redirect=${redirectUrl}`, request.url),
); );
} }
if (session.user.banned) { const user = session.user as User;
if (user.banned) {
await auth.api.signOut({ headers: await headers() }); await auth.api.signOut({ headers: await headers() });
return NextResponse.redirect(new URL("/login?error=banned", request.url)); return NextResponse.redirect(new URL("/login?error=banned", request.url));
} }
if (session.user.role === "pending") { if (user.role === "pending") {
await auth.api.signOut({ headers: await headers() }); await auth.api.signOut({ headers: await headers() });
return NextResponse.redirect( return NextResponse.redirect(
new URL(`/login?error=pending?redirect=${redirectUrl}`, request.url), new URL(`/login?error=pending?redirect=${redirectUrl}`, request.url),
@@ -38,6 +42,33 @@ export async function proxy(request: NextRequest) {
} }
if (url.pathname.startsWith("/api")) { if (url.pathname.startsWith("/api")) {
if (url.pathname.startsWith("/api/v1")) {
const apiEnabled = env.API_ENABLED;
if (!apiEnabled) {
return new NextResponse(
JSON.stringify({
message: "This API route does not exist.",
status: 404,
}),
{ status: 404, headers: { "Content-Type": "application/json" } },
);
}
const openapiEnabled = env.OPENAPI_ENABLED;
if (
!openapiEnabled &&
(url.pathname.startsWith("/api/v1/docs") ||
url.pathname.startsWith("/api/v1/openapi"))
) {
return new NextResponse(
JSON.stringify({
message: "This API route does not exist.",
status: 404,
}),
{ status: 404, headers: { "Content-Type": "application/json" } },
);
}
}
const routeExists = checkRouteExists(url.pathname); const routeExists = checkRouteExists(url.pathname);
if (!routeExists) { if (!routeExists) {
return new NextResponse( return new NextResponse(
@@ -71,7 +102,22 @@ function checkRouteExists(pathname: string) {
/^\/api\/tus\/hooks\/?$/, /^\/api\/tus\/hooks\/?$/,
/^\/api\/events\/?$/, /^\/api\/events\/?$/,
/^\/api\/config\/?$/, /^\/api\/config\/?$/,
/^\/api\/health\/?$/,
/^\/api\/google\/drive\/callback\/?$/, /^\/api\/google\/drive\/callback\/?$/,
/^\/api\/avatar\/?$/,
// v1 external API
/^\/api\/v1\/mcp\/?$/,
/^\/api\/v1\/docs\/?$/,
/^\/api\/v1\/openapi\/?$/,
/^\/api\/v1\/agents\/?$/,
/^\/api\/v1\/agents\/[^/]+\/?$/,
/^\/api\/v1\/agents\/[^/]+\/key\/?$/,
/^\/api\/v1\/databases\/?$/,
/^\/api\/v1\/databases\/[^/]+\/?$/,
/^\/api\/v1\/databases\/[^/]+\/backup\/?$/,
/^\/api\/v1\/databases\/[^/]+\/backup\/[^/]+\/?$/,
/^\/api\/v1\/databases\/[^/]+\/restore\/?$/,
/^\/api\/v1\/databases\/[^/]+\/status\/?$/,
]; ];
return routePatterns.some((pattern) => pattern.test(pathname)); return routePatterns.some((pattern) => pattern.test(pathname));
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 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"

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