Compare commits

..
104 Commits
Author SHA1 Message Date
github-actions[bot] 7f93abff82 chore: release 1.22.4 2026-07-09 07:37:37 +00:00
3e8a6d5006 fix: cron.ts (#365)
Co-authored-by: charles-gauthereau <charles.gauthereau@soluce-technologies.com>
2026-07-09 09:36:44 +02:00
github-actions[bot] 5e4a430049 chore: release 1.22.3 2026-07-07 17:40:10 +00:00
47d78943d8 fix: data-loading (#364)
* chore: remove dead comment in database page

* fix(data-table): manualPagination support

* fix(hooks): useServerDataTable for server-side pagination

* fix(database):  paginated backup and restoration list actions

* fix(database): server-paginate backup list

* fix(database): server-paginate restoration list

* fix(database): switch tabs via History API instead of router.push

* fix(logs): load job logs on demand when the modal opens

* perf(database): slim metadata poll and drop eager backup/log loading

* fix(database): invalidate backups/restorations list keys on mutations for instant refresh

* fix(logs): disable log button when row has no logs

* fix(logs): fetch logs on button click, open modal already populated

* fix: loading log modal

---------

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

* fix: README.md

---------

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

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

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

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

---------

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

* fix: s3.form.tsx

---------

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

* rmv my dashboard

* Stop tracking my-dashboard

* gitignore: ignore all .env files

* working UI, not yet tested

* Update credential validation

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

* tested with fake-gcs-server

* fix: migration

* fix: migration, init for gcs and refactoring

* fix: get object gcs

---------

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

* filtered agents

---------

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

* fix: migration

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

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

* feat(onboarding): add sso-gate step

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

* feat(onboarding): add security step

* feat(onboarding): add preferences step

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

* feat(onboarding): add org-create step

* feat(onboarding): add invite-members step

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

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

* feat(onboarding): add defaults step

* feat(onboarding): add agent-create step

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

* feat(onboarding): add project-create step

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

* feat(onboarding): add finish step with confetti

* feat(onboarding): wire full step graph

* feat(onboarding): add /welcome route

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

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

* docs: add onboarding channel config + style refactor spec

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

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

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

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

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

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

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

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

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

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

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

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

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

* feat(onboarding): add resolveOnboardingState server function

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* docs: add onboarding refactor design spec

* docs: add onboarding refactor implementation plan

* refactor(onboarding): extract useCreateAgent and useDeleteAgent hooks

* refactor(onboarding): extract useCreateOrg and useCreateProject hooks

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

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

* refactor(onboarding): extract useUpdateAccount hook

* refactor(onboarding): extract useAddNotifier and useRemoveNotifier hooks

* refactor(onboarding): extract useAddStorage and useRemoveStorage hooks

* refactor(onboarding): extract useAgentStatus and useGenerateEdgeKey hooks

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

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

* docs: add onboarding theme & progress bar implementation plan

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* feat(onboarding): add applyOnboardingDbSettingsAction server action

* feat(onboarding): add BackupScheduleSelector component

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

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

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

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

* add: onboarding

* fix(migration): merge

* fix(migration): apply for onboarding

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

* chore: checkpoint before features reorganization

* Task 2: Add migration script for features directory reorganization

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

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

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

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

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

* refactor(logs): reorganize into components/types

* refactor(migration): reorganize into actions/components

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

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

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

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

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

* refactor(statistics): reorganize into components/utils

* refactor(storages): reorganize into types/utils

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

* refactor(updates): reorganize into components/hooks

* refactor(upload): move action to actions/

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

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

* fix: .gitignore

* fix: onboarding

* fix: removed dead files

* feat: backfill onboarding flag for instances with existing data

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

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

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

* feat: allow avatar upload regardless of mode

* feat: remove avatarMode from layout and profile prop chains

* feat: remove avatar mode admin tab and supporting files

* feat: remove avatar mode selector from onboarding defaults step

* fix: remove preferences onboarding step and fix routing

* fix: remove stale preferences entry from onboarding checklist

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

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

* fix: use small destructive button for avatar removal

* fix

* Update step-defaults.tsx

* fix: default dev password for keycloak refer to env

* fix

* fix(migration): refactoring

* fix: api agent helpers.ts

* Update .env.example

---------

Co-authored-by: Théo LAGACHE <theo.lagache@soluce-technologies.com>
Co-authored-by: charles-gauthereau <charles.gauthereau@soluce-technologies.com>
2026-06-27 17:08:38 +02:00
github-actions[bot] 40d3228db6 chore: release 1.20.2 2026-06-25 12:57:03 +00:00
Charles GTEandGitHub 383564f988 Merge pull request #335 from Portabase/fix/backup-size-restoration
fix: size in restoration agent helper
2026-06-25 14:56:06 +02:00
Charles GTE fb2f7791fd fix: size in restoration agent helper 2026-06-25 14:42:42 +02:00
github-actions[bot] 80a3f8642a chore: release 1.20.1 2026-06-24 06:47:12 +00:00
Charles GTEandGitHub 18bdbafa75 Merge pull request #333 from Portabase/fix/retention-failed-backups
fix: clean up failed and ongoing backups
2026-06-24 08:46:22 +02:00
charles-gauthereau e31245b492 fix: clean up failed and ongoing backups 2026-06-24 08:32:40 +02:00
github-actions[bot] c7d10efefe chore: release 1.20.0 2026-06-21 14:40:11 +00:00
Charles GTEandGitHub 408d6d76ef Merge pull request #327 from Portabase/dev
Dev
2026-06-21 16:39:06 +02:00
Charles GTEandGitHub bf0ed69b60 Merge pull request #328 from jjingi/feature/Integrate-Azure-Blob
Feature/integrate azure blob
2026-06-21 15:49:52 +02:00
Charles GTE afd9a8eff2 chore: refactoring 2026-06-21 15:47:23 +02:00
Charles GTE baab060628 fix: azurite and refactoring 2026-06-20 19:35:40 +02:00
Charles GTE 3018515abf Merge branch 'dev' into feature/Integrate-Azure-Blob
# Conflicts:
#	src/db/migrations/meta/0063_snapshot.json
#	src/db/migrations/meta/_journal.json
2026-06-20 14:56:41 +02:00
Charles GTE 402d3f3d9d fix: README.md 2026-06-20 09:59:30 +02:00
Charles GTE e193a300b1 fix: README.md 2026-06-20 09:45:02 +02:00
Charles GTE 0ed3be16f5 fix: README.md 2026-06-20 09:23:31 +02:00
jingi c34d8c5532 fix: coderabbit review fixed(typo, url validation, and unique ping key 2026-06-19 14:45:24 -07:00
Charles GTE aaa0c1d9a3 fix: README.md 2026-06-19 23:13:34 +02:00
jingi 1dfd0c2e56 fix: changed lowercase blob provder to uppercase to match StoragePrivderKind 2026-06-19 14:06:15 -07:00
jingi 63a94143e2 chore: regenrate pnpm-lock.yaml after rebase 2026-06-19 13:54:44 -07:00
jingi 243d724fb2 feat: add blob to db migration and register in channel helpers 2026-06-19 13:53:58 -07:00
jingi d6235adccd fix: add blob to db schema 2026-06-19 13:51:12 -07:00
jingi ae0f9a770d fix: add form for azure blob 2026-06-19 13:51:12 -07:00
jingi 305d55d972 feat: add Blobl.form.tsx file 2026-06-19 13:51:12 -07:00
jingi aa14367f65 feat: add blob variant to SotrageChannelFormSchema 2026-06-19 13:51:12 -07:00
jingi 8f797df033 feat: add auzre blob on index.ts handler 2026-06-19 13:51:12 -07:00
jingi d680eeb40e fix: rename blob.schema.ts to Blob.schema.ts 2026-06-19 13:51:12 -07:00
jingi 6ede27667f feat: implement Azure Blob storage (getBlobClient, upload, get, delete, ping, copy) 2026-06-19 13:51:12 -07:00
jingi 187d100230 feat: add Zod schema for Azure Blob storage config 2026-06-19 13:51:12 -07:00
jingi 4f8df0cef6 feat: add azure storage dependency 2026-06-19 13:50:19 -07:00
jingi 76a7c2ce43 feat: add blob to storage types 2026-06-19 13:48:42 -07:00
Charles GTE 4911e49c6b fix: README.md 2026-06-19 22:45:04 +02:00
Charles GTE f2f5f767b4 fix: README.md 2026-06-19 22:22:20 +02:00
Charles GTEandGitHub f209989f11 Merge pull request #325 from pcrosthwaite/feature/pushover-notifications
Feature/pushover notifications
2026-06-19 19:22:33 +02:00
charles-gauthereau d651568921 fix: schema and form select 2026-06-19 19:20:37 +02:00
Peter Crosthwaite 27348a3341 Updated to use AbortController pattern 2026-06-19 22:04:03 +10:00
Peter Crosthwaite ceba848bdd pushover request fixes
Implemented truncation on data elements to comply with pushover restrictions.
Added a timeout for a request to the pushover api endpoint
2026-06-19 21:22:07 +10:00
Peter Crosthwaite 1a36b8b6ef Merge main into feature/pushover-notifications, add Microsoft Teams provider
Both branches added a migration at index 0062. Resolved by keeping
upstream's teams migration at 0062 and promoting pushover to 0063.
Both providers are now included in the enum, types, handlers, and forms.
2026-06-19 18:27:45 +10:00
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
Peter Crosthwaite db6eb221d8 Updated failed response to display in activity log 2026-06-19 17:24:44 +10:00
Peter Crosthwaite 6075872881 Added support for pushover notifications 2026-06-19 15:37:05 +10:00
503 changed files with 37720 additions and 8310 deletions
+3
View File
@@ -81,3 +81,6 @@ TRUSTED_DOMAINS="http://localhost:8887, http://localhost:3055, http://localhost:
# Default to false
#TUSD_BEHIND_PROXY=true
#Skip the onboarding on fresh instance
#SKIP_ONBOARDING=true
+1 -5
View File
@@ -1,11 +1,7 @@
{
"extends": [
"next/core-web-vitals",
"plugin:tailwindcss/recommended"
],
"extends": ["next/core-web-vitals", "plugin:tailwindcss/recommended"],
"rules": {
"react/no-unescaped-entities": "off",
"react-hooks/rules-of-hooks": "off"
}
}
+1 -1
View File
@@ -73,7 +73,7 @@ jobs:
tags: ${{ steps.prep.outputs.image }}
target: ${{ inputs.target }}
cache-from: type=gha,scope=build-${{ matrix.platform }}
cache-to: type=gha,mode=max,scope=build-${{ matrix.platform }}
cache-to: type=gha,mode=max,scope=build-${{ matrix.platform }},ignore-error=true
- name: Save image name for manifest
run: echo "${{ steps.prep.outputs.image }}" > image.txt
+5
View File
@@ -9,6 +9,8 @@
# testing
/coverage
gcs-data/
gcs-storage/
# next.js
/.next/
@@ -28,6 +30,8 @@ yarn-error.log*
# local env files
.env*.local
.env
**/.env
# vercel
.vercel
@@ -60,3 +64,4 @@ certificates
.codex
/docs
.worktrees
.superpowers
+1 -1
View File
@@ -33,5 +33,5 @@ keywords:
- web-ui
- agent
license: Apache-2.0
version: 1.19.0
version: 1.22.4
date-released: '2026-03-02'
+9 -1
View File
@@ -45,4 +45,12 @@ export-keycloak:
@docker cp kc-exporter:/tmp/kc-export/. ./seeds/keycloak/
@docker rm -f kc-exporter >/dev/null 2>&1
@docker compose -f docker-compose.func.yml start keycloak >/dev/null 2>&1
@echo "Keycloak configuration and users exported to seeds/keycloak/*.json"
@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
+1
View File
@@ -78,6 +78,7 @@ You have 5 ways to install Portabase:
| **Valkey** | ✅ Stable | 7.2+ | No |
| **Firebird** | ✅ Stable | 3.0, 4.0, 5.0 | Yes |
| **MSSQL Server** | ✅ Stable | 2017, 2019, 2022, Azure SQL | Yes |
| **Docker Volume** | ✅ Stable | Docker Engine 20.10+ | Yes |
See the [Database Servers documentation](https://portabase.io/docs/agent/db) for version-specific backup and restore details.
+2 -2
View File
@@ -1,9 +1,9 @@
import { CardContent, CardHeader } from "@/components/ui/card";
import { TooltipProvider } from "@/components/ui/tooltip";
import { ForgotPasswordForm } from "@/features/auth/forgot-password-form";
import { ForgotPasswordForm } from "@/features/auth/components/forgot-password-form";
import { env } from "@/env.mjs";
import { CardAuth } from "@/features/layout/card-auth";
import { CardAuth } from "@/features/layout/components/card-auth";
import { redirect } from "next/navigation";
export default async function RoutePage(props: { searchParams: Promise<{ callbackUrl: string | undefined }> }) {
+2 -2
View File
@@ -1,9 +1,9 @@
import {CardContent, CardHeader} from "@/components/ui/card";
import {TooltipProvider} from "@/components/ui/tooltip";
import {GuardForm} from "@/features/auth/guard-form";
import {GuardForm} from "@/features/auth/components/guard-form";
import {cookies} from "next/headers";
import {redirect} from "next/navigation";
import {CardAuth} from "@/features/layout/card-auth";
import {CardAuth} from "@/features/layout/components/card-auth";
export default async function GuardPage() {
+7 -1
View File
@@ -1,7 +1,9 @@
import React from "react";
import { redirect } from "next/navigation";
import { currentUser } from "@/lib/auth/current-user";
import { AuthLogoSection } from "@/features/auth/auth-logo-section";
import { isOnboardingDone } from "@/db/services/setting";
import { AuthLogoSection } from "@/features/auth/components/auth-logo-section";
import { env } from "@/env.mjs";
import { Heart } from "lucide-react";
export default async function Layout({
@@ -9,6 +11,10 @@ export default async function Layout({
}: {
children: React.ReactNode;
}) {
if (env.SKIP_ONBOARDING !== "true" && !(await isOnboardingDone())) {
redirect("/welcome");
}
const user = await currentUser();
if (user && !user.banned && user.role !== "pending") {
+3 -3
View File
@@ -1,12 +1,12 @@
import { LoginForm } from "@/features/auth/login-form";
import { LoginForm } from "@/features/auth/components/login-form";
import { Metadata } from "next";
import { SUPPORTED_PROVIDERS } from "@/lib/auth/config";
import { SocialAuthButtons } from "@/features/auth/social-buttons";
import { SocialAuthButtons } from "@/features/auth/components/social-buttons";
import { TooltipProvider } from "@/components/ui/tooltip";
import { CardContent, CardHeader } from "@/components/ui/card";
import Link from "next/link";
import { Separator } from "@/components/ui/separator";
import { CardAuth } from "@/features/layout/card-auth";
import { CardAuth } from "@/features/layout/components/card-auth";
import { env } from "@/env.mjs";
export const metadata: Metadata = {
+1 -1
View File
@@ -1,5 +1,5 @@
import { PageParams } from "@/types/next";
import { RegisterForm } from "@/features/auth/register-form";
import { RegisterForm } from "@/features/auth/components/register-form";
import { Metadata } from "next";
import { redirect } from "next/navigation";
import { env } from "@/env.mjs";
+2 -2
View File
@@ -1,10 +1,10 @@
import { CardContent, CardHeader } from "@/components/ui/card";
import { TooltipProvider } from "@/components/ui/tooltip";
import { ResetPasswordForm } from "@/features/auth/reset-password-form";
import { ResetPasswordForm } from "@/features/auth/components/reset-password-form";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth/auth";
import { Avatar, AvatarImage, AvatarFallback } from "@radix-ui/react-avatar";
import { CardAuth } from "@/features/layout/card-auth";
import { CardAuth } from "@/features/layout/components/card-auth";
import { env } from "@/env.mjs";
export default async function RoutePage(props: { searchParams: Promise<{ token: string | undefined }> }) {
@@ -3,8 +3,8 @@ import {eq} from "drizzle-orm";
import {db} from "@/db";
import * as drizzleDb from "@/db";
import {PageParams} from "@/types/next";
import {Page} from "@/features/layout/page";
import {OrganizationManagement} from "@/features/organizations/admin-organization-management";
import {Page} from "@/features/layout/components/page";
import {OrganizationManagement} from "@/features/organizations/components/admin-organization-management";
import {buildOrganizationWithMembers} from "@/utils/common";
import {isUUID} from "@/utils/text";
import {user} from "@/db/schema/02_user";
@@ -1,10 +1,10 @@
import {PageParams} from "@/types/next";
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/components/page";
import {db} from "@/db";
import {
AdminOrganizationAddModal
} from "@/features/organizations/admin-organization-add-modal";
import {AdminOrganizationList} from "@/features/organizations/admin-organization-list";
} from "@/features/organizations/components/admin-organization-add-modal";
import {AdminOrganizationList} from "@/features/organizations/components/admin-organization-list";
import {isNull} from "drizzle-orm";
export default async function RoutePage(props: PageParams<{}>) {
@@ -1,9 +1,9 @@
import {PageParams} from "@/types/next";
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/components/page";
import {db} from "@/db";
import {logger} from "@/lib/logger";
import {notFound} from "next/navigation";
import {SettingsTabs} from "@/features/settings/settings-tabs";
import {SettingsTabs} from "@/features/settings/components/settings-tabs";
import {desc, isNull} from "drizzle-orm";
import * as drizzleDb from "@/db";
import {StorageChannelWith} from "@/db/schema/12_storage-channel";
@@ -1,21 +1,28 @@
import {PageParams} from "@/types/next";
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/components/page";
import {db} from "@/db";
import {desc, isNull} from "drizzle-orm";
import {AdminUserList} from "@/features/users/admin-user-list";
import {AdminUserAddModal} from "@/features/users/admin-user-add-modal";
import {AdminUserList} from "@/features/users/components/admin-user-list";
import {AdminUserAddModal} from "@/features/users/components/admin-user-add-modal";
import {SUPPORTED_PROVIDERS} from "@/lib/auth/config";
import {getSettings} from "@/db/services/setting";
import {resolveAvatarUrl} from "@/utils/resolve-avatar-url";
export default async function RoutePage(props: PageParams<{}>) {
const users = await db.query.user.findMany({
where: (fields) => isNull(fields.deletedAt),
with: {
accounts: true
},
orderBy: (fields) => desc(fields.createdAt),
const [settings, users] = await Promise.all([
getSettings(),
db.query.user.findMany({
where: (fields) => isNull(fields.deletedAt),
with: { accounts: true },
orderBy: (fields) => desc(fields.createdAt),
}),
]);
const avatarUrls = Object.fromEntries(
users.map((u) => [u.id, resolveAvatarUrl(u, settings)])
);
});
const organizations = await db.query.organization.findMany({
with: {
members: true,
@@ -38,7 +45,7 @@ export default async function RoutePage(props: PageParams<{}>) {
</div>
</PageHeader>
<PageContent className="flex flex-col gap-5">
<AdminUserList users={users} isPasswordAuthEnabled={isPasswordAuthEnabled}/>
<AdminUserList users={users} isPasswordAuthEnabled={isPasswordAuthEnabled} avatarUrls={avatarUrls}/>
</PageContent>
</Page>
);
@@ -4,17 +4,17 @@ import {
PageContent,
PageDescription,
PageTitle,
} from "@/features/layout/page";
} from "@/features/layout/components/page";
import { db } from "@/db";
import * as drizzleDb from "@/db";
import {eq, isNull} from "drizzle-orm";
import { notFound } from "next/navigation";
import { ButtonDeleteAgent } from "@/features/agents/agent-delete-button";
import { ButtonDeleteAgent } from "@/features/agents/components/agent-delete-button";
import { capitalizeFirstLetter } from "@/utils/text";
import { generateEdgeKey } from "@/utils/edge_key";
import { getServerUrl } from "@/utils/get-server-url";
import { AgentContentPage } from "@/features/agents/agent-content";
import { AgentDialog } from "@/features/agents/agent-dialog";
import { AgentContentPage } from "@/features/agents/components/agent-content";
import { AgentDialog } from "@/features/agents/components/agent-dialog";
export default async function RoutePage(
@@ -81,7 +81,7 @@ export default async function RoutePage(
</PageDescription>
)}
<PageContent className="flex flex-col w-full h-full justify-between gap-6">
<AgentContentPage agent={agent} edgeKey={edgeKey} />
<AgentContentPage agent={agent} edgeKey={edgeKey} canDeleteDatabases={true} />
</PageContent>
</Page>
);
@@ -1,13 +1,13 @@
import {PageParams} from "@/types/next";
import {AgentCard} from "@/features/agents/agent-card";
import {AgentCard} from "@/features/agents/components/agent-card";
import {CardsWithPagination} from "@/components/common/cards-with-pagination";
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/components/page";
import {notFound} from "next/navigation";
import {db} from "@/db";
import * as drizzleDb from "@/db";
import {and, desc, eq, isNull, not} from "drizzle-orm";
import {Metadata} from "next";
import {AgentDialog} from "@/features/agents/agent-dialog";
import {AgentDialog} from "@/features/agents/components/agent-dialog";
export const metadata: Metadata = {
title: "Agents",
@@ -1,11 +1,11 @@
import {PageParams} from "@/types/next";
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/components/page";
import {Metadata} from "next";
import {db} from "@/db";
import {notificationChannel, NotificationChannelWith} from "@/db/schema/09_notification-channel";
import {desc, isNull} from "drizzle-orm";
import {ChannelsSection} from "@/features/channel/channels-section";
import {ChannelAddEditModal} from "@/features/channel/channel-add-edit-modal";
import {ChannelsSection} from "@/features/channel/components/channels-section";
import {ChannelAddEditModal} from "@/features/channel/components/channel-add-edit-modal";
import * as drizzleDb from "@/db";
export const metadata: Metadata = {
@@ -1,7 +1,7 @@
import {PageParams} from "@/types/next";
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/components/page";
import {Metadata} from "next";
import {NotificationLogsList} from "@/features/notifications/notification-logs-list";
import {NotificationLogsList} from "@/features/notifications/components/notification-logs-list";
import {notFound} from "next/navigation";
import {getNotificationHistory} from "@/db/services/notification-log";
@@ -1,12 +1,12 @@
import {PageParams} from "@/types/next";
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/components/page";
import {Metadata} from "next";
import {ChannelsSection} from "@/features/channel/channels-section";
import {ChannelsSection} from "@/features/channel/components/channels-section";
import {db} from "@/db";
import {desc, eq, isNull} from "drizzle-orm";
import * as drizzleDb from "@/db";
import {StorageChannelWith} from "@/db/schema/12_storage-channel";
import {ChannelAddEditModal} from "@/features/channel/channel-add-edit-modal";
import {ChannelAddEditModal} from "@/features/channel/components/channel-add-edit-modal";
export const metadata: Metadata = {
title: "Storage Channels",
@@ -1,10 +1,10 @@
import {PageParams} from "@/types/next";
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/components/page";
import {notFound} from "next/navigation";
import {getOrganization} from "@/lib/auth/auth";
import {Metadata} from "next";
import {db} from "@/db";
import {MigrationTool} from "@/features/migration/migration-tool";
import {MigrationTool} from "@/features/migration/components/migration-tool";
export const metadata: Metadata = {
title: "Projects",
@@ -1,122 +1,128 @@
import {PageParams} from "@/types/next";
import {notFound, redirect} from "next/navigation";
import {Page} from "@/features/layout/page";
import {db} from "@/db";
import {eq, and, inArray} from "drizzle-orm";
import { PageParams } from "@/types/next";
import { notFound, redirect } from "next/navigation";
import { Page } from "@/features/layout/components/page";
import { db } from "@/db";
import { eq, and, inArray, isNull } from "drizzle-orm";
import * as drizzleDb from "@/db";
import {getOrganizationProjectDatabases} from "@/db/services/project";
import {getActiveMember, getOrganization} from "@/lib/auth/auth";
import {BackupModalProvider} from "@/features/database/backup-modal-context";
import {DatabaseContent} from "@/features/database/database-content";
import {getHealthLast12hLogs} from "@/db/services/healthcheck";
import {LogsModalProvider} from "@/features/logs/logs-modal-context";
import { getOrganizationProjectDatabases } from "@/db/services/project";
import { getActiveMember, getOrganization } from "@/lib/auth/auth";
import { BackupModalProvider } from "@/features/database/components/backup-modal-context";
import { DatabaseContent } from "@/features/database/components/database-content";
import { getHealthLast12hLogs } from "@/db/services/healthcheck";
import { LogsModalProvider } from "@/features/logs/components/logs-modal-context";
export default async function RoutePage(props: PageParams<{
export default async function RoutePage(
props: PageParams<{
projectId: string;
databaseId: string
}>) {
const {projectId, databaseId} = await props.params;
databaseId: string;
}>,
) {
const { projectId, databaseId } = await props.params;
const organization = await getOrganization({});
const activeMember = await getActiveMember()
const organization = await getOrganization({});
const activeMember = await getActiveMember();
if (!organization || !activeMember) {
notFound();
}
if (!organization || !activeMember) {
notFound();
}
const databasesProject = await getOrganizationProjectDatabases({
organizationSlug: organization.slug,
projectId: projectId
})
const databasesProject = await getOrganizationProjectDatabases({
organizationSlug: organization.slug,
projectId: projectId,
});
const dbItem = await db.query.database.findFirst({
where: and(inArray(drizzleDb.schemas.backup.id, databasesProject.ids ?? []), eq(drizzleDb.schemas.database.id, databaseId), eq(drizzleDb.schemas.database.projectId, projectId)),
with: {
project: true,
retentionPolicy: true,
alertPolicies: true,
storagePolicies: true,
}
});
const dbItem = await db.query.database.findFirst({
where: and(
inArray(drizzleDb.schemas.backup.id, databasesProject.ids ?? []),
eq(drizzleDb.schemas.database.id, databaseId),
eq(drizzleDb.schemas.database.projectId, projectId),
),
with: {
project: true,
retentionPolicy: true,
alertPolicies: true,
storagePolicies: true,
},
});
if (!dbItem) {
redirect("/dashboard/projects");
}
if (!dbItem) {
redirect("/dashboard/projects");
}
const backups = await db.query.backup.findMany({
where: eq(drizzleDb.schemas.backup.databaseId, dbItem.id),
with: {
restorations: true,
storages: {
with: {
storageChannel: true
}
},
logs: true
},
orderBy: (b, {desc}) => [desc(b.createdAt)],
});
const totalBackups = await db
.select({ count: drizzleDb.schemas.backup.id })
.from(drizzleDb.schemas.backup)
.where(eq(drizzleDb.schemas.backup.databaseId, dbItem.id))
.then((rows) => rows.length);
const restorations = await db.query.restoration.findMany({
where: eq(drizzleDb.schemas.restoration.databaseId, dbItem.id),
with: {
logs: true
},
orderBy: (r, {desc}) => [desc(r.createdAt)],
});
const availableBackups = await db
.select({ count: drizzleDb.schemas.backup.id })
.from(drizzleDb.schemas.backup)
.where(
and(
eq(drizzleDb.schemas.backup.databaseId, dbItem.id),
isNull(drizzleDb.schemas.backup.deletedAt),
),
)
.then((rows) => rows.length);
const isAlreadyBackup = backups.some((b) => b.status === "waiting" || b.status === "ongoing");
const isAlreadyRestore = restorations.some((r) => r.status === "waiting");
const successfulBackups = await db
.select({ count: drizzleDb.schemas.backup.id })
.from(drizzleDb.schemas.backup)
.where(
and(
eq(drizzleDb.schemas.backup.databaseId, dbItem.id),
eq(drizzleDb.schemas.backup.status, "success"),
),
)
.then((rows) => rows.length);
const totalBackups = await db.select({count: drizzleDb.schemas.backup.id})
.from(drizzleDb.schemas.backup)
.where(eq(drizzleDb.schemas.backup.databaseId, dbItem.id))
.then(rows => rows.length);
const isAlreadyRestore = await db
.select({ count: drizzleDb.schemas.restoration.id })
.from(drizzleDb.schemas.restoration)
.where(
and(
eq(drizzleDb.schemas.restoration.databaseId, dbItem.id),
eq(drizzleDb.schemas.restoration.status, "waiting"),
),
)
.then((rows) => rows.length > 0);
const availableBackups = backups.filter(b => !b.deletedAt).length;
const [settings] = await db
.select()
.from(drizzleDb.schemas.setting)
.where(eq(drizzleDb.schemas.setting.name, "system"))
.limit(1);
if (!settings) {
notFound();
}
const successfulBackups = await db.select({count: drizzleDb.schemas.backup.id})
.from(drizzleDb.schemas.backup)
.where(and(
eq(drizzleDb.schemas.backup.databaseId, dbItem.id),
eq(drizzleDb.schemas.backup.status, "success")
))
.then(rows => rows.length);
const databaseHealthLogs = dbItem
? await getHealthLast12hLogs({ id: dbItem.id })
: [];
const successRate =
totalBackups > 0 ? (successfulBackups / totalBackups) * 100 : null;
const [settings] = await db.select().from(drizzleDb.schemas.setting).where(eq(drizzleDb.schemas.setting.name, "system")).limit(1);
if (!settings) {
notFound();
}
const databaseHealthLogs = dbItem ? await getHealthLast12hLogs({id: dbItem.id}) : []
const successRate = totalBackups > 0 ? (successfulBackups / totalBackups) * 100 : null;
const isMember = activeMember?.role === "member";
return (
<Page>
<LogsModalProvider>
<BackupModalProvider>
<DatabaseContent
activeMember={activeMember}
settings={settings}
database={dbItem}
databaseHealthLogs={databaseHealthLogs}
isAlreadyRestore={isAlreadyRestore}
restorations={restorations}
backups={backups}
totalBackups={totalBackups}
availableBackups={availableBackups}
successRate={successRate}
organizationId={organization.id}
activeOrganizationChannels={[]}
activeOrganizationStorageChannels={[]}
/>
</BackupModalProvider>
</LogsModalProvider>
</Page>
);
}
return (
<Page>
<LogsModalProvider>
<BackupModalProvider>
<DatabaseContent
activeMember={activeMember}
settings={settings}
database={dbItem}
databaseHealthLogs={databaseHealthLogs}
isAlreadyRestore={isAlreadyRestore}
totalBackups={totalBackups}
availableBackups={availableBackups}
successRate={successRate}
organizationId={organization.id}
activeOrganizationChannels={[]}
activeOrganizationStorageChannels={[]}
/>
</BackupModalProvider>
</LogsModalProvider>
</Page>
);
}
@@ -1,108 +1,123 @@
import {PageParams} from "@/types/next";
import {Page, PageContent, PageTitle} from "@/features/layout/page";
import {
ButtonDeleteProject
} from "@/features/projects/project-delete-button";
import {CardsWithPagination} from "@/components/common/cards-with-pagination";
import {ProjectDatabaseCard} from "@/features/projects/project-database-card";
import {notFound, redirect} from "next/navigation";
import {db} from "@/db";
import {eq} from "drizzle-orm";
import {getActiveMember, getOrganization} from "@/lib/auth/auth";
import { PageParams } from "@/types/next";
import { Page, PageContent, PageTitle } from "@/features/layout/components/page";
import { ButtonDeleteProject } from "@/features/projects/components/project-delete-button";
import { CardsWithPagination } from "@/components/common/cards-with-pagination";
import { ProjectDatabaseCard } from "@/features/projects/components/project-database-card";
import { ProjectDatabaseSelection } from "@/features/projects/components/project-database-selection";
import { notFound, redirect } from "next/navigation";
import { db } from "@/db";
import { eq } from "drizzle-orm";
import { getActiveMember, getOrganization } from "@/lib/auth/auth";
import * as drizzleDb from "@/db";
import {capitalizeFirstLetter} from "@/utils/text";
import {ProjectDialog} from "@/features/projects/project-dialog";
import {ProjectWith} from "@/db/schema/06_project";
import {isUuidv4} from "@/utils/verify-uuid";
import {getOrganizationAvailableDatabases} from "@/db/services/database";
import { capitalizeFirstLetter, isUUID } from "@/utils/text";
import { ProjectDialog } from "@/features/projects/components/project-dialog";
import { ProjectWith } from "@/db/schema/06_project";
import { getOrganizationAvailableDatabases } from "@/db/services/database";
export default async function RoutePage(
props: PageParams<{
projectId: string;
}>,
) {
const { projectId } = await props.params;
export default async function RoutePage(props: PageParams<{
projectId: string
}>) {
const {
projectId
} = await props.params;
if (!isUUID(projectId)) {
notFound();
}
if (!isUuidv4(projectId)) {
notFound()
}
const organization = await getOrganization({});
const activeMember = await getActiveMember();
const organization = await getOrganization({});
const activeMember = await getActiveMember()
if (!organization) {
notFound();
}
const org = await db.query.organization.findFirst({
where: eq(drizzleDb.schemas.organization.slug, organization.slug),
});
if (!organization) {
notFound();
}
const org = await db.query.organization.findFirst({
where: eq(drizzleDb.schemas.organization.slug, organization.slug),
});
if (!org) notFound();
if (!org) notFound();
const proj = await db.query.project.findFirst({
where: (proj, { and, eq, not }) =>
and(
eq(proj.id, projectId),
eq(proj.organizationId, org.id),
not(eq(proj.isArchived, true)),
),
with: {
databases: true,
},
});
const proj = await db.query.project.findFirst({
where: (proj, {
and,
eq,
not
}) => and(eq(proj.id, projectId), eq(proj.organizationId, org.id), not(eq(proj.isArchived, true))),
with: {
databases: true,
},
});
if (!proj) {
redirect("/dashboard/projects");
}
if (!proj) {
redirect("/dashboard/projects");
}
const availableDatabases = await getOrganizationAvailableDatabases(
organization.id,
proj.id,
);
const isMember = activeMember?.role === "member";
const availableDatabases = await getOrganizationAvailableDatabases(organization.id, proj.id)
const isMember = activeMember?.role === "member";
return (
<Page>
<div className="justify-between gap-2 sm:flex">
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full ">
<div className="min-w-full md:min-w-fit ">
{capitalizeFirstLetter(proj.name)}
</div>
{!isMember && (
<div className="flex items-center gap-2 md:justify-between w-full ">
<div className="flex items-center gap-2">
<ProjectDialog
databases={availableDatabases}
organization={org}
project={proj as ProjectWith}
isEdit={true}
/>
</div>
<div className="flex items-center gap-2">
<ButtonDeleteProject projectId={projectId} text={"Delete Project"}/>
</div>
</div>
)}
</PageTitle>
return (
<Page>
<div className="justify-between gap-2 sm:flex">
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full ">
<div className="min-w-full md:min-w-fit ">
{capitalizeFirstLetter(proj.name)}
</div>
{!isMember && (
<div className="flex items-center gap-2 md:justify-between w-full ">
<div className="flex items-center gap-2">
<ProjectDialog
databases={availableDatabases}
organization={org}
project={proj as ProjectWith}
isEdit={true}
/>
</div>
<div className="flex items-center gap-2">
<ButtonDeleteProject
projectId={projectId}
text={"Delete Project"}
/>
</div>
</div>
<PageContent className="flex flex-col w-full h-full">
{proj.databases.length > 0 ? (
<CardsWithPagination
data={[...proj.databases].sort((a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
)}
organizationSlug={organization.slug}
// @ts-ignore
cardItem={ProjectDatabaseCard}
cardsPerPage={20}
numberOfColumns={3}
pageSizeOptions={[10, 20, 50]}
extendedProps={proj}
/>
) : (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground py-20">
<p className="text-lg font-medium">No databases found</p>
<p className="text-sm mt-2">You havent added any databases to this project yet.</p>
</div>
)}
</PageContent>
</Page>
);
}
)}
</PageTitle>
</div>
<PageContent className="flex flex-col w-full h-full">
{proj.databases.length > 0 ? (
isMember ? (
<CardsWithPagination
data={[...proj.databases].sort(
(a, b) =>
new Date(b.createdAt).getTime() -
new Date(a.createdAt).getTime(),
)}
organizationSlug={organization.slug}
// @ts-ignore
cardItem={ProjectDatabaseCard}
cardsPerPage={20}
numberOfColumns={3}
pageSizeOptions={[10, 20, 50]}
extendedProps={proj}
/>
) : (
<ProjectDatabaseSelection
projectId={proj.id}
databases={proj.databases}
/>
)
) : (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground py-20">
<p className="text-lg font-medium">No databases found</p>
<p className="text-sm mt-2">
You havent added any databases to this project yet.
</p>
</div>
)}
</PageContent>
</Page>
);
}
@@ -1,13 +1,13 @@
import {PageParams} from "@/types/next";
import {CardsWithPagination} from "@/components/common/cards-with-pagination";
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {ProjectCard} from "@/features/projects/project-card";
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/components/page";
import {ProjectCard} from "@/features/projects/components/project-card";
import {db} from "@/db";
import {notFound} from "next/navigation";
import {getActiveMember, getOrganization} from "@/lib/auth/auth";
import {EmptyStatePlaceholder} from "@/components/common/empty-state-placeholder";
import {Metadata} from "next";
import {ProjectDialog} from "@/features/projects/project-dialog";
import {ProjectDialog} from "@/features/projects/components/project-dialog";
import {DatabaseWith} from "@/db/schema/07_database";
import {getOrganizationAvailableDatabases} from "@/db/services/database";
@@ -4,17 +4,17 @@ import {
PageContent,
PageDescription,
PageTitle,
} from "@/features/layout/page";
} from "@/features/layout/components/page";
import {db} from "@/db";
import * as drizzleDb from "@/db";
import {eq} from "drizzle-orm";
import {notFound} from "next/navigation";
import {ButtonDeleteAgent} from "@/features/agents/agent-delete-button";
import {ButtonDeleteAgent} from "@/features/agents/components/agent-delete-button";
import {capitalizeFirstLetter} from "@/utils/text";
import {generateEdgeKey} from "@/utils/edge_key";
import {getServerUrl} from "@/utils/get-server-url";
import {AgentContentPage} from "@/features/agents/agent-content";
import {AgentDialog} from "@/features/agents/agent-dialog";
import {AgentContentPage} from "@/features/agents/components/agent-content";
import {AgentDialog} from "@/features/agents/components/agent-dialog";
import {getActiveMember, getOrganization} from "@/lib/auth/auth";
import {currentUser} from "@/lib/auth/current-user";
import {computeOrganizationPermissions} from "@/lib/acl/organization-acl";
@@ -94,7 +94,7 @@ export default async function RoutePage(
</PageDescription>
)}
<PageContent className="flex flex-col w-full h-full justify-between gap-6">
<AgentContentPage agent={agent} edgeKey={edgeKey}/>
<AgentContentPage agent={agent} edgeKey={edgeKey} canDeleteDatabases={!!agent.organizationId}/>
</PageContent>
</Page>
);
@@ -4,17 +4,17 @@ import {
PageContent,
PageHeader,
PageTitle,
} from "@/features/layout/page";
} from "@/features/layout/components/page";
import {currentUser} from "@/lib/auth/current-user";
import {getActiveMember, getOrganization} from "@/lib/auth/auth";
import {notFound} from "next/navigation";
import {Metadata} from "next";
import {OrganizationTabs} from "@/features/organizations/organization-tabs";
import {OrganizationTabs} from "@/features/organizations/components/organization-tabs";
import {getOrganizationChannels} from "@/db/services/notification-channel";
import {computeOrganizationPermissions} from "@/lib/acl/organization-acl";
import {getOrganizationStorageChannels} from "@/db/services/storage-channel";
import {DeleteOrganizationButton} from "@/features/organizations/organization-delete-button";
import {EditOrganizationDialog} from "@/features/organizations/organization-edit-dialog";
import {DeleteOrganizationButton} from "@/features/organizations/components/organization-delete-button";
import {EditOrganizationDialog} from "@/features/organizations/components/organization-edit-dialog";
import {db} from "@/db";
import {isNull} from "drizzle-orm";
import * as drizzleDb from "@/db";
@@ -47,7 +47,9 @@ export default async function RoutePage(props: PageParams<{ slug: string }>) {
const organizationWithMembers = await db.query.organization.findFirst({
where: eq(drizzleDb.schemas.organization.id, organization.id),
with: {
projects: true,
projects: {
where: eq(drizzleDb.schemas.project.isArchived, false),
},
members: {
with: {
user: true,
@@ -1,8 +1,8 @@
import {PageParams} from "@/types/next";
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/components/page";
import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card";
import {EvolutionLineChart} from "@/features/statistics/evolution-line-chart";
import {PercentageLineChart} from "@/features/statistics/percentage-line-chart";
import {EvolutionLineChart} from "@/features/statistics/components/evolution-line-chart";
import {PercentageLineChart} from "@/features/statistics/components/percentage-line-chart";
import {notFound} from "next/navigation";
import {db} from "@/db";
import {and, asc, count, eq, inArray} from "drizzle-orm";
@@ -29,7 +29,7 @@ export default async function RoutePage(props: PageParams<{}>) {
if (!org) notFound();
const projects = await db.query.project.findMany({
where: eq(drizzleDb.schemas.project.organizationId, org.id),
where: and(eq(drizzleDb.schemas.project.organizationId, org.id), eq(drizzleDb.schemas.project.isArchived, false))
});
const projectIds = projects.map(project => project.id);
+6 -3
View File
@@ -1,11 +1,12 @@
import {PageParams} from "@/types/next";
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/components/page";
import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card";
import {Building2, Database, DatabaseBackup, Folder, RefreshCcw, Server, Workflow} from "lucide-react";
import {currentUser} from "@/lib/auth/current-user";
import {notFound} from "next/navigation";
import {db} from "@/db";
import {and, asc, eq, inArray} from "drizzle-orm";
import {and, asc, eq, inArray, not} from "drizzle-orm";
import * as drizzleDb from "@/db";
import {listOrganizations} from "@/lib/auth/auth";
import {Metadata} from "next";
@@ -23,7 +24,9 @@ export default async function RoutePage(props: PageParams<{}>) {
const organizationIds = organizations.map(project => project.id);
const agents = await db.query.agent.findMany({});
const agents = await db.query.agent.findMany({
where: not(eq(drizzleDb.schemas.agent.isArchived, true)),
});
const projects = await db.query.project.findMany({
where: and(inArray(drizzleDb.schemas.project.organizationId, organizationIds), eq(drizzleDb.schemas.project.isArchived, false)),
+10 -6
View File
@@ -2,21 +2,25 @@ import { ReactNode } from "react";
import { redirect } from "next/navigation";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { AppSidebar } from "@/features/layout/app-sidebar";
import { Header } from "@/features/layout/header";
import { AppSidebar } from "@/features/layout/components/app-sidebar";
import { Header } from "@/features/layout/components/header";
import { currentUser } from "@/lib/auth/current-user";
import { ThemeMetaUpdater } from "@/features/theme/theme-meta-updater";
import { ModeToggle } from "@/features/theme/mode-toggle";
import { UpdateNotification } from "@/features/updates/update-notification";
import { isOnboardingDone } from "@/db/services/setting";
import { env } from "@/env.mjs";
import { ModeToggle } from "@/features/theme/components/mode-toggle";
import { UpdateNotification } from "@/features/updates/components/update-notification";
export default async function Layout({ children }: { children: ReactNode }) {
if (env.SKIP_ONBOARDING !== "true" && !(await isOnboardingDone())) {
redirect("/welcome");
}
const user = await currentUser();
if (!user) redirect("/login");
return (
<SidebarProvider>
<div className="flex flex-col lg:flex-row w-full">
<ThemeMetaUpdater />
<AppSidebar updateNotification={<UpdateNotification />} />
<SidebarInset>
<Header actions={<ModeToggle />} />
+7 -1
View File
@@ -1,8 +1,14 @@
import { redirect } from "next/navigation";
import { getCurrentOrganizationSlug } from "@/features/organizations/organization-cookie";
import { getCurrentOrganizationSlug } from "@/features/organizations/utils/organization-cookie";
import { currentUser } from "@/lib/auth/current-user";
import { isOnboardingDone } from "@/db/services/setting";
import { env } from "@/env.mjs";
export default async function Index() {
if (env.SKIP_ONBOARDING !== "true" && !(await isOnboardingDone())) {
redirect("/welcome");
}
const user = await currentUser();
if (user) {
const currentOrganizationSlug = await getCurrentOrganizationSlug();
@@ -0,0 +1,29 @@
"use client";
import { useRouter } from "next/navigation";
import { OnboardingProvider } from "@onboardjs/react";
import { onboardingSteps } from "@/features/onboarding/onboarding-steps";
import { OnboardingShell } from "@/features/onboarding/onboarding-shell";
import type { OnboardingFlowData } from "@/features/onboarding/types";
type Props = {
initialStepId: string;
initialFlowData: Partial<OnboardingFlowData>;
};
export const OnboardingClient = ({ initialStepId, initialFlowData }: Props) => {
const router = useRouter();
return (
<OnboardingProvider
steps={onboardingSteps}
initialStepId={initialStepId}
initialContext={{ flowData: initialFlowData }}
onFlowComplete={async () => {
router.push("/dashboard/home");
}}
>
<OnboardingShell />
</OnboardingProvider>
);
};
+19
View File
@@ -0,0 +1,19 @@
import { redirect } from "next/navigation";
import { resolveOnboardingState } from "@/features/onboarding/onboarding-state";
import { OnboardingClient } from "./onboarding-client";
export default async function WelcomePage() {
const result = await resolveOnboardingState();
if ("redirect" in result) {
redirect(result.redirect);
}
return (
<OnboardingClient
key={result.stepId}
initialStepId={result.stepId}
initialFlowData={result.flowData}
/>
);
}
+2 -3
View File
@@ -6,8 +6,8 @@ import {getDatabaseOrThrow, withAgentCheck} from "./helpers";
import {Backup} from "@/db/schema/07_database";
import {withUpdatedAt} from "@/db/utils";
import {eventEmitter} from "@/lib/event";
import {sendNotificationsBackupRestore} from "@/features/notifications/notifications.helpers";
import {EventKind} from "@/features/notifications/notifications.types";
import {sendNotificationsBackupRestore} from "@/features/notifications/utils/notifications.helpers";
import {EventKind} from "@/features/notifications/types";
import {logger} from "@/lib/logger";
import {JobLogEntry} from "@/features/logs/types";
@@ -108,7 +108,6 @@ export const PATCH = withAgentCheck(async (request: Request, {params, agent}: {
}) => {
try {
const body: BodyPatch = await request.json();
log.info({data: body}, "Body from PATH in backup route");
const status = body.status
const backupId = body.backupId
@@ -1,79 +1,86 @@
import {NextResponse} from "next/server";
import {and, eq} from "drizzle-orm";
import { NextResponse } from "next/server";
import { and, eq } from "drizzle-orm";
import * as drizzleDb from "@/db";
import {db} from "@/db";
import {getDatabaseOrThrow, withAgentCheck} from "../../helpers";
import {isUuidv4} from "@/utils/verify-uuid";
import {eventEmitter} from "@/lib/event";
import {logger} from "@/lib/logger";
import { db } from "@/db";
import { getDatabaseOrThrow, withAgentCheck } from "../../helpers";
import { eventEmitter } from "@/lib/event";
import { logger } from "@/lib/logger";
import { isUUID } from "@/utils/text";
const log = logger.child({module: "api/agent/backup/upload/init"});
const log = logger.child({ module: "api/agent/backup/upload/init" });
export type Body = {
generatedId: string
storageChannelId: string
backupId: string
}
export const POST = withAgentCheck(async (request: Request, {params, agent}: {
params: Promise<{ agentId: string }>,
agent: any
}) => {
generatedId: string;
storageChannelId: string;
backupId: string;
};
export const POST = withAgentCheck(
async (
request: Request,
{
params,
agent,
}: {
params: Promise<{ agentId: string }>;
agent: any;
},
) => {
try {
const body: Body = await request.json();
const body: Body = await request.json();
log.info({data: body}, "Body for backup upload init");
log.info({ data: body }, "Body for backup upload init");
const generatedId = body.generatedId;
const storageChannelId = body.storageChannelId;
const backupId = body.backupId;
if (!generatedId || !isUuidv4(generatedId)) {
return NextResponse.json(
{error: "generatedId is not a valid UUID"},
{status: 400}
);
}
const database = await getDatabaseOrThrow(generatedId);
const backup = await db.query.backup.findFirst({
where: and(
eq(drizzleDb.schemas.backup.id, backupId),
eq(drizzleDb.schemas.backup.databaseId, database.id),
),
});
if (!backup) {
return NextResponse.json(
{error: "Unable to find the corresponding backup"},
{status: 404}
);
}
const [backupStorage] = await db
.insert(drizzleDb.schemas.backupStorage)
.values({
backupId: backup.id,
storageChannelId: storageChannelId,
status: "pending",
})
.returning();
eventEmitter.emit('modification', {update: true});
const generatedId = body.generatedId;
const storageChannelId = body.storageChannelId;
const backupId = body.backupId;
if (!generatedId || !isUUID(generatedId)) {
return NextResponse.json(
{
message: "Backup storage successfully created",
backupStorage: backupStorage
},
{status: 200}
{ error: "generatedId is not a valid UUID" },
{ status: 400 },
);
}
const database = await getDatabaseOrThrow(generatedId);
const backup = await db.query.backup.findFirst({
where: and(
eq(drizzleDb.schemas.backup.id, backupId),
eq(drizzleDb.schemas.backup.databaseId, database.id),
),
});
if (!backup) {
return NextResponse.json(
{ error: "Unable to find the corresponding backup" },
{ status: 404 },
);
}
const [backupStorage] = await db
.insert(drizzleDb.schemas.backupStorage)
.values({
backupId: backup.id,
storageChannelId: storageChannelId,
status: "pending",
})
.returning();
eventEmitter.emit("modification", { update: true });
return NextResponse.json(
{
message: "Backup storage successfully created",
backupStorage: backupStorage,
},
{ status: 200 },
);
} catch (error) {
log.error({error: error}, "Error in POST for INIT backup");
return NextResponse.json(
{error: "Internal server error"},
{status: 500}
);
log.error({ error: error }, "Error in POST for INIT backup");
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
});
},
);
+115 -101
View File
@@ -1,110 +1,124 @@
import {NextResponse} from "next/server";
import {isUuidv4} from "@/utils/verify-uuid";
import { NextResponse } from "next/server";
import * as drizzleDb from "@/db";
import {db as dbClient, db} from "@/db";
import {and, eq} from "drizzle-orm";
import {sendNotificationsBackupRestore} from "@/features/notifications/notifications.helpers";
import {logger} from "@/lib/logger";
import {withUpdatedAt} from "@/db/utils";
import {JobLogEntry} from "@/features/logs/types";
import { db as dbClient, db } from "@/db";
import { and, eq } from "drizzle-orm";
import { sendNotificationsBackupRestore } from "@/features/notifications/utils/notifications.helpers";
import { logger } from "@/lib/logger";
import { withUpdatedAt } from "@/db/utils";
import { JobLogEntry } from "@/features/logs/types";
import { isUUID } from "@/utils/text";
const log = logger.child({module: "api/agent/restore"});
const log = logger.child({ module: "api/agent/restore" });
export type BodyResultRestore = {
generatedId: string
status: string
logs: JobLogEntry[]
durationMs: number
}
type RestorationStatus = 'waiting' | 'ongoing' | 'failed' | 'success';
generatedId: string;
status: string;
logs: JobLogEntry[];
durationMs: number;
};
type RestorationStatus = "waiting" | "ongoing" | "failed" | "success";
export async function POST(
request: Request,
{params}: { params: Promise<{ agentId: string }> }
request: Request,
{ params }: { params: Promise<{ agentId: string }> },
) {
try {
const agentId = (await params).agentId;
const body: BodyResultRestore = await request.json();
try {
const agentId = (await params).agentId
const body: BodyResultRestore = await request.json();
if (!isUuidv4(body.generatedId)) {
return NextResponse.json(
{error: "generatedId is not a valid uuid"},
{status: 500}
);
}
const agent = await db.query.agent.findFirst({
where: and(eq(drizzleDb.schemas.agent.id, agentId), eq(drizzleDb.schemas.agent.isArchived, false)),
})
if (!agent) {
return NextResponse.json({error: "Agent not found"}, {status: 404})
}
const database = await db.query.database.findFirst({
where: eq(drizzleDb.schemas.database.agentDatabaseId, body.generatedId),
with: {
alertPolicies: true
}
})
if (!database) {
return NextResponse.json({error: "Database associated with generatedId provided not found"}, {status: 404})
}
const restoration = await db.query.restoration.findFirst({
where: and(eq(drizzleDb.schemas.restoration.status, "ongoing"), eq(drizzleDb.schemas.restoration.databaseId, database.id),)
})
if (!restoration) {
return NextResponse.json({error: "Unable to fin the corresponding restoration"}, {status: 404})
}
const [restorationUpdated] = await db
.update(drizzleDb.schemas.restoration)
.set(withUpdatedAt({status: body.status as RestorationStatus, durationMs: body.durationMs}))
.where(eq(drizzleDb.schemas.restoration.id, restoration.id)).returning();
const logsToInsert = body.logs.map((entry) => ({
backupId: null,
restorationId: restorationUpdated.id,
loggedAt: new Date(entry.timestamp),
entryType: entry.type,
level: entry.level,
message: entry.message,
command: entry.command ?? null,
output: entry.output ?? null,
exitCode: entry.exit_code ?? null,
durationMs: entry.duration_ms ?? null,
}));
if (logsToInsert.length > 0) {
await dbClient
.insert(drizzleDb.schemas.jobLog)
.values(logsToInsert);
}
await sendNotificationsBackupRestore(database, body.status == "failed" ? "error_restore" : "success_restore");
const response = {
status: true,
message: "Restoration successfully updated"
}
return Response.json(response, {status: 200})
} catch (error) {
log.error({error: error}, "Error in POST handler")
return NextResponse.json(
{error: 'Internal server error'},
{status: 500}
);
if (!isUUID(body.generatedId)) {
return NextResponse.json(
{ error: "generatedId is not a valid uuid" },
{ status: 500 },
);
}
}
const agent = await db.query.agent.findFirst({
where: and(
eq(drizzleDb.schemas.agent.id, agentId),
eq(drizzleDb.schemas.agent.isArchived, false),
),
});
if (!agent) {
return NextResponse.json({ error: "Agent not found" }, { status: 404 });
}
const database = await db.query.database.findFirst({
where: eq(drizzleDb.schemas.database.agentDatabaseId, body.generatedId),
with: {
alertPolicies: true,
},
});
if (!database) {
return NextResponse.json(
{ error: "Database associated with generatedId provided not found" },
{ status: 404 },
);
}
const restoration = await db.query.restoration.findFirst({
where: and(
eq(drizzleDb.schemas.restoration.status, "ongoing"),
eq(drizzleDb.schemas.restoration.databaseId, database.id),
),
});
if (!restoration) {
return NextResponse.json(
{ error: "Unable to fin the corresponding restoration" },
{ status: 404 },
);
}
const [restorationUpdated] = await db
.update(drizzleDb.schemas.restoration)
.set(
withUpdatedAt({
status: body.status as RestorationStatus,
durationMs: body.durationMs,
}),
)
.where(eq(drizzleDb.schemas.restoration.id, restoration.id))
.returning();
const logsToInsert = body.logs.map((entry) => ({
backupId: null,
restorationId: restorationUpdated.id,
loggedAt: new Date(entry.timestamp),
entryType: entry.type,
level: entry.level,
message: entry.message,
command: entry.command ?? null,
output: entry.output ?? null,
exitCode: entry.exit_code ?? null,
durationMs: entry.duration_ms ?? null,
}));
if (logsToInsert.length > 0) {
await dbClient.insert(drizzleDb.schemas.jobLog).values(logsToInsert);
}
await sendNotificationsBackupRestore(
database,
body.status == "failed" ? "error_restore" : "success_restore",
);
const response = {
status: true,
message: "Restoration successfully updated",
};
return Response.json(response, { status: 200 });
} catch (error) {
log.error({ error: error }, "Error in POST handler");
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
+17 -13
View File
@@ -1,24 +1,24 @@
import {NextResponse} from "next/server";
import {Body} from "./route";
import {isUuidv4} from "@/utils/verify-uuid";
import {Agent} from "@/db/schema/08_agent";
import {DatabaseWith} from "@/db/schema/07_database";
import * as drizzleDb from "@/db";
import {db, db as dbClient} from "@/db";
import {and, eq, inArray} from "drizzle-orm";
import {and, eq, inArray, desc, sql} from "drizzle-orm";
import {dbmsEnumSchema, EDbmsSchema} from "@/db/schema/types";
import {withUpdatedAt} from "@/db/utils";
import type {StorageInput} from "@/features/storages/storages.types";
import {dispatchStorage} from "@/features/storages/storages.dispatch";
import {Setting} from "@/db/schema/01_setting";
import {logger} from "@/lib/logger";
import {isUUID} from "@/utils/text";
import {StorageInput} from "@/features/storages/types";
import {dispatchStorage} from "@/features/storages/utils/storages.dispatch";
const log = logger.child({module: "api/agent/status/helpers"});
export async function handleDatabases(body: Body, agent: Agent, lastContact: Date, settings: Setting) {
const databasesResponse = [];
const formatDatabase = (database: DatabaseWith, backupAction: boolean, restoreAction: boolean, UrlBackup: string | null, storages: PingDatabaseStorageChannels[], urlMeta: string | null) => ({
const formatDatabase = (database: DatabaseWith, backupAction: boolean, restoreAction: boolean, UrlBackup: string | null, storages: PingDatabaseStorageChannels[], urlMeta: string | null, backupSize: number | null) => ({
generatedId: database.agentDatabaseId,
dbms: database.dbms,
storages: storages,
@@ -31,7 +31,8 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
restore: {
action: restoreAction,
file: UrlBackup,
metaFile: urlMeta
metaFile: urlMeta,
size: backupSize
},
},
});
@@ -49,9 +50,10 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
let restoreAction: boolean = false
let urlBackup: string | null = null;
let urlMeta: string | null = null
let backupSize: number | null = null
if (!existingDatabase) {
if (!isUuidv4(db.generatedId)) {
if (!isUUID(db.generatedId)) {
return NextResponse.json(
{error: "generatedId is not a valid uuid"},
{status: 500}
@@ -75,10 +77,7 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
})
.returning();
if (databaseCreated) {
await dbClient
.insert(drizzleDb.schemas.healthcheckLog)
.values({
@@ -90,7 +89,7 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
const storages = await getDatabaseStorageChannels(databaseCreated.id)
databasesResponse.push(formatDatabase(databaseCreated, backupAction, restoreAction, urlBackup, storages, null));
databasesResponse.push(formatDatabase(databaseCreated, backupAction, restoreAction, urlBackup, storages, null, null));
}
} else {
@@ -121,7 +120,11 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
where: and(
eq(drizzleDb.schemas.backup.databaseId, databaseUpdated.id),
inArray(drizzleDb.schemas.backup.status, ["waiting", "ongoing"])
)
),
orderBy: [
sql`case when ${drizzleDb.schemas.backup.status} = 'waiting' then 0 else 1 end`,
desc(drizzleDb.schemas.backup.createdAt)
]
})
const restoration = await dbClient.query.restoration.findFirst({
@@ -180,6 +183,7 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
if (result.success) {
urlBackup = result.url ?? null;
urlMeta = resultMeta.url ?? null
backupSize = restoration.backupStorage.size
} else {
await dbClient
.update(drizzleDb.schemas.restoration)
@@ -205,7 +209,7 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
.where(eq(drizzleDb.schemas.restoration.id, restoration.id));
}
const storages = await getDatabaseStorageChannels(databaseUpdated.id)
databasesResponse.push(formatDatabase(databaseUpdated, backupAction, restoreAction, urlBackup, storages, urlMeta));
databasesResponse.push(formatDatabase(databaseUpdated, backupAction, restoreAction, urlBackup, storages, urlMeta, backupSize));
}
}
return databasesResponse;
+95 -87
View File
@@ -1,99 +1,107 @@
import {NextResponse} from "next/server";
import {handleDatabases} from "./helpers";
import { NextResponse } from "next/server";
import { handleDatabases } from "./helpers";
import * as drizzleDb from "@/db";
import {db} from "@/db";
import {EDbmsSchema} from "@/db/schema/types";
import {and, eq} from "drizzle-orm";
import {isUuidv4} from "@/utils/verify-uuid";
import {withUpdatedAt} from "@/db/utils";
import {logger} from "@/lib/logger";
const log = logger.child({module: "api/agent/status/route"});
import { db } from "@/db";
import { EDbmsSchema } from "@/db/schema/types";
import { and, eq } from "drizzle-orm";
import { withUpdatedAt } from "@/db/utils";
import { logger } from "@/lib/logger";
import { isUUID } from "@/utils/text";
const log = logger.child({ module: "api/agent/status/route" });
export type databaseAgent = {
name: string,
dbms: EDbmsSchema,
generatedId: string
pingStatus: boolean
}
name: string;
dbms: EDbmsSchema;
generatedId: string;
pingStatus: boolean;
};
export type Body = {
version: string,
databases: databaseAgent[]
}
version: string;
databases: databaseAgent[];
};
export async function POST(
request: Request,
{params}: { params: Promise<{ agentId: string }> }
request: Request,
{ params }: { params: Promise<{ agentId: string }> },
) {
try {
const agentId = (await params).agentId
log.debug(`Agent ID: ${agentId}`)
const body: Body = await request.json();
const lastContact = new Date();
let message: string
try {
const agentId = (await params).agentId;
log.debug(`Agent ID: ${agentId}`);
const body: Body = await request.json();
const lastContact = new Date();
let message: string;
if (!isUuidv4(agentId)) {
message = "agentId is not a valid uuid"
log.error({error: message}, "An error occurred")
return NextResponse.json(
{error: "agentId is not a valid uuid"},
{status: 500}
);
}
const agent = await db.query.agent.findFirst({
where: and(eq(drizzleDb.schemas.agent.id, agentId), eq(drizzleDb.schemas.agent.isArchived, false)),
})
if (!agent) {
message = "Agent not found"
return NextResponse.json({error: message}, {status: 404})
}
const [settings] = await db.select().from(drizzleDb.schemas.setting).where(eq(drizzleDb.schemas.setting.name, "system")).limit(1);
if (!settings) {
return NextResponse.json({error: "An error occured"}, {status: 404})
}
const databasesResponse = await handleDatabases(body, agent, lastContact, settings)
await db
.update(drizzleDb.schemas.agent)
.set(withUpdatedAt({
version: body.version,
lastContact: lastContact,
healthErrorCount: null
}))
.where(eq(drizzleDb.schemas.agent.id, agentId));
await db
.insert(drizzleDb.schemas.healthcheckLog)
.values({
kind: "agent",
status: "success",
objectId: agentId,
date: lastContact
})
const response = {
agent: {
id: agentId,
lastContact: lastContact
},
databases: databasesResponse
}
return Response.json(response)
} catch (error) {
log.error({error: error}, "Error in POST handler")
return NextResponse.json(
{error: 'Internal server error'},
{status: 500}
);
if (!isUUID(agentId)) {
message = "agentId is not a valid uuid";
log.error({ error: message }, "An error occurred");
return NextResponse.json(
{ error: "agentId is not a valid uuid" },
{ status: 500 },
);
}
}
const agent = await db.query.agent.findFirst({
where: and(
eq(drizzleDb.schemas.agent.id, agentId),
eq(drizzleDb.schemas.agent.isArchived, false),
),
});
if (!agent) {
message = "Agent not found";
return NextResponse.json({ error: message }, { status: 404 });
}
const [settings] = await db
.select()
.from(drizzleDb.schemas.setting)
.where(eq(drizzleDb.schemas.setting.name, "system"))
.limit(1);
if (!settings) {
return NextResponse.json({ error: "An error occured" }, { status: 404 });
}
const databasesResponse = await handleDatabases(
body,
agent,
lastContact,
settings,
);
await db
.update(drizzleDb.schemas.agent)
.set(
withUpdatedAt({
version: body.version,
lastContact: lastContact,
healthErrorCount: null,
}),
)
.where(eq(drizzleDb.schemas.agent.id, agentId));
await db.insert(drizzleDb.schemas.healthcheckLog).values({
kind: "agent",
status: "success",
objectId: agentId,
date: lastContact,
});
const response = {
agent: {
id: agentId,
lastContact: lastContact,
},
databases: databasesResponse,
};
return Response.json(response);
} catch (error) {
log.error({ error: error }, "Error in POST handler");
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
+22 -20
View File
@@ -5,31 +5,33 @@ import { headers } from "next/headers";
const authHandler = toNextJsHandler(auth.handler);
async function blockApiKeyCreateForRestrictedUsers(req: NextRequest): Promise<NextResponse | null> {
const url = req.nextUrl;
if (req.method !== "POST" || !url.pathname.endsWith("/api-key/create")) {
return null;
}
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user) {
return null;
}
// @ts-ignore
if (session.user.banned || (session.user.role as string) === "pending") {
return NextResponse.json(
{ error: "Account not eligible to create API keys" },
{ status: 403 }
);
}
async function blockApiKeyCreateForRestrictedUsers(
req: NextRequest,
): Promise<NextResponse | null> {
const url = req.nextUrl;
if (req.method !== "POST" || !url.pathname.endsWith("/api-key/create")) {
return null;
}
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user) {
return null;
}
// @ts-ignore
if (session.user.banned || (session.user.role as string) === "pending") {
return NextResponse.json(
{ error: "Account not eligible to create API keys" },
{ status: 403 },
);
}
return null;
}
export async function GET(req: NextRequest) {
return authHandler.GET(req);
return authHandler.GET(req);
}
export async function POST(req: NextRequest) {
const guard = await blockApiKeyCreateForRestrictedUsers(req);
if (guard) return guard;
return authHandler.POST(req);
const guard = await blockApiKeyCreateForRestrictedUsers(req);
if (guard) return guard;
return authHandler.POST(req);
}
+2 -2
View File
@@ -1,7 +1,7 @@
import {NextResponse} from "next/server";
import path from "path";
import type {StorageInput} from "@/features/storages/storages.types";
import {dispatchStorage} from "@/features/storages/storages.dispatch";
import type {StorageInput} from "@/features/storages/types";
import {dispatchStorage} from "@/features/storages/utils/storages.dispatch";
import {Readable} from "node:stream";
import {logger} from "@/lib/logger";
+2 -2
View File
@@ -1,8 +1,8 @@
import {NextResponse} from "next/server";
import {auth} from "@/lib/auth/auth";
import {headers} from "next/headers";
import {StorageInput} from "@/features/storages/storages.types";
import {dispatchStorage} from "@/features/storages/storages.dispatch";
import {StorageInput} from "@/features/storages/types";
import {dispatchStorage} from "@/features/storages/utils/storages.dispatch";
import {Readable} from "node:stream";
import {logger} from "@/lib/logger";
+55 -6
View File
@@ -1,11 +1,16 @@
import {NextResponse} from "next/server";
import fs from "fs";
import path from "path";
import {and, eq} from "drizzle-orm";
import {db} from "@/db";
import * as drizzleDb from "@/db";
import {env} from "@/env.mjs";
import {logger} from "@/lib/logger";
const log = logger.child({module: "api/tus/hooks"});
const FILE_PATH_RE = /^backups\/\d{4}-\d{2}-\d{2}\/[A-Za-z0-9._-]+$/;
export async function POST(request: Request) {
try {
const body = await request.json();
@@ -24,17 +29,61 @@ export async function POST(request: Request) {
event.Upload.Offset === event.Upload.Size
) {
const id = event.Upload.ID;
const fileName = headers["X-File-Name"]?.[0];
const filePath = headers["X-File-Path"]?.[0];
if (!filePath) {
return NextResponse.json({error: "Missing X-File-Path"}, {status: 500});
if (!filePath || !FILE_PATH_RE.test(filePath)) {
log.warn({filePath}, "Rejected invalid X-File-Path in TUS hook");
return NextResponse.json({error: "Invalid file path"}, {status: 400});
}
const generatedId = headers["X-Generated-Id"]?.[0];
const backupStorageId = headers["X-Backup-Storage-Id"]?.[0];
console.log(`Backup Storage ID : ${backupStorageId}`);
console.log(`generatedId : ${generatedId}`);
if (backupStorageId) {
if (!generatedId) {
log.warn("Missing X-Generated-Id in TUS hook");
return NextResponse.json({error: "Forbidden"}, {status: 403});
}
const database = await db.query.database.findFirst({
where: eq(drizzleDb.schemas.database.agentDatabaseId, generatedId),
});
if (!database) {
log.warn({generatedId}, "No database for X-Generated-Id in TUS hook");
return NextResponse.json({error: "Forbidden"}, {status: 403});
}
const storage = await db.query.backupStorage.findFirst({
where: and(
eq(drizzleDb.schemas.backupStorage.id, backupStorageId),
),
with: {backup: true},
});
if (!storage || storage.backup?.databaseId !== database.id) {
log.warn({backupStorageId, generatedId}, "No pending backup storage matches TUS hook");
return NextResponse.json({error: "Forbidden"}, {status: 403});
}
} else {
log.warn("TUS hook without X-Backup-Storage-Id (legacy agent)");
}
const uploadDir = path.join(env.PRIVATE_PATH!, "/uploads/");
const oldFilePath = path.join(uploadDir, "tmp", id);
const newFilePath = path.join(uploadDir, filePath);
const resolvedBase = path.resolve(uploadDir);
const newFilePath = path.resolve(resolvedBase, filePath);
const rel = path.relative(resolvedBase, newFilePath);
if (rel.startsWith("..") || path.isAbsolute(rel)) {
log.warn({filePath}, "Rejected path traversal in TUS hook");
return NextResponse.json({error: "Invalid file path"}, {status: 400});
}
fs.mkdirSync(path.dirname(newFilePath), {recursive: true});
@@ -74,7 +123,7 @@ export async function POST(request: Request) {
}
return NextResponse.json({});
} catch (error) {
log.error({error: error},"TUS Hook error");
log.error({error: error}, "TUS Hook error");
return NextResponse.json({error: "Internal server error"}, {status: 500});
}
}
}
+1 -1
View File
@@ -3,7 +3,7 @@ import {withApiKey} from "@/lib/api-v1/middleware";
import {logger} from "@/lib/logger";
import {ApiKeyContext} from "@/lib/api-v1/types";
import {getAgent, resolveAgentAccess} from "@/lib/api-v1/services/agents";
import {deleteAgentService} from "@/features/agents/agent-delete.action";
import {deleteAgentService} from "@/features/agents/actions/agent-delete.action";
const log = logger.child({module: "api/v1/agents/[id]"});
+1 -1
View File
@@ -5,7 +5,7 @@ import * as drizzleDb from "@/db";
import { inArray, eq, and, or, isNull } from "drizzle-orm";
import { z } from "zod";
import { logger } from "@/lib/logger";
import {createAgentService} from "@/features/agents/agents.action";
import {createAgentService} from "@/features/agents/actions/agents.action";
import { ActionError } from "@/lib/safe-actions/actions";
import {getAccessibleAgentIds} from "@/lib/api-v1/services/agents";
import {ApiKeyContext} from "@/lib/api-v1/types";
+5 -2
View File
@@ -1,4 +1,5 @@
import BackButton from "@/components/common/back-button";
import Link from "next/link";
import { Button } from "@/components/ui/button";
export default async function NotFound() {
return (
@@ -12,7 +13,9 @@ export default async function NotFound() {
The content you are trying to view is not available.
</p>
</div>
<BackButton>Go home</BackButton>
<Button asChild>
<Link href="/">Go home</Link>
</Button>
</div>
</div>
);
+9 -4
View File
@@ -5,8 +5,8 @@ import { type PropsWithChildren, Suspense } from "react";
import { Toaster } from "@/components/ui/sonner";
import { ErrorLayout } from "@/components/common/error-layout";
import { ThemeMetaUpdaterRoot } from "@/features/theme/theme-meta-updater-root";
import { ThemeProvider } from "@/features/theme/theme-provider";
import { ThemeMetaUpdater } from "@/features/theme/components/theme-meta-updater";
import { ThemeProvider } from "@/features/theme/components/theme-provider";
export type ProviderProps = PropsWithChildren<{}>;
const queryClient = new QueryClient();
@@ -14,8 +14,13 @@ const queryClient = new QueryClient();
export const Providers = (props: ProviderProps) => {
return (
<Suspense fallback={null}>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<ThemeMetaUpdaterRoot />
<ThemeProvider
attribute="class"
defaultTheme="light"
enableSystem
storageKey="portabase_theme"
>
<ThemeMetaUpdater />
<QueryClientProvider client={queryClient}>
<ErrorLayout>
<Toaster />
+7 -7
View File
@@ -1,12 +1,12 @@
services:
app:
# build:
# context: .
# dockerfile: docker/dockerfile/Dockerfile
# target: prod
image: portabase/portabase:1
build:
context: .
dockerfile: docker/dockerfile/Dockerfile
target: prod
# image: portabase/portabase:1
ports:
- '8887:80'
- "8887:80"
environment:
TZ: "Europe/Paris"
env_file:
@@ -35,7 +35,7 @@ services:
- POSTGRES_USER=devuser
- POSTGRES_PASSWORD=changeme
healthcheck:
test: [ "CMD-SHELL", "pg_isready -U devuser -d devdb" ]
test: ["CMD-SHELL", "pg_isready -U devuser -d devdb"]
interval: 10s
timeout: 5s
retries: 5
+113
View File
@@ -41,5 +41,118 @@ services:
- "8025:8025"
- "1025:1025"
azurite:
image: mcr.microsoft.com/azure-storage/azurite
command: azurite --blobHost 0.0.0.0 --skipApiVersionCheck
ports:
- "10000:10000"
volumes:
- azurite-data:/data
extra_hosts:
- "localhost:host-gateway"
networks:
- portabase
# DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://127.0.0.1:10000/devstoreaccount1;QueueEndpoint=http://127.0.0.1:10001/devstoreaccount1;TableEndpoint=http://127.0.0.1:10002/devstoreaccount1;
gcs:
image: fsouza/fake-gcs-server:latest
container_name: fake-gcs
ports:
- "4443:4443"
command:
- "-scheme"
- "http"
- "-port"
- "4443"
- "-public-host"
- "localhost:4443"
- "-external-url"
- "http://localhost:4443"
- "-backend"
- "filesystem"
- "-filesystem-root"
- "/storage"
volumes:
- gcs-data:/data
- gcs-storage:/storage
networks:
- portabase
gcs-init:
image: curlimages/curl:latest
depends_on:
- gcs
entrypoint: ["/bin/sh", "-c"]
command: >
'until curl -fsS http://gcs:4443/storage/v1/b >/dev/null; do sleep 1; done;
curl -fsS http://gcs:4443/storage/v1/b/portabase >/dev/null
|| curl -fsS -X POST "http://gcs:4443/storage/v1/b?project=local"
-H "Content-Type: application/json"
-d "{\"name\":\"portabase\"}"'
networks:
- portabase
# curl http://localhost:4443/storage/v1/b
# projectId: "test-project",
# bucketName: "portabase",
# clientEmail: "fake@test.iam.gserviceaccount.com",
# privateKey: "unused-when-emulated",
rustfs:
image: rustfs/rustfs:latest
container_name: rustfs
environment:
- RUSTFS_ROOT_USER=admin
- RUSTFS_ROOT_PASSWORD=root-password
- RUSTFS_ACCESS_KEY=access-key
- RUSTFS_SECRET_KEY=secret-key
- RUSTFS_VOLUMES=/data
- RUSTFS_ADDRESS=:9000
- RUSTFS_CONSOLE_ENABLE=true
- RUSTFS_CONSOLE_ADDRESS=:9001
volumes:
- rustfs-data:/data
healthcheck:
test: [ "CMD", "curl", "-sf", "http://localhost:9000/health" ]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
ports:
- "9001:9001"
- "9000:9000"
extra_hosts:
- "localhost:host-gateway"
networks:
- portabase
rustfs-init:
image: rustfs/rc:latest
container_name: rustfs-init
depends_on:
rustfs:
condition: service_healthy
entrypoint: >
/bin/sh -c "
until (/usr/bin/rc alias set rustfs http://rustfs:9000 access-key secret-key) do echo '...waiting...' && sleep 1; done;
/usr/bin/rc mb rustfs/portabase;
echo 'RustFS initialization complete';
tail -f /dev/null
"
networks:
- portabase
volumes:
postgres-data:
azurite-data:
gcs-data:
gcs-storage:
rustfs-data:
networks:
portabase:
name: portabase_network
external: true
+26 -21
View File
@@ -1,11 +1,33 @@
FROM --platform=$BUILDPLATFORM node:22-bullseye AS base
FROM --platform=$BUILDPLATFORM node:22-slim AS runtime-base
RUN apt-get update && apt-get install -y \
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
ca-certificates \
bash \
tzdata \
libc6 \
nginx \
gnupg \
&& rm -rf /var/lib/apt/lists/*
RUN mkdir -p /etc/apt/keyrings \
&& curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc \
| gpg --dearmor -o /etc/apt/keyrings/postgresql.gpg \
&& echo "deb [signed-by=/etc/apt/keyrings/postgresql.gpg] http://apt.postgresql.org/pub/repos/apt bookworm-pgdg main" \
> /etc/apt/sources.list.d/pgdg.list
RUN apt-get update && apt-get install -y --no-install-recommends \
postgresql-18 \
postgresql-client-18 \
postgresql-contrib-18 \
&& rm -rf /var/lib/apt/lists/*
ENV PATH="/usr/lib/postgresql/18/bin:${PATH}"
FROM runtime-base AS build-base
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
g++ \
make \
@@ -13,25 +35,9 @@ RUN apt-get update && apt-get install -y \
automake \
libtool \
git \
nginx \
&& rm -rf /var/lib/apt/lists/*
RUN mkdir -p /etc/apt/keyrings \
&& curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc \
| gpg --dearmor -o /etc/apt/keyrings/postgresql.gpg \
&& echo "deb [signed-by=/etc/apt/keyrings/postgresql.gpg] http://apt.postgresql.org/pub/repos/apt bullseye-pgdg main" \
> /etc/apt/sources.list.d/pgdg.list
RUN apt-get update && apt-get install -y \
postgresql-18 \
postgresql-client-18 \
postgresql-contrib-18 \
&& rm -rf /var/lib/apt/lists/*
ENV PATH="/usr/lib/postgresql/18/bin:${PATH}"
FROM --platform=$BUILDPLATFORM golang:1.23-bookworm AS tusd-base
ARG TUSD_VERSION=2.8.0
@@ -51,7 +57,7 @@ FROM scratch AS tusd-dist
COPY --from=tusd-base /tusddist/tusd /tusd
FROM base AS build-env
FROM build-base AS build-env
RUN corepack enable && corepack prepare pnpm@latest --activate
@@ -83,7 +89,7 @@ COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN pnpm run build
FROM base AS prod
FROM runtime-base AS prod
WORKDIR /app
ENV NODE_ENV=production
@@ -105,7 +111,6 @@ COPY --from=builder /app/drizzle.config.ts ./
COPY --from=builder --chown=1001:1001 /app/.next/standalone ./
COPY --from=builder --chown=1001:1001 /app/.next/static ./.next/static
COPY --chown=1001:1001 src/db ./src/db
COPY --from=deps /app/node_modules ./node_modules
COPY --from=tusd-dist /tusd /usr/local/bin/tusd
RUN chmod +x /usr/local/bin/tusd
+5
View File
@@ -30,6 +30,11 @@ http {
proxy_set_header Connection "upgrade";
}
location = /api/tus/hooks {
deny all;
return 404;
}
location / {
proxy_pass http://127.0.0.1:3000;
+3
View File
@@ -0,0 +1,3 @@
{
"ignoreDependencies": ["pino-pretty"]
}
+80 -82
View File
@@ -1,102 +1,100 @@
import type {NextConfig} from "next";
import {PORTABASE_DEFAULT_SETTINGS} from "./portabase.config";
import type { NextConfig } from "next";
import { PORTABASE_DEFAULT_SETTINGS } from "./portabase.config";
const isDev = process.env.NODE_ENV === "development";
function buildCSPHeader(): string {
const {CSP} = PORTABASE_DEFAULT_SETTINGS.SECURITY;
const { CSP } = PORTABASE_DEFAULT_SETTINGS.SECURITY;
const directives = [
`default-src ${CSP.DEFAULT_SRC.join(" ")}`,
`script-src ${CSP.SCRIPT_SRC.join(" ")}`,
`style-src ${CSP.STYLE_SRC.join(" ")}`,
`img-src ${CSP.IMG_SRC.join(" ")}`,
`font-src ${CSP.FONT_SRC.join(" ")}`,
`object-src ${CSP.OBJECT_SRC.join(" ")}`,
`connect-src ${CSP.CONNECT_SRC.join(" ")}`,
`base-uri ${CSP.BASE_URI.join(" ")}`,
`form-action ${CSP.FORM_ACTION.join(" ")}`,
`frame-ancestors ${CSP.FRAME_ANCESTORS.join(" ")}`,
];
const directives = [
`default-src ${CSP.DEFAULT_SRC.join(" ")}`,
`script-src ${CSP.SCRIPT_SRC.join(" ")}`,
`style-src ${CSP.STYLE_SRC.join(" ")}`,
`img-src ${CSP.IMG_SRC.join(" ")}`,
`font-src ${CSP.FONT_SRC.join(" ")}`,
`object-src ${CSP.OBJECT_SRC.join(" ")}`,
`connect-src ${CSP.CONNECT_SRC.join(" ")}`,
`base-uri ${CSP.BASE_URI.join(" ")}`,
`form-action ${CSP.FORM_ACTION.join(" ")}`,
`frame-ancestors ${CSP.FRAME_ANCESTORS.join(" ")}`,
];
if (CSP.BLOCK_ALL_MIXED_CONTENT) {
directives.push("block-all-mixed-content");
}
if (CSP.BLOCK_ALL_MIXED_CONTENT) {
directives.push("block-all-mixed-content");
}
if (CSP.UPGRADE_INSECURE_REQUESTS) {
directives.push("upgrade-insecure-requests");
}
if (CSP.UPGRADE_INSECURE_REQUESTS) {
directives.push("upgrade-insecure-requests");
}
return directives.join("; ");
return directives.join("; ");
}
function buildPermissionsPolicy(): string {
return Object.entries(PORTABASE_DEFAULT_SETTINGS.SECURITY.PERMISSIONS_POLICY)
.map(([feature, values]) => `${feature.toLowerCase()}=${values.join(", ")}`)
.join(", ");
return Object.entries(PORTABASE_DEFAULT_SETTINGS.SECURITY.PERMISSIONS_POLICY)
.map(([feature, values]) => `${feature.toLowerCase()}=${values.join(", ")}`)
.join(", ");
}
const nextConfig: NextConfig = {
output: "standalone",
typescript: {
ignoreBuildErrors: true,
output: "standalone",
devIndicators: false,
typescript: {
ignoreBuildErrors: true,
},
logging: {
browserToTerminal: false,
},
experimental: {
serverActions: {
bodySizeLimit: "10gb",
},
logging: {
browserToTerminal: false,
},
experimental: {
serverActions: {
bodySizeLimit: "10gb",
},
proxyClientMaxBodySize: '10gb',
},
async rewrites() {
if (!isDev) return [];
proxyClientMaxBodySize: "10gb",
},
async rewrites() {
if (!isDev) return [];
return [
{
source: "/tus/:path*",
destination: "http://localhost:1080/tus/:path*",
},
];
},
return [
{
source: "/tus/:path*",
destination: "http://localhost:1080/tus/:path*",
},
];
},
async headers() {
return [
{
source: "/(.*)",
headers: [
{
key: "Content-Security-Policy",
value: buildCSPHeader(),
},
{
key: "Permissions-Policy",
value: buildPermissionsPolicy(),
},
{
key: 'X-Content-Type-Options',
value: 'nosniff',
},
{
key: 'X-Frame-Options',
value: 'DENY',
},
{
key: 'Referrer-Policy',
value: 'strict-origin-when-cross-origin',
},
{
key: 'Strict-Transport-Security',
value: 'max-age=63072000; includeSubDomains; preload',
}
// ...other security headers
],
},
];
},
async headers() {
return [
{
source: "/(.*)",
headers: [
{
key: "Content-Security-Policy",
value: buildCSPHeader(),
},
{
key: "Permissions-Policy",
value: buildPermissionsPolicy(),
},
{
key: "X-Content-Type-Options",
value: "nosniff",
},
{
key: "X-Frame-Options",
value: "DENY",
},
{
key: "Referrer-Policy",
value: "strict-origin-when-cross-origin",
},
{
key: "Strict-Transport-Security",
value: "max-age=63072000; includeSubDomains; preload",
},
],
},
];
},
};
export default nextConfig;
+11 -39
View File
@@ -1,6 +1,6 @@
{
"name": "portabase",
"version": "1.19.0",
"version": "1.22.4",
"private": true,
"scripts": {
"dev": "next dev --turbopack -p 8887",
@@ -16,59 +16,49 @@
},
"dependencies": {
"@asteasolutions/zod-to-openapi": "^8.5.0",
"@azure/storage-blob": "^12.32.0",
"@better-auth/api-key": "1.6.11",
"@better-auth/core": "1.6.11",
"@better-auth/passkey": "1.6.11",
"@better-auth/sso": "1.6.11",
"@google-cloud/storage": "^7.19.0",
"@hookform/resolvers": "^5.4.0",
"@modelcontextprotocol/sdk": "^1.29.0",
"@onboardjs/react": "1.0.0-rc.5",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-aspect-ratio": "^1.1.8",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-menubar": "^1.1.16",
"@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toast": "^1.2.15",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@react-email/components": "^0.0.41",
"@t3-oss/env-nextjs": "^0.13.11",
"@tanstack/react-query": "^5.100.14",
"@tanstack/react-table": "^8.21.3",
"@types/nodemailer": "^6.4.23",
"@types/ws": "^8.18.1",
"@zenstackhq/runtime": "2.14.2",
"argon2": "^0.43.1",
"bcrypt": "^6.0.0",
"better-auth": "1.6.11",
"canvas-confetti": "^1.9.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.3.0",
"dockerode": "^4.0.12",
"dotenv": "^16.6.1",
"drizzle-orm": "0.45.2",
"drizzle-zod": "0.8.3",
"embla-carousel-react": "^8.6.0",
"googleapis": "^170.1.0",
"input-otp": "^1.4.2",
"lucide-react": "^0.553.0",
@@ -78,33 +68,22 @@
"next-safe-action": "^7.10.8",
"next-themes": "^0.4.6",
"node-cron": "^4.2.1",
"node-forge": "^1.4.0",
"nodemailer": "8.0.5",
"npm-check-updates": "^18.3.1",
"pg": "^8.21.0",
"pino": "^10.3.1",
"pino-pretty": "^13.1.3",
"prettier": "^3.8.3",
"react": "^19.2.6",
"react-day-picker": "9.7.0",
"react-dom": "^19.2.6",
"react-dropzone": "^14.4.1",
"react-email": "^4.3.2",
"react-hook-form": "^7.76.1",
"react-qr-code": "^2.0.21",
"react-resizable-panels": "^3.0.6",
"react-twc": "^1.5.1",
"react-use-measure": "^2.1.7",
"recharts": "^2.15.4",
"server-only": "^0.0.1",
"sharp": "^0.34.5",
"socket.io": "^4.8.3",
"socket.io-client": "^4.8.3",
"sonner": "^2.0.7",
"swiper": "^12.1.4",
"tailwind-merge": "^3.6.0",
"uuid": "^11.1.1",
"vaul": "^1.1.2",
"ws": "^8.21.0",
"zod": "4.3.6"
},
"devDependencies": {
@@ -114,29 +93,22 @@
"@release-it/bumper": "^7.0.5",
"@release-it/conventional-changelog": "^10.0.6",
"@tailwindcss/postcss": "^4.3.0",
"@types/eslint-plugin-tailwindcss": "^3.17.0",
"@types/canvas-confetti": "^1.9.0",
"@types/node": "^22.19.19",
"@types/node-forge": "^1.3.14",
"@types/pg": "^8.20.0",
"@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3",
"@zenstackhq/openapi": "^2.22.1",
"@zenstackhq/tanstack-query": "^2.22.2",
"baseline-browser-mapping": "^2.10.32",
"drizzle-kit": "^0.31.10",
"esbuild": "^0.27.7",
"eslint": "^9.39.4",
"eslint-config-next": "^16.2.6",
"eslint-plugin-tailwindcss": "^3.18.3",
"framer-motion": "^12.40.0",
"node-pty": "^1.1.0",
"pino-pretty": "^13.1.3",
"postcss": "8.5.10",
"postcss-load-config": "^6.0.1",
"release-it": "^19.2.4",
"tailwindcss": "^4.3.0",
"tsx": "^4.22.3",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3",
"zenstack": "2.14.2"
"typescript": "^5.9.3"
},
"packageManager": "pnpm@11.5.2+sha512.71c631e382066efc25625d5cf029075de07b61b37f6e27350fbd84b1bda5864c8c1967adc280776b45c30a715c0359a3be08fef42d5bb09e2b99029979692916"
"packageManager": "pnpm@11.8.0+sha512.c1f5e7c4cb241c8f174b743851d82f42b802324afc8b0f116b96adb15aa06664948dde36960a3ba1079ba5b4b29dd0140135b94b5b5f5263592249d68e555f26"
}
+962 -2904
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -46,7 +46,7 @@ export const PORTABASE_DEFAULT_SETTINGS = {
"https://api.iconify.design",
"https://code.iconify.design",
"https://api.github.com",
"https://api.dicebear.com",
],
OBJECT_SRC: ["'none'"],
BASE_URI: ["'self'"],
+3 -2
View File
@@ -4,7 +4,7 @@ import { errorHandler } from "@/middleware/errorHandler";
import { auth } from "@/lib/auth/auth";
import { headers } from "next/headers";
import { env } from "@/env.mjs";
import {User} from "@/db/schema/02_user";
import { User } from "@/db/schema/02_user";
export async function proxy(request: NextRequest) {
const url = request.nextUrl.clone();
@@ -19,7 +19,7 @@ export async function proxy(request: NextRequest) {
new URL(`/login?redirect=${redirectUrl}`, request.url),
);
}
const user = session.user as User
const user = session.user as User;
if (user.banned) {
await auth.api.signOut({ headers: await headers() });
@@ -104,6 +104,7 @@ function checkRouteExists(pathname: string) {
/^\/api\/config\/?$/,
/^\/api\/health\/?$/,
/^\/api\/google\/drive\/callback\/?$/,
/^\/api\/avatar\/?$/,
// v1 external API
/^\/api\/v1\/mcp\/?$/,
/^\/api\/v1\/docs\/?$/,
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env bash
#
# azurite-list.sh — enumerate every container and blob in a local Azurite emulator.
#
# Usage:
# ./azurite-list.sh # list containers + blobs
# ./azurite-list.sh -v # also print blob size, last-modified, content-type
#
set -euo pipefail
export AZURE_STORAGE_CONNECTION_STRING="DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://127.0.0.1:10000/devstoreaccount1;QueueEndpoint=http://127.0.0.1:10001/devstoreaccount1;TableEndpoint=http://127.0.0.1:10002/devstoreaccount1;"
VERBOSE=0
[[ "${1:-}" == "-v" || "${1:-}" == "--verbose" ]] && VERBOSE=1
command -v az >/dev/null 2>&1 || { echo "ERROR: 'az' CLI not found in PATH." >&2; exit 1; }
command -v jq >/dev/null 2>&1 || { echo "ERROR: 'jq' not found in PATH." >&2; exit 1; }
if ! az storage container list --num-results 1 >/dev/null 2>&1; then
echo "ERROR: Cannot reach Azurite at 127.0.0.1:10000. Is the container running?" >&2
echo " Start it with: docker start azurite" >&2
exit 1
fi
containers=$(az storage container list --query "[].name" -o tsv)
if [[ -z "$containers" ]]; then
echo "(no containers found in this emulator)"
exit 0
fi
while IFS= read -r container; do
echo "📦 container: $container"
if [[ "$VERBOSE" -eq 1 ]]; then
az storage blob list --container-name "$container" \
--query "[].{name:name, bytes:properties.contentLength, modified:properties.lastModified, type:properties.contentSettings.contentType}" \
-o json \
| jq -r '.[] | " • \(.name) [\(.bytes) bytes] \(.type // "?") \(.modified)"'
else
az storage blob list --container-name "$container" \
--query "[].{name:name, bytes:properties.contentLength}" \
-o json \
| jq -r '.[] | " • \(.name) [\(.bytes) bytes]"'
fi
count=$(az storage blob list --container-name "$container" --query "length(@)" -o tsv)
[[ "$count" -eq 0 ]] && echo " (empty)"
echo
done <<< "$containers"
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
#
# azurite-seed.sh — seed a local Azurite emulator with a container + sample blob.
#
# Usage:
# ./azurite-seed.sh # create container "portabase" + sample blob
# ./azurite-seed.sh my-container # override container name
#
set -euo pipefail
export AZURE_STORAGE_CONNECTION_STRING="DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://127.0.0.1:10000/devstoreaccount1;QueueEndpoint=http://127.0.0.1:10001/devstoreaccount1;TableEndpoint=http://127.0.0.1:10002/devstoreaccount1;"
CONTAINER="${1:-portabase}"
BLOB_NAME="hello.txt"
command -v az >/dev/null 2>&1 || { echo "ERROR: 'az' CLI not found in PATH." >&2; exit 1; }
# Reachability check — surface the real az error instead of guessing "container down".
if ! err=$(az storage container list --num-results 1 2>&1 >/dev/null); then
echo "ERROR: Azurite query failed:" >&2
printf '%s\n' "$err" | sed 's/^/ /' >&2
echo " Is Azurite up? Start it with: docker compose up -d azurite" >&2
exit 1
fi
# Create container (idempotent — az returns created:false if it already exists).
az storage container create --name "$CONTAINER" -o none
echo "📦 container ready: $CONTAINER"
# Upload a sample blob from a temp file.
tmp=$(mktemp)
trap 'rm -f "$tmp"' EXIT
printf 'hello from azurite seed — %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$tmp"
az storage blob upload \
--container-name "$CONTAINER" \
--name "$BLOB_NAME" \
--file "$tmp" \
--overwrite true \
--no-progress \
-o none
echo " • uploaded blob: $BLOB_NAME"
echo
echo "Seed complete. Verify with: make list-blob"
+78 -2
View File
@@ -351,6 +351,14 @@
"clientRole" : true,
"containerId" : "18b5dfb2-44d4-448e-98df-9652daef1a22",
"attributes" : { }
} ],
"exemple-client" : [ {
"id" : "67766209-57dd-45c7-940d-ed3313473101",
"name" : "uma_protection",
"composite" : false,
"clientRole" : true,
"containerId" : "248d3eaa-466f-4b85-8903-9242959e67ba",
"attributes" : { }
} ]
}
},
@@ -458,8 +466,8 @@
"id" : "b1cedcac-5207-4479-bc29-065ba849e414",
"type" : "password",
"userLabel" : "My password",
"createdDate" : 1772179391727,
"secretData" : "{\"value\":\"IXr6L3pvO2BAkn/cH3rh3v2tg1R83u2f8SWEZK2fq5E=\",\"salt\":\"XWV8/yKm7Rw/YXiwXZZUig==\",\"additionalParameters\":{}}",
"createdDate" : 1782393421785,
"secretData" : "{\"value\":\"L585pLqSeHaNW4HzOu+GzZm+VJWWBO3Df+rPs04gUGk=\",\"salt\":\"w3pmQ0hsMDJnCX+qJ3K+zQ==\",\"additionalParameters\":{}}",
"credentialData" : "{\"hashIterations\":5,\"algorithm\":\"argon2\",\"additionalParameters\":{\"hashLength\":[\"32\"],\"memory\":[\"7168\"],\"type\":[\"id\"],\"version\":[\"1.3\"],\"parallelism\":[\"1\"]}}"
} ],
"disableableCredentialTypes" : [ ],
@@ -467,6 +475,23 @@
"realmRoles" : [ "admin", "default-roles-master" ],
"notBefore" : 0,
"groups" : [ "/Admin" ]
}, {
"id" : "8c749a0c-f2ed-4bc7-9f54-4e0bcbc36626",
"username" : "service-account-exemple-client",
"emailVerified" : false,
"enabled" : true,
"createdTimestamp" : 1772190246795,
"totp" : false,
"serviceAccountClientId" : "exemple-client",
"credentials" : [ ],
"disableableCredentialTypes" : [ ],
"requiredActions" : [ ],
"realmRoles" : [ "default-roles-master" ],
"clientRoles" : {
"exemple-client" : [ "uma_protection" ]
},
"notBefore" : 0,
"groups" : [ ]
}, {
"id" : "718dfe46-dbef-41a3-9120-78aeac240431",
"username" : "service-account-portabase",
@@ -625,6 +650,57 @@
"nodeReRegistrationTimeout" : 0,
"defaultClientScopes" : [ "web-origins", "acr", "profile", "roles", "basic", "email" ],
"optionalClientScopes" : [ "address", "phone", "organization", "offline_access", "microprofile-jwt" ]
}, {
"id" : "248d3eaa-466f-4b85-8903-9242959e67ba",
"clientId" : "exemple-client",
"name" : "Exemple Client",
"description" : "",
"rootUrl" : "https://your-instance.portabase.io",
"adminUrl" : "https://your-instance.portabase.io",
"baseUrl" : "https://your-instance.portabase.io",
"surrogateAuthRequired" : false,
"enabled" : true,
"alwaysDisplayInConsole" : false,
"clientAuthenticatorType" : "client-secret",
"secret" : "lFZY6fXzh9uxCXwvucw9JtXGhNRKgnnj",
"redirectUris" : [ "https://your-instance.portabase.io/api/auth/sso/callback/exemple-client" ],
"webOrigins" : [ "https://your-instance.portabase.io" ],
"notBefore" : 0,
"bearerOnly" : false,
"consentRequired" : false,
"standardFlowEnabled" : true,
"implicitFlowEnabled" : false,
"directAccessGrantsEnabled" : false,
"serviceAccountsEnabled" : true,
"authorizationServicesEnabled" : true,
"publicClient" : false,
"frontchannelLogout" : true,
"protocol" : "openid-connect",
"attributes" : {
"realm_client" : "false",
"oidc.ciba.grant.enabled" : "false",
"client.secret.creation.time" : "1772190246",
"backchannel.logout.session.required" : "true",
"standard.token.exchange.enabled" : "false",
"post.logout.redirect.uris" : "https://your-instance.portabase.io",
"oauth2.device.authorization.grant.enabled" : "true",
"pkce.code.challenge.method" : "S256",
"backchannel.logout.revoke.offline.tokens" : "false",
"dpop.bound.access.tokens" : "false"
},
"authenticationFlowBindingOverrides" : { },
"fullScopeAllowed" : true,
"nodeReRegistrationTimeout" : -1,
"defaultClientScopes" : [ "web-origins", "service_account", "acr", "profile", "roles", "basic", "email" ],
"optionalClientScopes" : [ "address", "phone", "organization", "offline_access", "microprofile-jwt" ],
"authorizationSettings" : {
"allowRemoteResourceManagement" : true,
"policyEnforcementMode" : "ENFORCING",
"resources" : [ ],
"policies" : [ ],
"scopes" : [ ],
"decisionStrategy" : "UNANIMOUS"
}
}, {
"id" : "6022c206-5f1f-45b7-af07-74ce6804cbc7",
"clientId" : "master-realm",
-18
View File
@@ -1,18 +0,0 @@
"use client";
import React from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
type BackButtonProps = React.ComponentProps<typeof Button> & {
children: React.ReactNode;
};
export default function BackButton({ children, ...props }: BackButtonProps) {
const router = useRouter();
return (
<Button onClick={() => router.back()} aria-label={children?.toString()} {...props}>
{children}
</Button>
);
}
+38 -11
View File
@@ -9,6 +9,8 @@ import {
SortingState,
ColumnFiltersState,
getFilteredRowModel,
PaginationState,
OnChangeFn,
} from "@tanstack/react-table";
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table";
@@ -42,6 +44,13 @@ interface DataTableProps<TData, TValue> {
};
highlightRow?: (row: TData) => boolean;
selectedActions?: (rows: TData[]) => ReactNode;
manualPagination?: boolean;
rowCount?: number;
paginationState?: { pageIndex: number; pageSize: number };
onPaginationChange?: OnChangeFn<PaginationState>;
sorting?: SortingState;
onSortingChange?: OnChangeFn<SortingState>;
isFetching?: boolean;
}
@@ -56,10 +65,19 @@ export function DataTable<TData, TValue>({
emptyButton,
highlightRow,
selectedActions,
manualPagination = false,
rowCount,
paginationState,
onPaginationChange,
sorting: controlledSorting,
onSortingChange: controlledOnSortingChange,
isFetching = false,
}: DataTableProps<TData, TValue>) {
const [sorting, setSorting] = useState<SortingState>([]);
const [internalSorting, setInternalSorting] = useState<SortingState>([]);
const sorting = controlledSorting ?? internalSorting;
const setSorting = controlledOnSortingChange ?? setInternalSorting;
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [rowSelection, setRowSelection] = useState({});
@@ -96,7 +114,7 @@ export function DataTable<TData, TValue>({
data,
columns: finalColumns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getPaginationRowModel: manualPagination ? undefined : getPaginationRowModel(),
onSortingChange: setSorting,
getSortedRowModel: getSortedRowModel(),
onColumnFiltersChange: setColumnFilters,
@@ -106,10 +124,15 @@ export function DataTable<TData, TValue>({
autoResetPageIndex: false,
autoResetExpanded: false,
enableRowSelection: true,
manualPagination,
manualSorting: manualPagination,
rowCount: manualPagination ? rowCount : undefined,
onPaginationChange: manualPagination ? onPaginationChange : undefined,
state: {
sorting,
columnFilters,
rowSelection,
...(manualPagination && paginationState ? { pagination: paginationState } : {}),
},
});
const router = useRouter();
@@ -194,18 +217,22 @@ export function DataTable<TData, TValue>({
selected.
</div>
)}
{enablePagination && table.getFilteredRowModel().rows.length >= 1 && (
{enablePagination && (manualPagination ? (rowCount ?? 0) > 0 : table.getFilteredRowModel().rows.length >= 1) && (
<TablePagination
table={table}
maxVisiblePages={paginationOptions?.pageVisible}
pageSizeOptions={(() => {
const rowCount = table.getFilteredRowModel().rows.length;
const allSizes = paginationOptions.pageSize.sort((a, b) => a - b);
const validSizes = allSizes.filter((size) => size <= rowCount);
const nextSize = allSizes.find((size) => size > rowCount);
if (nextSize) validSizes.push(nextSize);
return validSizes;
})()}
pageSizeOptions={
manualPagination
? paginationOptions.pageSize
: (() => {
const rowCountLocal = table.getFilteredRowModel().rows.length;
const allSizes = paginationOptions.pageSize.sort((a, b) => a - b);
const validSizes = allSizes.filter((size) => size <= rowCountLocal);
const nextSize = allSizes.find((size) => size > rowCountLocal);
if (nextSize) validSizes.push(nextSize);
return validSizes;
})()
}
className={paginationOptions.className}
/>
)}
+37 -8
View File
@@ -1,6 +1,4 @@
"use client";
import Link from "next/link";
import Image from "next/image";
import {useState} from "react";
import {Card} from "@/components/ui/card";
@@ -8,28 +6,54 @@ import {ConnectionIndicator} from "@/components/common/connection-indicator";
import {formatDateLastContact} from "@/utils/date-formatting";
import {Database} from "@/db/schema/07_database";
import {ChevronRight, Activity, Fingerprint, Copy, Check} from "lucide-react";
import {cn} from "@/lib/utils";
export type DatabaseCardProps = {
data: Database;
withDetails?: boolean;
selectable?: boolean;
selected?: boolean;
onToggleSelect?: (databaseId: string) => void;
deleteButton?: React.ReactNode;
};
export const DatabaseCard = (props: DatabaseCardProps) => {
const {data: database, withDetails = true} = props;
const {data: database, withDetails = true, selectable = false, selected = false, onToggleSelect, deleteButton} = props;
const [isCopied, setIsCopied] = useState(false);
const handleCopy = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
navigator.clipboard.writeText(database.agentDatabaseId);
navigator.clipboard.writeText(database.agentDatabaseId ?? "");
setIsCopied(true);
setTimeout(() => setIsCopied(false), 2000);
};
return (
<Card
className="relative h-full flex flex-col p-4 transition-all border-border/50 bg-card hover:bg-accent/50 hover:border-primary/50 group-hover:shadow-lg overflow-hidden gap-0">
<div className="flex items-start justify-between mb-2">
className={cn(
"relative h-full flex flex-col p-4 transition-all border-border/50 bg-card hover:bg-accent/50 hover:border-primary/50 group-hover:shadow-lg overflow-hidden gap-0",
selected && "ring-1 ring-primary border-primary",
)}>
{selectable && (
<button
type="button"
aria-pressed={selected}
aria-label={selected ? "Deselect database" : "Select database"}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onToggleSelect?.(database.id);
}}
className={cn(
"absolute top-3 left-3 z-20 w-5 h-5 rounded-md border-2 flex items-center justify-center transition-colors",
selected ? "bg-primary border-primary text-primary-foreground" : "border-muted-foreground/50 bg-background/70",
)}
>
{selected && <Check className="w-3 h-3" />}
</button>
)}
<div className={cn("flex items-start justify-between mb-2", selectable && "pl-7")}>
<div
className="relative w-12 h-12 p-2 bg-background rounded-xl border border-border/50 shadow-sm flex items-center justify-center group-hover:border-primary/30 transition-all duration-300 group-hover:scale-105">
<Image
@@ -82,7 +106,12 @@ export const DatabaseCard = (props: DatabaseCardProps) => {
</div>
</div>
{withDetails && (
{deleteButton ? (
<div className="mt-4 flex items-center justify-end pt-3 border-t border-border/50"
onClick={(e) => e.preventDefault()}>
{deleteButton}
</div>
) : withDetails ? (
<div className="mt-4 flex items-center justify-between pt-3 border-t border-border/50">
<span
className="text-[10px] font-black uppercase tracking-widest text-primary opacity-0 group-hover:opacity-100 transition-all duration-300 transform translate-x-[-10px] group-hover:translate-x-0">Open</span>
@@ -93,7 +122,7 @@ export const DatabaseCard = (props: DatabaseCardProps) => {
<ChevronRight className="w-3.5 h-3.5 group-hover:translate-x-1 transition-transform"/>
</div>
</div>
)}
) : null}
</Card>
);
};
+122
View File
@@ -0,0 +1,122 @@
"use client";
import { cn } from "@/lib/utils";
export type ThemeKey = "dark" | "light" | "system";
const themes: { value: ThemeKey }[] = [
{ value: "light" },
{ value: "dark" },
{ value: "system" },
];
const THEME_TEXT: Record<ThemeKey, string> = {
dark: "Dark",
light: "Light",
system: "System",
};
interface ThemeSelectorProps {
value?: string;
onSelect: (value: ThemeKey) => void;
className?: string;
}
export function ThemeSelector({
value,
onSelect,
className,
}: ThemeSelectorProps) {
return (
<div
className={cn("grid grid-cols-1 sm:grid-cols-3 gap-4 w-full", className)}
>
{themes.map((item) => {
const isDark = item.value === "dark";
const isSystem = item.value === "system";
const isActive = value === item.value;
return (
<div
key={item.value}
className={cn(
"border-2 rounded-xl p-1 cursor-pointer transition-all hover:bg-accent/50 space-y-2",
isActive ? "border-primary bg-primary/5" : "border-muted/40",
)}
onClick={() => onSelect(item.value)}
>
<div
className={cn(
"p-2 rounded-lg aspect-4/3 flex flex-col gap-2 relative overflow-hidden border",
isDark
? "bg-slate-950 border-slate-800"
: "bg-white border-slate-200",
isSystem && "bg-linear-to-br from-white to-slate-950",
)}
>
<div
className={cn(
"h-3 w-full rounded-sm shadow-sm opacity-80",
isDark ? "bg-slate-800" : "bg-slate-100",
)}
/>
<div className="flex gap-2 flex-1 relative">
<div
className={cn(
"w-1/4 h-full rounded-sm shadow-sm opacity-80",
isDark ? "bg-slate-800" : "bg-slate-100",
)}
/>
<div className="flex-1 flex flex-col gap-2">
<div
className={cn(
"h-3 w-full rounded-sm shadow-sm opacity-80",
isDark ? "bg-slate-800" : "bg-slate-100",
)}
/>
<div
className={cn(
"flex-1 rounded-sm shadow-sm p-1 space-y-2 opacity-50",
isDark ? "bg-slate-800" : "bg-slate-100",
)}
>
<div
className={cn(
"h-2 w-3/4 rounded-full",
isDark ? "bg-slate-700" : "bg-slate-300",
)}
/>
<div
className={cn(
"h-2 w-full rounded-full",
isDark ? "bg-slate-700" : "bg-slate-300",
)}
/>
</div>
</div>
</div>
</div>
<div className="flex items-center justify-between p-1 px-2">
<span className="font-medium text-sm">
{THEME_TEXT[item.value]}
</span>
<div
className={cn(
"w-4 h-4 rounded-full border flex items-center justify-center transition-all",
isActive
? "border-primary bg-primary"
: "border-muted-foreground/30",
)}
>
{isActive && (
<div className="w-1.5 h-1.5 rounded-full bg-primary-foreground" />
)}
</div>
</div>
</div>
);
})}
</div>
);
}
+2 -2
View File
@@ -1,7 +1,7 @@
// import * as React from "react";
// import EmailLayout from "./email-layout";
// import {Text, Section, Button} from "@react-email/components";
// import type {EventPayload} from "@/features/notifications/notifications.types";
// import type {EventPayload} from "@/features/notifications/types";
//
// export interface EmailNotificationProps {
// payload: EventPayload
@@ -48,7 +48,7 @@
import * as React from "react";
import EmailLayout from "./email-layout";
import {Text, Section, Button} from "@react-email/components";
import type {EventPayload} from "@/features/notifications/notifications.types";
import type {EventPayload} from "@/features/notifications/types";
export interface EmailNotificationProps {
payload: EventPayload;
@@ -0,0 +1 @@
ALTER TYPE "public"."provider_kind" ADD VALUE 'pushover';
@@ -0,0 +1 @@
ALTER TYPE "public"."provider_storage_kind" ADD VALUE 'blob';
@@ -0,0 +1 @@
ALTER TABLE "settings" ADD COLUMN "onboarding" boolean DEFAULT false NOT NULL;
+11
View File
@@ -0,0 +1,11 @@
-- Custom SQL migration file, put your code below! --
UPDATE settings SET onboarding = true
WHERE onboarding = false
AND (
EXISTS (SELECT 1 FROM organization WHERE slug <> 'default')
OR EXISTS (SELECT 1 FROM agents)
OR EXISTS (SELECT 1 FROM projects)
OR EXISTS (SELECT 1 FROM databases)
OR EXISTS (SELECT 1 FROM notification_channel)
OR EXISTS (SELECT 1 FROM storage_channel WHERE provider <> 'local')
);
@@ -0,0 +1 @@
ALTER TYPE "public"."dbms_status" ADD VALUE 'postgresql-cluster' BEFORE 'mysql';
+1
View File
@@ -0,0 +1 @@
ALTER TYPE "public"."provider_storage_kind" ADD VALUE 'google-cloud-storage' BEFORE 'blob';
@@ -0,0 +1 @@
ALTER TYPE "public"."dbms_status" ADD VALUE 'docker-volume';
@@ -0,0 +1,2 @@
ALTER TABLE "databases" ALTER COLUMN "agent_database_id" DROP NOT NULL;--> statement-breakpoint
ALTER TABLE "databases" ALTER COLUMN "agent_id" DROP NOT NULL;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+56
View File
@@ -442,6 +442,62 @@
"when": 1780934519843,
"tag": "0062_futuristic_dragon_lord",
"breakpoints": true
},
{
"idx": 63,
"version": "7",
"when": 1781854323074,
"tag": "0063_open_proudstar",
"breakpoints": true
},
{
"idx": 64,
"version": "7",
"when": 1781960184847,
"tag": "0064_orange_richard_fisk",
"breakpoints": true
},
{
"idx": 65,
"version": "7",
"when": 1782474059918,
"tag": "0065_mighty_killer_shrike",
"breakpoints": true
},
{
"idx": 66,
"version": "7",
"when": 1782474105293,
"tag": "0066_milky_puma",
"breakpoints": true
},
{
"idx": 67,
"version": "7",
"when": 1782573099089,
"tag": "0067_clammy_stephen_strange",
"breakpoints": true
},
{
"idx": 68,
"version": "7",
"when": 1782575192480,
"tag": "0068_busy_zeigeist",
"breakpoints": true
},
{
"idx": 69,
"version": "7",
"when": 1782937885439,
"tag": "0069_worried_alex_wilder",
"breakpoints": true
},
{
"idx": 70,
"version": "7",
"when": 1783264084846,
"tag": "0070_illegal_joseph",
"breakpoints": true
}
]
}
+2 -1
View File
@@ -20,10 +20,11 @@ export const setting = pgTable("settings", {
defaultStorageChannelId: uuid('default_storage_channel_id')
.references(() => storageChannel.id, {onDelete: "set null"}),
encryption: boolean("encryption").default(false),
onboarding: boolean("onboarding").default(false).notNull(),
...timestamps
});
export const settingRelations = relations(setting, ({one, many}) => ({
export const settingRelations = relations(setting, ({one}) => ({
storageChannel: one(storageChannel, {fields: [setting.defaultStorageChannelId], references: [storageChannel.id]}),
notificationChannel: one(notificationChannel, {fields: [setting.defaultNotificationChannelId], references: [notificationChannel.id]}),
}));
+3 -2
View File
@@ -13,7 +13,7 @@ import {JobLog, jobLog} from "@/db/schema/17_job-log";
export const database = pgTable("databases", {
id: uuid("id").primaryKey().defaultRandom(),
agentDatabaseId: uuid("agent_database_id").notNull().defaultRandom(),
agentDatabaseId: uuid("agent_database_id").defaultRandom(),
name: text("name").notNull(),
dbms: dbmsEnum("dbms").notNull(),
description: text("description"),
@@ -22,7 +22,6 @@ export const database = pgTable("databases", {
backupToRestore: text("backup_to_restore"),
healthErrorCount: integer("health_error_count"),
agentId: uuid("agent_id")
.notNull()
.references(() => agent.id, {onDelete: "cascade"}),
lastContact: timestamp("last_contact"),
projectId: uuid("project_id")
@@ -143,10 +142,12 @@ export type BackupWith = Backup & {
restorations?: Restoration[] | null;
storages?: BackupStorage[] | null;
logs?: JobLog[] | null;
hasLogs?: boolean;
};
export type RestorationWith = Restoration & {
logs?: JobLog[] | null;
hasLogs?: boolean;
};
+1 -1
View File
@@ -7,7 +7,7 @@ import {z} from "zod";
import {OrganizationInvitation} from "@/db/schema/05_invitation";
export const providerKindEnum = pgEnum('provider_kind', ['slack', 'smtp', 'discord', 'telegram', 'gotify', 'ntfy', 'webhook', 'nextcloud', 'teams']);
export const providerKindEnum = pgEnum('provider_kind', ['slack', 'smtp', 'discord', 'telegram', 'gotify', 'ntfy', 'webhook', 'nextcloud', 'teams', 'pushover']);
export const notificationChannel = pgTable('notification_channel', {
id: uuid("id").defaultRandom().primaryKey(),
+1 -1
View File
@@ -5,7 +5,7 @@ import {relations} from "drizzle-orm";
import {createSelectSchema} from "drizzle-zod";
import {z} from "zod";
export const providerStorageKindEnum = pgEnum('provider_storage_kind', ['local', 's3', 'google-drive']);
export const providerStorageKindEnum = pgEnum('provider_storage_kind', ['local', 's3', 'google-drive', 'google-cloud-storage', 'blob']);
export const storageChannel = pgTable('storage_channel', {
id: uuid("id").defaultRandom().primaryKey(),
+1 -1
View File
@@ -2,7 +2,7 @@ import {pgEnum} from "drizzle-orm/pg-core";
import {createSelectSchema} from "drizzle-zod";
import {z} from "zod";
export const dbmsEnum = pgEnum("dbms_status", ["postgresql", "mysql", "mariadb", "mongodb", "sqlite", "redis", "valkey", "firebird", "mssql"]);
export const dbmsEnum = pgEnum("dbms_status", ["postgresql", "postgresql-cluster", "mysql", "mariadb", "mongodb", "sqlite", "redis", "valkey", "firebird", "mssql", "docker-volume"]);
export const statusEnum = pgEnum("status", ["waiting", "ongoing", "failed", "success"]);
export const typeStorageEnum = pgEnum("type_storage", ["local", "s3"]);
+33 -35
View File
@@ -1,43 +1,41 @@
import {and, desc, eq, sql} from "drizzle-orm";
import {db} from "@/db";
import {Agent, agent, organizationAgent} from "@/db/schema/08_agent";
import {Database, database} from "@/db/schema/07_database";
"use server";
import { and, desc, eq, sql } from "drizzle-orm";
import { db } from "@/db";
import { Agent, agent, organizationAgent } from "@/db/schema/08_agent";
import { Database, database } from "@/db/schema/07_database";
export async function getOrganizationAgents(organizationId: string) {
return await db
.select({
id: agent.id,
name: agent.name,
organizationId: agent.organizationId,
slug: agent.slug,
healthErrorCount: agent.healthErrorCount,
description: agent.description,
isArchived: agent.isArchived,
lastContact: agent.lastContact,
version: agent.version,
updatedAt: agent.updatedAt,
createdAt: agent.createdAt,
deletedAt: agent.deletedAt,
databases: sql<Database[]>`
return (await db
.select({
id: agent.id,
name: agent.name,
organizationId: agent.organizationId,
slug: agent.slug,
healthErrorCount: agent.healthErrorCount,
description: agent.description,
isArchived: agent.isArchived,
lastContact: agent.lastContact,
version: agent.version,
updatedAt: agent.updatedAt,
createdAt: agent.createdAt,
deletedAt: agent.deletedAt,
databases: sql<Database[]>`
COALESCE(
json_agg(${database}.*) FILTER (WHERE ${database}.id IS NOT NULL),
'[]'
)
`,
})
.from(organizationAgent)
.innerJoin(
agent,
eq(organizationAgent.agentId, agent.id)
)
.leftJoin(database, eq(database.agentId, agent.id))
.groupBy(agent.id)
.orderBy(desc(agent.createdAt))
.where(
and(
eq(organizationAgent.organizationId, organizationId),
eq(agent.isArchived, false)
)
) as unknown as Agent[];
})
.from(organizationAgent)
.innerJoin(agent, eq(organizationAgent.agentId, agent.id))
.leftJoin(database, eq(database.agentId, agent.id))
.groupBy(agent.id)
.orderBy(desc(agent.createdAt))
.where(
and(
eq(organizationAgent.organizationId, organizationId),
eq(agent.isArchived, false),
),
)) as unknown as Agent[];
}
+13 -13
View File
@@ -1,19 +1,19 @@
"use server"
import {eq} from "drizzle-orm";
"use server";
import { eq } from "drizzle-orm";
import * as drizzleDb from "@/db";
import {db} from "@/db";
import { db } from "@/db";
export async function getDatabaseBackups(databaseId: string) {
return await db.query.backup.findMany({
where: eq(drizzleDb.schemas.backup.databaseId, databaseId),
return await db.query.backup.findMany({
where: eq(drizzleDb.schemas.backup.databaseId, databaseId),
with: {
restorations: true,
storages: {
with: {
restorations: true,
storages: {
with: {
storageChannel: true,
},
},
storageChannel: true,
},
orderBy: (b, {desc}) => [desc(b.createdAt)],
});
},
},
orderBy: (b, { desc }) => [desc(b.createdAt)],
});
}
+121 -34
View File
@@ -1,39 +1,126 @@
"use server"
import {db} from "@/db";
import {DatabaseWith} from "@/db/schema/07_database";
import {AgentWith} from "@/db/schema/08_agent";
"use server";
import { inArray } from "drizzle-orm";
import { db } from "@/db";
import { database, retentionPolicy } from "@/db/schema/07_database";
import { alertPolicy } from "@/db/schema/10_alert-policy";
import { storagePolicy } from "@/db/schema/13_storage-policy";
import { DatabaseWith } from "@/db/schema/07_database";
import { AgentWith } from "@/db/schema/08_agent";
import type {
OnboardingDbSettings,
EventKind,
} from "@/features/onboarding/types";
export async function getOrganizationAvailableDatabases(
organizationId: string,
projectId?: string
organizationId: string,
projectId?: string,
) {
const availableDatabases = (await db.query.database.findMany({
where: (db, { eq, or, and, isNull }) =>
and(
projectId
? or(isNull(db.projectId), eq(db.projectId, projectId))
: isNull(db.projectId),
isNull(db.deletedAt),
),
with: {
agent: {
with: {
organizations: true,
},
},
project: true,
backups: true,
restorations: true,
},
orderBy: (db, { desc }) => [desc(db.createdAt)],
})) as DatabaseWith[];
const availableDatabases = (
await db.query.database.findMany({
where: (db, { eq, or, isNull }) =>
projectId
? or(isNull(db.projectId), eq(db.projectId, projectId))
: isNull(db.projectId),
with: {
agent: {
with: {
organizations: true
}
},
project: true,
backups: true,
restorations: true,
},
orderBy: (db, {desc}) => [desc(db.createdAt)],
})
) as DatabaseWith[];
return availableDatabases.filter(db => {
const agent = db.agent as AgentWith;
if (agent?.isArchived) return false;
return (
agent?.organizationId === organizationId ||
agent?.organizations?.some(org => org.organizationId === organizationId)
);
})
return availableDatabases.filter((db) => {
const agent = db.agent as AgentWith;
if (agent?.isArchived) return false;
return (
agent?.organizationId === organizationId ||
agent?.organizations?.some((org) => org.organizationId === organizationId)
);
});
}
export async function getDatabasesSettings(
databaseIds: string[],
): Promise<Record<string, OnboardingDbSettings>> {
if (databaseIds.length === 0) return {};
const [retentionPolicies, dbs, alertPolicies, storagePolicies] =
await Promise.all([
db
.select()
.from(retentionPolicy)
.where(inArray(retentionPolicy.databaseId, databaseIds)),
db
.select({ id: database.id, backupPolicy: database.backupPolicy })
.from(database)
.where(inArray(database.id, databaseIds)),
db
.select()
.from(alertPolicy)
.where(inArray(alertPolicy.databaseId, databaseIds)),
db
.select()
.from(storagePolicy)
.where(inArray(storagePolicy.databaseId, databaseIds)),
]);
const result: Record<string, OnboardingDbSettings> = {};
for (const dbId of databaseIds) {
const rp = retentionPolicies.find((r) => r.databaseId === dbId);
const dbRow = dbs.find((d) => d.id === dbId);
const alerts = alertPolicies.filter((a) => a.databaseId === dbId);
const storages = storagePolicies.filter((s) => s.databaseId === dbId);
const settings: OnboardingDbSettings = {};
if (rp) {
settings.retention = {
type: rp.type,
count: rp.count ?? 7,
days: rp.days ?? 30,
gfs: {
daily: rp.gfsDaily ?? 7,
weekly: rp.gfsWeekly ?? 4,
monthly: rp.gfsMonthly ?? 12,
yearly: rp.gfsYearly ?? 3,
},
};
}
if (dbRow) {
if (dbRow.backupPolicy) {
settings.backupMethod = "automatic";
settings.backupCron = dbRow.backupPolicy;
} else {
settings.backupMethod = "manual";
}
}
if (alerts.length > 0) {
settings.notificationPolicies = alerts.map((a) => ({
channelId: a.notificationChannelId,
eventKinds: a.eventKinds as EventKind[],
enabled: a.enabled,
}));
}
if (storages.length > 0) {
settings.storagePolicies = storages.map((s) => ({
channelId: s.storageChannelId,
enabled: s.enabled,
}));
}
result[dbId] = settings;
}
return result;
}
+175 -157
View File
@@ -1,185 +1,203 @@
import {db} from "@/db";
"use server";
import { db } from "@/db";
import * as drizzleDb from "@/db";
import {and, eq, gte, isNotNull, lt} from "drizzle-orm";
import {dispatchNotification} from "@/features/notifications/notifications.dispatch";
import {EventPayload} from "@/features/notifications/notifications.types";
import {logger} from "@/lib/logger";
import { and, eq, gte, isNotNull, isNull, lt } from "drizzle-orm";
import { dispatchNotification } from "@/features/notifications/utils/notifications.dispatch";
import { EventPayload } from "@/features/notifications/types";
import { logger } from "@/lib/logger";
const log = logger.child({module: "tasks/healthcheck"});
const log = logger.child({ module: "tasks/healthcheck" });
export async function getHealthLast12hLogs({id}: { id: string }) {
const now = new Date()
const since = new Date(now.getTime() - 12 * 60 * 60 * 1000)
export async function getHealthLast12hLogs({ id }: { id: string }) {
const now = new Date();
const since = new Date(now.getTime() - 12 * 60 * 60 * 1000);
return db
.select()
.from(drizzleDb.schemas.healthcheckLog)
.where(
and(
eq(drizzleDb.schemas.healthcheckLog.objectId, id),
gte(drizzleDb.schemas.healthcheckLog.date, since)
)
)
return db
.select()
.from(drizzleDb.schemas.healthcheckLog)
.where(
and(
eq(drizzleDb.schemas.healthcheckLog.objectId, id),
gte(drizzleDb.schemas.healthcheckLog.date, since),
),
);
}
export async function deleteHealthLogsOlderThan12h() {
const now = new Date()
const threshold = new Date(now.getTime() - 12 * 60 * 60 * 1000)
const now = new Date();
const threshold = new Date(now.getTime() - 12 * 60 * 60 * 1000);
const logsToDelete = await db
.select()
.from(drizzleDb.schemas.healthcheckLog)
.where(
lt(drizzleDb.schemas.healthcheckLog.date, threshold)
)
const logsToDelete = await db
.select()
.from(drizzleDb.schemas.healthcheckLog)
.where(lt(drizzleDb.schemas.healthcheckLog.date, threshold));
log.info({name: "deleteHealthLogsOlderThan12h"},`Number of logs found to delete: ${logsToDelete.length}`)
log.info(
{ name: "deleteHealthLogsOlderThan12h" },
`Number of logs found to delete: ${logsToDelete.length}`,
);
await db
.delete(drizzleDb.schemas.healthcheckLog)
.where(
lt(drizzleDb.schemas.healthcheckLog.date, threshold)
)
await db
.delete(drizzleDb.schemas.healthcheckLog)
.where(lt(drizzleDb.schemas.healthcheckLog.date, threshold));
return logsToDelete.length
return logsToDelete.length;
}
export async function checkAgentsHealthError() {
const agents = await db.query.agent.findMany({
where: isNotNull(drizzleDb.schemas.agent.lastContact),
});
const agents = await db.query.agent.findMany({
where: isNotNull(drizzleDb.schemas.agent.lastContact),
});
const settings = await db.query.setting.findFirst({
where: (fields, {eq}) => eq(fields.name, "system"),
});
const settings = await db.query.setting.findFirst({
where: (fields, { eq }) => eq(fields.name, "system"),
});
if (!settings) {
throw new Error("System settings not found");
}
if (!settings.defaultNotificationChannelId) {
log.error({name: "checkAgentsHealthError"},`No default notification channel id found.`)
return
}
const now = new Date();
for (const agent of agents) {
if (!agent.lastContact) continue;
const lastContactDate = new Date(agent.lastContact);
const diffMinutes = (now.getTime() - lastContactDate.getTime()) / 1000 / 60;
if (diffMinutes > 10) {
if ((agent.healthErrorCount ?? 0) < 3) {
const newHealthErrorCount = (agent.healthErrorCount ?? 0) + 1
await db.update(drizzleDb.schemas.agent)
.set({
healthErrorCount: newHealthErrorCount,
})
.where(eq(drizzleDb.schemas.agent.id, agent.id));
const payload: EventPayload = {
title: "Agent down",
message: `Agent ${agent.name} is down, (notification number: ${newHealthErrorCount}/3)`,
level: "critical",
event: "error_health_agent",
data: {
agent: agent.name,
id: agent.id,
error: "Agent is down",
},
};
log.info({name: "checkAgentsHealthError", payload: payload},`Agent Healthcheck Notification`)
await dispatchNotification(
payload,
undefined,
settings.defaultNotificationChannelId,
undefined
);
}
}
if (!settings) {
throw new Error("System settings not found");
}
if (!settings.defaultNotificationChannelId) {
log.error(
{ name: "checkAgentsHealthError" },
`No default notification channel id found.`,
);
return;
}
const now = new Date();
for (const agent of agents) {
if (!agent.lastContact) continue;
const lastContactDate = new Date(agent.lastContact);
const diffMinutes = (now.getTime() - lastContactDate.getTime()) / 1000 / 60;
if (diffMinutes > 10) {
if ((agent.healthErrorCount ?? 0) < 3) {
const newHealthErrorCount = (agent.healthErrorCount ?? 0) + 1;
await db
.update(drizzleDb.schemas.agent)
.set({
healthErrorCount: newHealthErrorCount,
})
.where(eq(drizzleDb.schemas.agent.id, agent.id));
const payload: EventPayload = {
title: "Agent down",
message: `Agent ${agent.name} is down, (notification number: ${newHealthErrorCount}/3)`,
level: "critical",
event: "error_health_agent",
data: {
agent: agent.name,
id: agent.id,
error: "Agent is down",
},
};
log.info(
{ name: "checkAgentsHealthError", payload: payload },
`Agent Healthcheck Notification`,
);
await dispatchNotification(
payload,
undefined,
settings.defaultNotificationChannelId,
undefined,
);
}
}
}
}
export async function checkDatabasesHealthError() {
const databases = await db.query.database.findMany({
where: and(
isNotNull(drizzleDb.schemas.database.lastContact),
isNull(drizzleDb.schemas.database.deletedAt),
),
with: {
agent: true,
alertPolicies: true,
},
});
const databases = await db.query.database.findMany({
where: isNotNull(drizzleDb.schemas.database.lastContact),
with: {
agent: true,
alertPolicies: true
const now = new Date();
for (const database of databases) {
if (!database.lastContact) continue;
const lastContactDate = new Date(database.lastContact);
const diffMinutes = (now.getTime() - lastContactDate.getTime()) / 1000 / 60;
if (diffMinutes > 10) {
if ((database.healthErrorCount ?? 0) < 3) {
const newHealthErrorCount = (database.healthErrorCount ?? 0) + 1;
await db
.update(drizzleDb.schemas.database)
.set({
healthErrorCount: newHealthErrorCount,
})
.where(eq(drizzleDb.schemas.database.id, database.id));
const settings = await db.query.setting.findFirst({
where: eq(drizzleDb.schemas.setting.name, "system"),
with: { notificationChannel: true },
});
const defaultPolicy = settings?.notificationChannel
? [
{
id: null,
notificationChannelId: settings.notificationChannel.id,
enabled: settings.notificationChannel.enabled,
eventKinds: ["error_health_database"],
},
]
: [];
const policiesToUse =
database.alertPolicies && database.alertPolicies.length > 0
? database.alertPolicies.filter(
(policy) =>
policy.enabled &&
policy.eventKinds.includes("error_health_database"),
)
: defaultPolicy;
if (!policiesToUse || policiesToUse.length === 0) {
continue;
}
})
const now = new Date();
const promises = policiesToUse.map((alertPolicy) => {
const payload: EventPayload = {
title: "Database down",
message: `Database ${database.name} is down, (notification number: ${newHealthErrorCount}/3)`,
level: "critical",
event: "error_health_database",
data: {
agent: database.name,
id: database.id,
error: "Database is down",
},
};
for (const database of databases) {
if (!database.lastContact) continue;
log.info(
{ name: "checkDatabasesHealthError", payload: payload },
`Database Healthcheck Notification`,
);
const lastContactDate = new Date(database.lastContact);
const diffMinutes = (now.getTime() - lastContactDate.getTime()) / 1000 / 60;
return dispatchNotification(
payload,
alertPolicy.id == null ? undefined : alertPolicy.id,
alertPolicy.id ? undefined : alertPolicy.notificationChannelId,
undefined,
);
});
if (diffMinutes > 10) {
if ((database.healthErrorCount ?? 0) < 3) {
const newHealthErrorCount = (database.healthErrorCount ?? 0) + 1
await db.update(drizzleDb.schemas.database)
.set({
healthErrorCount: newHealthErrorCount,
})
.where(eq(drizzleDb.schemas.database.id, database.id));
const settings = await db.query.setting.findFirst({
where: eq(drizzleDb.schemas.setting.name, "system"),
with: { notificationChannel: true },
});
const defaultPolicy = settings?.notificationChannel
? [{
id: null,
notificationChannelId: settings.notificationChannel.id,
enabled: settings.notificationChannel.enabled,
eventKinds: ["error_health_database"]
}]
: [];
const policiesToUse = (database.alertPolicies && database.alertPolicies.length > 0)
? database.alertPolicies.filter(policy => policy.enabled && policy.eventKinds.includes("error_health_database"))
: defaultPolicy;
if (!policiesToUse || policiesToUse.length === 0) {
continue
}
const promises = policiesToUse.map(alertPolicy => {
const payload: EventPayload = {
title: "Database down",
message: `Database ${database.name} is down, (notification number: ${newHealthErrorCount}/3)`,
level: "critical",
event: "error_health_database",
data: {
agent: database.name,
id: database.id,
error: "Database is down",
},
};
log.info({name: "checkDatabasesHealthError", payload: payload},`Database Healthcheck Notification`)
return dispatchNotification(payload, alertPolicy.id == null ? undefined : alertPolicy.id, alertPolicy.id ? undefined : alertPolicy.notificationChannelId, undefined);
});
await Promise.all(promises);
}
}
await Promise.all(promises);
}
}
}
}
+60 -28
View File
@@ -1,30 +1,62 @@
import {desc, eq} from "drizzle-orm";
import {db} from "@/db";
import {
NotificationChannel,
notificationChannel,
organizationNotificationChannel
} from "@/db/schema/09_notification-channel";
import {storageChannel} from "@/db/schema/12_storage-channel";
"use server";
export async function getOrganizationChannels(organizationId: string) {
return await db
.select({
id: notificationChannel.id,
name: notificationChannel.name,
provider: notificationChannel.provider,
config: notificationChannel.config,
enabled: notificationChannel.enabled,
updatedAt: notificationChannel.updatedAt,
createdAt: notificationChannel.createdAt,
deletedAt: notificationChannel.deletedAt,
organizationId: notificationChannel.organizationId
})
.from(organizationNotificationChannel)
.innerJoin(
notificationChannel,
eq(organizationNotificationChannel.notificationChannelId, notificationChannel.id)
)
.orderBy(desc(notificationChannel.createdAt))
.where(eq(organizationNotificationChannel.organizationId, organizationId)) as unknown as NotificationChannel[];
import { desc, eq, isNull } from "drizzle-orm";
import { db } from "@/db";
import {
NotificationChannel,
notificationChannel,
organizationNotificationChannel,
} from "@/db/schema/09_notification-channel";
export async function getOrganizationChannels(
organizationId: string,
): Promise<NotificationChannel[]> {
const [orgChannels, systemChannels] = await Promise.all([
db
.select({
id: notificationChannel.id,
name: notificationChannel.name,
provider: notificationChannel.provider,
config: notificationChannel.config,
enabled: notificationChannel.enabled,
updatedAt: notificationChannel.updatedAt,
createdAt: notificationChannel.createdAt,
deletedAt: notificationChannel.deletedAt,
organizationId: notificationChannel.organizationId,
})
.from(organizationNotificationChannel)
.innerJoin(
notificationChannel,
eq(
organizationNotificationChannel.notificationChannelId,
notificationChannel.id,
),
)
.orderBy(desc(notificationChannel.createdAt))
.where(
eq(organizationNotificationChannel.organizationId, organizationId),
),
db
.select({
id: notificationChannel.id,
name: notificationChannel.name,
provider: notificationChannel.provider,
config: notificationChannel.config,
enabled: notificationChannel.enabled,
updatedAt: notificationChannel.updatedAt,
createdAt: notificationChannel.createdAt,
deletedAt: notificationChannel.deletedAt,
organizationId: notificationChannel.organizationId,
})
.from(notificationChannel)
.orderBy(desc(notificationChannel.createdAt))
.where(isNull(notificationChannel.organizationId)),
]);
const seen = new Set<string>();
return [...orgChannels, ...systemChannels].filter((c) => {
if (seen.has(c.id)) return false;
seen.add(c.id);
return true;
}) as NotificationChannel[];
}
+83 -73
View File
@@ -1,81 +1,91 @@
import {and, desc, eq, gte, lte} from 'drizzle-orm';
import {NotificationLevel, notificationLog} from "@/db/schema/11_notification-log";
import {notificationChannel} from "@/db/schema/09_notification-channel";
import {db} from "@/db";
import {Json} from "drizzle-zod";
"use server";
import { and, desc, eq, gte, lte } from "drizzle-orm";
import {
NotificationLevel,
notificationLog,
} from "@/db/schema/11_notification-log";
import { notificationChannel } from "@/db/schema/09_notification-channel";
import { db } from "@/db";
import { Json } from "drizzle-zod";
export type NotificationLogWithRelations = {
id: string;
id: string;
title: string;
level: NotificationLevel;
success: boolean;
error: string | null;
sentAt: Date;
payload: Json | null;
content: {
title: string;
level: NotificationLevel;
success: boolean;
error: string | null;
sentAt: Date;
payload: Json | null;
content: {
title: string;
message: string;
},
channel: {
name: string;
provider: string;
} | null;
policy: {
event: string | null;
} | null;
message: string;
};
channel: {
name: string;
provider: string;
} | null;
policy: {
event: string | null;
} | null;
};
export async function getNotificationHistory(
filters?: {
channelId?: string;
policyId?: string;
organizationId?: string;
level?: NotificationLevel;
success?: boolean;
from?: Date;
to?: Date;
limit?: number;
}
): Promise<NotificationLogWithRelations[]> {
const where = [];
if (filters?.channelId) where.push(eq(notificationLog.channelId, filters.channelId));
if (filters?.policyId) where.push(eq(notificationLog.policyId, filters.policyId));
if (filters?.organizationId) where.push(eq(notificationLog.organizationId, filters.organizationId));
if (filters?.level) where.push(eq(notificationLog.level, filters.level));
if (typeof filters?.success === 'boolean') where.push(eq(notificationLog.success, filters.success));
if (filters?.from) where.push(gte(notificationLog.sentAt, filters.from));
if (filters?.to) where.push(lte(notificationLog.sentAt, filters.to));
export async function getNotificationHistory(filters?: {
channelId?: string;
policyId?: string;
organizationId?: string;
level?: NotificationLevel;
success?: boolean;
from?: Date;
to?: Date;
limit?: number;
}): Promise<NotificationLogWithRelations[]> {
const where = [];
if (filters?.channelId)
where.push(eq(notificationLog.channelId, filters.channelId));
if (filters?.policyId)
where.push(eq(notificationLog.policyId, filters.policyId));
if (filters?.organizationId)
where.push(eq(notificationLog.organizationId, filters.organizationId));
if (filters?.level) where.push(eq(notificationLog.level, filters.level));
if (typeof filters?.success === "boolean")
where.push(eq(notificationLog.success, filters.success));
if (filters?.from) where.push(gte(notificationLog.sentAt, filters.from));
if (filters?.to) where.push(lte(notificationLog.sentAt, filters.to));
const rows = await db
.select({
id: notificationLog.id,
title: notificationLog.title,
level: notificationLog.level,
success: notificationLog.success,
error: notificationLog.error,
sentAt: notificationLog.sentAt,
payload: notificationLog.payload,
content: {
title: notificationLog.title,
message: notificationLog.message,
},
channel: {
name: notificationLog.providerName,
provider: notificationLog.provider,
},
policy: {
event: notificationLog.event,
},
})
.from(notificationLog)
.leftJoin(notificationChannel, eq(notificationLog.channelId, notificationChannel.id))
// .leftJoin(alertPolicy, eq(notificationLog.policyId, alertPolicy.id))
.where(and(...where))
.orderBy(desc(notificationLog.sentAt))
.limit(filters?.limit || 100);
const rows = await db
.select({
id: notificationLog.id,
title: notificationLog.title,
level: notificationLog.level,
success: notificationLog.success,
error: notificationLog.error,
sentAt: notificationLog.sentAt,
payload: notificationLog.payload,
content: {
title: notificationLog.title,
message: notificationLog.message,
},
channel: {
name: notificationLog.providerName,
provider: notificationLog.provider,
},
policy: {
event: notificationLog.event,
},
})
.from(notificationLog)
.leftJoin(
notificationChannel,
eq(notificationLog.channelId, notificationChannel.id),
)
// .leftJoin(alertPolicy, eq(notificationLog.policyId, alertPolicy.id))
.where(and(...where))
.orderBy(desc(notificationLog.sentAt))
.limit(filters?.limit || 100);
return rows.map(row => ({
...row,
payload: row.payload as Json,
}));
return rows.map((row) => ({
...row,
payload: row.payload as Json,
}));
}
+29
View File
@@ -0,0 +1,29 @@
"use server";
import { db } from "@/db";
import { and, eq, inArray, ne } from "drizzle-orm";
import { member } from "@/db/schema/04_member";
import { organization } from "@/db/schema/03_organization";
export async function getUserOrganization(userId: string) {
const memberRow = await db.query.member.findFirst({
columns: { organizationId: true },
where: eq(member.userId, userId),
});
if (!memberRow) return null;
return db.query.organization.findFirst({
where: eq(organization.id, memberRow.organizationId),
});
}
export async function getUserOwnOrganization(userId: string) {
const memberRows = await db.query.member.findMany({
columns: { organizationId: true },
where: eq(member.userId, userId),
});
if (!memberRows.length) return null;
const orgIds = memberRows.map((r) => r.organizationId);
return db.query.organization.findFirst({
where: and(inArray(organization.id, orgIds), ne(organization.slug, "default")),
});
}
+74 -54
View File
@@ -1,61 +1,81 @@
import {getOrganization} from "@/lib/auth/auth";
import {db} from "@/db";
import {and, eq} from "drizzle-orm";
"use server";
import { getOrganization } from "@/lib/auth/auth";
import { db } from "@/db";
import { and, eq, isNull } from "drizzle-orm";
import * as drizzleDb from "@/db";
import { project } from "@/db/schema/06_project";
export async function getOrganizationProject(organizationId: string) {
return db.query.project.findFirst({
where: eq(project.organizationId, organizationId),
with: {
databases: {
where: isNull(drizzleDb.schemas.database.deletedAt),
},
},
});
}
export const getOrganizationProjectDatabases = async ({organizationSlug, projectId}: {
organizationSlug: string, projectId: string
export const getOrganizationProjectDatabases = async ({
organizationSlug,
projectId,
}: {
organizationSlug: string;
projectId: string;
}) => {
try {
try {
const organization = await getOrganization({});
const organization = await getOrganization({});
if (!organization) {
return {
name: "ErrorGettingOrganizationProjectDatabases",
message: "No organization found.",
status: 400,
cause: "Unknown error occurred.",
};
}
const databasesProject = await db.query.project.findFirst({
where: and(eq(drizzleDb.schemas.project.organizationId, organization.id), eq(drizzleDb.schemas.project.id, projectId)),
with: {
databases: true
}
});
if (!databasesProject) {
return {
name: "ErrorGettingOrganizationProjectDatabases",
message: "No organization found.",
status: 400,
cause: "Unknown error occurred.",
};
}
return {
data: databasesProject.databases,
ids: databasesProject.databases.map((project) => project.id)
}
} catch (e: any) {
const errorMessage = e?.response?.data?.message || e?.message || "Unknown auth error";
const status = e?.response?.status || 500;
console.error("API GettingOrganizationProjectDatabases error:", {
message: errorMessage,
status,
raw: e,
});
throw {
name: "ErrorGettingOrganizationProjectDatabases",
message: errorMessage,
status,
cause: e,
};
if (!organization) {
return {
name: "ErrorGettingOrganizationProjectDatabases",
message: "No organization found.",
status: 400,
cause: "Unknown error occurred.",
};
}
const databasesProject = await db.query.project.findFirst({
where: and(
eq(drizzleDb.schemas.project.organizationId, organization.id),
eq(drizzleDb.schemas.project.id, projectId),
),
with: {
databases: {
where: isNull(drizzleDb.schemas.database.deletedAt),
},
},
});
if (!databasesProject) {
return {
name: "ErrorGettingOrganizationProjectDatabases",
message: "No organization found.",
status: 400,
cause: "Unknown error occurred.",
};
}
return {
data: databasesProject.databases,
ids: databasesProject.databases.map((project) => project.id),
};
} catch (e: any) {
const errorMessage =
e?.response?.data?.message || e?.message || "Unknown auth error";
const status = e?.response?.status || 500;
console.error("API GettingOrganizationProjectDatabases error:", {
message: errorMessage,
status,
raw: e,
});
throw {
name: "ErrorGettingOrganizationProjectDatabases",
message: errorMessage,
status,
cause: e,
};
}
};
+12
View File
@@ -0,0 +1,12 @@
"use server";
import { db } from "@/db";
export async function getSettings() {
return db.query.setting.findFirst();
}
export async function isOnboardingDone(): Promise<boolean> {
const settings = await db.query.setting.findFirst();
return settings?.onboarding ?? false;
}
+55 -23
View File
@@ -1,25 +1,57 @@
import {desc, eq} from "drizzle-orm";
import {db} from "@/db";
import {organizationStorageChannel, StorageChannel, storageChannel} from "@/db/schema/12_storage-channel";
"use server";
export async function getOrganizationStorageChannels(organizationId: string) {
return await db
.select({
id: storageChannel.id,
name: storageChannel.name,
provider: storageChannel.provider,
organizationId: storageChannel.organizationId,
config: storageChannel.config,
enabled: storageChannel.enabled,
updatedAt: storageChannel.updatedAt,
createdAt: storageChannel.createdAt,
deletedAt: storageChannel.deletedAt,
})
.from(organizationStorageChannel)
.innerJoin(
storageChannel,
eq(organizationStorageChannel.storageChannelId, storageChannel.id)
)
.orderBy(desc(storageChannel.createdAt))
.where(eq(organizationStorageChannel.organizationId, organizationId)) as unknown as StorageChannel[];
import { desc, eq, isNull } from "drizzle-orm";
import { db } from "@/db";
import {
organizationStorageChannel,
StorageChannel,
storageChannel,
} from "@/db/schema/12_storage-channel";
export async function getOrganizationStorageChannels(
organizationId: string,
): Promise<StorageChannel[]> {
const [orgChannels, systemChannels] = await Promise.all([
db
.select({
id: storageChannel.id,
name: storageChannel.name,
provider: storageChannel.provider,
organizationId: storageChannel.organizationId,
config: storageChannel.config,
enabled: storageChannel.enabled,
updatedAt: storageChannel.updatedAt,
createdAt: storageChannel.createdAt,
deletedAt: storageChannel.deletedAt,
})
.from(organizationStorageChannel)
.innerJoin(
storageChannel,
eq(organizationStorageChannel.storageChannelId, storageChannel.id),
)
.orderBy(desc(storageChannel.createdAt))
.where(eq(organizationStorageChannel.organizationId, organizationId)),
db
.select({
id: storageChannel.id,
name: storageChannel.name,
provider: storageChannel.provider,
organizationId: storageChannel.organizationId,
config: storageChannel.config,
enabled: storageChannel.enabled,
updatedAt: storageChannel.updatedAt,
createdAt: storageChannel.createdAt,
deletedAt: storageChannel.deletedAt,
})
.from(storageChannel)
.orderBy(desc(storageChannel.createdAt))
.where(isNull(storageChannel.organizationId)),
]);
const seen = new Set<string>();
return [...orgChannels, ...systemChannels].filter((c) => {
if (seen.has(c.id)) return false;
seen.add(c.id);
return true;
}) as StorageChannel[];
}
+41 -32
View File
@@ -1,40 +1,49 @@
import {SignUpUser} from "@/types/auth";
import {hashPassword} from "better-auth/crypto";
import {db} from "@/db";
import * as drizzleDb from "@/db";
import {User, UserThemeEnum} from "@/db/schema/02_user";
import {assertValidPassword} from "@/utils/password";
"use server";
import { SignUpUser } from "@/types/auth";
import { hashPassword } from "better-auth/crypto";
import { db } from "@/db";
import * as drizzleDb from "@/db";
import { User, UserThemeEnum } from "@/db/schema/02_user";
import { assertValidPassword } from "@/utils/password";
export async function hasUsers(): Promise<boolean> {
const result = await db.select().from(drizzleDb.schemas.user).limit(1);
return result.length > 0;
}
export async function createUserDb(data: SignUpUser): Promise<User> {
assertValidPassword(data.password);
assertValidPassword(data.password);
const now = new Date();
const userId = crypto.randomUUID();
const now = new Date();
const userId = crypto.randomUUID();
const [newUser] = await db.insert(drizzleDb.schemas.user).values({
...data,
id: userId,
name: data.name,
email: data.email,
emailVerified: true,
role: data.role,
createdAt: now,
updatedAt: now,
theme: data.theme as UserThemeEnum,
}).returning();
const [newUser] = await db
.insert(drizzleDb.schemas.user)
.values({
...data,
id: userId,
name: data.name,
email: data.email,
emailVerified: true,
role: data.role,
createdAt: now,
updatedAt: now,
theme: data.theme as UserThemeEnum,
})
.returning();
if (data.password) {
const hashedPassword = await hashPassword(data.password);
await db.insert(drizzleDb.schemas.account).values({
providerId: "credential",
accountId: userId,
userId: userId,
password: hashedPassword,
createdAt: now,
updatedAt: now,
});
}
if (data.password) {
const hashedPassword = await hashPassword(data.password);
await db.insert(drizzleDb.schemas.account).values({
providerId: "credential",
accountId: userId,
userId: userId,
password: hashedPassword,
createdAt: now,
updatedAt: now,
});
}
return newUser
return newUser;
}

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