Compare commits

..
29 Commits
Author SHA1 Message Date
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
476 changed files with 27184 additions and 8828 deletions
+3
View File
@@ -81,3 +81,6 @@ TRUSTED_DOMAINS="http://localhost:8887, http://localhost:3055, http://localhost:
# Default to false # Default to false
#TUSD_BEHIND_PROXY=true #TUSD_BEHIND_PROXY=true
#Skip the onboarding on fresh instance
#SKIP_ONBOARDING=true
+1 -5
View File
@@ -1,11 +1,7 @@
{ {
"extends": [ "extends": ["next/core-web-vitals", "plugin:tailwindcss/recommended"],
"next/core-web-vitals",
"plugin:tailwindcss/recommended"
],
"rules": { "rules": {
"react/no-unescaped-entities": "off", "react/no-unescaped-entities": "off",
"react-hooks/rules-of-hooks": "off" "react-hooks/rules-of-hooks": "off"
} }
} }
+1 -1
View File
@@ -73,7 +73,7 @@ jobs:
tags: ${{ steps.prep.outputs.image }} tags: ${{ steps.prep.outputs.image }}
target: ${{ inputs.target }} target: ${{ inputs.target }}
cache-from: type=gha,scope=build-${{ matrix.platform }} cache-from: type=gha,scope=build-${{ matrix.platform }}
cache-to: type=gha,mode=max,scope=build-${{ matrix.platform }} cache-to: type=gha,mode=max,scope=build-${{ matrix.platform }},ignore-error=true
- name: Save image name for manifest - name: Save image name for manifest
run: echo "${{ steps.prep.outputs.image }}" > image.txt run: echo "${{ steps.prep.outputs.image }}" > image.txt
+5
View File
@@ -9,6 +9,8 @@
# testing # testing
/coverage /coverage
gcs-data/
gcs-storage/
# next.js # next.js
/.next/ /.next/
@@ -28,6 +30,8 @@ yarn-error.log*
# local env files # local env files
.env*.local .env*.local
.env
**/.env
# vercel # vercel
.vercel .vercel
@@ -60,3 +64,4 @@ certificates
.codex .codex
/docs /docs
.worktrees .worktrees
.superpowers
+1 -1
View File
@@ -33,5 +33,5 @@ keywords:
- web-ui - web-ui
- agent - agent
license: Apache-2.0 license: Apache-2.0
version: 1.20.2 version: 1.21.3
date-released: '2026-03-02' date-released: '2026-03-02'
+1
View File
@@ -78,6 +78,7 @@ You have 5 ways to install Portabase:
| **Valkey** | ✅ Stable | 7.2+ | No | | **Valkey** | ✅ Stable | 7.2+ | No |
| **Firebird** | ✅ Stable | 3.0, 4.0, 5.0 | Yes | | **Firebird** | ✅ Stable | 3.0, 4.0, 5.0 | Yes |
| **MSSQL Server** | ✅ Stable | 2017, 2019, 2022, Azure SQL | 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. 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 { CardContent, CardHeader } from "@/components/ui/card";
import { TooltipProvider } from "@/components/ui/tooltip"; 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 { env } from "@/env.mjs";
import { CardAuth } from "@/features/layout/card-auth"; import { CardAuth } from "@/features/layout/components/card-auth";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
export default async function RoutePage(props: { searchParams: Promise<{ callbackUrl: string | undefined }> }) { export default async function RoutePage(props: { searchParams: Promise<{ callbackUrl: string | undefined }> }) {
+2 -2
View File
@@ -1,9 +1,9 @@
import {CardContent, CardHeader} from "@/components/ui/card"; import {CardContent, CardHeader} from "@/components/ui/card";
import {TooltipProvider} from "@/components/ui/tooltip"; import {TooltipProvider} from "@/components/ui/tooltip";
import {GuardForm} from "@/features/auth/guard-form"; import {GuardForm} from "@/features/auth/components/guard-form";
import {cookies} from "next/headers"; import {cookies} from "next/headers";
import {redirect} from "next/navigation"; import {redirect} from "next/navigation";
import {CardAuth} from "@/features/layout/card-auth"; import {CardAuth} from "@/features/layout/components/card-auth";
export default async function GuardPage() { export default async function GuardPage() {
+7 -1
View File
@@ -1,7 +1,9 @@
import React from "react"; import React from "react";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { currentUser } from "@/lib/auth/current-user"; import { currentUser } from "@/lib/auth/current-user";
import { AuthLogoSection } from "@/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"; import { Heart } from "lucide-react";
export default async function Layout({ export default async function Layout({
@@ -9,6 +11,10 @@ export default async function Layout({
}: { }: {
children: React.ReactNode; children: React.ReactNode;
}) { }) {
if (env.SKIP_ONBOARDING !== "true" && !(await isOnboardingDone())) {
redirect("/welcome");
}
const user = await currentUser(); const user = await currentUser();
if (user && !user.banned && user.role !== "pending") { if (user && !user.banned && user.role !== "pending") {
+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 { Metadata } from "next";
import { SUPPORTED_PROVIDERS } from "@/lib/auth/config"; 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 { TooltipProvider } from "@/components/ui/tooltip";
import { CardContent, CardHeader } from "@/components/ui/card"; import { CardContent, CardHeader } from "@/components/ui/card";
import Link from "next/link"; import Link from "next/link";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { CardAuth } from "@/features/layout/card-auth"; import { CardAuth } from "@/features/layout/components/card-auth";
import { env } from "@/env.mjs"; import { env } from "@/env.mjs";
export const metadata: Metadata = { export const metadata: Metadata = {
+1 -1
View File
@@ -1,5 +1,5 @@
import { PageParams } from "@/types/next"; 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 { Metadata } from "next";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { env } from "@/env.mjs"; import { env } from "@/env.mjs";
+2 -2
View File
@@ -1,10 +1,10 @@
import { CardContent, CardHeader } from "@/components/ui/card"; import { CardContent, CardHeader } from "@/components/ui/card";
import { TooltipProvider } from "@/components/ui/tooltip"; import { TooltipProvider } from "@/components/ui/tooltip";
import { ResetPasswordForm } from "@/features/auth/reset-password-form"; import { ResetPasswordForm } from "@/features/auth/components/reset-password-form";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { auth } from "@/lib/auth/auth"; import { auth } from "@/lib/auth/auth";
import { Avatar, AvatarImage, AvatarFallback } from "@radix-ui/react-avatar"; import { Avatar, AvatarImage, AvatarFallback } from "@radix-ui/react-avatar";
import { CardAuth } from "@/features/layout/card-auth"; import { CardAuth } from "@/features/layout/components/card-auth";
import { env } from "@/env.mjs"; import { env } from "@/env.mjs";
export default async function RoutePage(props: { searchParams: Promise<{ token: string | undefined }> }) { export default async function RoutePage(props: { searchParams: Promise<{ token: string | undefined }> }) {
@@ -3,8 +3,8 @@ import {eq} from "drizzle-orm";
import {db} from "@/db"; import {db} from "@/db";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {PageParams} from "@/types/next"; import {PageParams} from "@/types/next";
import {Page} from "@/features/layout/page"; import {Page} from "@/features/layout/components/page";
import {OrganizationManagement} from "@/features/organizations/admin-organization-management"; import {OrganizationManagement} from "@/features/organizations/components/admin-organization-management";
import {buildOrganizationWithMembers} from "@/utils/common"; import {buildOrganizationWithMembers} from "@/utils/common";
import {isUUID} from "@/utils/text"; import {isUUID} from "@/utils/text";
import {user} from "@/db/schema/02_user"; import {user} from "@/db/schema/02_user";
@@ -1,10 +1,10 @@
import {PageParams} from "@/types/next"; import {PageParams} from "@/types/next";
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page"; import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/components/page";
import {db} from "@/db"; import {db} from "@/db";
import { import {
AdminOrganizationAddModal AdminOrganizationAddModal
} from "@/features/organizations/admin-organization-add-modal"; } from "@/features/organizations/components/admin-organization-add-modal";
import {AdminOrganizationList} from "@/features/organizations/admin-organization-list"; import {AdminOrganizationList} from "@/features/organizations/components/admin-organization-list";
import {isNull} from "drizzle-orm"; import {isNull} from "drizzle-orm";
export default async function RoutePage(props: PageParams<{}>) { export default async function RoutePage(props: PageParams<{}>) {
@@ -1,9 +1,9 @@
import {PageParams} from "@/types/next"; import {PageParams} from "@/types/next";
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page"; import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/components/page";
import {db} from "@/db"; import {db} from "@/db";
import {logger} from "@/lib/logger"; import {logger} from "@/lib/logger";
import {notFound} from "next/navigation"; 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 {desc, isNull} from "drizzle-orm";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {StorageChannelWith} from "@/db/schema/12_storage-channel"; import {StorageChannelWith} from "@/db/schema/12_storage-channel";
@@ -1,21 +1,28 @@
import {PageParams} from "@/types/next"; import {PageParams} from "@/types/next";
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page"; import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/components/page";
import {db} from "@/db"; import {db} from "@/db";
import {desc, isNull} from "drizzle-orm"; import {desc, isNull} from "drizzle-orm";
import {AdminUserList} from "@/features/users/admin-user-list"; import {AdminUserList} from "@/features/users/components/admin-user-list";
import {AdminUserAddModal} from "@/features/users/admin-user-add-modal"; import {AdminUserAddModal} from "@/features/users/components/admin-user-add-modal";
import {SUPPORTED_PROVIDERS} from "@/lib/auth/config"; import {SUPPORTED_PROVIDERS} from "@/lib/auth/config";
import {getSettings} from "@/db/services/setting";
import {resolveAvatarUrl} from "@/utils/resolve-avatar-url";
export default async function RoutePage(props: PageParams<{}>) { export default async function RoutePage(props: PageParams<{}>) {
const users = await db.query.user.findMany({ const [settings, users] = await Promise.all([
getSettings(),
db.query.user.findMany({
where: (fields) => isNull(fields.deletedAt), where: (fields) => isNull(fields.deletedAt),
with: { with: { accounts: true },
accounts: true
},
orderBy: (fields) => desc(fields.createdAt), orderBy: (fields) => desc(fields.createdAt),
}),
]);
const avatarUrls = Object.fromEntries(
users.map((u) => [u.id, resolveAvatarUrl(u, settings)])
);
});
const organizations = await db.query.organization.findMany({ const organizations = await db.query.organization.findMany({
with: { with: {
members: true, members: true,
@@ -38,7 +45,7 @@ export default async function RoutePage(props: PageParams<{}>) {
</div> </div>
</PageHeader> </PageHeader>
<PageContent className="flex flex-col gap-5"> <PageContent className="flex flex-col gap-5">
<AdminUserList users={users} isPasswordAuthEnabled={isPasswordAuthEnabled}/> <AdminUserList users={users} isPasswordAuthEnabled={isPasswordAuthEnabled} avatarUrls={avatarUrls}/>
</PageContent> </PageContent>
</Page> </Page>
); );
@@ -4,17 +4,17 @@ import {
PageContent, PageContent,
PageDescription, PageDescription,
PageTitle, PageTitle,
} from "@/features/layout/page"; } from "@/features/layout/components/page";
import { db } from "@/db"; import { db } from "@/db";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {eq, isNull} from "drizzle-orm"; import {eq, isNull} from "drizzle-orm";
import { notFound } from "next/navigation"; 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 { capitalizeFirstLetter } from "@/utils/text";
import { generateEdgeKey } from "@/utils/edge_key"; import { generateEdgeKey } from "@/utils/edge_key";
import { getServerUrl } from "@/utils/get-server-url"; import { getServerUrl } from "@/utils/get-server-url";
import { AgentContentPage } from "@/features/agents/agent-content"; import { AgentContentPage } from "@/features/agents/components/agent-content";
import { AgentDialog } from "@/features/agents/agent-dialog"; import { AgentDialog } from "@/features/agents/components/agent-dialog";
export default async function RoutePage( export default async function RoutePage(
@@ -1,13 +1,13 @@
import {PageParams} from "@/types/next"; 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 {CardsWithPagination} from "@/components/common/cards-with-pagination";
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page"; import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/components/page";
import {notFound} from "next/navigation"; import {notFound} from "next/navigation";
import {db} from "@/db"; import {db} from "@/db";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {and, desc, eq, isNull, not} from "drizzle-orm"; import {and, desc, eq, isNull, not} from "drizzle-orm";
import {Metadata} from "next"; import {Metadata} from "next";
import {AgentDialog} from "@/features/agents/agent-dialog"; import {AgentDialog} from "@/features/agents/components/agent-dialog";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Agents", title: "Agents",
@@ -1,11 +1,11 @@
import {PageParams} from "@/types/next"; import {PageParams} from "@/types/next";
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page"; import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/components/page";
import {Metadata} from "next"; import {Metadata} from "next";
import {db} from "@/db"; import {db} from "@/db";
import {notificationChannel, NotificationChannelWith} from "@/db/schema/09_notification-channel"; import {notificationChannel, NotificationChannelWith} from "@/db/schema/09_notification-channel";
import {desc, isNull} from "drizzle-orm"; import {desc, isNull} from "drizzle-orm";
import {ChannelsSection} from "@/features/channel/channels-section"; import {ChannelsSection} from "@/features/channel/components/channels-section";
import {ChannelAddEditModal} from "@/features/channel/channel-add-edit-modal"; import {ChannelAddEditModal} from "@/features/channel/components/channel-add-edit-modal";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
export const metadata: Metadata = { export const metadata: Metadata = {
@@ -1,7 +1,7 @@
import {PageParams} from "@/types/next"; import {PageParams} from "@/types/next";
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page"; import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/components/page";
import {Metadata} from "next"; import {Metadata} from "next";
import {NotificationLogsList} from "@/features/notifications/notification-logs-list"; import {NotificationLogsList} from "@/features/notifications/components/notification-logs-list";
import {notFound} from "next/navigation"; import {notFound} from "next/navigation";
import {getNotificationHistory} from "@/db/services/notification-log"; import {getNotificationHistory} from "@/db/services/notification-log";
@@ -1,12 +1,12 @@
import {PageParams} from "@/types/next"; import {PageParams} from "@/types/next";
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page"; import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/components/page";
import {Metadata} from "next"; import {Metadata} from "next";
import {ChannelsSection} from "@/features/channel/channels-section"; import {ChannelsSection} from "@/features/channel/components/channels-section";
import {db} from "@/db"; import {db} from "@/db";
import {desc, eq, isNull} from "drizzle-orm"; import {desc, eq, isNull} from "drizzle-orm";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {StorageChannelWith} from "@/db/schema/12_storage-channel"; import {StorageChannelWith} from "@/db/schema/12_storage-channel";
import {ChannelAddEditModal} from "@/features/channel/channel-add-edit-modal"; import {ChannelAddEditModal} from "@/features/channel/components/channel-add-edit-modal";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Storage Channels", title: "Storage Channels",
@@ -1,10 +1,10 @@
import {PageParams} from "@/types/next"; import {PageParams} from "@/types/next";
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page"; import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/components/page";
import {notFound} from "next/navigation"; import {notFound} from "next/navigation";
import {getOrganization} from "@/lib/auth/auth"; import {getOrganization} from "@/lib/auth/auth";
import {Metadata} from "next"; import {Metadata} from "next";
import {db} from "@/db"; import {db} from "@/db";
import {MigrationTool} from "@/features/migration/migration-tool"; import {MigrationTool} from "@/features/migration/components/migration-tool";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Projects", title: "Projects",
@@ -1,24 +1,26 @@
import {PageParams} from "@/types/next"; import { PageParams } from "@/types/next";
import {notFound, redirect} from "next/navigation"; import { notFound, redirect } from "next/navigation";
import {Page} from "@/features/layout/page"; import { Page } from "@/features/layout/components/page";
import {db} from "@/db"; import { db } from "@/db";
import {eq, and, inArray} from "drizzle-orm"; import { eq, and, inArray } from "drizzle-orm";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {getOrganizationProjectDatabases} from "@/db/services/project"; import { getOrganizationProjectDatabases } from "@/db/services/project";
import {getActiveMember, getOrganization} from "@/lib/auth/auth"; import { getActiveMember, getOrganization } from "@/lib/auth/auth";
import {BackupModalProvider} from "@/features/database/backup-modal-context"; import { BackupModalProvider } from "@/features/database/components/backup-modal-context";
import {DatabaseContent} from "@/features/database/database-content"; import { DatabaseContent } from "@/features/database/components/database-content";
import {getHealthLast12hLogs} from "@/db/services/healthcheck"; import { getHealthLast12hLogs } from "@/db/services/healthcheck";
import {LogsModalProvider} from "@/features/logs/logs-modal-context"; import { LogsModalProvider } from "@/features/logs/components/logs-modal-context";
export default async function RoutePage(props: PageParams<{ export default async function RoutePage(
props: PageParams<{
projectId: string; projectId: string;
databaseId: string databaseId: string;
}>) { }>,
const {projectId, databaseId} = await props.params; ) {
const { projectId, databaseId } = await props.params;
const organization = await getOrganization({}); const organization = await getOrganization({});
const activeMember = await getActiveMember() const activeMember = await getActiveMember();
if (!organization || !activeMember) { if (!organization || !activeMember) {
notFound(); notFound();
@@ -26,17 +28,21 @@ export default async function RoutePage(props: PageParams<{
const databasesProject = await getOrganizationProjectDatabases({ const databasesProject = await getOrganizationProjectDatabases({
organizationSlug: organization.slug, organizationSlug: organization.slug,
projectId: projectId projectId: projectId,
}) });
const dbItem = await db.query.database.findFirst({ const dbItem = await db.query.database.findFirst({
where: and(inArray(drizzleDb.schemas.backup.id, databasesProject.ids ?? []), eq(drizzleDb.schemas.database.id, databaseId), eq(drizzleDb.schemas.database.projectId, projectId)), where: and(
inArray(drizzleDb.schemas.backup.id, databasesProject.ids ?? []),
eq(drizzleDb.schemas.database.id, databaseId),
eq(drizzleDb.schemas.database.projectId, projectId),
),
with: { with: {
project: true, project: true,
retentionPolicy: true, retentionPolicy: true,
alertPolicies: true, alertPolicies: true,
storagePolicies: true, storagePolicies: true,
} },
}); });
if (!dbItem) { if (!dbItem) {
@@ -49,52 +55,61 @@ export default async function RoutePage(props: PageParams<{
restorations: true, restorations: true,
storages: { storages: {
with: { with: {
storageChannel: true storageChannel: true,
}
}, },
logs: true
}, },
orderBy: (b, {desc}) => [desc(b.createdAt)], logs: true,
},
orderBy: (b, { desc }) => [desc(b.createdAt)],
}); });
const restorations = await db.query.restoration.findMany({ const restorations = await db.query.restoration.findMany({
where: eq(drizzleDb.schemas.restoration.databaseId, dbItem.id), where: eq(drizzleDb.schemas.restoration.databaseId, dbItem.id),
with: { with: {
logs: true logs: true,
}, },
orderBy: (r, {desc}) => [desc(r.createdAt)], orderBy: (r, { desc }) => [desc(r.createdAt)],
}); });
const isAlreadyBackup = backups.some((b) => b.status === "waiting" || b.status === "ongoing"); //const isAlreadyBackup = backups.some((b) => b.status === "waiting" || b.status === "ongoing");
const isAlreadyRestore = restorations.some((r) => r.status === "waiting"); const isAlreadyRestore = restorations.some((r) => r.status === "waiting");
const totalBackups = await db.select({count: drizzleDb.schemas.backup.id}) const totalBackups = await db
.select({ count: drizzleDb.schemas.backup.id })
.from(drizzleDb.schemas.backup) .from(drizzleDb.schemas.backup)
.where(eq(drizzleDb.schemas.backup.databaseId, dbItem.id)) .where(eq(drizzleDb.schemas.backup.databaseId, dbItem.id))
.then(rows => rows.length); .then((rows) => rows.length);
const availableBackups = backups.filter(b => !b.deletedAt).length; const availableBackups = backups.filter((b) => !b.deletedAt).length;
const successfulBackups = await db.select({count: drizzleDb.schemas.backup.id}) const successfulBackups = await db
.select({ count: drizzleDb.schemas.backup.id })
.from(drizzleDb.schemas.backup) .from(drizzleDb.schemas.backup)
.where(and( .where(
and(
eq(drizzleDb.schemas.backup.databaseId, dbItem.id), eq(drizzleDb.schemas.backup.databaseId, dbItem.id),
eq(drizzleDb.schemas.backup.status, "success") eq(drizzleDb.schemas.backup.status, "success"),
)) ),
.then(rows => rows.length); )
.then((rows) => rows.length);
const [settings] = await db
const [settings] = await db.select().from(drizzleDb.schemas.setting).where(eq(drizzleDb.schemas.setting.name, "system")).limit(1); .select()
.from(drizzleDb.schemas.setting)
.where(eq(drizzleDb.schemas.setting.name, "system"))
.limit(1);
if (!settings) { if (!settings) {
notFound(); notFound();
} }
const databaseHealthLogs = dbItem ? await getHealthLast12hLogs({id: dbItem.id}) : [] const databaseHealthLogs = dbItem
? await getHealthLast12hLogs({ id: dbItem.id })
: [];
const successRate =
totalBackups > 0 ? (successfulBackups / totalBackups) * 100 : null;
const successRate = totalBackups > 0 ? (successfulBackups / totalBackups) * 100 : null; //const isMember = activeMember?.role === "member";
const isMember = activeMember?.role === "member";
return ( return (
<Page> <Page>
@@ -1,35 +1,31 @@
import {PageParams} from "@/types/next"; import { PageParams } from "@/types/next";
import {Page, PageContent, PageTitle} from "@/features/layout/page"; import { Page, PageContent, PageTitle } from "@/features/layout/components/page";
import { import { ButtonDeleteProject } from "@/features/projects/components/project-delete-button";
ButtonDeleteProject import { CardsWithPagination } from "@/components/common/cards-with-pagination";
} from "@/features/projects/project-delete-button"; import { ProjectDatabaseCard } from "@/features/projects/components/project-database-card";
import {CardsWithPagination} from "@/components/common/cards-with-pagination"; import { notFound, redirect } from "next/navigation";
import {ProjectDatabaseCard} from "@/features/projects/project-database-card"; import { db } from "@/db";
import {notFound, redirect} from "next/navigation"; import { eq } from "drizzle-orm";
import {db} from "@/db"; import { getActiveMember, getOrganization } from "@/lib/auth/auth";
import {eq} from "drizzle-orm";
import {getActiveMember, getOrganization} from "@/lib/auth/auth";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {capitalizeFirstLetter} from "@/utils/text"; import { capitalizeFirstLetter, isUUID } from "@/utils/text";
import {ProjectDialog} from "@/features/projects/project-dialog"; import { ProjectDialog } from "@/features/projects/components/project-dialog";
import {ProjectWith} from "@/db/schema/06_project"; import { ProjectWith } from "@/db/schema/06_project";
import {isUuidv4} from "@/utils/verify-uuid"; import { getOrganizationAvailableDatabases } from "@/db/services/database";
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<{ if (!isUUID(projectId)) {
projectId: string notFound();
}>) {
const {
projectId
} = await props.params;
if (!isUuidv4(projectId)) {
notFound()
} }
const organization = await getOrganization({}); const organization = await getOrganization({});
const activeMember = await getActiveMember() const activeMember = await getActiveMember();
if (!organization) { if (!organization) {
notFound(); notFound();
@@ -41,11 +37,12 @@ export default async function RoutePage(props: PageParams<{
if (!org) notFound(); if (!org) notFound();
const proj = await db.query.project.findFirst({ const proj = await db.query.project.findFirst({
where: (proj, { where: (proj, { and, eq, not }) =>
and, and(
eq, eq(proj.id, projectId),
not eq(proj.organizationId, org.id),
}) => and(eq(proj.id, projectId), eq(proj.organizationId, org.id), not(eq(proj.isArchived, true))), not(eq(proj.isArchived, true)),
),
with: { with: {
databases: true, databases: true,
}, },
@@ -55,7 +52,10 @@ export default async function RoutePage(props: PageParams<{
redirect("/dashboard/projects"); redirect("/dashboard/projects");
} }
const availableDatabases = await getOrganizationAvailableDatabases(organization.id, proj.id) const availableDatabases = await getOrganizationAvailableDatabases(
organization.id,
proj.id,
);
const isMember = activeMember?.role === "member"; const isMember = activeMember?.role === "member";
return ( return (
@@ -76,7 +76,10 @@ export default async function RoutePage(props: PageParams<{
/> />
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ButtonDeleteProject projectId={projectId} text={"Delete Project"}/> <ButtonDeleteProject
projectId={projectId}
text={"Delete Project"}
/>
</div> </div>
</div> </div>
)} )}
@@ -85,8 +88,10 @@ export default async function RoutePage(props: PageParams<{
<PageContent className="flex flex-col w-full h-full"> <PageContent className="flex flex-col w-full h-full">
{proj.databases.length > 0 ? ( {proj.databases.length > 0 ? (
<CardsWithPagination <CardsWithPagination
data={[...proj.databases].sort((a, b) => data={[...proj.databases].sort(
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() (a, b) =>
new Date(b.createdAt).getTime() -
new Date(a.createdAt).getTime(),
)} )}
organizationSlug={organization.slug} organizationSlug={organization.slug}
// @ts-ignore // @ts-ignore
@@ -99,7 +104,9 @@ export default async function RoutePage(props: PageParams<{
) : ( ) : (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground py-20"> <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-lg font-medium">No databases found</p>
<p className="text-sm mt-2">You havent added any databases to this project yet.</p> <p className="text-sm mt-2">
You havent added any databases to this project yet.
</p>
</div> </div>
)} )}
</PageContent> </PageContent>
@@ -1,13 +1,13 @@
import {PageParams} from "@/types/next"; import {PageParams} from "@/types/next";
import {CardsWithPagination} from "@/components/common/cards-with-pagination"; import {CardsWithPagination} from "@/components/common/cards-with-pagination";
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page"; import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/components/page";
import {ProjectCard} from "@/features/projects/project-card"; import {ProjectCard} from "@/features/projects/components/project-card";
import {db} from "@/db"; import {db} from "@/db";
import {notFound} from "next/navigation"; import {notFound} from "next/navigation";
import {getActiveMember, getOrganization} from "@/lib/auth/auth"; import {getActiveMember, getOrganization} from "@/lib/auth/auth";
import {EmptyStatePlaceholder} from "@/components/common/empty-state-placeholder"; import {EmptyStatePlaceholder} from "@/components/common/empty-state-placeholder";
import {Metadata} from "next"; import {Metadata} from "next";
import {ProjectDialog} from "@/features/projects/project-dialog"; import {ProjectDialog} from "@/features/projects/components/project-dialog";
import {DatabaseWith} from "@/db/schema/07_database"; import {DatabaseWith} from "@/db/schema/07_database";
import {getOrganizationAvailableDatabases} from "@/db/services/database"; import {getOrganizationAvailableDatabases} from "@/db/services/database";
@@ -4,17 +4,17 @@ import {
PageContent, PageContent,
PageDescription, PageDescription,
PageTitle, PageTitle,
} from "@/features/layout/page"; } from "@/features/layout/components/page";
import {db} from "@/db"; import {db} from "@/db";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {eq} from "drizzle-orm"; import {eq} from "drizzle-orm";
import {notFound} from "next/navigation"; 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 {capitalizeFirstLetter} from "@/utils/text";
import {generateEdgeKey} from "@/utils/edge_key"; import {generateEdgeKey} from "@/utils/edge_key";
import {getServerUrl} from "@/utils/get-server-url"; import {getServerUrl} from "@/utils/get-server-url";
import {AgentContentPage} from "@/features/agents/agent-content"; import {AgentContentPage} from "@/features/agents/components/agent-content";
import {AgentDialog} from "@/features/agents/agent-dialog"; import {AgentDialog} from "@/features/agents/components/agent-dialog";
import {getActiveMember, getOrganization} from "@/lib/auth/auth"; import {getActiveMember, getOrganization} from "@/lib/auth/auth";
import {currentUser} from "@/lib/auth/current-user"; import {currentUser} from "@/lib/auth/current-user";
import {computeOrganizationPermissions} from "@/lib/acl/organization-acl"; import {computeOrganizationPermissions} from "@/lib/acl/organization-acl";
@@ -4,17 +4,17 @@ import {
PageContent, PageContent,
PageHeader, PageHeader,
PageTitle, PageTitle,
} from "@/features/layout/page"; } from "@/features/layout/components/page";
import {currentUser} from "@/lib/auth/current-user"; import {currentUser} from "@/lib/auth/current-user";
import {getActiveMember, getOrganization} from "@/lib/auth/auth"; import {getActiveMember, getOrganization} from "@/lib/auth/auth";
import {notFound} from "next/navigation"; import {notFound} from "next/navigation";
import {Metadata} from "next"; import {Metadata} from "next";
import {OrganizationTabs} from "@/features/organizations/organization-tabs"; import {OrganizationTabs} from "@/features/organizations/components/organization-tabs";
import {getOrganizationChannels} from "@/db/services/notification-channel"; import {getOrganizationChannels} from "@/db/services/notification-channel";
import {computeOrganizationPermissions} from "@/lib/acl/organization-acl"; import {computeOrganizationPermissions} from "@/lib/acl/organization-acl";
import {getOrganizationStorageChannels} from "@/db/services/storage-channel"; import {getOrganizationStorageChannels} from "@/db/services/storage-channel";
import {DeleteOrganizationButton} from "@/features/organizations/organization-delete-button"; import {DeleteOrganizationButton} from "@/features/organizations/components/organization-delete-button";
import {EditOrganizationDialog} from "@/features/organizations/organization-edit-dialog"; import {EditOrganizationDialog} from "@/features/organizations/components/organization-edit-dialog";
import {db} from "@/db"; import {db} from "@/db";
import {isNull} from "drizzle-orm"; import {isNull} from "drizzle-orm";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
@@ -1,8 +1,8 @@
import {PageParams} from "@/types/next"; import {PageParams} from "@/types/next";
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page"; import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/components/page";
import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card"; import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card";
import {EvolutionLineChart} from "@/features/statistics/evolution-line-chart"; import {EvolutionLineChart} from "@/features/statistics/components/evolution-line-chart";
import {PercentageLineChart} from "@/features/statistics/percentage-line-chart"; import {PercentageLineChart} from "@/features/statistics/components/percentage-line-chart";
import {notFound} from "next/navigation"; import {notFound} from "next/navigation";
import {db} from "@/db"; import {db} from "@/db";
import {and, asc, count, eq, inArray} from "drizzle-orm"; import {and, asc, count, eq, inArray} from "drizzle-orm";
+6 -3
View File
@@ -1,11 +1,12 @@
import {PageParams} from "@/types/next"; import {PageParams} from "@/types/next";
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page"; import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/components/page";
import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card"; import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card";
import {Building2, Database, DatabaseBackup, Folder, RefreshCcw, Server, Workflow} from "lucide-react"; import {Building2, Database, DatabaseBackup, Folder, RefreshCcw, Server, Workflow} from "lucide-react";
import {currentUser} from "@/lib/auth/current-user"; import {currentUser} from "@/lib/auth/current-user";
import {notFound} from "next/navigation"; import {notFound} from "next/navigation";
import {db} from "@/db"; import {db} from "@/db";
import {and, asc, eq, inArray} from "drizzle-orm";
import {and, asc, eq, inArray, not} from "drizzle-orm";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {listOrganizations} from "@/lib/auth/auth"; import {listOrganizations} from "@/lib/auth/auth";
import {Metadata} from "next"; import {Metadata} from "next";
@@ -23,7 +24,9 @@ export default async function RoutePage(props: PageParams<{}>) {
const organizationIds = organizations.map(project => project.id); const organizationIds = organizations.map(project => project.id);
const agents = await db.query.agent.findMany({}); const agents = await db.query.agent.findMany({
where: not(eq(drizzleDb.schemas.agent.isArchived, true)),
});
const projects = await db.query.project.findMany({ const projects = await db.query.project.findMany({
where: and(inArray(drizzleDb.schemas.project.organizationId, organizationIds), eq(drizzleDb.schemas.project.isArchived, false)), 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 { redirect } from "next/navigation";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"; import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { AppSidebar } from "@/features/layout/app-sidebar"; import { AppSidebar } from "@/features/layout/components/app-sidebar";
import { Header } from "@/features/layout/header"; import { Header } from "@/features/layout/components/header";
import { currentUser } from "@/lib/auth/current-user"; import { currentUser } from "@/lib/auth/current-user";
import { ThemeMetaUpdater } from "@/features/theme/theme-meta-updater"; import { isOnboardingDone } from "@/db/services/setting";
import { ModeToggle } from "@/features/theme/mode-toggle"; import { env } from "@/env.mjs";
import { UpdateNotification } from "@/features/updates/update-notification"; import { ModeToggle } from "@/features/theme/components/mode-toggle";
import { UpdateNotification } from "@/features/updates/components/update-notification";
export default async function Layout({ children }: { children: ReactNode }) { export default async function Layout({ children }: { children: ReactNode }) {
if (env.SKIP_ONBOARDING !== "true" && !(await isOnboardingDone())) {
redirect("/welcome");
}
const user = await currentUser(); const user = await currentUser();
if (!user) redirect("/login"); if (!user) redirect("/login");
return ( return (
<SidebarProvider> <SidebarProvider>
<div className="flex flex-col lg:flex-row w-full"> <div className="flex flex-col lg:flex-row w-full">
<ThemeMetaUpdater />
<AppSidebar updateNotification={<UpdateNotification />} /> <AppSidebar updateNotification={<UpdateNotification />} />
<SidebarInset> <SidebarInset>
<Header actions={<ModeToggle />} /> <Header actions={<ModeToggle />} />
+7 -1
View File
@@ -1,8 +1,14 @@
import { redirect } from "next/navigation"; 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 { currentUser } from "@/lib/auth/current-user";
import { isOnboardingDone } from "@/db/services/setting";
import { env } from "@/env.mjs";
export default async function Index() { export default async function Index() {
if (env.SKIP_ONBOARDING !== "true" && !(await isOnboardingDone())) {
redirect("/welcome");
}
const user = await currentUser(); const user = await currentUser();
if (user) { if (user) {
const currentOrganizationSlug = await getCurrentOrganizationSlug(); const currentOrganizationSlug = await getCurrentOrganizationSlug();
@@ -0,0 +1,29 @@
"use client";
import { useRouter } from "next/navigation";
import { OnboardingProvider } from "@onboardjs/react";
import { onboardingSteps } from "@/features/onboarding/onboarding-steps";
import { OnboardingShell } from "@/features/onboarding/onboarding-shell";
import type { OnboardingFlowData } from "@/features/onboarding/types";
type Props = {
initialStepId: string;
initialFlowData: Partial<OnboardingFlowData>;
};
export const OnboardingClient = ({ initialStepId, initialFlowData }: Props) => {
const router = useRouter();
return (
<OnboardingProvider
steps={onboardingSteps}
initialStepId={initialStepId}
initialContext={{ flowData: initialFlowData }}
onFlowComplete={async () => {
router.push("/dashboard/home");
}}
>
<OnboardingShell />
</OnboardingProvider>
);
};
+19
View File
@@ -0,0 +1,19 @@
import { redirect } from "next/navigation";
import { resolveOnboardingState } from "@/features/onboarding/onboarding-state";
import { OnboardingClient } from "./onboarding-client";
export default async function WelcomePage() {
const result = await resolveOnboardingState();
if ("redirect" in result) {
redirect(result.redirect);
}
return (
<OnboardingClient
key={result.stepId}
initialStepId={result.stepId}
initialFlowData={result.flowData}
/>
);
}
+2 -2
View File
@@ -6,8 +6,8 @@ import {getDatabaseOrThrow, withAgentCheck} from "./helpers";
import {Backup} from "@/db/schema/07_database"; import {Backup} from "@/db/schema/07_database";
import {withUpdatedAt} from "@/db/utils"; import {withUpdatedAt} from "@/db/utils";
import {eventEmitter} from "@/lib/event"; import {eventEmitter} from "@/lib/event";
import {sendNotificationsBackupRestore} from "@/features/notifications/notifications.helpers"; import {sendNotificationsBackupRestore} from "@/features/notifications/utils/notifications.helpers";
import {EventKind} from "@/features/notifications/notifications.types"; import {EventKind} from "@/features/notifications/types";
import {logger} from "@/lib/logger"; import {logger} from "@/lib/logger";
import {JobLogEntry} from "@/features/logs/types"; import {JobLogEntry} from "@/features/logs/types";
@@ -1,36 +1,43 @@
import {NextResponse} from "next/server"; import { NextResponse } from "next/server";
import {and, eq} from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {db} from "@/db"; import { db } from "@/db";
import {getDatabaseOrThrow, withAgentCheck} from "../../helpers"; import { getDatabaseOrThrow, withAgentCheck } from "../../helpers";
import {isUuidv4} from "@/utils/verify-uuid"; import { eventEmitter } from "@/lib/event";
import {eventEmitter} from "@/lib/event"; import { logger } from "@/lib/logger";
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 = { export type Body = {
generatedId: string generatedId: string;
storageChannelId: string storageChannelId: string;
backupId: string backupId: string;
} };
export const POST = withAgentCheck(async (request: Request, {params, agent}: { export const POST = withAgentCheck(
params: Promise<{ agentId: string }>, async (
agent: any request: Request,
}) => { {
params,
agent,
}: {
params: Promise<{ agentId: string }>;
agent: any;
},
) => {
try { try {
const body: Body = await request.json(); const body: Body = await request.json();
log.info({data: body}, "Body for backup upload init"); log.info({ data: body }, "Body for backup upload init");
const generatedId = body.generatedId; const generatedId = body.generatedId;
const storageChannelId = body.storageChannelId; const storageChannelId = body.storageChannelId;
const backupId = body.backupId; const backupId = body.backupId;
if (!generatedId || !isUuidv4(generatedId)) { if (!generatedId || !isUUID(generatedId)) {
return NextResponse.json( return NextResponse.json(
{error: "generatedId is not a valid UUID"}, { error: "generatedId is not a valid UUID" },
{status: 400} { status: 400 },
); );
} }
@@ -45,8 +52,8 @@ export const POST = withAgentCheck(async (request: Request, {params, agent}: {
if (!backup) { if (!backup) {
return NextResponse.json( return NextResponse.json(
{error: "Unable to find the corresponding backup"}, { error: "Unable to find the corresponding backup" },
{status: 404} { status: 404 },
); );
} }
@@ -59,21 +66,21 @@ export const POST = withAgentCheck(async (request: Request, {params, agent}: {
}) })
.returning(); .returning();
eventEmitter.emit('modification', {update: true}); eventEmitter.emit("modification", { update: true });
return NextResponse.json( return NextResponse.json(
{ {
message: "Backup storage successfully created", message: "Backup storage successfully created",
backupStorage: backupStorage backupStorage: backupStorage,
}, },
{status: 200} { status: 200 },
); );
} catch (error) { } catch (error) {
log.error({error: error}, "Error in POST for INIT backup"); log.error({ error: error }, "Error in POST for INIT backup");
return NextResponse.json( return NextResponse.json(
{error: "Internal server error"}, { error: "Internal server error" },
{status: 500} { status: 500 },
); );
} }
}); },
);
+61 -47
View File
@@ -1,73 +1,86 @@
import {NextResponse} from "next/server"; import { NextResponse } from "next/server";
import {isUuidv4} from "@/utils/verify-uuid";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {db as dbClient, db} from "@/db"; import { db as dbClient, db } from "@/db";
import {and, eq} from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import {sendNotificationsBackupRestore} from "@/features/notifications/notifications.helpers"; import { sendNotificationsBackupRestore } from "@/features/notifications/utils/notifications.helpers";
import {logger} from "@/lib/logger"; import { logger } from "@/lib/logger";
import {withUpdatedAt} from "@/db/utils"; import { withUpdatedAt } from "@/db/utils";
import {JobLogEntry} from "@/features/logs/types"; 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 = { export type BodyResultRestore = {
generatedId: string generatedId: string;
status: string status: string;
logs: JobLogEntry[] logs: JobLogEntry[];
durationMs: number durationMs: number;
} };
type RestorationStatus = 'waiting' | 'ongoing' | 'failed' | 'success'; type RestorationStatus = "waiting" | "ongoing" | "failed" | "success";
export async function POST( export async function POST(
request: Request, request: Request,
{params}: { params: Promise<{ agentId: string }> } { params }: { params: Promise<{ agentId: string }> },
) { ) {
try { try {
const agentId = (await params).agentId;
const agentId = (await params).agentId
const body: BodyResultRestore = await request.json(); const body: BodyResultRestore = await request.json();
if (!isUUID(body.generatedId)) {
if (!isUuidv4(body.generatedId)) {
return NextResponse.json( return NextResponse.json(
{error: "generatedId is not a valid uuid"}, { error: "generatedId is not a valid uuid" },
{status: 500} { status: 500 },
); );
} }
const agent = await db.query.agent.findFirst({ const agent = await db.query.agent.findFirst({
where: and(eq(drizzleDb.schemas.agent.id, agentId), eq(drizzleDb.schemas.agent.isArchived, false)), where: and(
}) eq(drizzleDb.schemas.agent.id, agentId),
eq(drizzleDb.schemas.agent.isArchived, false),
),
});
if (!agent) { if (!agent) {
return NextResponse.json({error: "Agent not found"}, {status: 404}) return NextResponse.json({ error: "Agent not found" }, { status: 404 });
} }
const database = await db.query.database.findFirst({ const database = await db.query.database.findFirst({
where: eq(drizzleDb.schemas.database.agentDatabaseId, body.generatedId), where: eq(drizzleDb.schemas.database.agentDatabaseId, body.generatedId),
with: { with: {
alertPolicies: true alertPolicies: true,
} },
}) });
if (!database) { if (!database) {
return NextResponse.json({error: "Database associated with generatedId provided not found"}, {status: 404}) return NextResponse.json(
{ error: "Database associated with generatedId provided not found" },
{ status: 404 },
);
} }
const restoration = await db.query.restoration.findFirst({ const restoration = await db.query.restoration.findFirst({
where: and(eq(drizzleDb.schemas.restoration.status, "ongoing"), eq(drizzleDb.schemas.restoration.databaseId, database.id),) where: and(
}) eq(drizzleDb.schemas.restoration.status, "ongoing"),
eq(drizzleDb.schemas.restoration.databaseId, database.id),
),
});
if (!restoration) { if (!restoration) {
return NextResponse.json({error: "Unable to fin the corresponding restoration"}, {status: 404}) return NextResponse.json(
{ error: "Unable to fin the corresponding restoration" },
{ status: 404 },
);
} }
const [restorationUpdated] = await db const [restorationUpdated] = await db
.update(drizzleDb.schemas.restoration) .update(drizzleDb.schemas.restoration)
.set(withUpdatedAt({status: body.status as RestorationStatus, durationMs: body.durationMs})) .set(
.where(eq(drizzleDb.schemas.restoration.id, restoration.id)).returning(); withUpdatedAt({
status: body.status as RestorationStatus,
durationMs: body.durationMs,
}),
)
.where(eq(drizzleDb.schemas.restoration.id, restoration.id))
.returning();
const logsToInsert = body.logs.map((entry) => ({ const logsToInsert = body.logs.map((entry) => ({
backupId: null, backupId: null,
@@ -87,24 +100,25 @@ export async function POST(
})); }));
if (logsToInsert.length > 0) { if (logsToInsert.length > 0) {
await dbClient await dbClient.insert(drizzleDb.schemas.jobLog).values(logsToInsert);
.insert(drizzleDb.schemas.jobLog)
.values(logsToInsert);
} }
await sendNotificationsBackupRestore(database, body.status == "failed" ? "error_restore" : "success_restore"); await sendNotificationsBackupRestore(
database,
body.status == "failed" ? "error_restore" : "success_restore",
);
const response = { const response = {
status: true, status: true,
message: "Restoration successfully updated" message: "Restoration successfully updated",
} };
return Response.json(response, {status: 200}) return Response.json(response, { status: 200 });
} catch (error) { } catch (error) {
log.error({error: error}, "Error in POST handler") log.error({ error: error }, "Error in POST handler");
return NextResponse.json( return NextResponse.json(
{error: 'Internal server error'}, { error: "Internal server error" },
{status: 500} { status: 500 },
); );
} }
} }
+10 -6
View File
@@ -1,17 +1,17 @@
import {NextResponse} from "next/server"; import {NextResponse} from "next/server";
import {Body} from "./route"; import {Body} from "./route";
import {isUuidv4} from "@/utils/verify-uuid";
import {Agent} from "@/db/schema/08_agent"; import {Agent} from "@/db/schema/08_agent";
import {DatabaseWith} from "@/db/schema/07_database"; import {DatabaseWith} from "@/db/schema/07_database";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {db, db as dbClient} from "@/db"; import {db, db as dbClient} from "@/db";
import {and, eq, inArray} from "drizzle-orm"; import {and, eq, inArray, desc, sql} from "drizzle-orm";
import {dbmsEnumSchema, EDbmsSchema} from "@/db/schema/types"; import {dbmsEnumSchema, EDbmsSchema} from "@/db/schema/types";
import {withUpdatedAt} from "@/db/utils"; import {withUpdatedAt} from "@/db/utils";
import type {StorageInput} from "@/features/storages/storages.types";
import {dispatchStorage} from "@/features/storages/storages.dispatch";
import {Setting} from "@/db/schema/01_setting"; import {Setting} from "@/db/schema/01_setting";
import {logger} from "@/lib/logger"; 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"}); const log = logger.child({module: "api/agent/status/helpers"});
@@ -53,7 +53,7 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
let backupSize: number | null = null let backupSize: number | null = null
if (!existingDatabase) { if (!existingDatabase) {
if (!isUuidv4(db.generatedId)) { if (!isUUID(db.generatedId)) {
return NextResponse.json( return NextResponse.json(
{error: "generatedId is not a valid uuid"}, {error: "generatedId is not a valid uuid"},
{status: 500} {status: 500}
@@ -123,7 +123,11 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
where: and( where: and(
eq(drizzleDb.schemas.backup.databaseId, databaseUpdated.id), eq(drizzleDb.schemas.backup.databaseId, databaseUpdated.id),
inArray(drizzleDb.schemas.backup.status, ["waiting", "ongoing"]) 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({ const restoration = await dbClient.query.restoration.findFirst({
+60 -52
View File
@@ -1,99 +1,107 @@
import {NextResponse} from "next/server"; import { NextResponse } from "next/server";
import {handleDatabases} from "./helpers"; import { handleDatabases } from "./helpers";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {db} from "@/db"; import { db } from "@/db";
import {EDbmsSchema} from "@/db/schema/types"; import { EDbmsSchema } from "@/db/schema/types";
import {and, eq} from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import {isUuidv4} from "@/utils/verify-uuid"; import { withUpdatedAt } from "@/db/utils";
import {withUpdatedAt} from "@/db/utils"; import { logger } from "@/lib/logger";
import {logger} from "@/lib/logger"; import { isUUID } from "@/utils/text";
const log = logger.child({module: "api/agent/status/route"});
const log = logger.child({ module: "api/agent/status/route" });
export type databaseAgent = { export type databaseAgent = {
name: string, name: string;
dbms: EDbmsSchema, dbms: EDbmsSchema;
generatedId: string generatedId: string;
pingStatus: boolean pingStatus: boolean;
} };
export type Body = { export type Body = {
version: string, version: string;
databases: databaseAgent[] databases: databaseAgent[];
} };
export async function POST( export async function POST(
request: Request, request: Request,
{params}: { params: Promise<{ agentId: string }> } { params }: { params: Promise<{ agentId: string }> },
) { ) {
try { try {
const agentId = (await params).agentId const agentId = (await params).agentId;
log.debug(`Agent ID: ${agentId}`) log.debug(`Agent ID: ${agentId}`);
const body: Body = await request.json(); const body: Body = await request.json();
const lastContact = new Date(); const lastContact = new Date();
let message: string let message: string;
if (!isUuidv4(agentId)) { if (!isUUID(agentId)) {
message = "agentId is not a valid uuid" message = "agentId is not a valid uuid";
log.error({error: message}, "An error occurred") log.error({ error: message }, "An error occurred");
return NextResponse.json( return NextResponse.json(
{error: "agentId is not a valid uuid"}, { error: "agentId is not a valid uuid" },
{status: 500} { status: 500 },
); );
} }
const agent = await db.query.agent.findFirst({ const agent = await db.query.agent.findFirst({
where: and(eq(drizzleDb.schemas.agent.id, agentId), eq(drizzleDb.schemas.agent.isArchived, false)), where: and(
}) eq(drizzleDb.schemas.agent.id, agentId),
eq(drizzleDb.schemas.agent.isArchived, false),
),
});
if (!agent) { if (!agent) {
message = "Agent not found" message = "Agent not found";
return NextResponse.json({error: message}, {status: 404}) 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); const [settings] = await db
.select()
.from(drizzleDb.schemas.setting)
.where(eq(drizzleDb.schemas.setting.name, "system"))
.limit(1);
if (!settings) { if (!settings) {
return NextResponse.json({error: "An error occured"}, {status: 404}) return NextResponse.json({ error: "An error occured" }, { status: 404 });
} }
const databasesResponse = await handleDatabases(body, agent, lastContact, settings) const databasesResponse = await handleDatabases(
body,
agent,
lastContact,
settings,
);
await db await db
.update(drizzleDb.schemas.agent) .update(drizzleDb.schemas.agent)
.set(withUpdatedAt({ .set(
withUpdatedAt({
version: body.version, version: body.version,
lastContact: lastContact, lastContact: lastContact,
healthErrorCount: null healthErrorCount: null,
})) }),
)
.where(eq(drizzleDb.schemas.agent.id, agentId)); .where(eq(drizzleDb.schemas.agent.id, agentId));
await db await db.insert(drizzleDb.schemas.healthcheckLog).values({
.insert(drizzleDb.schemas.healthcheckLog)
.values({
kind: "agent", kind: "agent",
status: "success", status: "success",
objectId: agentId, objectId: agentId,
date: lastContact date: lastContact,
}) });
const response = { const response = {
agent: { agent: {
id: agentId, id: agentId,
lastContact: lastContact lastContact: lastContact,
}, },
databases: databasesResponse databases: databasesResponse,
} };
return Response.json(response) return Response.json(response);
} catch (error) { } catch (error) {
log.error({error: error}, "Error in POST handler") log.error({ error: error }, "Error in POST handler");
return NextResponse.json( return NextResponse.json(
{error: 'Internal server error'}, { error: "Internal server error" },
{status: 500} { status: 500 },
); );
} }
} }
+4 -2
View File
@@ -5,7 +5,9 @@ import { headers } from "next/headers";
const authHandler = toNextJsHandler(auth.handler); const authHandler = toNextJsHandler(auth.handler);
async function blockApiKeyCreateForRestrictedUsers(req: NextRequest): Promise<NextResponse | null> { async function blockApiKeyCreateForRestrictedUsers(
req: NextRequest,
): Promise<NextResponse | null> {
const url = req.nextUrl; const url = req.nextUrl;
if (req.method !== "POST" || !url.pathname.endsWith("/api-key/create")) { if (req.method !== "POST" || !url.pathname.endsWith("/api-key/create")) {
return null; return null;
@@ -18,7 +20,7 @@ async function blockApiKeyCreateForRestrictedUsers(req: NextRequest): Promise<Ne
if (session.user.banned || (session.user.role as string) === "pending") { if (session.user.banned || (session.user.role as string) === "pending") {
return NextResponse.json( return NextResponse.json(
{ error: "Account not eligible to create API keys" }, { error: "Account not eligible to create API keys" },
{ status: 403 } { status: 403 },
); );
} }
return null; return null;
+2 -2
View File
@@ -1,7 +1,7 @@
import {NextResponse} from "next/server"; import {NextResponse} from "next/server";
import path from "path"; import path from "path";
import type {StorageInput} from "@/features/storages/storages.types"; import type {StorageInput} from "@/features/storages/types";
import {dispatchStorage} from "@/features/storages/storages.dispatch"; import {dispatchStorage} from "@/features/storages/utils/storages.dispatch";
import {Readable} from "node:stream"; import {Readable} from "node:stream";
import {logger} from "@/lib/logger"; import {logger} from "@/lib/logger";
+2 -2
View File
@@ -1,8 +1,8 @@
import {NextResponse} from "next/server"; import {NextResponse} from "next/server";
import {auth} from "@/lib/auth/auth"; import {auth} from "@/lib/auth/auth";
import {headers} from "next/headers"; import {headers} from "next/headers";
import {StorageInput} from "@/features/storages/storages.types"; import {StorageInput} from "@/features/storages/types";
import {dispatchStorage} from "@/features/storages/storages.dispatch"; import {dispatchStorage} from "@/features/storages/utils/storages.dispatch";
import {Readable} from "node:stream"; import {Readable} from "node:stream";
import {logger} from "@/lib/logger"; import {logger} from "@/lib/logger";
+54 -5
View File
@@ -1,11 +1,16 @@
import {NextResponse} from "next/server"; import {NextResponse} from "next/server";
import fs from "fs"; import fs from "fs";
import path from "path"; 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 {env} from "@/env.mjs";
import {logger} from "@/lib/logger"; import {logger} from "@/lib/logger";
const log = logger.child({module: "api/tus/hooks"}); 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) { export async function POST(request: Request) {
try { try {
const body = await request.json(); const body = await request.json();
@@ -24,17 +29,61 @@ export async function POST(request: Request) {
event.Upload.Offset === event.Upload.Size event.Upload.Offset === event.Upload.Size
) { ) {
const id = event.Upload.ID; const id = event.Upload.ID;
const fileName = headers["X-File-Name"]?.[0];
const filePath = headers["X-File-Path"]?.[0]; const filePath = headers["X-File-Path"]?.[0];
if (!filePath) { if (!filePath || !FILE_PATH_RE.test(filePath)) {
return NextResponse.json({error: "Missing X-File-Path"}, {status: 500}); 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 uploadDir = path.join(env.PRIVATE_PATH!, "/uploads/");
const oldFilePath = path.join(uploadDir, "tmp", id); const oldFilePath = path.join(uploadDir, "tmp", id);
const newFilePath = path.join(uploadDir, filePath);
const 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}); fs.mkdirSync(path.dirname(newFilePath), {recursive: true});
@@ -74,7 +123,7 @@ export async function POST(request: Request) {
} }
return NextResponse.json({}); return NextResponse.json({});
} catch (error) { } 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}); 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 {logger} from "@/lib/logger";
import {ApiKeyContext} from "@/lib/api-v1/types"; import {ApiKeyContext} from "@/lib/api-v1/types";
import {getAgent, resolveAgentAccess} from "@/lib/api-v1/services/agents"; 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]"}); 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 { inArray, eq, and, or, isNull } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import { logger } from "@/lib/logger"; 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 { ActionError } from "@/lib/safe-actions/actions";
import {getAccessibleAgentIds} from "@/lib/api-v1/services/agents"; import {getAccessibleAgentIds} from "@/lib/api-v1/services/agents";
import {ApiKeyContext} from "@/lib/api-v1/types"; 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() { export default async function NotFound() {
return ( return (
@@ -12,7 +13,9 @@ export default async function NotFound() {
The content you are trying to view is not available. The content you are trying to view is not available.
</p> </p>
</div> </div>
<BackButton>Go home</BackButton> <Button asChild>
<Link href="/">Go home</Link>
</Button>
</div> </div>
</div> </div>
); );
+9 -4
View File
@@ -5,8 +5,8 @@ import { type PropsWithChildren, Suspense } from "react";
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
import { ErrorLayout } from "@/components/common/error-layout"; import { ErrorLayout } from "@/components/common/error-layout";
import { ThemeMetaUpdaterRoot } from "@/features/theme/theme-meta-updater-root"; import { ThemeMetaUpdater } from "@/features/theme/components/theme-meta-updater";
import { ThemeProvider } from "@/features/theme/theme-provider"; import { ThemeProvider } from "@/features/theme/components/theme-provider";
export type ProviderProps = PropsWithChildren<{}>; export type ProviderProps = PropsWithChildren<{}>;
const queryClient = new QueryClient(); const queryClient = new QueryClient();
@@ -14,8 +14,13 @@ const queryClient = new QueryClient();
export const Providers = (props: ProviderProps) => { export const Providers = (props: ProviderProps) => {
return ( return (
<Suspense fallback={null}> <Suspense fallback={null}>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem> <ThemeProvider
<ThemeMetaUpdaterRoot /> attribute="class"
defaultTheme="light"
enableSystem
storageKey="portabase_theme"
>
<ThemeMetaUpdater />
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<ErrorLayout> <ErrorLayout>
<Toaster /> <Toaster />
+7 -7
View File
@@ -1,12 +1,12 @@
services: services:
app: app:
# build: build:
# context: . context: .
# dockerfile: docker/dockerfile/Dockerfile dockerfile: docker/dockerfile/Dockerfile
# target: prod target: prod
image: portabase/portabase:1 # image: portabase/portabase:1
ports: ports:
- '8887:80' - "8887:80"
environment: environment:
TZ: "Europe/Paris" TZ: "Europe/Paris"
env_file: env_file:
@@ -35,7 +35,7 @@ services:
- POSTGRES_USER=devuser - POSTGRES_USER=devuser
- POSTGRES_PASSWORD=changeme - POSTGRES_PASSWORD=changeme
healthcheck: healthcheck:
test: [ "CMD-SHELL", "pg_isready -U devuser -d devdb" ] test: ["CMD-SHELL", "pg_isready -U devuser -d devdb"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5
+93
View File
@@ -55,9 +55,102 @@ services:
# 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; # 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: volumes:
postgres-data: postgres-data:
azurite-data: azurite-data:
gcs-data:
gcs-storage:
rustfs-data:
networks: networks:
portabase: portabase:
+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 \ curl \
ca-certificates \ ca-certificates \
bash \ bash \
tzdata \ tzdata \
libc6 \ 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 \ build-essential \
g++ \ g++ \
make \ make \
@@ -13,25 +35,9 @@ RUN apt-get update && apt-get install -y \
automake \ automake \
libtool \ libtool \
git \ git \
nginx \
&& rm -rf /var/lib/apt/lists/* && 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 FROM --platform=$BUILDPLATFORM golang:1.23-bookworm AS tusd-base
ARG TUSD_VERSION=2.8.0 ARG TUSD_VERSION=2.8.0
@@ -51,7 +57,7 @@ FROM scratch AS tusd-dist
COPY --from=tusd-base /tusddist/tusd /tusd 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 RUN corepack enable && corepack prepare pnpm@latest --activate
@@ -83,7 +89,7 @@ COPY . .
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
RUN pnpm run build RUN pnpm run build
FROM base AS prod FROM runtime-base AS prod
WORKDIR /app WORKDIR /app
ENV NODE_ENV=production 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/standalone ./
COPY --from=builder --chown=1001:1001 /app/.next/static ./.next/static COPY --from=builder --chown=1001:1001 /app/.next/static ./.next/static
COPY --chown=1001:1001 src/db ./src/db 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 COPY --from=tusd-dist /tusd /usr/local/bin/tusd
RUN chmod +x /usr/local/bin/tusd RUN chmod +x /usr/local/bin/tusd
+5
View File
@@ -30,6 +30,11 @@ http {
proxy_set_header Connection "upgrade"; proxy_set_header Connection "upgrade";
} }
location = /api/tus/hooks {
deny all;
return 404;
}
location / { location / {
proxy_pass http://127.0.0.1:3000; proxy_pass http://127.0.0.1:3000;
+3
View File
@@ -0,0 +1,3 @@
{
"ignoreDependencies": ["pino-pretty"]
}
+14 -16
View File
@@ -1,11 +1,10 @@
import type {NextConfig} from "next"; import type { NextConfig } from "next";
import {PORTABASE_DEFAULT_SETTINGS} from "./portabase.config"; import { PORTABASE_DEFAULT_SETTINGS } from "./portabase.config";
const isDev = process.env.NODE_ENV === "development"; const isDev = process.env.NODE_ENV === "development";
function buildCSPHeader(): string { function buildCSPHeader(): string {
const {CSP} = PORTABASE_DEFAULT_SETTINGS.SECURITY; const { CSP } = PORTABASE_DEFAULT_SETTINGS.SECURITY;
const directives = [ const directives = [
`default-src ${CSP.DEFAULT_SRC.join(" ")}`, `default-src ${CSP.DEFAULT_SRC.join(" ")}`,
@@ -37,9 +36,9 @@ function buildPermissionsPolicy(): string {
.join(", "); .join(", ");
} }
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
output: "standalone", output: "standalone",
devIndicators: false,
typescript: { typescript: {
ignoreBuildErrors: true, ignoreBuildErrors: true,
}, },
@@ -50,7 +49,7 @@ const nextConfig: NextConfig = {
serverActions: { serverActions: {
bodySizeLimit: "10gb", bodySizeLimit: "10gb",
}, },
proxyClientMaxBodySize: '10gb', proxyClientMaxBodySize: "10gb",
}, },
async rewrites() { async rewrites() {
if (!isDev) return []; if (!isDev) return [];
@@ -77,22 +76,21 @@ const nextConfig: NextConfig = {
value: buildPermissionsPolicy(), value: buildPermissionsPolicy(),
}, },
{ {
key: 'X-Content-Type-Options', key: "X-Content-Type-Options",
value: 'nosniff', value: "nosniff",
}, },
{ {
key: 'X-Frame-Options', key: "X-Frame-Options",
value: 'DENY', value: "DENY",
}, },
{ {
key: 'Referrer-Policy', key: "Referrer-Policy",
value: 'strict-origin-when-cross-origin', value: "strict-origin-when-cross-origin",
}, },
{ {
key: 'Strict-Transport-Security', key: "Strict-Transport-Security",
value: 'max-age=63072000; includeSubDomains; preload', value: "max-age=63072000; includeSubDomains; preload",
} },
// ...other security headers
], ],
}, },
]; ];
+9 -38
View File
@@ -1,6 +1,6 @@
{ {
"name": "portabase", "name": "portabase",
"version": "1.20.2", "version": "1.21.3",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --turbopack -p 8887", "dev": "next dev --turbopack -p 8887",
@@ -21,55 +21,44 @@
"@better-auth/core": "1.6.11", "@better-auth/core": "1.6.11",
"@better-auth/passkey": "1.6.11", "@better-auth/passkey": "1.6.11",
"@better-auth/sso": "1.6.11", "@better-auth/sso": "1.6.11",
"@google-cloud/storage": "^7.19.0",
"@hookform/resolvers": "^5.4.0", "@hookform/resolvers": "^5.4.0",
"@modelcontextprotocol/sdk": "^1.29.0", "@modelcontextprotocol/sdk": "^1.29.0",
"@onboardjs/react": "1.0.0-rc.5",
"@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-aspect-ratio": "^1.1.8",
"@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12", "@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-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@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-icons": "^1.3.2",
"@radix-ui/react-label": "^2.1.8", "@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-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.8", "@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-radio-group": "^1.3.8", "@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6", "@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13", "@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": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@react-email/components": "^0.0.41", "@react-email/components": "^0.0.41",
"@t3-oss/env-nextjs": "^0.13.11", "@t3-oss/env-nextjs": "^0.13.11",
"@tanstack/react-query": "^5.100.14", "@tanstack/react-query": "^5.100.14",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"@types/nodemailer": "^6.4.23", "@types/nodemailer": "^6.4.23",
"@types/ws": "^8.18.1",
"@zenstackhq/runtime": "2.14.2",
"argon2": "^0.43.1",
"bcrypt": "^6.0.0",
"better-auth": "1.6.11", "better-auth": "1.6.11",
"canvas-confetti": "^1.9.4",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"date-fns": "^4.3.0", "date-fns": "^4.3.0",
"dockerode": "^4.0.12",
"dotenv": "^16.6.1", "dotenv": "^16.6.1",
"drizzle-orm": "0.45.2", "drizzle-orm": "0.45.2",
"drizzle-zod": "0.8.3", "drizzle-zod": "0.8.3",
"embla-carousel-react": "^8.6.0",
"googleapis": "^170.1.0", "googleapis": "^170.1.0",
"input-otp": "^1.4.2", "input-otp": "^1.4.2",
"lucide-react": "^0.553.0", "lucide-react": "^0.553.0",
@@ -79,33 +68,22 @@
"next-safe-action": "^7.10.8", "next-safe-action": "^7.10.8",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"node-cron": "^4.2.1", "node-cron": "^4.2.1",
"node-forge": "^1.4.0",
"nodemailer": "8.0.5", "nodemailer": "8.0.5",
"npm-check-updates": "^18.3.1",
"pg": "^8.21.0", "pg": "^8.21.0",
"pino": "^10.3.1", "pino": "^10.3.1",
"pino-pretty": "^13.1.3",
"prettier": "^3.8.3",
"react": "^19.2.6", "react": "^19.2.6",
"react-day-picker": "9.7.0",
"react-dom": "^19.2.6", "react-dom": "^19.2.6",
"react-dropzone": "^14.4.1", "react-dropzone": "^14.4.1",
"react-email": "^4.3.2", "react-email": "^4.3.2",
"react-hook-form": "^7.76.1", "react-hook-form": "^7.76.1",
"react-qr-code": "^2.0.21", "react-qr-code": "^2.0.21",
"react-resizable-panels": "^3.0.6",
"react-twc": "^1.5.1", "react-twc": "^1.5.1",
"react-use-measure": "^2.1.7",
"recharts": "^2.15.4", "recharts": "^2.15.4",
"server-only": "^0.0.1",
"sharp": "^0.34.5", "sharp": "^0.34.5",
"socket.io": "^4.8.3",
"socket.io-client": "^4.8.3",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"swiper": "^12.1.4",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0",
"uuid": "^11.1.1", "uuid": "^11.1.1",
"vaul": "^1.1.2",
"ws": "^8.21.0",
"zod": "4.3.6" "zod": "4.3.6"
}, },
"devDependencies": { "devDependencies": {
@@ -115,29 +93,22 @@
"@release-it/bumper": "^7.0.5", "@release-it/bumper": "^7.0.5",
"@release-it/conventional-changelog": "^10.0.6", "@release-it/conventional-changelog": "^10.0.6",
"@tailwindcss/postcss": "^4.3.0", "@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": "^22.19.19",
"@types/node-forge": "^1.3.14",
"@types/pg": "^8.20.0", "@types/pg": "^8.20.0",
"@types/react": "^19.2.15", "@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3", "@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", "drizzle-kit": "^0.31.10",
"esbuild": "^0.27.7",
"eslint": "^9.39.4", "eslint": "^9.39.4",
"eslint-config-next": "^16.2.6", "eslint-config-next": "^16.2.6",
"eslint-plugin-tailwindcss": "^3.18.3",
"framer-motion": "^12.40.0", "framer-motion": "^12.40.0",
"node-pty": "^1.1.0", "pino-pretty": "^13.1.3",
"postcss": "8.5.10", "postcss": "8.5.10",
"postcss-load-config": "^6.0.1",
"release-it": "^19.2.4", "release-it": "^19.2.4",
"tailwindcss": "^4.3.0", "tailwindcss": "^4.3.0",
"tsx": "^4.22.3",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"typescript": "^5.9.3", "typescript": "^5.9.3"
"zenstack": "2.14.2"
}, },
"packageManager": "pnpm@11.8.0+sha512.c1f5e7c4cb241c8f174b743851d82f42b802324afc8b0f116b96adb15aa06664948dde36960a3ba1079ba5b4b29dd0140135b94b5b5f5263592249d68e555f26" "packageManager": "pnpm@11.8.0+sha512.c1f5e7c4cb241c8f174b743851d82f42b802324afc8b0f116b96adb15aa06664948dde36960a3ba1079ba5b4b29dd0140135b94b5b5f5263592249d68e555f26"
} }
+1471 -3594
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://api.iconify.design",
"https://code.iconify.design", "https://code.iconify.design",
"https://api.github.com", "https://api.github.com",
"https://api.dicebear.com",
], ],
OBJECT_SRC: ["'none'"], OBJECT_SRC: ["'none'"],
BASE_URI: ["'self'"], BASE_URI: ["'self'"],
+3 -2
View File
@@ -4,7 +4,7 @@ import { errorHandler } from "@/middleware/errorHandler";
import { auth } from "@/lib/auth/auth"; import { auth } from "@/lib/auth/auth";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { env } from "@/env.mjs"; import { env } from "@/env.mjs";
import {User} from "@/db/schema/02_user"; import { User } from "@/db/schema/02_user";
export async function proxy(request: NextRequest) { export async function proxy(request: NextRequest) {
const url = request.nextUrl.clone(); const url = request.nextUrl.clone();
@@ -19,7 +19,7 @@ export async function proxy(request: NextRequest) {
new URL(`/login?redirect=${redirectUrl}`, request.url), new URL(`/login?redirect=${redirectUrl}`, request.url),
); );
} }
const user = session.user as User const user = session.user as User;
if (user.banned) { if (user.banned) {
await auth.api.signOut({ headers: await headers() }); await auth.api.signOut({ headers: await headers() });
@@ -104,6 +104,7 @@ function checkRouteExists(pathname: string) {
/^\/api\/config\/?$/, /^\/api\/config\/?$/,
/^\/api\/health\/?$/, /^\/api\/health\/?$/,
/^\/api\/google\/drive\/callback\/?$/, /^\/api\/google\/drive\/callback\/?$/,
/^\/api\/avatar\/?$/,
// v1 external API // v1 external API
/^\/api\/v1\/mcp\/?$/, /^\/api\/v1\/mcp\/?$/,
/^\/api\/v1\/docs\/?$/, /^\/api\/v1\/docs\/?$/,
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

+78 -2
View File
@@ -351,6 +351,14 @@
"clientRole" : true, "clientRole" : true,
"containerId" : "18b5dfb2-44d4-448e-98df-9652daef1a22", "containerId" : "18b5dfb2-44d4-448e-98df-9652daef1a22",
"attributes" : { } "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", "id" : "b1cedcac-5207-4479-bc29-065ba849e414",
"type" : "password", "type" : "password",
"userLabel" : "My password", "userLabel" : "My password",
"createdDate" : 1772179391727, "createdDate" : 1782393421785,
"secretData" : "{\"value\":\"IXr6L3pvO2BAkn/cH3rh3v2tg1R83u2f8SWEZK2fq5E=\",\"salt\":\"XWV8/yKm7Rw/YXiwXZZUig==\",\"additionalParameters\":{}}", "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\"]}}" "credentialData" : "{\"hashIterations\":5,\"algorithm\":\"argon2\",\"additionalParameters\":{\"hashLength\":[\"32\"],\"memory\":[\"7168\"],\"type\":[\"id\"],\"version\":[\"1.3\"],\"parallelism\":[\"1\"]}}"
} ], } ],
"disableableCredentialTypes" : [ ], "disableableCredentialTypes" : [ ],
@@ -467,6 +475,23 @@
"realmRoles" : [ "admin", "default-roles-master" ], "realmRoles" : [ "admin", "default-roles-master" ],
"notBefore" : 0, "notBefore" : 0,
"groups" : [ "/Admin" ] "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", "id" : "718dfe46-dbef-41a3-9120-78aeac240431",
"username" : "service-account-portabase", "username" : "service-account-portabase",
@@ -625,6 +650,57 @@
"nodeReRegistrationTimeout" : 0, "nodeReRegistrationTimeout" : 0,
"defaultClientScopes" : [ "web-origins", "acr", "profile", "roles", "basic", "email" ], "defaultClientScopes" : [ "web-origins", "acr", "profile", "roles", "basic", "email" ],
"optionalClientScopes" : [ "address", "phone", "organization", "offline_access", "microprofile-jwt" ] "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", "id" : "6022c206-5f1f-45b7-af07-74ce6804cbc7",
"clientId" : "master-realm", "clientId" : "master-realm",
-18
View File
@@ -1,18 +0,0 @@
"use client";
import React from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
type BackButtonProps = React.ComponentProps<typeof Button> & {
children: React.ReactNode;
};
export default function BackButton({ children, ...props }: BackButtonProps) {
const router = useRouter();
return (
<Button onClick={() => router.back()} aria-label={children?.toString()} {...props}>
{children}
</Button>
);
}
+122
View File
@@ -0,0 +1,122 @@
"use client";
import { cn } from "@/lib/utils";
export type ThemeKey = "dark" | "light" | "system";
const themes: { value: ThemeKey }[] = [
{ value: "light" },
{ value: "dark" },
{ value: "system" },
];
const THEME_TEXT: Record<ThemeKey, string> = {
dark: "Dark",
light: "Light",
system: "System",
};
interface ThemeSelectorProps {
value?: string;
onSelect: (value: ThemeKey) => void;
className?: string;
}
export function ThemeSelector({
value,
onSelect,
className,
}: ThemeSelectorProps) {
return (
<div
className={cn("grid grid-cols-1 sm:grid-cols-3 gap-4 w-full", className)}
>
{themes.map((item) => {
const isDark = item.value === "dark";
const isSystem = item.value === "system";
const isActive = value === item.value;
return (
<div
key={item.value}
className={cn(
"border-2 rounded-xl p-1 cursor-pointer transition-all hover:bg-accent/50 space-y-2",
isActive ? "border-primary bg-primary/5" : "border-muted/40",
)}
onClick={() => onSelect(item.value)}
>
<div
className={cn(
"p-2 rounded-lg aspect-4/3 flex flex-col gap-2 relative overflow-hidden border",
isDark
? "bg-slate-950 border-slate-800"
: "bg-white border-slate-200",
isSystem && "bg-linear-to-br from-white to-slate-950",
)}
>
<div
className={cn(
"h-3 w-full rounded-sm shadow-sm opacity-80",
isDark ? "bg-slate-800" : "bg-slate-100",
)}
/>
<div className="flex gap-2 flex-1 relative">
<div
className={cn(
"w-1/4 h-full rounded-sm shadow-sm opacity-80",
isDark ? "bg-slate-800" : "bg-slate-100",
)}
/>
<div className="flex-1 flex flex-col gap-2">
<div
className={cn(
"h-3 w-full rounded-sm shadow-sm opacity-80",
isDark ? "bg-slate-800" : "bg-slate-100",
)}
/>
<div
className={cn(
"flex-1 rounded-sm shadow-sm p-1 space-y-2 opacity-50",
isDark ? "bg-slate-800" : "bg-slate-100",
)}
>
<div
className={cn(
"h-2 w-3/4 rounded-full",
isDark ? "bg-slate-700" : "bg-slate-300",
)}
/>
<div
className={cn(
"h-2 w-full rounded-full",
isDark ? "bg-slate-700" : "bg-slate-300",
)}
/>
</div>
</div>
</div>
</div>
<div className="flex items-center justify-between p-1 px-2">
<span className="font-medium text-sm">
{THEME_TEXT[item.value]}
</span>
<div
className={cn(
"w-4 h-4 rounded-full border flex items-center justify-center transition-all",
isActive
? "border-primary bg-primary"
: "border-muted-foreground/30",
)}
>
{isActive && (
<div className="w-1.5 h-1.5 rounded-full bg-primary-foreground" />
)}
</div>
</div>
</div>
);
})}
</div>
);
}
+2 -2
View File
@@ -1,7 +1,7 @@
// import * as React from "react"; // import * as React from "react";
// import EmailLayout from "./email-layout"; // import EmailLayout from "./email-layout";
// import {Text, Section, Button} from "@react-email/components"; // 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 { // export interface EmailNotificationProps {
// payload: EventPayload // payload: EventPayload
@@ -48,7 +48,7 @@
import * as React from "react"; import * as React from "react";
import EmailLayout from "./email-layout"; import EmailLayout from "./email-layout";
import {Text, Section, Button} from "@react-email/components"; 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 { export interface EmailNotificationProps {
payload: EventPayload; payload: EventPayload;
@@ -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';
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
+35
View File
@@ -456,6 +456,41 @@
"when": 1781960184847, "when": 1781960184847,
"tag": "0064_orange_richard_fisk", "tag": "0064_orange_richard_fisk",
"breakpoints": true "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
} }
] ]
} }
+2 -1
View File
@@ -20,10 +20,11 @@ export const setting = pgTable("settings", {
defaultStorageChannelId: uuid('default_storage_channel_id') defaultStorageChannelId: uuid('default_storage_channel_id')
.references(() => storageChannel.id, {onDelete: "set null"}), .references(() => storageChannel.id, {onDelete: "set null"}),
encryption: boolean("encryption").default(false), encryption: boolean("encryption").default(false),
onboarding: boolean("onboarding").default(false).notNull(),
...timestamps ...timestamps
}); });
export const settingRelations = relations(setting, ({one, many}) => ({ export const settingRelations = relations(setting, ({one}) => ({
storageChannel: one(storageChannel, {fields: [setting.defaultStorageChannelId], references: [storageChannel.id]}), storageChannel: one(storageChannel, {fields: [setting.defaultStorageChannelId], references: [storageChannel.id]}),
notificationChannel: one(notificationChannel, {fields: [setting.defaultNotificationChannelId], references: [notificationChannel.id]}), notificationChannel: one(notificationChannel, {fields: [setting.defaultNotificationChannelId], references: [notificationChannel.id]}),
})); }));
+1 -1
View File
@@ -5,7 +5,7 @@ import {relations} from "drizzle-orm";
import {createSelectSchema} from "drizzle-zod"; import {createSelectSchema} from "drizzle-zod";
import {z} from "zod"; import {z} from "zod";
export const providerStorageKindEnum = pgEnum('provider_storage_kind', ['local', 's3', 'google-drive', 'blob']); export const providerStorageKindEnum = pgEnum('provider_storage_kind', ['local', 's3', 'google-drive', 'google-cloud-storage', 'blob']);
export const storageChannel = pgTable('storage_channel', { export const storageChannel = pgTable('storage_channel', {
id: uuid("id").defaultRandom().primaryKey(), 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 {createSelectSchema} from "drizzle-zod";
import {z} from "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 statusEnum = pgEnum("status", ["waiting", "ongoing", "failed", "success"]);
export const typeStorageEnum = pgEnum("type_storage", ["local", "s3"]); export const typeStorageEnum = pgEnum("type_storage", ["local", "s3"]);
+11 -13
View File
@@ -1,11 +1,12 @@
import {and, desc, eq, sql} from "drizzle-orm"; "use server";
import {db} from "@/db";
import {Agent, agent, organizationAgent} from "@/db/schema/08_agent"; import { and, desc, eq, sql } from "drizzle-orm";
import {Database, database} from "@/db/schema/07_database"; 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) { export async function getOrganizationAgents(organizationId: string) {
return (await db
return await db
.select({ .select({
id: agent.id, id: agent.id,
name: agent.name, name: agent.name,
@@ -27,17 +28,14 @@ export async function getOrganizationAgents(organizationId: string) {
`, `,
}) })
.from(organizationAgent) .from(organizationAgent)
.innerJoin( .innerJoin(agent, eq(organizationAgent.agentId, agent.id))
agent,
eq(organizationAgent.agentId, agent.id)
)
.leftJoin(database, eq(database.agentId, agent.id)) .leftJoin(database, eq(database.agentId, agent.id))
.groupBy(agent.id) .groupBy(agent.id)
.orderBy(desc(agent.createdAt)) .orderBy(desc(agent.createdAt))
.where( .where(
and( and(
eq(organizationAgent.organizationId, organizationId), eq(organizationAgent.organizationId, organizationId),
eq(agent.isArchived, false) eq(agent.isArchived, false),
) ),
) as unknown as Agent[]; )) as unknown as Agent[];
} }
+4 -4
View File
@@ -1,7 +1,7 @@
"use server" "use server";
import {eq} from "drizzle-orm"; import { eq } from "drizzle-orm";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {db} from "@/db"; import { db } from "@/db";
export async function getDatabaseBackups(databaseId: string) { export async function getDatabaseBackups(databaseId: string) {
return await db.query.backup.findMany({ return await db.query.backup.findMany({
@@ -14,6 +14,6 @@ export async function getDatabaseBackups(databaseId: string) {
}, },
}, },
}, },
orderBy: (b, {desc}) => [desc(b.createdAt)], orderBy: (b, { desc }) => [desc(b.createdAt)],
}); });
} }
+100 -16
View File
@@ -1,15 +1,21 @@
"use server" "use server";
import {db} from "@/db"; import { inArray } from "drizzle-orm";
import {DatabaseWith} from "@/db/schema/07_database"; import { db } from "@/db";
import {AgentWith} from "@/db/schema/08_agent"; 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( export async function getOrganizationAvailableDatabases(
organizationId: string, organizationId: string,
projectId?: string projectId?: string,
) { ) {
const availableDatabases = (await db.query.database.findMany({
const availableDatabases = (
await db.query.database.findMany({
where: (db, { eq, or, isNull }) => where: (db, { eq, or, isNull }) =>
projectId projectId
? or(isNull(db.projectId), eq(db.projectId, projectId)) ? or(isNull(db.projectId), eq(db.projectId, projectId))
@@ -17,23 +23,101 @@ export async function getOrganizationAvailableDatabases(
with: { with: {
agent: { agent: {
with: { with: {
organizations: true organizations: true,
} },
}, },
project: true, project: true,
backups: true, backups: true,
restorations: true, restorations: true,
}, },
orderBy: (db, {desc}) => [desc(db.createdAt)], orderBy: (db, { desc }) => [desc(db.createdAt)],
}) })) as DatabaseWith[];
) as DatabaseWith[];
return availableDatabases.filter(db => { return availableDatabases.filter((db) => {
const agent = db.agent as AgentWith; const agent = db.agent as AgentWith;
if (agent?.isArchived) return false; if (agent?.isArchived) return false;
return ( return (
agent?.organizationId === organizationId || agent?.organizationId === organizationId ||
agent?.organizations?.some(org => org.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;
} }
+67 -52
View File
@@ -1,15 +1,17 @@
import {db} from "@/db"; "use server";
import { db } from "@/db";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {and, eq, gte, isNotNull, lt} from "drizzle-orm"; import { and, eq, gte, isNotNull, lt } from "drizzle-orm";
import {dispatchNotification} from "@/features/notifications/notifications.dispatch"; import { dispatchNotification } from "@/features/notifications/utils/notifications.dispatch";
import {EventPayload} from "@/features/notifications/notifications.types"; import { EventPayload } from "@/features/notifications/types";
import {logger} from "@/lib/logger"; 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 }) { export async function getHealthLast12hLogs({ id }: { id: string }) {
const now = new Date() const now = new Date();
const since = new Date(now.getTime() - 12 * 60 * 60 * 1000) const since = new Date(now.getTime() - 12 * 60 * 60 * 1000);
return db return db
.select() .select()
@@ -17,31 +19,30 @@ export async function getHealthLast12hLogs({id}: { id: string }) {
.where( .where(
and( and(
eq(drizzleDb.schemas.healthcheckLog.objectId, id), eq(drizzleDb.schemas.healthcheckLog.objectId, id),
gte(drizzleDb.schemas.healthcheckLog.date, since) gte(drizzleDb.schemas.healthcheckLog.date, since),
) ),
) );
} }
export async function deleteHealthLogsOlderThan12h() { export async function deleteHealthLogsOlderThan12h() {
const now = new Date() const now = new Date();
const threshold = new Date(now.getTime() - 12 * 60 * 60 * 1000) const threshold = new Date(now.getTime() - 12 * 60 * 60 * 1000);
const logsToDelete = await db const logsToDelete = await db
.select() .select()
.from(drizzleDb.schemas.healthcheckLog) .from(drizzleDb.schemas.healthcheckLog)
.where( .where(lt(drizzleDb.schemas.healthcheckLog.date, threshold));
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 await db
.delete(drizzleDb.schemas.healthcheckLog) .delete(drizzleDb.schemas.healthcheckLog)
.where( .where(lt(drizzleDb.schemas.healthcheckLog.date, threshold));
lt(drizzleDb.schemas.healthcheckLog.date, threshold)
)
return logsToDelete.length return logsToDelete.length;
} }
export async function checkAgentsHealthError() { export async function checkAgentsHealthError() {
@@ -50,7 +51,7 @@ export async function checkAgentsHealthError() {
}); });
const settings = await db.query.setting.findFirst({ const settings = await db.query.setting.findFirst({
where: (fields, {eq}) => eq(fields.name, "system"), where: (fields, { eq }) => eq(fields.name, "system"),
}); });
if (!settings) { if (!settings) {
@@ -58,8 +59,11 @@ export async function checkAgentsHealthError() {
} }
if (!settings.defaultNotificationChannelId) { if (!settings.defaultNotificationChannelId) {
log.error({name: "checkAgentsHealthError"},`No default notification channel id found.`) log.error(
return { name: "checkAgentsHealthError" },
`No default notification channel id found.`,
);
return;
} }
const now = new Date(); const now = new Date();
@@ -72,9 +76,9 @@ export async function checkAgentsHealthError() {
if (diffMinutes > 10) { if (diffMinutes > 10) {
if ((agent.healthErrorCount ?? 0) < 3) { if ((agent.healthErrorCount ?? 0) < 3) {
const newHealthErrorCount = (agent.healthErrorCount ?? 0) + 1;
const newHealthErrorCount = (agent.healthErrorCount ?? 0) + 1 await db
await db.update(drizzleDb.schemas.agent) .update(drizzleDb.schemas.agent)
.set({ .set({
healthErrorCount: newHealthErrorCount, healthErrorCount: newHealthErrorCount,
}) })
@@ -91,31 +95,30 @@ export async function checkAgentsHealthError() {
error: "Agent is down", error: "Agent is down",
}, },
}; };
log.info({name: "checkAgentsHealthError", payload: payload},`Agent Healthcheck Notification`) log.info(
{ name: "checkAgentsHealthError", payload: payload },
`Agent Healthcheck Notification`,
);
await dispatchNotification( await dispatchNotification(
payload, payload,
undefined, undefined,
settings.defaultNotificationChannelId, settings.defaultNotificationChannelId,
undefined undefined,
); );
} }
} }
} }
} }
export async function checkDatabasesHealthError() { export async function checkDatabasesHealthError() {
const databases = await db.query.database.findMany({ const databases = await db.query.database.findMany({
where: isNotNull(drizzleDb.schemas.database.lastContact), where: isNotNull(drizzleDb.schemas.database.lastContact),
with: { with: {
agent: true, agent: true,
alertPolicies: true alertPolicies: true,
} },
}) });
const now = new Date(); const now = new Date();
@@ -127,9 +130,9 @@ export async function checkDatabasesHealthError() {
if (diffMinutes > 10) { if (diffMinutes > 10) {
if ((database.healthErrorCount ?? 0) < 3) { if ((database.healthErrorCount ?? 0) < 3) {
const newHealthErrorCount = (database.healthErrorCount ?? 0) + 1;
const newHealthErrorCount = (database.healthErrorCount ?? 0) + 1 await db
await db.update(drizzleDb.schemas.database) .update(drizzleDb.schemas.database)
.set({ .set({
healthErrorCount: newHealthErrorCount, healthErrorCount: newHealthErrorCount,
}) })
@@ -141,24 +144,30 @@ export async function checkDatabasesHealthError() {
}); });
const defaultPolicy = settings?.notificationChannel const defaultPolicy = settings?.notificationChannel
? [{ ? [
{
id: null, id: null,
notificationChannelId: settings.notificationChannel.id, notificationChannelId: settings.notificationChannel.id,
enabled: settings.notificationChannel.enabled, enabled: settings.notificationChannel.enabled,
eventKinds: ["error_health_database"] eventKinds: ["error_health_database"],
}] },
]
: []; : [];
const policiesToUse = (database.alertPolicies && database.alertPolicies.length > 0) const policiesToUse =
? database.alertPolicies.filter(policy => policy.enabled && policy.eventKinds.includes("error_health_database")) database.alertPolicies && database.alertPolicies.length > 0
? database.alertPolicies.filter(
(policy) =>
policy.enabled &&
policy.eventKinds.includes("error_health_database"),
)
: defaultPolicy; : defaultPolicy;
if (!policiesToUse || policiesToUse.length === 0) { if (!policiesToUse || policiesToUse.length === 0) {
continue continue;
} }
const promises = policiesToUse.map(alertPolicy => { const promises = policiesToUse.map((alertPolicy) => {
const payload: EventPayload = { const payload: EventPayload = {
title: "Database down", title: "Database down",
message: `Database ${database.name} is down, (notification number: ${newHealthErrorCount}/3)`, message: `Database ${database.name} is down, (notification number: ${newHealthErrorCount}/3)`,
@@ -171,9 +180,17 @@ export async function checkDatabasesHealthError() {
}, },
}; };
log.info({name: "checkDatabasesHealthError", payload: payload},`Database Healthcheck Notification`) log.info(
{ name: "checkDatabasesHealthError", payload: payload },
`Database Healthcheck Notification`,
);
return dispatchNotification(payload, alertPolicy.id == null ? undefined : alertPolicy.id, alertPolicy.id ? undefined : alertPolicy.notificationChannelId, undefined); return dispatchNotification(
payload,
alertPolicy.id == null ? undefined : alertPolicy.id,
alertPolicy.id ? undefined : alertPolicy.notificationChannelId,
undefined,
);
}); });
await Promise.all(promises); await Promise.all(promises);
@@ -181,5 +198,3 @@ export async function checkDatabasesHealthError() {
} }
} }
} }
+41 -9
View File
@@ -1,14 +1,18 @@
import {desc, eq} from "drizzle-orm"; "use server";
import {db} from "@/db";
import { desc, eq, isNull } from "drizzle-orm";
import { db } from "@/db";
import { import {
NotificationChannel, NotificationChannel,
notificationChannel, notificationChannel,
organizationNotificationChannel organizationNotificationChannel,
} from "@/db/schema/09_notification-channel"; } from "@/db/schema/09_notification-channel";
import {storageChannel} from "@/db/schema/12_storage-channel";
export async function getOrganizationChannels(organizationId: string) { export async function getOrganizationChannels(
return await db organizationId: string,
): Promise<NotificationChannel[]> {
const [orgChannels, systemChannels] = await Promise.all([
db
.select({ .select({
id: notificationChannel.id, id: notificationChannel.id,
name: notificationChannel.name, name: notificationChannel.name,
@@ -18,13 +22,41 @@ export async function getOrganizationChannels(organizationId: string) {
updatedAt: notificationChannel.updatedAt, updatedAt: notificationChannel.updatedAt,
createdAt: notificationChannel.createdAt, createdAt: notificationChannel.createdAt,
deletedAt: notificationChannel.deletedAt, deletedAt: notificationChannel.deletedAt,
organizationId: notificationChannel.organizationId organizationId: notificationChannel.organizationId,
}) })
.from(organizationNotificationChannel) .from(organizationNotificationChannel)
.innerJoin( .innerJoin(
notificationChannel, notificationChannel,
eq(organizationNotificationChannel.notificationChannelId, notificationChannel.id) eq(
organizationNotificationChannel.notificationChannelId,
notificationChannel.id,
),
) )
.orderBy(desc(notificationChannel.createdAt)) .orderBy(desc(notificationChannel.createdAt))
.where(eq(organizationNotificationChannel.organizationId, organizationId)) as unknown as NotificationChannel[]; .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[];
} }
+26 -16
View File
@@ -1,8 +1,13 @@
import {and, desc, eq, gte, lte} from 'drizzle-orm'; "use server";
import {NotificationLevel, notificationLog} from "@/db/schema/11_notification-log";
import {notificationChannel} from "@/db/schema/09_notification-channel"; import { and, desc, eq, gte, lte } from "drizzle-orm";
import {db} from "@/db"; import {
import {Json} from "drizzle-zod"; 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 = { export type NotificationLogWithRelations = {
id: string; id: string;
@@ -15,7 +20,7 @@ export type NotificationLogWithRelations = {
content: { content: {
title: string; title: string;
message: string; message: string;
}, };
channel: { channel: {
name: string; name: string;
provider: string; provider: string;
@@ -25,8 +30,7 @@ export type NotificationLogWithRelations = {
} | null; } | null;
}; };
export async function getNotificationHistory( export async function getNotificationHistory(filters?: {
filters?: {
channelId?: string; channelId?: string;
policyId?: string; policyId?: string;
organizationId?: string; organizationId?: string;
@@ -35,14 +39,17 @@ export async function getNotificationHistory(
from?: Date; from?: Date;
to?: Date; to?: Date;
limit?: number; limit?: number;
} }): Promise<NotificationLogWithRelations[]> {
): Promise<NotificationLogWithRelations[]> {
const where = []; const where = [];
if (filters?.channelId) where.push(eq(notificationLog.channelId, filters.channelId)); if (filters?.channelId)
if (filters?.policyId) where.push(eq(notificationLog.policyId, filters.policyId)); where.push(eq(notificationLog.channelId, filters.channelId));
if (filters?.organizationId) where.push(eq(notificationLog.organizationId, filters.organizationId)); 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 (filters?.level) where.push(eq(notificationLog.level, filters.level));
if (typeof filters?.success === 'boolean') where.push(eq(notificationLog.success, filters.success)); if (typeof filters?.success === "boolean")
where.push(eq(notificationLog.success, filters.success));
if (filters?.from) where.push(gte(notificationLog.sentAt, filters.from)); if (filters?.from) where.push(gte(notificationLog.sentAt, filters.from));
if (filters?.to) where.push(lte(notificationLog.sentAt, filters.to)); if (filters?.to) where.push(lte(notificationLog.sentAt, filters.to));
@@ -68,13 +75,16 @@ export async function getNotificationHistory(
}, },
}) })
.from(notificationLog) .from(notificationLog)
.leftJoin(notificationChannel, eq(notificationLog.channelId, notificationChannel.id)) .leftJoin(
notificationChannel,
eq(notificationLog.channelId, notificationChannel.id),
)
// .leftJoin(alertPolicy, eq(notificationLog.policyId, alertPolicy.id)) // .leftJoin(alertPolicy, eq(notificationLog.policyId, alertPolicy.id))
.where(and(...where)) .where(and(...where))
.orderBy(desc(notificationLog.sentAt)) .orderBy(desc(notificationLog.sentAt))
.limit(filters?.limit || 100); .limit(filters?.limit || 100);
return rows.map(row => ({ return rows.map((row) => ({
...row, ...row,
payload: row.payload as Json, 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")),
});
}
+30 -14
View File
@@ -1,14 +1,28 @@
import {getOrganization} from "@/lib/auth/auth"; "use server";
import {db} from "@/db";
import {and, eq} from "drizzle-orm"; import { getOrganization } from "@/lib/auth/auth";
import { db } from "@/db";
import { and, eq } from "drizzle-orm";
import * as drizzleDb from "@/db"; 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: true,
},
});
}
export const getOrganizationProjectDatabases = async ({organizationSlug, projectId}: { export const getOrganizationProjectDatabases = async ({
organizationSlug: string, projectId: string organizationSlug,
projectId,
}: {
organizationSlug: string;
projectId: string;
}) => { }) => {
try { try {
const organization = await getOrganization({}); const organization = await getOrganization({});
if (!organization) { if (!organization) {
@@ -20,10 +34,13 @@ export const getOrganizationProjectDatabases = async ({organizationSlug, project
}; };
} }
const databasesProject = await db.query.project.findFirst({ const databasesProject = await db.query.project.findFirst({
where: and(eq(drizzleDb.schemas.project.organizationId, organization.id), eq(drizzleDb.schemas.project.id, projectId)), where: and(
eq(drizzleDb.schemas.project.organizationId, organization.id),
eq(drizzleDb.schemas.project.id, projectId),
),
with: { with: {
databases: true databases: true,
} },
}); });
if (!databasesProject) { if (!databasesProject) {
@@ -37,12 +54,11 @@ export const getOrganizationProjectDatabases = async ({organizationSlug, project
return { return {
data: databasesProject.databases, data: databasesProject.databases,
ids: databasesProject.databases.map((project) => project.id) ids: databasesProject.databases.map((project) => project.id),
} };
} catch (e: any) { } catch (e: any) {
const errorMessage = e?.response?.data?.message || e?.message || "Unknown auth error"; const errorMessage =
e?.response?.data?.message || e?.message || "Unknown auth error";
const status = e?.response?.status || 500; const status = e?.response?.status || 500;
console.error("API GettingOrganizationProjectDatabases error:", { console.error("API GettingOrganizationProjectDatabases error:", {
+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;
}
+39 -7
View File
@@ -1,9 +1,18 @@
import {desc, eq} from "drizzle-orm"; "use server";
import {db} from "@/db";
import {organizationStorageChannel, StorageChannel, storageChannel} from "@/db/schema/12_storage-channel";
export async function getOrganizationStorageChannels(organizationId: string) { import { desc, eq, isNull } from "drizzle-orm";
return await db 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({ .select({
id: storageChannel.id, id: storageChannel.id,
name: storageChannel.name, name: storageChannel.name,
@@ -18,8 +27,31 @@ export async function getOrganizationStorageChannels(organizationId: string) {
.from(organizationStorageChannel) .from(organizationStorageChannel)
.innerJoin( .innerJoin(
storageChannel, storageChannel,
eq(organizationStorageChannel.storageChannelId, storageChannel.id) eq(organizationStorageChannel.storageChannelId, storageChannel.id),
) )
.orderBy(desc(storageChannel.createdAt)) .orderBy(desc(storageChannel.createdAt))
.where(eq(organizationStorageChannel.organizationId, organizationId)) as unknown as StorageChannel[]; .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[];
} }
+18 -9
View File
@@ -1,10 +1,16 @@
import {SignUpUser} from "@/types/auth"; "use server";
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";
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> { export async function createUserDb(data: SignUpUser): Promise<User> {
assertValidPassword(data.password); assertValidPassword(data.password);
@@ -12,7 +18,9 @@ export async function createUserDb(data: SignUpUser): Promise<User> {
const now = new Date(); const now = new Date();
const userId = crypto.randomUUID(); const userId = crypto.randomUUID();
const [newUser] = await db.insert(drizzleDb.schemas.user).values({ const [newUser] = await db
.insert(drizzleDb.schemas.user)
.values({
...data, ...data,
id: userId, id: userId,
name: data.name, name: data.name,
@@ -22,7 +30,8 @@ export async function createUserDb(data: SignUpUser): Promise<User> {
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
theme: data.theme as UserThemeEnum, theme: data.theme as UserThemeEnum,
}).returning(); })
.returning();
if (data.password) { if (data.password) {
const hashedPassword = await hashPassword(data.password); const hashedPassword = await hashPassword(data.password);
@@ -36,5 +45,5 @@ export async function createUserDb(data: SignUpUser): Promise<User> {
}); });
} }
return newUser return newUser;
} }
+6
View File
@@ -64,6 +64,7 @@ export const env = createEnv({
process.env.NODE_ENV === "production" ? "0 * * * *" : "* * * * *", process.env.NODE_ENV === "production" ? "0 * * * *" : "* * * * *",
), ),
STALE_BACKUP_THRESHOLD_HOURS: z.coerce.number().default(6),
AUTH_OIDC_ID: z.string().optional().default("oidc"), AUTH_OIDC_ID: z.string().optional().default("oidc"),
AUTH_OIDC_TITLE: z.string().optional(), AUTH_OIDC_TITLE: z.string().optional(),
@@ -88,6 +89,8 @@ export const env = createEnv({
ALLOWED_GROUP: z.string().optional(), ALLOWED_GROUP: z.string().optional(),
SKIP_ONBOARDING: z.string().optional().default("false"),
AUTH_EMAIL_PASSWORD_ENABLED: z.string().optional().default("true"), AUTH_EMAIL_PASSWORD_ENABLED: z.string().optional().default("true"),
AUTH_SIGNUP_ENABLED: z.string().optional().default("true"), AUTH_SIGNUP_ENABLED: z.string().optional().default("true"),
AUTH_PASSKEY_ENABLED: z.string().optional().default("false"), AUTH_PASSKEY_ENABLED: z.string().optional().default("false"),
@@ -145,6 +148,7 @@ export const env = createEnv({
RETENTION_CRON: process.env.RETENTION_CRON, RETENTION_CRON: process.env.RETENTION_CRON,
CLEANING_HEALTHCHECK_LOGS_CRON: process.env.CLEANING_HEALTHCHECK_LOGS_CRON, CLEANING_HEALTHCHECK_LOGS_CRON: process.env.CLEANING_HEALTHCHECK_LOGS_CRON,
STALE_BACKUP_THRESHOLD_HOURS: process.env.STALE_BACKUP_THRESHOLD_HOURS,
AUTH_OIDC_ID: process.env.AUTH_OIDC_ID, AUTH_OIDC_ID: process.env.AUTH_OIDC_ID,
AUTH_OIDC_TITLE: process.env.AUTH_OIDC_TITLE, AUTH_OIDC_TITLE: process.env.AUTH_OIDC_TITLE,
@@ -174,6 +178,8 @@ export const env = createEnv({
ALLOWED_GROUP: process.env.ALLOWED_GROUP, ALLOWED_GROUP: process.env.ALLOWED_GROUP,
SKIP_ONBOARDING: process.env.SKIP_ONBOARDING,
AUTH_EMAIL_PASSWORD_ENABLED: process.env.AUTH_EMAIL_PASSWORD_ENABLED, AUTH_EMAIL_PASSWORD_ENABLED: process.env.AUTH_EMAIL_PASSWORD_ENABLED,
AUTH_SIGNUP_ENABLED: process.env.AUTH_SIGNUP_ENABLED, AUTH_SIGNUP_ENABLED: process.env.AUTH_SIGNUP_ENABLED,
AUTH_PASSKEY_ENABLED: process.env.AUTH_PASSKEY_ENABLED, AUTH_PASSKEY_ENABLED: process.env.AUTH_PASSKEY_ENABLED,
@@ -1,6 +1,6 @@
"use server"; "use server";
import {action, ActionError, userAction} from "@/lib/safe-actions/actions"; import {action, ActionError, userAction} from "@/lib/safe-actions/actions";
import {AgentSchema} from "@/features/agents/agents.schema"; import {AgentSchema} from "@/features/agents/schemas/agents.schema";
import {z} from "zod"; import {z} from "zod";
import {eq, and, ne, count, desc} from "drizzle-orm"; import {eq, and, ne, count, desc} from "drizzle-orm";
import {db} from "@/db"; import {db} from "@/db";
@@ -11,7 +11,7 @@ import {Badge} from "@/components/ui/badge";
import {truncateWords} from "@/utils/text"; import {truncateWords} from "@/utils/text";
import {useIsMobile} from "@/hooks/use-mobile"; import {useIsMobile} from "@/hooks/use-mobile";
import {Tooltip, TooltipContent, TooltipTrigger} from "@/components/ui/tooltip"; import {Tooltip, TooltipContent, TooltipTrigger} from "@/components/ui/tooltip";
import {useAgentUpdateCheck} from "@/features/agents/use-agent-update-check"; import {useAgentUpdateCheck} from "@/features/agents/hooks/use-agent-update-check";
export type agentCardProps = { export type agentCardProps = {
data: AgentWith; data: AgentWith;
@@ -3,10 +3,10 @@
import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card"; import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card";
import {Server} from "lucide-react"; import {Server} from "lucide-react";
import {formatDateLastContact} from "@/utils/date-formatting"; import {formatDateLastContact} from "@/utils/date-formatting";
import {AgentCardKey} from "@/features/agents/agent-card-key"; import {AgentCardKey} from "@/features/agents/components/agent-card-key";
import {AgentWithDatabases} from "@/db/schema/08_agent"; import {AgentWithDatabases} from "@/db/schema/08_agent";
import {useQuery} from "@tanstack/react-query"; import {useQuery} from "@tanstack/react-query";
import {getAgentAction} from "@/features/agents/agents.action"; import {getAgentAction} from "@/features/agents/actions/agents.action";
import { import {
Accordion, Accordion,
AccordionContent, AccordionContent,
@@ -16,8 +16,8 @@ import {
import {Separator} from "@/components/ui/separator"; import {Separator} from "@/components/ui/separator";
import {Badge} from "@/components/ui/badge"; import {Badge} from "@/components/ui/badge";
import {CardsWithPagination} from "@/components/common/cards-with-pagination"; import {CardsWithPagination} from "@/components/common/cards-with-pagination";
import {AgentDatabaseCard} from "@/features/agents/agent-database-card"; import {AgentDatabaseCard} from "@/features/agents/components/agent-database-card";
import {HealthCheckGraph} from "@/features/database/health-grid"; import {HealthCheckGraph} from "@/features/database/components/health-grid";
import {HealthcheckLog} from "@/db/schema/15_healthcheck-log"; import {HealthcheckLog} from "@/db/schema/15_healthcheck-log";
type AgentContentPageProps = { type AgentContentPageProps = {
@@ -5,7 +5,7 @@ import {ButtonWithConfirm} from "@/components/common/button-with-confirm";
import {useMutation} from "@tanstack/react-query"; import {useMutation} from "@tanstack/react-query";
import {useRouter} from "next/navigation"; import {useRouter} from "next/navigation";
import {toast} from "sonner"; import {toast} from "sonner";
import {deleteAgentAction} from "@/features/agents/agent-delete.action"; import {deleteAgentAction} from "@/features/agents/actions/agent-delete.action";
import {useIsMobile} from "@/hooks/use-mobile"; import {useIsMobile} from "@/hooks/use-mobile";
export type ButtonDeleteAgentProps = { export type ButtonDeleteAgentProps = {
@@ -7,7 +7,7 @@ import {
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import {AgentForm} from "@/features/agents/agent-form"; import {AgentForm} from "@/features/agents/components/agent-form";
import {Button, buttonVariants} from "@/components/ui/button"; import {Button, buttonVariants} from "@/components/ui/button";
import {Plus} from "lucide-react"; import {Plus} from "lucide-react";
import {GearIcon} from "@radix-ui/react-icons"; import {GearIcon} from "@radix-ui/react-icons";
@@ -16,7 +16,7 @@ import {useState} from "react";
import {useRouter} from "next/navigation"; import {useRouter} from "next/navigation";
import {OrganizationWithMembers} from "@/db/schema/03_organization"; import {OrganizationWithMembers} from "@/db/schema/03_organization";
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs"; import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs";
import {AgentOrganisationForm} from "@/features/agents/agent-organizations.form"; import {AgentOrganisationForm} from "@/features/agents/components/agent-organizations.form";
import {AgentWith} from "@/db/schema/08_agent"; import {AgentWith} from "@/db/schema/08_agent";
type AgentDialogProps = { type AgentDialogProps = {
@@ -15,9 +15,9 @@ import {Button} from "@/components/ui/button";
import {useRouter} from "next/navigation"; import {useRouter} from "next/navigation";
import {useMutation, useQueryClient} from "@tanstack/react-query"; import {useMutation, useQueryClient} from "@tanstack/react-query";
import {TooltipProvider} from "@/components/ui/tooltip"; import {TooltipProvider} from "@/components/ui/tooltip";
import {AgentSchema, AgentType} from "@/features/agents/agents.schema"; import {AgentSchema, AgentType} from "@/features/agents/schemas/agents.schema";
import {toast} from "sonner"; import {toast} from "sonner";
import {createAgentAction, updateAgentAction} from "@/features/agents/agents.action"; import {createAgentAction, updateAgentAction} from "@/features/agents/actions/agents.action";
import {OrganizationWithMembers} from "@/db/schema/03_organization"; import {OrganizationWithMembers} from "@/db/schema/03_organization";
export type agentFormProps = { export type agentFormProps = {

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