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>
This commit is contained in:
Théo LAGACHE
2026-06-27 17:08:38 +02:00
committed by GitHub
co-authored by Théo LAGACHE charles-gauthereau
parent 40d3228db6
commit 40247efddd
451 changed files with 15904 additions and 5054 deletions
+3
View File
@@ -81,3 +81,6 @@ TRUSTED_DOMAINS="http://localhost:8887, http://localhost:3055, http://localhost:
# Default to false
#TUSD_BEHIND_PROXY=true
#Skip the onboarding on fresh instance
#SKIP_ONBOARDING=true
+1 -5
View File
@@ -1,11 +1,7 @@
{
"extends": [
"next/core-web-vitals",
"plugin:tailwindcss/recommended"
],
"extends": ["next/core-web-vitals", "plugin:tailwindcss/recommended"],
"rules": {
"react/no-unescaped-entities": "off",
"react-hooks/rules-of-hooks": "off"
}
}
+1
View File
@@ -60,3 +60,4 @@ certificates
.codex
/docs
.worktrees
.superpowers
+1
View File
@@ -111,3 +111,4 @@ Distributed under the Apache License. See `LICENSE.txt` for more details.
[ShadcnUI-url]: https://ui.shadcn.com/
[Docker-url]: https://www.docker.com/
+2 -2
View File
@@ -1,9 +1,9 @@
import { CardContent, CardHeader } from "@/components/ui/card";
import { TooltipProvider } from "@/components/ui/tooltip";
import { ForgotPasswordForm } from "@/features/auth/forgot-password-form";
import { ForgotPasswordForm } from "@/features/auth/components/forgot-password-form";
import { env } from "@/env.mjs";
import { CardAuth } from "@/features/layout/card-auth";
import { CardAuth } from "@/features/layout/components/card-auth";
import { redirect } from "next/navigation";
export default async function RoutePage(props: { searchParams: Promise<{ callbackUrl: string | undefined }> }) {
+2 -2
View File
@@ -1,9 +1,9 @@
import {CardContent, CardHeader} from "@/components/ui/card";
import {TooltipProvider} from "@/components/ui/tooltip";
import {GuardForm} from "@/features/auth/guard-form";
import {GuardForm} from "@/features/auth/components/guard-form";
import {cookies} from "next/headers";
import {redirect} from "next/navigation";
import {CardAuth} from "@/features/layout/card-auth";
import {CardAuth} from "@/features/layout/components/card-auth";
export default async function GuardPage() {
+7 -1
View File
@@ -1,7 +1,9 @@
import React from "react";
import { redirect } from "next/navigation";
import { currentUser } from "@/lib/auth/current-user";
import { AuthLogoSection } from "@/features/auth/auth-logo-section";
import { isOnboardingDone } from "@/db/services/setting";
import { AuthLogoSection } from "@/features/auth/components/auth-logo-section";
import { env } from "@/env.mjs";
import { Heart } from "lucide-react";
export default async function Layout({
@@ -9,6 +11,10 @@ export default async function Layout({
}: {
children: React.ReactNode;
}) {
if (env.SKIP_ONBOARDING !== "true" && !(await isOnboardingDone())) {
redirect("/welcome");
}
const user = await currentUser();
if (user && !user.banned && user.role !== "pending") {
+3 -3
View File
@@ -1,12 +1,12 @@
import { LoginForm } from "@/features/auth/login-form";
import { LoginForm } from "@/features/auth/components/login-form";
import { Metadata } from "next";
import { SUPPORTED_PROVIDERS } from "@/lib/auth/config";
import { SocialAuthButtons } from "@/features/auth/social-buttons";
import { SocialAuthButtons } from "@/features/auth/components/social-buttons";
import { TooltipProvider } from "@/components/ui/tooltip";
import { CardContent, CardHeader } from "@/components/ui/card";
import Link from "next/link";
import { Separator } from "@/components/ui/separator";
import { CardAuth } from "@/features/layout/card-auth";
import { CardAuth } from "@/features/layout/components/card-auth";
import { env } from "@/env.mjs";
export const metadata: Metadata = {
+1 -1
View File
@@ -1,5 +1,5 @@
import { PageParams } from "@/types/next";
import { RegisterForm } from "@/features/auth/register-form";
import { RegisterForm } from "@/features/auth/components/register-form";
import { Metadata } from "next";
import { redirect } from "next/navigation";
import { env } from "@/env.mjs";
+2 -2
View File
@@ -1,10 +1,10 @@
import { CardContent, CardHeader } from "@/components/ui/card";
import { TooltipProvider } from "@/components/ui/tooltip";
import { ResetPasswordForm } from "@/features/auth/reset-password-form";
import { ResetPasswordForm } from "@/features/auth/components/reset-password-form";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth/auth";
import { Avatar, AvatarImage, AvatarFallback } from "@radix-ui/react-avatar";
import { CardAuth } from "@/features/layout/card-auth";
import { CardAuth } from "@/features/layout/components/card-auth";
import { env } from "@/env.mjs";
export default async function RoutePage(props: { searchParams: Promise<{ token: string | undefined }> }) {
@@ -3,8 +3,8 @@ import {eq} from "drizzle-orm";
import {db} from "@/db";
import * as drizzleDb from "@/db";
import {PageParams} from "@/types/next";
import {Page} from "@/features/layout/page";
import {OrganizationManagement} from "@/features/organizations/admin-organization-management";
import {Page} from "@/features/layout/components/page";
import {OrganizationManagement} from "@/features/organizations/components/admin-organization-management";
import {buildOrganizationWithMembers} from "@/utils/common";
import {isUUID} from "@/utils/text";
import {user} from "@/db/schema/02_user";
@@ -1,10 +1,10 @@
import {PageParams} from "@/types/next";
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/components/page";
import {db} from "@/db";
import {
AdminOrganizationAddModal
} from "@/features/organizations/admin-organization-add-modal";
import {AdminOrganizationList} from "@/features/organizations/admin-organization-list";
} from "@/features/organizations/components/admin-organization-add-modal";
import {AdminOrganizationList} from "@/features/organizations/components/admin-organization-list";
import {isNull} from "drizzle-orm";
export default async function RoutePage(props: PageParams<{}>) {
@@ -1,9 +1,9 @@
import {PageParams} from "@/types/next";
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/components/page";
import {db} from "@/db";
import {logger} from "@/lib/logger";
import {notFound} from "next/navigation";
import {SettingsTabs} from "@/features/settings/settings-tabs";
import {SettingsTabs} from "@/features/settings/components/settings-tabs";
import {desc, isNull} from "drizzle-orm";
import * as drizzleDb from "@/db";
import {StorageChannelWith} from "@/db/schema/12_storage-channel";
@@ -1,21 +1,28 @@
import {PageParams} from "@/types/next";
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/components/page";
import {db} from "@/db";
import {desc, isNull} from "drizzle-orm";
import {AdminUserList} from "@/features/users/admin-user-list";
import {AdminUserAddModal} from "@/features/users/admin-user-add-modal";
import {AdminUserList} from "@/features/users/components/admin-user-list";
import {AdminUserAddModal} from "@/features/users/components/admin-user-add-modal";
import {SUPPORTED_PROVIDERS} from "@/lib/auth/config";
import {getSettings} from "@/db/services/setting";
import {resolveAvatarUrl} from "@/utils/resolve-avatar-url";
export default async function RoutePage(props: PageParams<{}>) {
const users = await db.query.user.findMany({
where: (fields) => isNull(fields.deletedAt),
with: {
accounts: true
},
orderBy: (fields) => desc(fields.createdAt),
const [settings, users] = await Promise.all([
getSettings(),
db.query.user.findMany({
where: (fields) => isNull(fields.deletedAt),
with: { accounts: true },
orderBy: (fields) => desc(fields.createdAt),
}),
]);
const avatarUrls = Object.fromEntries(
users.map((u) => [u.id, resolveAvatarUrl(u, settings)])
);
});
const organizations = await db.query.organization.findMany({
with: {
members: true,
@@ -38,7 +45,7 @@ export default async function RoutePage(props: PageParams<{}>) {
</div>
</PageHeader>
<PageContent className="flex flex-col gap-5">
<AdminUserList users={users} isPasswordAuthEnabled={isPasswordAuthEnabled}/>
<AdminUserList users={users} isPasswordAuthEnabled={isPasswordAuthEnabled} avatarUrls={avatarUrls}/>
</PageContent>
</Page>
);
@@ -4,17 +4,17 @@ import {
PageContent,
PageDescription,
PageTitle,
} from "@/features/layout/page";
} from "@/features/layout/components/page";
import { db } from "@/db";
import * as drizzleDb from "@/db";
import {eq, isNull} from "drizzle-orm";
import { notFound } from "next/navigation";
import { ButtonDeleteAgent } from "@/features/agents/agent-delete-button";
import { ButtonDeleteAgent } from "@/features/agents/components/agent-delete-button";
import { capitalizeFirstLetter } from "@/utils/text";
import { generateEdgeKey } from "@/utils/edge_key";
import { getServerUrl } from "@/utils/get-server-url";
import { AgentContentPage } from "@/features/agents/agent-content";
import { AgentDialog } from "@/features/agents/agent-dialog";
import { AgentContentPage } from "@/features/agents/components/agent-content";
import { AgentDialog } from "@/features/agents/components/agent-dialog";
export default async function RoutePage(
@@ -1,13 +1,13 @@
import {PageParams} from "@/types/next";
import {AgentCard} from "@/features/agents/agent-card";
import {AgentCard} from "@/features/agents/components/agent-card";
import {CardsWithPagination} from "@/components/common/cards-with-pagination";
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/components/page";
import {notFound} from "next/navigation";
import {db} from "@/db";
import * as drizzleDb from "@/db";
import {and, desc, eq, isNull, not} from "drizzle-orm";
import {Metadata} from "next";
import {AgentDialog} from "@/features/agents/agent-dialog";
import {AgentDialog} from "@/features/agents/components/agent-dialog";
export const metadata: Metadata = {
title: "Agents",
@@ -1,11 +1,11 @@
import {PageParams} from "@/types/next";
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/components/page";
import {Metadata} from "next";
import {db} from "@/db";
import {notificationChannel, NotificationChannelWith} from "@/db/schema/09_notification-channel";
import {desc, isNull} from "drizzle-orm";
import {ChannelsSection} from "@/features/channel/channels-section";
import {ChannelAddEditModal} from "@/features/channel/channel-add-edit-modal";
import {ChannelsSection} from "@/features/channel/components/channels-section";
import {ChannelAddEditModal} from "@/features/channel/components/channel-add-edit-modal";
import * as drizzleDb from "@/db";
export const metadata: Metadata = {
@@ -1,7 +1,7 @@
import {PageParams} from "@/types/next";
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/components/page";
import {Metadata} from "next";
import {NotificationLogsList} from "@/features/notifications/notification-logs-list";
import {NotificationLogsList} from "@/features/notifications/components/notification-logs-list";
import {notFound} from "next/navigation";
import {getNotificationHistory} from "@/db/services/notification-log";
@@ -1,12 +1,12 @@
import {PageParams} from "@/types/next";
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/components/page";
import {Metadata} from "next";
import {ChannelsSection} from "@/features/channel/channels-section";
import {ChannelsSection} from "@/features/channel/components/channels-section";
import {db} from "@/db";
import {desc, eq, isNull} from "drizzle-orm";
import * as drizzleDb from "@/db";
import {StorageChannelWith} from "@/db/schema/12_storage-channel";
import {ChannelAddEditModal} from "@/features/channel/channel-add-edit-modal";
import {ChannelAddEditModal} from "@/features/channel/components/channel-add-edit-modal";
export const metadata: Metadata = {
title: "Storage Channels",
@@ -1,10 +1,10 @@
import {PageParams} from "@/types/next";
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/components/page";
import {notFound} from "next/navigation";
import {getOrganization} from "@/lib/auth/auth";
import {Metadata} from "next";
import {db} from "@/db";
import {MigrationTool} from "@/features/migration/migration-tool";
import {MigrationTool} from "@/features/migration/components/migration-tool";
export const metadata: Metadata = {
title: "Projects",
@@ -1,122 +1,137 @@
import {PageParams} from "@/types/next";
import {notFound, redirect} from "next/navigation";
import {Page} from "@/features/layout/page";
import {db} from "@/db";
import {eq, and, inArray} from "drizzle-orm";
import { PageParams } from "@/types/next";
import { notFound, redirect } from "next/navigation";
import { Page } from "@/features/layout/components/page";
import { db } from "@/db";
import { eq, and, inArray } from "drizzle-orm";
import * as drizzleDb from "@/db";
import {getOrganizationProjectDatabases} from "@/db/services/project";
import {getActiveMember, getOrganization} from "@/lib/auth/auth";
import {BackupModalProvider} from "@/features/database/backup-modal-context";
import {DatabaseContent} from "@/features/database/database-content";
import {getHealthLast12hLogs} from "@/db/services/healthcheck";
import {LogsModalProvider} from "@/features/logs/logs-modal-context";
import { getOrganizationProjectDatabases } from "@/db/services/project";
import { getActiveMember, getOrganization } from "@/lib/auth/auth";
import { BackupModalProvider } from "@/features/database/components/backup-modal-context";
import { DatabaseContent } from "@/features/database/components/database-content";
import { getHealthLast12hLogs } from "@/db/services/healthcheck";
import { LogsModalProvider } from "@/features/logs/components/logs-modal-context";
export default async function RoutePage(props: PageParams<{
export default async function RoutePage(
props: PageParams<{
projectId: string;
databaseId: string
}>) {
const {projectId, databaseId} = await props.params;
databaseId: string;
}>,
) {
const { projectId, databaseId } = await props.params;
const organization = await getOrganization({});
const activeMember = await getActiveMember()
const organization = await getOrganization({});
const activeMember = await getActiveMember();
if (!organization || !activeMember) {
notFound();
}
if (!organization || !activeMember) {
notFound();
}
const databasesProject = await getOrganizationProjectDatabases({
organizationSlug: organization.slug,
projectId: projectId
})
const databasesProject = await getOrganizationProjectDatabases({
organizationSlug: organization.slug,
projectId: projectId,
});
const dbItem = await db.query.database.findFirst({
where: and(inArray(drizzleDb.schemas.backup.id, databasesProject.ids ?? []), eq(drizzleDb.schemas.database.id, databaseId), eq(drizzleDb.schemas.database.projectId, projectId)),
const dbItem = await db.query.database.findFirst({
where: and(
inArray(drizzleDb.schemas.backup.id, databasesProject.ids ?? []),
eq(drizzleDb.schemas.database.id, databaseId),
eq(drizzleDb.schemas.database.projectId, projectId),
),
with: {
project: true,
retentionPolicy: true,
alertPolicies: true,
storagePolicies: true,
},
});
if (!dbItem) {
redirect("/dashboard/projects");
}
const backups = await db.query.backup.findMany({
where: eq(drizzleDb.schemas.backup.databaseId, dbItem.id),
with: {
restorations: true,
storages: {
with: {
project: true,
retentionPolicy: true,
alertPolicies: true,
storagePolicies: true,
}
});
if (!dbItem) {
redirect("/dashboard/projects");
}
const backups = await db.query.backup.findMany({
where: eq(drizzleDb.schemas.backup.databaseId, dbItem.id),
with: {
restorations: true,
storages: {
with: {
storageChannel: true
}
},
logs: true
storageChannel: true,
},
orderBy: (b, {desc}) => [desc(b.createdAt)],
});
},
logs: true,
},
orderBy: (b, { desc }) => [desc(b.createdAt)],
});
const restorations = await db.query.restoration.findMany({
where: eq(drizzleDb.schemas.restoration.databaseId, dbItem.id),
with: {
logs: true
},
orderBy: (r, {desc}) => [desc(r.createdAt)],
});
const restorations = await db.query.restoration.findMany({
where: eq(drizzleDb.schemas.restoration.databaseId, dbItem.id),
with: {
logs: true,
},
orderBy: (r, { desc }) => [desc(r.createdAt)],
});
const isAlreadyBackup = backups.some((b) => b.status === "waiting" || b.status === "ongoing");
const isAlreadyRestore = restorations.some((r) => r.status === "waiting");
//const isAlreadyBackup = backups.some((b) => b.status === "waiting" || b.status === "ongoing");
const isAlreadyRestore = restorations.some((r) => r.status === "waiting");
const totalBackups = await db.select({count: drizzleDb.schemas.backup.id})
.from(drizzleDb.schemas.backup)
.where(eq(drizzleDb.schemas.backup.databaseId, dbItem.id))
.then(rows => rows.length);
const totalBackups = await db
.select({ count: drizzleDb.schemas.backup.id })
.from(drizzleDb.schemas.backup)
.where(eq(drizzleDb.schemas.backup.databaseId, dbItem.id))
.then((rows) => rows.length);
const availableBackups = backups.filter(b => !b.deletedAt).length;
const availableBackups = backups.filter((b) => !b.deletedAt).length;
const successfulBackups = await db.select({count: drizzleDb.schemas.backup.id})
.from(drizzleDb.schemas.backup)
.where(and(
eq(drizzleDb.schemas.backup.databaseId, dbItem.id),
eq(drizzleDb.schemas.backup.status, "success")
))
.then(rows => rows.length);
const successfulBackups = await db
.select({ count: drizzleDb.schemas.backup.id })
.from(drizzleDb.schemas.backup)
.where(
and(
eq(drizzleDb.schemas.backup.databaseId, dbItem.id),
eq(drizzleDb.schemas.backup.status, "success"),
),
)
.then((rows) => rows.length);
const [settings] = await db
.select()
.from(drizzleDb.schemas.setting)
.where(eq(drizzleDb.schemas.setting.name, "system"))
.limit(1);
if (!settings) {
notFound();
}
const [settings] = await db.select().from(drizzleDb.schemas.setting).where(eq(drizzleDb.schemas.setting.name, "system")).limit(1);
if (!settings) {
notFound();
}
const databaseHealthLogs = dbItem
? await getHealthLast12hLogs({ id: dbItem.id })
: [];
const databaseHealthLogs = dbItem ? await getHealthLast12hLogs({id: dbItem.id}) : []
const successRate =
totalBackups > 0 ? (successfulBackups / totalBackups) * 100 : null;
//const isMember = activeMember?.role === "member";
const successRate = totalBackups > 0 ? (successfulBackups / totalBackups) * 100 : null;
const isMember = activeMember?.role === "member";
return (
<Page>
<LogsModalProvider>
<BackupModalProvider>
<DatabaseContent
activeMember={activeMember}
settings={settings}
database={dbItem}
databaseHealthLogs={databaseHealthLogs}
isAlreadyRestore={isAlreadyRestore}
restorations={restorations}
backups={backups}
totalBackups={totalBackups}
availableBackups={availableBackups}
successRate={successRate}
organizationId={organization.id}
activeOrganizationChannels={[]}
activeOrganizationStorageChannels={[]}
/>
</BackupModalProvider>
</LogsModalProvider>
</Page>
);
return (
<Page>
<LogsModalProvider>
<BackupModalProvider>
<DatabaseContent
activeMember={activeMember}
settings={settings}
database={dbItem}
databaseHealthLogs={databaseHealthLogs}
isAlreadyRestore={isAlreadyRestore}
restorations={restorations}
backups={backups}
totalBackups={totalBackups}
availableBackups={availableBackups}
successRate={successRate}
organizationId={organization.id}
activeOrganizationChannels={[]}
activeOrganizationStorageChannels={[]}
/>
</BackupModalProvider>
</LogsModalProvider>
</Page>
);
}
@@ -1,108 +1,115 @@
import {PageParams} from "@/types/next";
import {Page, PageContent, PageTitle} from "@/features/layout/page";
import {
ButtonDeleteProject
} from "@/features/projects/project-delete-button";
import {CardsWithPagination} from "@/components/common/cards-with-pagination";
import {ProjectDatabaseCard} from "@/features/projects/project-database-card";
import {notFound, redirect} from "next/navigation";
import {db} from "@/db";
import {eq} from "drizzle-orm";
import {getActiveMember, getOrganization} from "@/lib/auth/auth";
import { PageParams } from "@/types/next";
import { Page, PageContent, PageTitle } from "@/features/layout/components/page";
import { ButtonDeleteProject } from "@/features/projects/components/project-delete-button";
import { CardsWithPagination } from "@/components/common/cards-with-pagination";
import { ProjectDatabaseCard } from "@/features/projects/components/project-database-card";
import { notFound, redirect } from "next/navigation";
import { db } from "@/db";
import { eq } from "drizzle-orm";
import { getActiveMember, getOrganization } from "@/lib/auth/auth";
import * as drizzleDb from "@/db";
import {capitalizeFirstLetter} from "@/utils/text";
import {ProjectDialog} from "@/features/projects/project-dialog";
import {ProjectWith} from "@/db/schema/06_project";
import {isUuidv4} from "@/utils/verify-uuid";
import {getOrganizationAvailableDatabases} from "@/db/services/database";
import { capitalizeFirstLetter, isUUID } from "@/utils/text";
import { ProjectDialog } from "@/features/projects/components/project-dialog";
import { ProjectWith } from "@/db/schema/06_project";
import { getOrganizationAvailableDatabases } from "@/db/services/database";
export default async function RoutePage(
props: PageParams<{
projectId: string;
}>,
) {
const { projectId } = await props.params;
export default async function RoutePage(props: PageParams<{
projectId: string
}>) {
const {
projectId
} = await props.params;
if (!isUUID(projectId)) {
notFound();
}
if (!isUuidv4(projectId)) {
notFound()
}
const organization = await getOrganization({});
const activeMember = await getActiveMember();
const organization = await getOrganization({});
const activeMember = await getActiveMember()
if (!organization) {
notFound();
}
const org = await db.query.organization.findFirst({
where: eq(drizzleDb.schemas.organization.slug, organization.slug),
});
if (!organization) {
notFound();
}
const org = await db.query.organization.findFirst({
where: eq(drizzleDb.schemas.organization.slug, organization.slug),
});
if (!org) notFound();
if (!org) notFound();
const proj = await db.query.project.findFirst({
where: (proj, { and, eq, not }) =>
and(
eq(proj.id, projectId),
eq(proj.organizationId, org.id),
not(eq(proj.isArchived, true)),
),
with: {
databases: true,
},
});
const proj = await db.query.project.findFirst({
where: (proj, {
and,
eq,
not
}) => and(eq(proj.id, projectId), eq(proj.organizationId, org.id), not(eq(proj.isArchived, true))),
with: {
databases: true,
},
});
if (!proj) {
redirect("/dashboard/projects");
}
if (!proj) {
redirect("/dashboard/projects");
}
const availableDatabases = await getOrganizationAvailableDatabases(
organization.id,
proj.id,
);
const isMember = activeMember?.role === "member";
const availableDatabases = await getOrganizationAvailableDatabases(organization.id, proj.id)
const isMember = activeMember?.role === "member";
return (
<Page>
<div className="justify-between gap-2 sm:flex">
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full ">
<div className="min-w-full md:min-w-fit ">
{capitalizeFirstLetter(proj.name)}
</div>
{!isMember && (
<div className="flex items-center gap-2 md:justify-between w-full ">
<div className="flex items-center gap-2">
<ProjectDialog
databases={availableDatabases}
organization={org}
project={proj as ProjectWith}
isEdit={true}
/>
</div>
<div className="flex items-center gap-2">
<ButtonDeleteProject projectId={projectId} text={"Delete Project"}/>
</div>
</div>
)}
</PageTitle>
return (
<Page>
<div className="justify-between gap-2 sm:flex">
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full ">
<div className="min-w-full md:min-w-fit ">
{capitalizeFirstLetter(proj.name)}
</div>
{!isMember && (
<div className="flex items-center gap-2 md:justify-between w-full ">
<div className="flex items-center gap-2">
<ProjectDialog
databases={availableDatabases}
organization={org}
project={proj as ProjectWith}
isEdit={true}
/>
</div>
<div className="flex items-center gap-2">
<ButtonDeleteProject
projectId={projectId}
text={"Delete Project"}
/>
</div>
</div>
<PageContent className="flex flex-col w-full h-full">
{proj.databases.length > 0 ? (
<CardsWithPagination
data={[...proj.databases].sort((a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
)}
organizationSlug={organization.slug}
// @ts-ignore
cardItem={ProjectDatabaseCard}
cardsPerPage={20}
numberOfColumns={3}
pageSizeOptions={[10, 20, 50]}
extendedProps={proj}
/>
) : (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground py-20">
<p className="text-lg font-medium">No databases found</p>
<p className="text-sm mt-2">You havent added any databases to this project yet.</p>
</div>
)}
</PageContent>
</Page>
);
)}
</PageTitle>
</div>
<PageContent className="flex flex-col w-full h-full">
{proj.databases.length > 0 ? (
<CardsWithPagination
data={[...proj.databases].sort(
(a, b) =>
new Date(b.createdAt).getTime() -
new Date(a.createdAt).getTime(),
)}
organizationSlug={organization.slug}
// @ts-ignore
cardItem={ProjectDatabaseCard}
cardsPerPage={20}
numberOfColumns={3}
pageSizeOptions={[10, 20, 50]}
extendedProps={proj}
/>
) : (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground py-20">
<p className="text-lg font-medium">No databases found</p>
<p className="text-sm mt-2">
You havent added any databases to this project yet.
</p>
</div>
)}
</PageContent>
</Page>
);
}
@@ -1,13 +1,13 @@
import {PageParams} from "@/types/next";
import {CardsWithPagination} from "@/components/common/cards-with-pagination";
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {ProjectCard} from "@/features/projects/project-card";
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/components/page";
import {ProjectCard} from "@/features/projects/components/project-card";
import {db} from "@/db";
import {notFound} from "next/navigation";
import {getActiveMember, getOrganization} from "@/lib/auth/auth";
import {EmptyStatePlaceholder} from "@/components/common/empty-state-placeholder";
import {Metadata} from "next";
import {ProjectDialog} from "@/features/projects/project-dialog";
import {ProjectDialog} from "@/features/projects/components/project-dialog";
import {DatabaseWith} from "@/db/schema/07_database";
import {getOrganizationAvailableDatabases} from "@/db/services/database";
@@ -4,17 +4,17 @@ import {
PageContent,
PageDescription,
PageTitle,
} from "@/features/layout/page";
} from "@/features/layout/components/page";
import {db} from "@/db";
import * as drizzleDb from "@/db";
import {eq} from "drizzle-orm";
import {notFound} from "next/navigation";
import {ButtonDeleteAgent} from "@/features/agents/agent-delete-button";
import {ButtonDeleteAgent} from "@/features/agents/components/agent-delete-button";
import {capitalizeFirstLetter} from "@/utils/text";
import {generateEdgeKey} from "@/utils/edge_key";
import {getServerUrl} from "@/utils/get-server-url";
import {AgentContentPage} from "@/features/agents/agent-content";
import {AgentDialog} from "@/features/agents/agent-dialog";
import {AgentContentPage} from "@/features/agents/components/agent-content";
import {AgentDialog} from "@/features/agents/components/agent-dialog";
import {getActiveMember, getOrganization} from "@/lib/auth/auth";
import {currentUser} from "@/lib/auth/current-user";
import {computeOrganizationPermissions} from "@/lib/acl/organization-acl";
@@ -4,17 +4,17 @@ import {
PageContent,
PageHeader,
PageTitle,
} from "@/features/layout/page";
} from "@/features/layout/components/page";
import {currentUser} from "@/lib/auth/current-user";
import {getActiveMember, getOrganization} from "@/lib/auth/auth";
import {notFound} from "next/navigation";
import {Metadata} from "next";
import {OrganizationTabs} from "@/features/organizations/organization-tabs";
import {OrganizationTabs} from "@/features/organizations/components/organization-tabs";
import {getOrganizationChannels} from "@/db/services/notification-channel";
import {computeOrganizationPermissions} from "@/lib/acl/organization-acl";
import {getOrganizationStorageChannels} from "@/db/services/storage-channel";
import {DeleteOrganizationButton} from "@/features/organizations/organization-delete-button";
import {EditOrganizationDialog} from "@/features/organizations/organization-edit-dialog";
import {DeleteOrganizationButton} from "@/features/organizations/components/organization-delete-button";
import {EditOrganizationDialog} from "@/features/organizations/components/organization-edit-dialog";
import {db} from "@/db";
import {isNull} from "drizzle-orm";
import * as drizzleDb from "@/db";
@@ -1,8 +1,8 @@
import {PageParams} from "@/types/next";
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/components/page";
import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card";
import {EvolutionLineChart} from "@/features/statistics/evolution-line-chart";
import {PercentageLineChart} from "@/features/statistics/percentage-line-chart";
import {EvolutionLineChart} from "@/features/statistics/components/evolution-line-chart";
import {PercentageLineChart} from "@/features/statistics/components/percentage-line-chart";
import {notFound} from "next/navigation";
import {db} from "@/db";
import {and, asc, count, eq, inArray} from "drizzle-orm";
+1 -1
View File
@@ -1,5 +1,5 @@
import {PageParams} from "@/types/next";
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/components/page";
import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card";
import {Building2, Database, DatabaseBackup, Folder, RefreshCcw, Server, Workflow} from "lucide-react";
import {currentUser} from "@/lib/auth/current-user";
+10 -6
View File
@@ -2,21 +2,25 @@ import { ReactNode } from "react";
import { redirect } from "next/navigation";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { AppSidebar } from "@/features/layout/app-sidebar";
import { Header } from "@/features/layout/header";
import { AppSidebar } from "@/features/layout/components/app-sidebar";
import { Header } from "@/features/layout/components/header";
import { currentUser } from "@/lib/auth/current-user";
import { ThemeMetaUpdater } from "@/features/theme/theme-meta-updater";
import { ModeToggle } from "@/features/theme/mode-toggle";
import { UpdateNotification } from "@/features/updates/update-notification";
import { isOnboardingDone } from "@/db/services/setting";
import { env } from "@/env.mjs";
import { ModeToggle } from "@/features/theme/components/mode-toggle";
import { UpdateNotification } from "@/features/updates/components/update-notification";
export default async function Layout({ children }: { children: ReactNode }) {
if (env.SKIP_ONBOARDING !== "true" && !(await isOnboardingDone())) {
redirect("/welcome");
}
const user = await currentUser();
if (!user) redirect("/login");
return (
<SidebarProvider>
<div className="flex flex-col lg:flex-row w-full">
<ThemeMetaUpdater />
<AppSidebar updateNotification={<UpdateNotification />} />
<SidebarInset>
<Header actions={<ModeToggle />} />
+7 -1
View File
@@ -1,8 +1,14 @@
import { redirect } from "next/navigation";
import { getCurrentOrganizationSlug } from "@/features/organizations/organization-cookie";
import { getCurrentOrganizationSlug } from "@/features/organizations/utils/organization-cookie";
import { currentUser } from "@/lib/auth/current-user";
import { isOnboardingDone } from "@/db/services/setting";
import { env } from "@/env.mjs";
export default async function Index() {
if (env.SKIP_ONBOARDING !== "true" && !(await isOnboardingDone())) {
redirect("/welcome");
}
const user = await currentUser();
if (user) {
const currentOrganizationSlug = await getCurrentOrganizationSlug();
@@ -0,0 +1,29 @@
"use client";
import { useRouter } from "next/navigation";
import { OnboardingProvider } from "@onboardjs/react";
import { onboardingSteps } from "@/features/onboarding/onboarding-steps";
import { OnboardingShell } from "@/features/onboarding/onboarding-shell";
import type { OnboardingFlowData } from "@/features/onboarding/types";
type Props = {
initialStepId: string;
initialFlowData: Partial<OnboardingFlowData>;
};
export const OnboardingClient = ({ initialStepId, initialFlowData }: Props) => {
const router = useRouter();
return (
<OnboardingProvider
steps={onboardingSteps}
initialStepId={initialStepId}
initialContext={{ flowData: initialFlowData }}
onFlowComplete={async () => {
router.push("/dashboard/home");
}}
>
<OnboardingShell />
</OnboardingProvider>
);
};
+19
View File
@@ -0,0 +1,19 @@
import { redirect } from "next/navigation";
import { resolveOnboardingState } from "@/features/onboarding/onboarding-state";
import { OnboardingClient } from "./onboarding-client";
export default async function WelcomePage() {
const result = await resolveOnboardingState();
if ("redirect" in result) {
redirect(result.redirect);
}
return (
<OnboardingClient
key={result.stepId}
initialStepId={result.stepId}
initialFlowData={result.flowData}
/>
);
}
+2 -2
View File
@@ -6,8 +6,8 @@ import {getDatabaseOrThrow, withAgentCheck} from "./helpers";
import {Backup} from "@/db/schema/07_database";
import {withUpdatedAt} from "@/db/utils";
import {eventEmitter} from "@/lib/event";
import {sendNotificationsBackupRestore} from "@/features/notifications/notifications.helpers";
import {EventKind} from "@/features/notifications/notifications.types";
import {sendNotificationsBackupRestore} from "@/features/notifications/utils/notifications.helpers";
import {EventKind} from "@/features/notifications/types";
import {logger} from "@/lib/logger";
import {JobLogEntry} from "@/features/logs/types";
@@ -1,79 +1,86 @@
import {NextResponse} from "next/server";
import {and, eq} from "drizzle-orm";
import { NextResponse } from "next/server";
import { and, eq } from "drizzle-orm";
import * as drizzleDb from "@/db";
import {db} from "@/db";
import {getDatabaseOrThrow, withAgentCheck} from "../../helpers";
import {isUuidv4} from "@/utils/verify-uuid";
import {eventEmitter} from "@/lib/event";
import {logger} from "@/lib/logger";
import { db } from "@/db";
import { getDatabaseOrThrow, withAgentCheck } from "../../helpers";
import { eventEmitter } from "@/lib/event";
import { logger } from "@/lib/logger";
import { isUUID } from "@/utils/text";
const log = logger.child({module: "api/agent/backup/upload/init"});
const log = logger.child({ module: "api/agent/backup/upload/init" });
export type Body = {
generatedId: string
storageChannelId: string
backupId: string
}
export const POST = withAgentCheck(async (request: Request, {params, agent}: {
params: Promise<{ agentId: string }>,
agent: any
}) => {
generatedId: string;
storageChannelId: string;
backupId: string;
};
export const POST = withAgentCheck(
async (
request: Request,
{
params,
agent,
}: {
params: Promise<{ agentId: string }>;
agent: any;
},
) => {
try {
const body: Body = await request.json();
const body: Body = await request.json();
log.info({data: body}, "Body for backup upload init");
log.info({ data: body }, "Body for backup upload init");
const generatedId = body.generatedId;
const storageChannelId = body.storageChannelId;
const backupId = body.backupId;
if (!generatedId || !isUuidv4(generatedId)) {
return NextResponse.json(
{error: "generatedId is not a valid UUID"},
{status: 400}
);
}
const database = await getDatabaseOrThrow(generatedId);
const backup = await db.query.backup.findFirst({
where: and(
eq(drizzleDb.schemas.backup.id, backupId),
eq(drizzleDb.schemas.backup.databaseId, database.id),
),
});
if (!backup) {
return NextResponse.json(
{error: "Unable to find the corresponding backup"},
{status: 404}
);
}
const [backupStorage] = await db
.insert(drizzleDb.schemas.backupStorage)
.values({
backupId: backup.id,
storageChannelId: storageChannelId,
status: "pending",
})
.returning();
eventEmitter.emit('modification', {update: true});
const generatedId = body.generatedId;
const storageChannelId = body.storageChannelId;
const backupId = body.backupId;
if (!generatedId || !isUUID(generatedId)) {
return NextResponse.json(
{
message: "Backup storage successfully created",
backupStorage: backupStorage
},
{status: 200}
{ error: "generatedId is not a valid UUID" },
{ status: 400 },
);
}
const database = await getDatabaseOrThrow(generatedId);
const backup = await db.query.backup.findFirst({
where: and(
eq(drizzleDb.schemas.backup.id, backupId),
eq(drizzleDb.schemas.backup.databaseId, database.id),
),
});
if (!backup) {
return NextResponse.json(
{ error: "Unable to find the corresponding backup" },
{ status: 404 },
);
}
const [backupStorage] = await db
.insert(drizzleDb.schemas.backupStorage)
.values({
backupId: backup.id,
storageChannelId: storageChannelId,
status: "pending",
})
.returning();
eventEmitter.emit("modification", { update: true });
return NextResponse.json(
{
message: "Backup storage successfully created",
backupStorage: backupStorage,
},
{ status: 200 },
);
} catch (error) {
log.error({error: error}, "Error in POST for INIT backup");
return NextResponse.json(
{error: "Internal server error"},
{status: 500}
);
log.error({ error: error }, "Error in POST for INIT backup");
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
});
},
);
+114 -100
View File
@@ -1,110 +1,124 @@
import {NextResponse} from "next/server";
import {isUuidv4} from "@/utils/verify-uuid";
import { NextResponse } from "next/server";
import * as drizzleDb from "@/db";
import {db as dbClient, db} from "@/db";
import {and, eq} from "drizzle-orm";
import {sendNotificationsBackupRestore} from "@/features/notifications/notifications.helpers";
import {logger} from "@/lib/logger";
import {withUpdatedAt} from "@/db/utils";
import {JobLogEntry} from "@/features/logs/types";
import { db as dbClient, db } from "@/db";
import { and, eq } from "drizzle-orm";
import { sendNotificationsBackupRestore } from "@/features/notifications/utils/notifications.helpers";
import { logger } from "@/lib/logger";
import { withUpdatedAt } from "@/db/utils";
import { JobLogEntry } from "@/features/logs/types";
import { isUUID } from "@/utils/text";
const log = logger.child({module: "api/agent/restore"});
const log = logger.child({ module: "api/agent/restore" });
export type BodyResultRestore = {
generatedId: string
status: string
logs: JobLogEntry[]
durationMs: number
}
type RestorationStatus = 'waiting' | 'ongoing' | 'failed' | 'success';
generatedId: string;
status: string;
logs: JobLogEntry[];
durationMs: number;
};
type RestorationStatus = "waiting" | "ongoing" | "failed" | "success";
export async function POST(
request: Request,
{params}: { params: Promise<{ agentId: string }> }
request: Request,
{ params }: { params: Promise<{ agentId: string }> },
) {
try {
const agentId = (await params).agentId;
const body: BodyResultRestore = await request.json();
try {
const agentId = (await params).agentId
const body: BodyResultRestore = await request.json();
if (!isUuidv4(body.generatedId)) {
return NextResponse.json(
{error: "generatedId is not a valid uuid"},
{status: 500}
);
}
const agent = await db.query.agent.findFirst({
where: and(eq(drizzleDb.schemas.agent.id, agentId), eq(drizzleDb.schemas.agent.isArchived, false)),
})
if (!agent) {
return NextResponse.json({error: "Agent not found"}, {status: 404})
}
const database = await db.query.database.findFirst({
where: eq(drizzleDb.schemas.database.agentDatabaseId, body.generatedId),
with: {
alertPolicies: true
}
})
if (!database) {
return NextResponse.json({error: "Database associated with generatedId provided not found"}, {status: 404})
}
const restoration = await db.query.restoration.findFirst({
where: and(eq(drizzleDb.schemas.restoration.status, "ongoing"), eq(drizzleDb.schemas.restoration.databaseId, database.id),)
})
if (!restoration) {
return NextResponse.json({error: "Unable to fin the corresponding restoration"}, {status: 404})
}
const [restorationUpdated] = await db
.update(drizzleDb.schemas.restoration)
.set(withUpdatedAt({status: body.status as RestorationStatus, durationMs: body.durationMs}))
.where(eq(drizzleDb.schemas.restoration.id, restoration.id)).returning();
const logsToInsert = body.logs.map((entry) => ({
backupId: null,
restorationId: restorationUpdated.id,
loggedAt: new Date(entry.timestamp),
entryType: entry.type,
level: entry.level,
message: entry.message,
command: entry.command ?? null,
output: entry.output ?? null,
exitCode: entry.exit_code ?? null,
durationMs: entry.duration_ms ?? null,
}));
if (logsToInsert.length > 0) {
await dbClient
.insert(drizzleDb.schemas.jobLog)
.values(logsToInsert);
}
await sendNotificationsBackupRestore(database, body.status == "failed" ? "error_restore" : "success_restore");
const response = {
status: true,
message: "Restoration successfully updated"
}
return Response.json(response, {status: 200})
} catch (error) {
log.error({error: error}, "Error in POST handler")
return NextResponse.json(
{error: 'Internal server error'},
{status: 500}
);
if (!isUUID(body.generatedId)) {
return NextResponse.json(
{ error: "generatedId is not a valid uuid" },
{ status: 500 },
);
}
const agent = await db.query.agent.findFirst({
where: and(
eq(drizzleDb.schemas.agent.id, agentId),
eq(drizzleDb.schemas.agent.isArchived, false),
),
});
if (!agent) {
return NextResponse.json({ error: "Agent not found" }, { status: 404 });
}
const database = await db.query.database.findFirst({
where: eq(drizzleDb.schemas.database.agentDatabaseId, body.generatedId),
with: {
alertPolicies: true,
},
});
if (!database) {
return NextResponse.json(
{ error: "Database associated with generatedId provided not found" },
{ status: 404 },
);
}
const restoration = await db.query.restoration.findFirst({
where: and(
eq(drizzleDb.schemas.restoration.status, "ongoing"),
eq(drizzleDb.schemas.restoration.databaseId, database.id),
),
});
if (!restoration) {
return NextResponse.json(
{ error: "Unable to fin the corresponding restoration" },
{ status: 404 },
);
}
const [restorationUpdated] = await db
.update(drizzleDb.schemas.restoration)
.set(
withUpdatedAt({
status: body.status as RestorationStatus,
durationMs: body.durationMs,
}),
)
.where(eq(drizzleDb.schemas.restoration.id, restoration.id))
.returning();
const logsToInsert = body.logs.map((entry) => ({
backupId: null,
restorationId: restorationUpdated.id,
loggedAt: new Date(entry.timestamp),
entryType: entry.type,
level: entry.level,
message: entry.message,
command: entry.command ?? null,
output: entry.output ?? null,
exitCode: entry.exit_code ?? null,
durationMs: entry.duration_ms ?? null,
}));
if (logsToInsert.length > 0) {
await dbClient.insert(drizzleDb.schemas.jobLog).values(logsToInsert);
}
await sendNotificationsBackupRestore(
database,
body.status == "failed" ? "error_restore" : "success_restore",
);
const response = {
status: true,
message: "Restoration successfully updated",
};
return Response.json(response, { status: 200 });
} catch (error) {
log.error({ error: error }, "Error in POST handler");
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
+4 -4
View File
@@ -1,6 +1,5 @@
import {NextResponse} from "next/server";
import {Body} from "./route";
import {isUuidv4} from "@/utils/verify-uuid";
import {Agent} from "@/db/schema/08_agent";
import {DatabaseWith} from "@/db/schema/07_database";
import * as drizzleDb from "@/db";
@@ -8,10 +7,11 @@ import {db, db as dbClient} from "@/db";
import {and, eq, inArray} from "drizzle-orm";
import {dbmsEnumSchema, EDbmsSchema} from "@/db/schema/types";
import {withUpdatedAt} from "@/db/utils";
import type {StorageInput} from "@/features/storages/storages.types";
import {dispatchStorage} from "@/features/storages/storages.dispatch";
import {Setting} from "@/db/schema/01_setting";
import {logger} from "@/lib/logger";
import {isUUID} from "@/utils/text";
import {StorageInput} from "@/features/storages/types";
import {dispatchStorage} from "@/features/storages/utils/storages.dispatch";
const log = logger.child({module: "api/agent/status/helpers"});
@@ -53,7 +53,7 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
let backupSize: number | null = null
if (!existingDatabase) {
if (!isUuidv4(db.generatedId)) {
if (!isUUID(db.generatedId)) {
return NextResponse.json(
{error: "generatedId is not a valid uuid"},
{status: 500}
+95 -87
View File
@@ -1,99 +1,107 @@
import {NextResponse} from "next/server";
import {handleDatabases} from "./helpers";
import { NextResponse } from "next/server";
import { handleDatabases } from "./helpers";
import * as drizzleDb from "@/db";
import {db} from "@/db";
import {EDbmsSchema} from "@/db/schema/types";
import {and, eq} from "drizzle-orm";
import {isUuidv4} from "@/utils/verify-uuid";
import {withUpdatedAt} from "@/db/utils";
import {logger} from "@/lib/logger";
const log = logger.child({module: "api/agent/status/route"});
import { db } from "@/db";
import { EDbmsSchema } from "@/db/schema/types";
import { and, eq } from "drizzle-orm";
import { withUpdatedAt } from "@/db/utils";
import { logger } from "@/lib/logger";
import { isUUID } from "@/utils/text";
const log = logger.child({ module: "api/agent/status/route" });
export type databaseAgent = {
name: string,
dbms: EDbmsSchema,
generatedId: string
pingStatus: boolean
}
name: string;
dbms: EDbmsSchema;
generatedId: string;
pingStatus: boolean;
};
export type Body = {
version: string,
databases: databaseAgent[]
}
version: string;
databases: databaseAgent[];
};
export async function POST(
request: Request,
{params}: { params: Promise<{ agentId: string }> }
request: Request,
{ params }: { params: Promise<{ agentId: string }> },
) {
try {
const agentId = (await params).agentId
log.debug(`Agent ID: ${agentId}`)
const body: Body = await request.json();
const lastContact = new Date();
let message: string
try {
const agentId = (await params).agentId;
log.debug(`Agent ID: ${agentId}`);
const body: Body = await request.json();
const lastContact = new Date();
let message: string;
if (!isUuidv4(agentId)) {
message = "agentId is not a valid uuid"
log.error({error: message}, "An error occurred")
return NextResponse.json(
{error: "agentId is not a valid uuid"},
{status: 500}
);
}
const agent = await db.query.agent.findFirst({
where: and(eq(drizzleDb.schemas.agent.id, agentId), eq(drizzleDb.schemas.agent.isArchived, false)),
})
if (!agent) {
message = "Agent not found"
return NextResponse.json({error: message}, {status: 404})
}
const [settings] = await db.select().from(drizzleDb.schemas.setting).where(eq(drizzleDb.schemas.setting.name, "system")).limit(1);
if (!settings) {
return NextResponse.json({error: "An error occured"}, {status: 404})
}
const databasesResponse = await handleDatabases(body, agent, lastContact, settings)
await db
.update(drizzleDb.schemas.agent)
.set(withUpdatedAt({
version: body.version,
lastContact: lastContact,
healthErrorCount: null
}))
.where(eq(drizzleDb.schemas.agent.id, agentId));
await db
.insert(drizzleDb.schemas.healthcheckLog)
.values({
kind: "agent",
status: "success",
objectId: agentId,
date: lastContact
})
const response = {
agent: {
id: agentId,
lastContact: lastContact
},
databases: databasesResponse
}
return Response.json(response)
} catch (error) {
log.error({error: error}, "Error in POST handler")
return NextResponse.json(
{error: 'Internal server error'},
{status: 500}
);
if (!isUUID(agentId)) {
message = "agentId is not a valid uuid";
log.error({ error: message }, "An error occurred");
return NextResponse.json(
{ error: "agentId is not a valid uuid" },
{ status: 500 },
);
}
}
const agent = await db.query.agent.findFirst({
where: and(
eq(drizzleDb.schemas.agent.id, agentId),
eq(drizzleDb.schemas.agent.isArchived, false),
),
});
if (!agent) {
message = "Agent not found";
return NextResponse.json({ error: message }, { status: 404 });
}
const [settings] = await db
.select()
.from(drizzleDb.schemas.setting)
.where(eq(drizzleDb.schemas.setting.name, "system"))
.limit(1);
if (!settings) {
return NextResponse.json({ error: "An error occured" }, { status: 404 });
}
const databasesResponse = await handleDatabases(
body,
agent,
lastContact,
settings,
);
await db
.update(drizzleDb.schemas.agent)
.set(
withUpdatedAt({
version: body.version,
lastContact: lastContact,
healthErrorCount: null,
}),
)
.where(eq(drizzleDb.schemas.agent.id, agentId));
await db.insert(drizzleDb.schemas.healthcheckLog).values({
kind: "agent",
status: "success",
objectId: agentId,
date: lastContact,
});
const response = {
agent: {
id: agentId,
lastContact: lastContact,
},
databases: databasesResponse,
};
return Response.json(response);
} catch (error) {
log.error({ error: error }, "Error in POST handler");
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
+22 -20
View File
@@ -5,31 +5,33 @@ import { headers } from "next/headers";
const authHandler = toNextJsHandler(auth.handler);
async function blockApiKeyCreateForRestrictedUsers(req: NextRequest): Promise<NextResponse | null> {
const url = req.nextUrl;
if (req.method !== "POST" || !url.pathname.endsWith("/api-key/create")) {
return null;
}
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user) {
return null;
}
// @ts-ignore
if (session.user.banned || (session.user.role as string) === "pending") {
return NextResponse.json(
{ error: "Account not eligible to create API keys" },
{ status: 403 }
);
}
async function blockApiKeyCreateForRestrictedUsers(
req: NextRequest,
): Promise<NextResponse | null> {
const url = req.nextUrl;
if (req.method !== "POST" || !url.pathname.endsWith("/api-key/create")) {
return null;
}
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user) {
return null;
}
// @ts-ignore
if (session.user.banned || (session.user.role as string) === "pending") {
return NextResponse.json(
{ error: "Account not eligible to create API keys" },
{ status: 403 },
);
}
return null;
}
export async function GET(req: NextRequest) {
return authHandler.GET(req);
return authHandler.GET(req);
}
export async function POST(req: NextRequest) {
const guard = await blockApiKeyCreateForRestrictedUsers(req);
if (guard) return guard;
return authHandler.POST(req);
const guard = await blockApiKeyCreateForRestrictedUsers(req);
if (guard) return guard;
return authHandler.POST(req);
}
+2 -2
View File
@@ -1,7 +1,7 @@
import {NextResponse} from "next/server";
import path from "path";
import type {StorageInput} from "@/features/storages/storages.types";
import {dispatchStorage} from "@/features/storages/storages.dispatch";
import type {StorageInput} from "@/features/storages/types";
import {dispatchStorage} from "@/features/storages/utils/storages.dispatch";
import {Readable} from "node:stream";
import {logger} from "@/lib/logger";
+2 -2
View File
@@ -1,8 +1,8 @@
import {NextResponse} from "next/server";
import {auth} from "@/lib/auth/auth";
import {headers} from "next/headers";
import {StorageInput} from "@/features/storages/storages.types";
import {dispatchStorage} from "@/features/storages/storages.dispatch";
import {StorageInput} from "@/features/storages/types";
import {dispatchStorage} from "@/features/storages/utils/storages.dispatch";
import {Readable} from "node:stream";
import {logger} from "@/lib/logger";
+1 -1
View File
@@ -3,7 +3,7 @@ import {withApiKey} from "@/lib/api-v1/middleware";
import {logger} from "@/lib/logger";
import {ApiKeyContext} from "@/lib/api-v1/types";
import {getAgent, resolveAgentAccess} from "@/lib/api-v1/services/agents";
import {deleteAgentService} from "@/features/agents/agent-delete.action";
import {deleteAgentService} from "@/features/agents/actions/agent-delete.action";
const log = logger.child({module: "api/v1/agents/[id]"});
+1 -1
View File
@@ -5,7 +5,7 @@ import * as drizzleDb from "@/db";
import { inArray, eq, and, or, isNull } from "drizzle-orm";
import { z } from "zod";
import { logger } from "@/lib/logger";
import {createAgentService} from "@/features/agents/agents.action";
import {createAgentService} from "@/features/agents/actions/agents.action";
import { ActionError } from "@/lib/safe-actions/actions";
import {getAccessibleAgentIds} from "@/lib/api-v1/services/agents";
import {ApiKeyContext} from "@/lib/api-v1/types";
+5 -2
View File
@@ -1,4 +1,5 @@
import BackButton from "@/components/common/back-button";
import Link from "next/link";
import { Button } from "@/components/ui/button";
export default async function NotFound() {
return (
@@ -12,7 +13,9 @@ export default async function NotFound() {
The content you are trying to view is not available.
</p>
</div>
<BackButton>Go home</BackButton>
<Button asChild>
<Link href="/">Go home</Link>
</Button>
</div>
</div>
);
+3 -3
View File
@@ -5,8 +5,8 @@ import { type PropsWithChildren, Suspense } from "react";
import { Toaster } from "@/components/ui/sonner";
import { ErrorLayout } from "@/components/common/error-layout";
import { ThemeMetaUpdaterRoot } from "@/features/theme/theme-meta-updater-root";
import { ThemeProvider } from "@/features/theme/theme-provider";
import { ThemeMetaUpdater } from "@/features/theme/components/theme-meta-updater";
import { ThemeProvider } from "@/features/theme/components/theme-provider";
export type ProviderProps = PropsWithChildren<{}>;
const queryClient = new QueryClient();
@@ -15,7 +15,7 @@ export const Providers = (props: ProviderProps) => {
return (
<Suspense fallback={null}>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<ThemeMetaUpdaterRoot />
<ThemeMetaUpdater />
<QueryClientProvider client={queryClient}>
<ErrorLayout>
<Toaster />
+6 -6
View File
@@ -1,12 +1,12 @@
services:
app:
# build:
# context: .
# dockerfile: docker/dockerfile/Dockerfile
# target: prod
# build:
# context: .
# dockerfile: docker/dockerfile/Dockerfile
# target: prod
image: portabase/portabase:1
ports:
- '8887:80'
- "8887:80"
environment:
TZ: "Europe/Paris"
env_file:
@@ -35,7 +35,7 @@ services:
- POSTGRES_USER=devuser
- POSTGRES_PASSWORD=changeme
healthcheck:
test: [ "CMD-SHELL", "pg_isready -U devuser -d devdb" ]
test: ["CMD-SHELL", "pg_isready -U devuser -d devdb"]
interval: 10s
timeout: 5s
retries: 5
+80 -82
View File
@@ -1,102 +1,100 @@
import type {NextConfig} from "next";
import {PORTABASE_DEFAULT_SETTINGS} from "./portabase.config";
import type { NextConfig } from "next";
import { PORTABASE_DEFAULT_SETTINGS } from "./portabase.config";
const isDev = process.env.NODE_ENV === "development";
function buildCSPHeader(): string {
const {CSP} = PORTABASE_DEFAULT_SETTINGS.SECURITY;
const { CSP } = PORTABASE_DEFAULT_SETTINGS.SECURITY;
const directives = [
`default-src ${CSP.DEFAULT_SRC.join(" ")}`,
`script-src ${CSP.SCRIPT_SRC.join(" ")}`,
`style-src ${CSP.STYLE_SRC.join(" ")}`,
`img-src ${CSP.IMG_SRC.join(" ")}`,
`font-src ${CSP.FONT_SRC.join(" ")}`,
`object-src ${CSP.OBJECT_SRC.join(" ")}`,
`connect-src ${CSP.CONNECT_SRC.join(" ")}`,
`base-uri ${CSP.BASE_URI.join(" ")}`,
`form-action ${CSP.FORM_ACTION.join(" ")}`,
`frame-ancestors ${CSP.FRAME_ANCESTORS.join(" ")}`,
];
const directives = [
`default-src ${CSP.DEFAULT_SRC.join(" ")}`,
`script-src ${CSP.SCRIPT_SRC.join(" ")}`,
`style-src ${CSP.STYLE_SRC.join(" ")}`,
`img-src ${CSP.IMG_SRC.join(" ")}`,
`font-src ${CSP.FONT_SRC.join(" ")}`,
`object-src ${CSP.OBJECT_SRC.join(" ")}`,
`connect-src ${CSP.CONNECT_SRC.join(" ")}`,
`base-uri ${CSP.BASE_URI.join(" ")}`,
`form-action ${CSP.FORM_ACTION.join(" ")}`,
`frame-ancestors ${CSP.FRAME_ANCESTORS.join(" ")}`,
];
if (CSP.BLOCK_ALL_MIXED_CONTENT) {
directives.push("block-all-mixed-content");
}
if (CSP.BLOCK_ALL_MIXED_CONTENT) {
directives.push("block-all-mixed-content");
}
if (CSP.UPGRADE_INSECURE_REQUESTS) {
directives.push("upgrade-insecure-requests");
}
if (CSP.UPGRADE_INSECURE_REQUESTS) {
directives.push("upgrade-insecure-requests");
}
return directives.join("; ");
return directives.join("; ");
}
function buildPermissionsPolicy(): string {
return Object.entries(PORTABASE_DEFAULT_SETTINGS.SECURITY.PERMISSIONS_POLICY)
.map(([feature, values]) => `${feature.toLowerCase()}=${values.join(", ")}`)
.join(", ");
return Object.entries(PORTABASE_DEFAULT_SETTINGS.SECURITY.PERMISSIONS_POLICY)
.map(([feature, values]) => `${feature.toLowerCase()}=${values.join(", ")}`)
.join(", ");
}
const nextConfig: NextConfig = {
output: "standalone",
typescript: {
ignoreBuildErrors: true,
output: "standalone",
devIndicators: false,
typescript: {
ignoreBuildErrors: true,
},
logging: {
browserToTerminal: false,
},
experimental: {
serverActions: {
bodySizeLimit: "10gb",
},
logging: {
browserToTerminal: false,
},
experimental: {
serverActions: {
bodySizeLimit: "10gb",
},
proxyClientMaxBodySize: '10gb',
},
async rewrites() {
if (!isDev) return [];
proxyClientMaxBodySize: "10gb",
},
async rewrites() {
if (!isDev) return [];
return [
{
source: "/tus/:path*",
destination: "http://localhost:1080/tus/:path*",
},
];
},
return [
{
source: "/tus/:path*",
destination: "http://localhost:1080/tus/:path*",
},
];
},
async headers() {
return [
{
source: "/(.*)",
headers: [
{
key: "Content-Security-Policy",
value: buildCSPHeader(),
},
{
key: "Permissions-Policy",
value: buildPermissionsPolicy(),
},
{
key: 'X-Content-Type-Options',
value: 'nosniff',
},
{
key: 'X-Frame-Options',
value: 'DENY',
},
{
key: 'Referrer-Policy',
value: 'strict-origin-when-cross-origin',
},
{
key: 'Strict-Transport-Security',
value: 'max-age=63072000; includeSubDomains; preload',
}
// ...other security headers
],
},
];
},
async headers() {
return [
{
source: "/(.*)",
headers: [
{
key: "Content-Security-Policy",
value: buildCSPHeader(),
},
{
key: "Permissions-Policy",
value: buildPermissionsPolicy(),
},
{
key: "X-Content-Type-Options",
value: "nosniff",
},
{
key: "X-Frame-Options",
value: "DENY",
},
{
key: "Referrer-Policy",
value: "strict-origin-when-cross-origin",
},
{
key: "Strict-Transport-Security",
value: "max-age=63072000; includeSubDomains; preload",
},
],
},
];
},
};
export default nextConfig;
+3
View File
@@ -23,6 +23,7 @@
"@better-auth/sso": "1.6.11",
"@hookform/resolvers": "^5.4.0",
"@modelcontextprotocol/sdk": "^1.29.0",
"@onboardjs/react": "1.0.0-rc.5",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-aspect-ratio": "^1.1.8",
@@ -61,6 +62,7 @@
"argon2": "^0.43.1",
"bcrypt": "^6.0.0",
"better-auth": "1.6.11",
"canvas-confetti": "^1.9.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
@@ -115,6 +117,7 @@
"@release-it/bumper": "^7.0.5",
"@release-it/conventional-changelog": "^10.0.6",
"@tailwindcss/postcss": "^4.3.0",
"@types/canvas-confetti": "^1.9.0",
"@types/eslint-plugin-tailwindcss": "^3.17.0",
"@types/node": "^22.19.19",
"@types/node-forge": "^1.3.14",
+56 -2
View File
@@ -32,6 +32,9 @@ importers:
'@modelcontextprotocol/sdk':
specifier: ^1.29.0
version: 1.29.0(zod@4.3.6)
'@onboardjs/react':
specifier: 1.0.0-rc.5
version: 1.0.0-rc.5(@onboardjs/core@1.0.0-rc.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-accordion':
specifier: ^1.2.12
version: 1.2.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
@@ -146,6 +149,9 @@ importers:
better-auth:
specifier: 1.6.11
version: 1.6.11(@prisma/client@6.7.0(prisma@6.7.0(typescript@5.9.3))(typescript@5.9.3))(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@prisma/client@6.7.0(prisma@6.7.0(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.20.0)(kysely@0.28.17)(pg@8.21.0)(prisma@6.7.0(typescript@5.9.3)))(next@16.2.3(@babel/core@7.29.7)(@playwright/test@1.58.2)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.21.0)(prisma@6.7.0(typescript@5.9.3))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
canvas-confetti:
specifier: ^1.9.4
version: 1.9.4
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@@ -303,6 +309,9 @@ importers:
'@tailwindcss/postcss':
specifier: ^4.3.0
version: 4.3.0
'@types/canvas-confetti':
specifier: ^1.9.0
version: 1.9.0
'@types/eslint-plugin-tailwindcss':
specifier: ^3.17.0
version: 3.17.0
@@ -2237,6 +2246,16 @@ packages:
'@octokit/types@16.0.0':
resolution: {integrity: sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==}
'@onboardjs/core@1.0.0-rc.4':
resolution: {integrity: sha512-G22h1I2JUiEyDGf06YEUT2Afg26rDZM4Ku0PAZFqmr7HVukg9651UdPxZ1/9TVvAFFmW6Qq+u8oQ75KVJu80pw==}
'@onboardjs/react@1.0.0-rc.5':
resolution: {integrity: sha512-iqC4MjrQe4VLi3HKCkczFigkjLxdjWTYrzoI1jEi3QpKOVmDAn4bmXngazW2nfPSOItkxdQq38VO7q8J2yVMhg==}
peerDependencies:
'@onboardjs/core': '>=1.0.0-rc.1'
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
'@opentelemetry/semantic-conventions@1.41.1':
resolution: {integrity: sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==}
engines: {node: '>=14'}
@@ -3849,6 +3868,9 @@ packages:
'@tybys/wasm-util@0.10.2':
resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==}
'@types/canvas-confetti@1.9.0':
resolution: {integrity: sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg==}
'@types/cors@2.8.19':
resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==}
@@ -4656,6 +4678,9 @@ packages:
caniuse-lite@1.0.30001797:
resolution: {integrity: sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w==}
canvas-confetti@1.9.4:
resolution: {integrity: sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw==}
capital-case@1.0.4:
resolution: {integrity: sha512-ds37W8CytHgwnhGGTi88pcPyR15qoNkOpYwmMMfnWqqWgESapLqvDx6huFjQ5vqWSn2Z06173XNA7LtMOeUh1A==}
@@ -6997,6 +7022,14 @@ packages:
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
engines: {node: '>=10'}
p-queue@8.1.1:
resolution: {integrity: sha512-aNZ+VfjobsWryoiPnEApGGmf5WmNsCo9xu8dfaYamG5qaLP7ClhLN6NgsFe6SwJ2UbLEBK5dv9x8Mn5+RVhMWQ==}
engines: {node: '>=18'}
p-timeout@6.1.4:
resolution: {integrity: sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg==}
engines: {node: '>=14.16'}
pac-proxy-agent@7.2.0:
resolution: {integrity: sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==}
engines: {node: '>= 14'}
@@ -10062,6 +10095,16 @@ snapshots:
dependencies:
'@octokit/openapi-types': 27.0.0
'@onboardjs/core@1.0.0-rc.4':
dependencies:
p-queue: 8.1.1
'@onboardjs/react@1.0.0-rc.5(@onboardjs/core@1.0.0-rc.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@onboardjs/core': 1.0.0-rc.4
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
'@opentelemetry/semantic-conventions@1.41.1': {}
'@paralleldrive/cuid2@2.3.1':
@@ -11837,6 +11880,8 @@ snapshots:
tslib: 2.8.1
optional: true
'@types/canvas-confetti@1.9.0': {}
'@types/cors@2.8.19':
dependencies:
'@types/node': 22.19.20
@@ -12793,6 +12838,8 @@ snapshots:
caniuse-lite@1.0.30001797: {}
canvas-confetti@1.9.4: {}
capital-case@1.0.4:
dependencies:
no-case: 3.0.4
@@ -15359,6 +15406,13 @@ snapshots:
dependencies:
p-limit: 3.1.0
p-queue@8.1.1:
dependencies:
eventemitter3: 5.0.4
p-timeout: 6.1.4
p-timeout@6.1.4: {}
pac-proxy-agent@7.2.0:
dependencies:
'@tootallnate/quickjs-emscripten': 0.23.0
@@ -16657,7 +16711,7 @@ snapshots:
ansi-escapes: 4.3.2
supports-hyperlinks: 2.3.0
terser-webpack-plugin@5.6.1(esbuild@0.25.10)(postcss@8.5.10)(webpack@5.107.2(esbuild@0.27.7)(postcss@8.5.10)):
terser-webpack-plugin@5.6.1(esbuild@0.25.10)(postcss@8.5.10)(webpack@5.107.2(esbuild@0.25.10)(postcss@8.5.10)):
dependencies:
'@jridgewell/trace-mapping': 0.3.31
jest-worker: 27.5.1
@@ -17082,7 +17136,7 @@ snapshots:
neo-async: 2.6.2
schema-utils: 4.3.3
tapable: 2.3.3
terser-webpack-plugin: 5.6.1(esbuild@0.25.10)(postcss@8.5.10)(webpack@5.107.2(esbuild@0.27.7)(postcss@8.5.10))
terser-webpack-plugin: 5.6.1(esbuild@0.25.10)(postcss@8.5.10)(webpack@5.107.2(esbuild@0.25.10)(postcss@8.5.10))
watchpack: 2.5.1
webpack-sources: 3.5.0
transitivePeerDependencies:
+1 -1
View File
@@ -46,7 +46,7 @@ export const PORTABASE_DEFAULT_SETTINGS = {
"https://api.iconify.design",
"https://code.iconify.design",
"https://api.github.com",
"https://api.dicebear.com",
],
OBJECT_SRC: ["'none'"],
BASE_URI: ["'self'"],
+3 -2
View File
@@ -4,7 +4,7 @@ import { errorHandler } from "@/middleware/errorHandler";
import { auth } from "@/lib/auth/auth";
import { headers } from "next/headers";
import { env } from "@/env.mjs";
import {User} from "@/db/schema/02_user";
import { User } from "@/db/schema/02_user";
export async function proxy(request: NextRequest) {
const url = request.nextUrl.clone();
@@ -19,7 +19,7 @@ export async function proxy(request: NextRequest) {
new URL(`/login?redirect=${redirectUrl}`, request.url),
);
}
const user = session.user as User
const user = session.user as User;
if (user.banned) {
await auth.api.signOut({ headers: await headers() });
@@ -104,6 +104,7 @@ function checkRouteExists(pathname: string) {
/^\/api\/config\/?$/,
/^\/api\/health\/?$/,
/^\/api\/google\/drive\/callback\/?$/,
/^\/api\/avatar\/?$/,
// v1 external API
/^\/api\/v1\/mcp\/?$/,
/^\/api\/v1\/docs\/?$/,
+78 -2
View File
@@ -351,6 +351,14 @@
"clientRole" : true,
"containerId" : "18b5dfb2-44d4-448e-98df-9652daef1a22",
"attributes" : { }
} ],
"exemple-client" : [ {
"id" : "67766209-57dd-45c7-940d-ed3313473101",
"name" : "uma_protection",
"composite" : false,
"clientRole" : true,
"containerId" : "248d3eaa-466f-4b85-8903-9242959e67ba",
"attributes" : { }
} ]
}
},
@@ -458,8 +466,8 @@
"id" : "b1cedcac-5207-4479-bc29-065ba849e414",
"type" : "password",
"userLabel" : "My password",
"createdDate" : 1772179391727,
"secretData" : "{\"value\":\"IXr6L3pvO2BAkn/cH3rh3v2tg1R83u2f8SWEZK2fq5E=\",\"salt\":\"XWV8/yKm7Rw/YXiwXZZUig==\",\"additionalParameters\":{}}",
"createdDate" : 1782393421785,
"secretData" : "{\"value\":\"L585pLqSeHaNW4HzOu+GzZm+VJWWBO3Df+rPs04gUGk=\",\"salt\":\"w3pmQ0hsMDJnCX+qJ3K+zQ==\",\"additionalParameters\":{}}",
"credentialData" : "{\"hashIterations\":5,\"algorithm\":\"argon2\",\"additionalParameters\":{\"hashLength\":[\"32\"],\"memory\":[\"7168\"],\"type\":[\"id\"],\"version\":[\"1.3\"],\"parallelism\":[\"1\"]}}"
} ],
"disableableCredentialTypes" : [ ],
@@ -467,6 +475,23 @@
"realmRoles" : [ "admin", "default-roles-master" ],
"notBefore" : 0,
"groups" : [ "/Admin" ]
}, {
"id" : "8c749a0c-f2ed-4bc7-9f54-4e0bcbc36626",
"username" : "service-account-exemple-client",
"emailVerified" : false,
"enabled" : true,
"createdTimestamp" : 1772190246795,
"totp" : false,
"serviceAccountClientId" : "exemple-client",
"credentials" : [ ],
"disableableCredentialTypes" : [ ],
"requiredActions" : [ ],
"realmRoles" : [ "default-roles-master" ],
"clientRoles" : {
"exemple-client" : [ "uma_protection" ]
},
"notBefore" : 0,
"groups" : [ ]
}, {
"id" : "718dfe46-dbef-41a3-9120-78aeac240431",
"username" : "service-account-portabase",
@@ -625,6 +650,57 @@
"nodeReRegistrationTimeout" : 0,
"defaultClientScopes" : [ "web-origins", "acr", "profile", "roles", "basic", "email" ],
"optionalClientScopes" : [ "address", "phone", "organization", "offline_access", "microprofile-jwt" ]
}, {
"id" : "248d3eaa-466f-4b85-8903-9242959e67ba",
"clientId" : "exemple-client",
"name" : "Exemple Client",
"description" : "",
"rootUrl" : "https://your-instance.portabase.io",
"adminUrl" : "https://your-instance.portabase.io",
"baseUrl" : "https://your-instance.portabase.io",
"surrogateAuthRequired" : false,
"enabled" : true,
"alwaysDisplayInConsole" : false,
"clientAuthenticatorType" : "client-secret",
"secret" : "lFZY6fXzh9uxCXwvucw9JtXGhNRKgnnj",
"redirectUris" : [ "https://your-instance.portabase.io/api/auth/sso/callback/exemple-client" ],
"webOrigins" : [ "https://your-instance.portabase.io" ],
"notBefore" : 0,
"bearerOnly" : false,
"consentRequired" : false,
"standardFlowEnabled" : true,
"implicitFlowEnabled" : false,
"directAccessGrantsEnabled" : false,
"serviceAccountsEnabled" : true,
"authorizationServicesEnabled" : true,
"publicClient" : false,
"frontchannelLogout" : true,
"protocol" : "openid-connect",
"attributes" : {
"realm_client" : "false",
"oidc.ciba.grant.enabled" : "false",
"client.secret.creation.time" : "1772190246",
"backchannel.logout.session.required" : "true",
"standard.token.exchange.enabled" : "false",
"post.logout.redirect.uris" : "https://your-instance.portabase.io",
"oauth2.device.authorization.grant.enabled" : "true",
"pkce.code.challenge.method" : "S256",
"backchannel.logout.revoke.offline.tokens" : "false",
"dpop.bound.access.tokens" : "false"
},
"authenticationFlowBindingOverrides" : { },
"fullScopeAllowed" : true,
"nodeReRegistrationTimeout" : -1,
"defaultClientScopes" : [ "web-origins", "service_account", "acr", "profile", "roles", "basic", "email" ],
"optionalClientScopes" : [ "address", "phone", "organization", "offline_access", "microprofile-jwt" ],
"authorizationSettings" : {
"allowRemoteResourceManagement" : true,
"policyEnforcementMode" : "ENFORCING",
"resources" : [ ],
"policies" : [ ],
"scopes" : [ ],
"decisionStrategy" : "UNANIMOUS"
}
}, {
"id" : "6022c206-5f1f-45b7-af07-74ce6804cbc7",
"clientId" : "master-realm",
-18
View File
@@ -1,18 +0,0 @@
"use client";
import React from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
type BackButtonProps = React.ComponentProps<typeof Button> & {
children: React.ReactNode;
};
export default function BackButton({ children, ...props }: BackButtonProps) {
const router = useRouter();
return (
<Button onClick={() => router.back()} aria-label={children?.toString()} {...props}>
{children}
</Button>
);
}
+122
View File
@@ -0,0 +1,122 @@
"use client";
import { cn } from "@/lib/utils";
export type ThemeKey = "dark" | "light" | "system";
const themes: { value: ThemeKey }[] = [
{ value: "light" },
{ value: "dark" },
{ value: "system" },
];
const THEME_TEXT: Record<ThemeKey, string> = {
dark: "Dark",
light: "Light",
system: "System",
};
interface ThemeSelectorProps {
value?: string;
onSelect: (value: ThemeKey) => void;
className?: string;
}
export function ThemeSelector({
value,
onSelect,
className,
}: ThemeSelectorProps) {
return (
<div
className={cn("grid grid-cols-1 sm:grid-cols-3 gap-4 w-full", className)}
>
{themes.map((item) => {
const isDark = item.value === "dark";
const isSystem = item.value === "system";
const isActive = value === item.value;
return (
<div
key={item.value}
className={cn(
"border-2 rounded-xl p-1 cursor-pointer transition-all hover:bg-accent/50 space-y-2",
isActive ? "border-primary bg-primary/5" : "border-muted/40",
)}
onClick={() => onSelect(item.value)}
>
<div
className={cn(
"p-2 rounded-lg aspect-4/3 flex flex-col gap-2 relative overflow-hidden border",
isDark
? "bg-slate-950 border-slate-800"
: "bg-white border-slate-200",
isSystem && "bg-linear-to-br from-white to-slate-950",
)}
>
<div
className={cn(
"h-3 w-full rounded-sm shadow-sm opacity-80",
isDark ? "bg-slate-800" : "bg-slate-100",
)}
/>
<div className="flex gap-2 flex-1 relative">
<div
className={cn(
"w-1/4 h-full rounded-sm shadow-sm opacity-80",
isDark ? "bg-slate-800" : "bg-slate-100",
)}
/>
<div className="flex-1 flex flex-col gap-2">
<div
className={cn(
"h-3 w-full rounded-sm shadow-sm opacity-80",
isDark ? "bg-slate-800" : "bg-slate-100",
)}
/>
<div
className={cn(
"flex-1 rounded-sm shadow-sm p-1 space-y-2 opacity-50",
isDark ? "bg-slate-800" : "bg-slate-100",
)}
>
<div
className={cn(
"h-2 w-3/4 rounded-full",
isDark ? "bg-slate-700" : "bg-slate-300",
)}
/>
<div
className={cn(
"h-2 w-full rounded-full",
isDark ? "bg-slate-700" : "bg-slate-300",
)}
/>
</div>
</div>
</div>
</div>
<div className="flex items-center justify-between p-1 px-2">
<span className="font-medium text-sm">
{THEME_TEXT[item.value]}
</span>
<div
className={cn(
"w-4 h-4 rounded-full border flex items-center justify-center transition-all",
isActive
? "border-primary bg-primary"
: "border-muted-foreground/30",
)}
>
{isActive && (
<div className="w-1.5 h-1.5 rounded-full bg-primary-foreground" />
)}
</div>
</div>
</div>
);
})}
</div>
);
}
+2 -2
View File
@@ -1,7 +1,7 @@
// import * as React from "react";
// import EmailLayout from "./email-layout";
// import {Text, Section, Button} from "@react-email/components";
// import type {EventPayload} from "@/features/notifications/notifications.types";
// import type {EventPayload} from "@/features/notifications/types";
//
// export interface EmailNotificationProps {
// payload: EventPayload
@@ -48,7 +48,7 @@
import * as React from "react";
import EmailLayout from "./email-layout";
import {Text, Section, Button} from "@react-email/components";
import type {EventPayload} from "@/features/notifications/notifications.types";
import type {EventPayload} from "@/features/notifications/types";
export interface EmailNotificationProps {
payload: EventPayload;
@@ -0,0 +1 @@
ALTER 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')
);
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+14
View File
@@ -456,6 +456,20 @@
"when": 1781960184847,
"tag": "0064_orange_richard_fisk",
"breakpoints": true
},
{
"idx": 65,
"version": "7",
"when": 1782474059918,
"tag": "0065_mighty_killer_shrike",
"breakpoints": true
},
{
"idx": 66,
"version": "7",
"when": 1782474105293,
"tag": "0066_milky_puma",
"breakpoints": true
}
]
}
+2 -1
View File
@@ -20,10 +20,11 @@ export const setting = pgTable("settings", {
defaultStorageChannelId: uuid('default_storage_channel_id')
.references(() => storageChannel.id, {onDelete: "set null"}),
encryption: boolean("encryption").default(false),
onboarding: boolean("onboarding").default(false).notNull(),
...timestamps
});
export const settingRelations = relations(setting, ({one, many}) => ({
export const settingRelations = relations(setting, ({one}) => ({
storageChannel: one(storageChannel, {fields: [setting.defaultStorageChannelId], references: [storageChannel.id]}),
notificationChannel: one(notificationChannel, {fields: [setting.defaultNotificationChannelId], references: [notificationChannel.id]}),
}));
+33 -35
View File
@@ -1,43 +1,41 @@
import {and, desc, eq, sql} from "drizzle-orm";
import {db} from "@/db";
import {Agent, agent, organizationAgent} from "@/db/schema/08_agent";
import {Database, database} from "@/db/schema/07_database";
"use server";
import { and, desc, eq, sql } from "drizzle-orm";
import { db } from "@/db";
import { Agent, agent, organizationAgent } from "@/db/schema/08_agent";
import { Database, database } from "@/db/schema/07_database";
export async function getOrganizationAgents(organizationId: string) {
return await db
.select({
id: agent.id,
name: agent.name,
organizationId: agent.organizationId,
slug: agent.slug,
healthErrorCount: agent.healthErrorCount,
description: agent.description,
isArchived: agent.isArchived,
lastContact: agent.lastContact,
version: agent.version,
updatedAt: agent.updatedAt,
createdAt: agent.createdAt,
deletedAt: agent.deletedAt,
databases: sql<Database[]>`
return (await db
.select({
id: agent.id,
name: agent.name,
organizationId: agent.organizationId,
slug: agent.slug,
healthErrorCount: agent.healthErrorCount,
description: agent.description,
isArchived: agent.isArchived,
lastContact: agent.lastContact,
version: agent.version,
updatedAt: agent.updatedAt,
createdAt: agent.createdAt,
deletedAt: agent.deletedAt,
databases: sql<Database[]>`
COALESCE(
json_agg(${database}.*) FILTER (WHERE ${database}.id IS NOT NULL),
'[]'
)
`,
})
.from(organizationAgent)
.innerJoin(
agent,
eq(organizationAgent.agentId, agent.id)
)
.leftJoin(database, eq(database.agentId, agent.id))
.groupBy(agent.id)
.orderBy(desc(agent.createdAt))
.where(
and(
eq(organizationAgent.organizationId, organizationId),
eq(agent.isArchived, false)
)
) as unknown as Agent[];
})
.from(organizationAgent)
.innerJoin(agent, eq(organizationAgent.agentId, agent.id))
.leftJoin(database, eq(database.agentId, agent.id))
.groupBy(agent.id)
.orderBy(desc(agent.createdAt))
.where(
and(
eq(organizationAgent.organizationId, organizationId),
eq(agent.isArchived, false),
),
)) as unknown as Agent[];
}
+13 -13
View File
@@ -1,19 +1,19 @@
"use server"
import {eq} from "drizzle-orm";
"use server";
import { eq } from "drizzle-orm";
import * as drizzleDb from "@/db";
import {db} from "@/db";
import { db } from "@/db";
export async function getDatabaseBackups(databaseId: string) {
return await db.query.backup.findMany({
where: eq(drizzleDb.schemas.backup.databaseId, databaseId),
return await db.query.backup.findMany({
where: eq(drizzleDb.schemas.backup.databaseId, databaseId),
with: {
restorations: true,
storages: {
with: {
restorations: true,
storages: {
with: {
storageChannel: true,
},
},
storageChannel: true,
},
orderBy: (b, {desc}) => [desc(b.createdAt)],
});
},
},
orderBy: (b, { desc }) => [desc(b.createdAt)],
});
}
+118 -34
View File
@@ -1,39 +1,123 @@
"use server"
import {db} from "@/db";
import {DatabaseWith} from "@/db/schema/07_database";
import {AgentWith} from "@/db/schema/08_agent";
"use server";
import { inArray } from "drizzle-orm";
import { db } from "@/db";
import { database, retentionPolicy } from "@/db/schema/07_database";
import { alertPolicy } from "@/db/schema/10_alert-policy";
import { storagePolicy } from "@/db/schema/13_storage-policy";
import { DatabaseWith } from "@/db/schema/07_database";
import { AgentWith } from "@/db/schema/08_agent";
import type {
OnboardingDbSettings,
EventKind,
} from "@/features/onboarding/types";
export async function getOrganizationAvailableDatabases(
organizationId: string,
projectId?: string
organizationId: string,
projectId?: string,
) {
const availableDatabases = (await db.query.database.findMany({
where: (db, { eq, or, isNull }) =>
projectId
? or(isNull(db.projectId), eq(db.projectId, projectId))
: isNull(db.projectId),
with: {
agent: {
with: {
organizations: true,
},
},
project: true,
backups: true,
restorations: true,
},
orderBy: (db, { desc }) => [desc(db.createdAt)],
})) as DatabaseWith[];
const availableDatabases = (
await db.query.database.findMany({
where: (db, { eq, or, isNull }) =>
projectId
? or(isNull(db.projectId), eq(db.projectId, projectId))
: isNull(db.projectId),
with: {
agent: {
with: {
organizations: true
}
},
project: true,
backups: true,
restorations: true,
},
orderBy: (db, {desc}) => [desc(db.createdAt)],
})
) as DatabaseWith[];
return availableDatabases.filter(db => {
const agent = db.agent as AgentWith;
if (agent?.isArchived) return false;
return (
agent?.organizationId === organizationId ||
agent?.organizations?.some(org => org.organizationId === organizationId)
);
})
return availableDatabases.filter((db) => {
const agent = db.agent as AgentWith;
if (agent?.isArchived) return false;
return (
agent?.organizationId === organizationId ||
agent?.organizations?.some((org) => org.organizationId === organizationId)
);
});
}
export async function getDatabasesSettings(
databaseIds: string[],
): Promise<Record<string, OnboardingDbSettings>> {
if (databaseIds.length === 0) return {};
const [retentionPolicies, dbs, alertPolicies, storagePolicies] =
await Promise.all([
db
.select()
.from(retentionPolicy)
.where(inArray(retentionPolicy.databaseId, databaseIds)),
db
.select({ id: database.id, backupPolicy: database.backupPolicy })
.from(database)
.where(inArray(database.id, databaseIds)),
db
.select()
.from(alertPolicy)
.where(inArray(alertPolicy.databaseId, databaseIds)),
db
.select()
.from(storagePolicy)
.where(inArray(storagePolicy.databaseId, databaseIds)),
]);
const result: Record<string, OnboardingDbSettings> = {};
for (const dbId of databaseIds) {
const rp = retentionPolicies.find((r) => r.databaseId === dbId);
const dbRow = dbs.find((d) => d.id === dbId);
const alerts = alertPolicies.filter((a) => a.databaseId === dbId);
const storages = storagePolicies.filter((s) => s.databaseId === dbId);
const settings: OnboardingDbSettings = {};
if (rp) {
settings.retention = {
type: rp.type,
count: rp.count ?? 7,
days: rp.days ?? 30,
gfs: {
daily: rp.gfsDaily ?? 7,
weekly: rp.gfsWeekly ?? 4,
monthly: rp.gfsMonthly ?? 12,
yearly: rp.gfsYearly ?? 3,
},
};
}
if (dbRow) {
if (dbRow.backupPolicy) {
settings.backupMethod = "automatic";
settings.backupCron = dbRow.backupPolicy;
} else {
settings.backupMethod = "manual";
}
}
if (alerts.length > 0) {
settings.notificationPolicies = alerts.map((a) => ({
channelId: a.notificationChannelId,
eventKinds: a.eventKinds as EventKind[],
enabled: a.enabled,
}));
}
if (storages.length > 0) {
settings.storagePolicies = storages.map((s) => ({
channelId: s.storageChannelId,
enabled: s.enabled,
}));
}
result[dbId] = settings;
}
return result;
}
+172 -157
View File
@@ -1,185 +1,200 @@
import {db} from "@/db";
"use server";
import { db } from "@/db";
import * as drizzleDb from "@/db";
import {and, eq, gte, isNotNull, lt} from "drizzle-orm";
import {dispatchNotification} from "@/features/notifications/notifications.dispatch";
import {EventPayload} from "@/features/notifications/notifications.types";
import {logger} from "@/lib/logger";
import { and, eq, gte, isNotNull, lt } from "drizzle-orm";
import { dispatchNotification } from "@/features/notifications/utils/notifications.dispatch";
import { EventPayload } from "@/features/notifications/types";
import { logger } from "@/lib/logger";
const log = logger.child({module: "tasks/healthcheck"});
const log = logger.child({ module: "tasks/healthcheck" });
export async function getHealthLast12hLogs({id}: { id: string }) {
const now = new Date()
const since = new Date(now.getTime() - 12 * 60 * 60 * 1000)
export async function getHealthLast12hLogs({ id }: { id: string }) {
const now = new Date();
const since = new Date(now.getTime() - 12 * 60 * 60 * 1000);
return db
.select()
.from(drizzleDb.schemas.healthcheckLog)
.where(
and(
eq(drizzleDb.schemas.healthcheckLog.objectId, id),
gte(drizzleDb.schemas.healthcheckLog.date, since)
)
)
return db
.select()
.from(drizzleDb.schemas.healthcheckLog)
.where(
and(
eq(drizzleDb.schemas.healthcheckLog.objectId, id),
gte(drizzleDb.schemas.healthcheckLog.date, since),
),
);
}
export async function deleteHealthLogsOlderThan12h() {
const now = new Date()
const threshold = new Date(now.getTime() - 12 * 60 * 60 * 1000)
const now = new Date();
const threshold = new Date(now.getTime() - 12 * 60 * 60 * 1000);
const logsToDelete = await db
.select()
.from(drizzleDb.schemas.healthcheckLog)
.where(
lt(drizzleDb.schemas.healthcheckLog.date, threshold)
)
const logsToDelete = await db
.select()
.from(drizzleDb.schemas.healthcheckLog)
.where(lt(drizzleDb.schemas.healthcheckLog.date, threshold));
log.info({name: "deleteHealthLogsOlderThan12h"},`Number of logs found to delete: ${logsToDelete.length}`)
log.info(
{ name: "deleteHealthLogsOlderThan12h" },
`Number of logs found to delete: ${logsToDelete.length}`,
);
await db
.delete(drizzleDb.schemas.healthcheckLog)
.where(
lt(drizzleDb.schemas.healthcheckLog.date, threshold)
)
await db
.delete(drizzleDb.schemas.healthcheckLog)
.where(lt(drizzleDb.schemas.healthcheckLog.date, threshold));
return logsToDelete.length
return logsToDelete.length;
}
export async function checkAgentsHealthError() {
const agents = await db.query.agent.findMany({
where: isNotNull(drizzleDb.schemas.agent.lastContact),
});
const agents = await db.query.agent.findMany({
where: isNotNull(drizzleDb.schemas.agent.lastContact),
});
const settings = await db.query.setting.findFirst({
where: (fields, {eq}) => eq(fields.name, "system"),
});
const settings = await db.query.setting.findFirst({
where: (fields, { eq }) => eq(fields.name, "system"),
});
if (!settings) {
throw new Error("System settings not found");
}
if (!settings.defaultNotificationChannelId) {
log.error({name: "checkAgentsHealthError"},`No default notification channel id found.`)
return
}
const now = new Date();
for (const agent of agents) {
if (!agent.lastContact) continue;
const lastContactDate = new Date(agent.lastContact);
const diffMinutes = (now.getTime() - lastContactDate.getTime()) / 1000 / 60;
if (diffMinutes > 10) {
if ((agent.healthErrorCount ?? 0) < 3) {
const newHealthErrorCount = (agent.healthErrorCount ?? 0) + 1
await db.update(drizzleDb.schemas.agent)
.set({
healthErrorCount: newHealthErrorCount,
})
.where(eq(drizzleDb.schemas.agent.id, agent.id));
const payload: EventPayload = {
title: "Agent down",
message: `Agent ${agent.name} is down, (notification number: ${newHealthErrorCount}/3)`,
level: "critical",
event: "error_health_agent",
data: {
agent: agent.name,
id: agent.id,
error: "Agent is down",
},
};
log.info({name: "checkAgentsHealthError", payload: payload},`Agent Healthcheck Notification`)
await dispatchNotification(
payload,
undefined,
settings.defaultNotificationChannelId,
undefined
);
}
}
if (!settings) {
throw new Error("System settings not found");
}
if (!settings.defaultNotificationChannelId) {
log.error(
{ name: "checkAgentsHealthError" },
`No default notification channel id found.`,
);
return;
}
const now = new Date();
for (const agent of agents) {
if (!agent.lastContact) continue;
const lastContactDate = new Date(agent.lastContact);
const diffMinutes = (now.getTime() - lastContactDate.getTime()) / 1000 / 60;
if (diffMinutes > 10) {
if ((agent.healthErrorCount ?? 0) < 3) {
const newHealthErrorCount = (agent.healthErrorCount ?? 0) + 1;
await db
.update(drizzleDb.schemas.agent)
.set({
healthErrorCount: newHealthErrorCount,
})
.where(eq(drizzleDb.schemas.agent.id, agent.id));
const payload: EventPayload = {
title: "Agent down",
message: `Agent ${agent.name} is down, (notification number: ${newHealthErrorCount}/3)`,
level: "critical",
event: "error_health_agent",
data: {
agent: agent.name,
id: agent.id,
error: "Agent is down",
},
};
log.info(
{ name: "checkAgentsHealthError", payload: payload },
`Agent Healthcheck Notification`,
);
await dispatchNotification(
payload,
undefined,
settings.defaultNotificationChannelId,
undefined,
);
}
}
}
}
export async function checkDatabasesHealthError() {
const databases = await db.query.database.findMany({
where: isNotNull(drizzleDb.schemas.database.lastContact),
with: {
agent: true,
alertPolicies: true,
},
});
const databases = await db.query.database.findMany({
where: isNotNull(drizzleDb.schemas.database.lastContact),
with: {
agent: true,
alertPolicies: true
const now = new Date();
for (const database of databases) {
if (!database.lastContact) continue;
const lastContactDate = new Date(database.lastContact);
const diffMinutes = (now.getTime() - lastContactDate.getTime()) / 1000 / 60;
if (diffMinutes > 10) {
if ((database.healthErrorCount ?? 0) < 3) {
const newHealthErrorCount = (database.healthErrorCount ?? 0) + 1;
await db
.update(drizzleDb.schemas.database)
.set({
healthErrorCount: newHealthErrorCount,
})
.where(eq(drizzleDb.schemas.database.id, database.id));
const settings = await db.query.setting.findFirst({
where: eq(drizzleDb.schemas.setting.name, "system"),
with: { notificationChannel: true },
});
const defaultPolicy = settings?.notificationChannel
? [
{
id: null,
notificationChannelId: settings.notificationChannel.id,
enabled: settings.notificationChannel.enabled,
eventKinds: ["error_health_database"],
},
]
: [];
const policiesToUse =
database.alertPolicies && database.alertPolicies.length > 0
? database.alertPolicies.filter(
(policy) =>
policy.enabled &&
policy.eventKinds.includes("error_health_database"),
)
: defaultPolicy;
if (!policiesToUse || policiesToUse.length === 0) {
continue;
}
})
const now = new Date();
const promises = policiesToUse.map((alertPolicy) => {
const payload: EventPayload = {
title: "Database down",
message: `Database ${database.name} is down, (notification number: ${newHealthErrorCount}/3)`,
level: "critical",
event: "error_health_database",
data: {
agent: database.name,
id: database.id,
error: "Database is down",
},
};
for (const database of databases) {
if (!database.lastContact) continue;
log.info(
{ name: "checkDatabasesHealthError", payload: payload },
`Database Healthcheck Notification`,
);
const lastContactDate = new Date(database.lastContact);
const diffMinutes = (now.getTime() - lastContactDate.getTime()) / 1000 / 60;
return dispatchNotification(
payload,
alertPolicy.id == null ? undefined : alertPolicy.id,
alertPolicy.id ? undefined : alertPolicy.notificationChannelId,
undefined,
);
});
if (diffMinutes > 10) {
if ((database.healthErrorCount ?? 0) < 3) {
const newHealthErrorCount = (database.healthErrorCount ?? 0) + 1
await db.update(drizzleDb.schemas.database)
.set({
healthErrorCount: newHealthErrorCount,
})
.where(eq(drizzleDb.schemas.database.id, database.id));
const settings = await db.query.setting.findFirst({
where: eq(drizzleDb.schemas.setting.name, "system"),
with: { notificationChannel: true },
});
const defaultPolicy = settings?.notificationChannel
? [{
id: null,
notificationChannelId: settings.notificationChannel.id,
enabled: settings.notificationChannel.enabled,
eventKinds: ["error_health_database"]
}]
: [];
const policiesToUse = (database.alertPolicies && database.alertPolicies.length > 0)
? database.alertPolicies.filter(policy => policy.enabled && policy.eventKinds.includes("error_health_database"))
: defaultPolicy;
if (!policiesToUse || policiesToUse.length === 0) {
continue
}
const promises = policiesToUse.map(alertPolicy => {
const payload: EventPayload = {
title: "Database down",
message: `Database ${database.name} is down, (notification number: ${newHealthErrorCount}/3)`,
level: "critical",
event: "error_health_database",
data: {
agent: database.name,
id: database.id,
error: "Database is down",
},
};
log.info({name: "checkDatabasesHealthError", payload: payload},`Database Healthcheck Notification`)
return dispatchNotification(payload, alertPolicy.id == null ? undefined : alertPolicy.id, alertPolicy.id ? undefined : alertPolicy.notificationChannelId, undefined);
});
await Promise.all(promises);
}
}
await Promise.all(promises);
}
}
}
}
+60 -28
View File
@@ -1,30 +1,62 @@
import {desc, eq} from "drizzle-orm";
import {db} from "@/db";
import {
NotificationChannel,
notificationChannel,
organizationNotificationChannel
} from "@/db/schema/09_notification-channel";
import {storageChannel} from "@/db/schema/12_storage-channel";
"use server";
export async function getOrganizationChannels(organizationId: string) {
return await db
.select({
id: notificationChannel.id,
name: notificationChannel.name,
provider: notificationChannel.provider,
config: notificationChannel.config,
enabled: notificationChannel.enabled,
updatedAt: notificationChannel.updatedAt,
createdAt: notificationChannel.createdAt,
deletedAt: notificationChannel.deletedAt,
organizationId: notificationChannel.organizationId
})
.from(organizationNotificationChannel)
.innerJoin(
notificationChannel,
eq(organizationNotificationChannel.notificationChannelId, notificationChannel.id)
)
.orderBy(desc(notificationChannel.createdAt))
.where(eq(organizationNotificationChannel.organizationId, organizationId)) as unknown as NotificationChannel[];
import { desc, eq, isNull } from "drizzle-orm";
import { db } from "@/db";
import {
NotificationChannel,
notificationChannel,
organizationNotificationChannel,
} from "@/db/schema/09_notification-channel";
export async function getOrganizationChannels(
organizationId: string,
): Promise<NotificationChannel[]> {
const [orgChannels, systemChannels] = await Promise.all([
db
.select({
id: notificationChannel.id,
name: notificationChannel.name,
provider: notificationChannel.provider,
config: notificationChannel.config,
enabled: notificationChannel.enabled,
updatedAt: notificationChannel.updatedAt,
createdAt: notificationChannel.createdAt,
deletedAt: notificationChannel.deletedAt,
organizationId: notificationChannel.organizationId,
})
.from(organizationNotificationChannel)
.innerJoin(
notificationChannel,
eq(
organizationNotificationChannel.notificationChannelId,
notificationChannel.id,
),
)
.orderBy(desc(notificationChannel.createdAt))
.where(
eq(organizationNotificationChannel.organizationId, organizationId),
),
db
.select({
id: notificationChannel.id,
name: notificationChannel.name,
provider: notificationChannel.provider,
config: notificationChannel.config,
enabled: notificationChannel.enabled,
updatedAt: notificationChannel.updatedAt,
createdAt: notificationChannel.createdAt,
deletedAt: notificationChannel.deletedAt,
organizationId: notificationChannel.organizationId,
})
.from(notificationChannel)
.orderBy(desc(notificationChannel.createdAt))
.where(isNull(notificationChannel.organizationId)),
]);
const seen = new Set<string>();
return [...orgChannels, ...systemChannels].filter((c) => {
if (seen.has(c.id)) return false;
seen.add(c.id);
return true;
}) as NotificationChannel[];
}
+83 -73
View File
@@ -1,81 +1,91 @@
import {and, desc, eq, gte, lte} from 'drizzle-orm';
import {NotificationLevel, notificationLog} from "@/db/schema/11_notification-log";
import {notificationChannel} from "@/db/schema/09_notification-channel";
import {db} from "@/db";
import {Json} from "drizzle-zod";
"use server";
import { and, desc, eq, gte, lte } from "drizzle-orm";
import {
NotificationLevel,
notificationLog,
} from "@/db/schema/11_notification-log";
import { notificationChannel } from "@/db/schema/09_notification-channel";
import { db } from "@/db";
import { Json } from "drizzle-zod";
export type NotificationLogWithRelations = {
id: string;
id: string;
title: string;
level: NotificationLevel;
success: boolean;
error: string | null;
sentAt: Date;
payload: Json | null;
content: {
title: string;
level: NotificationLevel;
success: boolean;
error: string | null;
sentAt: Date;
payload: Json | null;
content: {
title: string;
message: string;
},
channel: {
name: string;
provider: string;
} | null;
policy: {
event: string | null;
} | null;
message: string;
};
channel: {
name: string;
provider: string;
} | null;
policy: {
event: string | null;
} | null;
};
export async function getNotificationHistory(
filters?: {
channelId?: string;
policyId?: string;
organizationId?: string;
level?: NotificationLevel;
success?: boolean;
from?: Date;
to?: Date;
limit?: number;
}
): Promise<NotificationLogWithRelations[]> {
const where = [];
if (filters?.channelId) where.push(eq(notificationLog.channelId, filters.channelId));
if (filters?.policyId) where.push(eq(notificationLog.policyId, filters.policyId));
if (filters?.organizationId) where.push(eq(notificationLog.organizationId, filters.organizationId));
if (filters?.level) where.push(eq(notificationLog.level, filters.level));
if (typeof filters?.success === 'boolean') where.push(eq(notificationLog.success, filters.success));
if (filters?.from) where.push(gte(notificationLog.sentAt, filters.from));
if (filters?.to) where.push(lte(notificationLog.sentAt, filters.to));
export async function getNotificationHistory(filters?: {
channelId?: string;
policyId?: string;
organizationId?: string;
level?: NotificationLevel;
success?: boolean;
from?: Date;
to?: Date;
limit?: number;
}): Promise<NotificationLogWithRelations[]> {
const where = [];
if (filters?.channelId)
where.push(eq(notificationLog.channelId, filters.channelId));
if (filters?.policyId)
where.push(eq(notificationLog.policyId, filters.policyId));
if (filters?.organizationId)
where.push(eq(notificationLog.organizationId, filters.organizationId));
if (filters?.level) where.push(eq(notificationLog.level, filters.level));
if (typeof filters?.success === "boolean")
where.push(eq(notificationLog.success, filters.success));
if (filters?.from) where.push(gte(notificationLog.sentAt, filters.from));
if (filters?.to) where.push(lte(notificationLog.sentAt, filters.to));
const rows = await db
.select({
id: notificationLog.id,
title: notificationLog.title,
level: notificationLog.level,
success: notificationLog.success,
error: notificationLog.error,
sentAt: notificationLog.sentAt,
payload: notificationLog.payload,
content: {
title: notificationLog.title,
message: notificationLog.message,
},
channel: {
name: notificationLog.providerName,
provider: notificationLog.provider,
},
policy: {
event: notificationLog.event,
},
})
.from(notificationLog)
.leftJoin(notificationChannel, eq(notificationLog.channelId, notificationChannel.id))
// .leftJoin(alertPolicy, eq(notificationLog.policyId, alertPolicy.id))
.where(and(...where))
.orderBy(desc(notificationLog.sentAt))
.limit(filters?.limit || 100);
const rows = await db
.select({
id: notificationLog.id,
title: notificationLog.title,
level: notificationLog.level,
success: notificationLog.success,
error: notificationLog.error,
sentAt: notificationLog.sentAt,
payload: notificationLog.payload,
content: {
title: notificationLog.title,
message: notificationLog.message,
},
channel: {
name: notificationLog.providerName,
provider: notificationLog.provider,
},
policy: {
event: notificationLog.event,
},
})
.from(notificationLog)
.leftJoin(
notificationChannel,
eq(notificationLog.channelId, notificationChannel.id),
)
// .leftJoin(alertPolicy, eq(notificationLog.policyId, alertPolicy.id))
.where(and(...where))
.orderBy(desc(notificationLog.sentAt))
.limit(filters?.limit || 100);
return rows.map(row => ({
...row,
payload: row.payload as Json,
}));
return rows.map((row) => ({
...row,
payload: row.payload as Json,
}));
}
+29
View File
@@ -0,0 +1,29 @@
"use server";
import { db } from "@/db";
import { and, eq, inArray, ne } from "drizzle-orm";
import { member } from "@/db/schema/04_member";
import { organization } from "@/db/schema/03_organization";
export async function getUserOrganization(userId: string) {
const memberRow = await db.query.member.findFirst({
columns: { organizationId: true },
where: eq(member.userId, userId),
});
if (!memberRow) return null;
return db.query.organization.findFirst({
where: eq(organization.id, memberRow.organizationId),
});
}
export async function getUserOwnOrganization(userId: string) {
const memberRows = await db.query.member.findMany({
columns: { organizationId: true },
where: eq(member.userId, userId),
});
if (!memberRows.length) return null;
const orgIds = memberRows.map((r) => r.organizationId);
return db.query.organization.findFirst({
where: and(inArray(organization.id, orgIds), ne(organization.slug, "default")),
});
}
+70 -54
View File
@@ -1,61 +1,77 @@
import {getOrganization} from "@/lib/auth/auth";
import {db} from "@/db";
import {and, eq} from "drizzle-orm";
"use server";
import { getOrganization } from "@/lib/auth/auth";
import { db } from "@/db";
import { and, eq } from "drizzle-orm";
import * as drizzleDb from "@/db";
import { project } from "@/db/schema/06_project";
export async function getOrganizationProject(organizationId: string) {
return db.query.project.findFirst({
where: eq(project.organizationId, organizationId),
with: {
databases: true,
},
});
}
export const getOrganizationProjectDatabases = async ({organizationSlug, projectId}: {
organizationSlug: string, projectId: string
export const getOrganizationProjectDatabases = async ({
organizationSlug,
projectId,
}: {
organizationSlug: string;
projectId: string;
}) => {
try {
try {
const organization = await getOrganization({});
const organization = await getOrganization({});
if (!organization) {
return {
name: "ErrorGettingOrganizationProjectDatabases",
message: "No organization found.",
status: 400,
cause: "Unknown error occurred.",
};
}
const databasesProject = await db.query.project.findFirst({
where: and(eq(drizzleDb.schemas.project.organizationId, organization.id), eq(drizzleDb.schemas.project.id, projectId)),
with: {
databases: true
}
});
if (!databasesProject) {
return {
name: "ErrorGettingOrganizationProjectDatabases",
message: "No organization found.",
status: 400,
cause: "Unknown error occurred.",
};
}
return {
data: databasesProject.databases,
ids: databasesProject.databases.map((project) => project.id)
}
} catch (e: any) {
const errorMessage = e?.response?.data?.message || e?.message || "Unknown auth error";
const status = e?.response?.status || 500;
console.error("API GettingOrganizationProjectDatabases error:", {
message: errorMessage,
status,
raw: e,
});
throw {
name: "ErrorGettingOrganizationProjectDatabases",
message: errorMessage,
status,
cause: e,
};
if (!organization) {
return {
name: "ErrorGettingOrganizationProjectDatabases",
message: "No organization found.",
status: 400,
cause: "Unknown error occurred.",
};
}
const databasesProject = await db.query.project.findFirst({
where: and(
eq(drizzleDb.schemas.project.organizationId, organization.id),
eq(drizzleDb.schemas.project.id, projectId),
),
with: {
databases: true,
},
});
if (!databasesProject) {
return {
name: "ErrorGettingOrganizationProjectDatabases",
message: "No organization found.",
status: 400,
cause: "Unknown error occurred.",
};
}
return {
data: databasesProject.databases,
ids: databasesProject.databases.map((project) => project.id),
};
} catch (e: any) {
const errorMessage =
e?.response?.data?.message || e?.message || "Unknown auth error";
const status = e?.response?.status || 500;
console.error("API GettingOrganizationProjectDatabases error:", {
message: errorMessage,
status,
raw: e,
});
throw {
name: "ErrorGettingOrganizationProjectDatabases",
message: errorMessage,
status,
cause: e,
};
}
};
+12
View File
@@ -0,0 +1,12 @@
"use server";
import { db } from "@/db";
export async function getSettings() {
return db.query.setting.findFirst();
}
export async function isOnboardingDone(): Promise<boolean> {
const settings = await db.query.setting.findFirst();
return settings?.onboarding ?? false;
}
+55 -23
View File
@@ -1,25 +1,57 @@
import {desc, eq} from "drizzle-orm";
import {db} from "@/db";
import {organizationStorageChannel, StorageChannel, storageChannel} from "@/db/schema/12_storage-channel";
"use server";
export async function getOrganizationStorageChannels(organizationId: string) {
return await db
.select({
id: storageChannel.id,
name: storageChannel.name,
provider: storageChannel.provider,
organizationId: storageChannel.organizationId,
config: storageChannel.config,
enabled: storageChannel.enabled,
updatedAt: storageChannel.updatedAt,
createdAt: storageChannel.createdAt,
deletedAt: storageChannel.deletedAt,
})
.from(organizationStorageChannel)
.innerJoin(
storageChannel,
eq(organizationStorageChannel.storageChannelId, storageChannel.id)
)
.orderBy(desc(storageChannel.createdAt))
.where(eq(organizationStorageChannel.organizationId, organizationId)) as unknown as StorageChannel[];
import { desc, eq, isNull } from "drizzle-orm";
import { db } from "@/db";
import {
organizationStorageChannel,
StorageChannel,
storageChannel,
} from "@/db/schema/12_storage-channel";
export async function getOrganizationStorageChannels(
organizationId: string,
): Promise<StorageChannel[]> {
const [orgChannels, systemChannels] = await Promise.all([
db
.select({
id: storageChannel.id,
name: storageChannel.name,
provider: storageChannel.provider,
organizationId: storageChannel.organizationId,
config: storageChannel.config,
enabled: storageChannel.enabled,
updatedAt: storageChannel.updatedAt,
createdAt: storageChannel.createdAt,
deletedAt: storageChannel.deletedAt,
})
.from(organizationStorageChannel)
.innerJoin(
storageChannel,
eq(organizationStorageChannel.storageChannelId, storageChannel.id),
)
.orderBy(desc(storageChannel.createdAt))
.where(eq(organizationStorageChannel.organizationId, organizationId)),
db
.select({
id: storageChannel.id,
name: storageChannel.name,
provider: storageChannel.provider,
organizationId: storageChannel.organizationId,
config: storageChannel.config,
enabled: storageChannel.enabled,
updatedAt: storageChannel.updatedAt,
createdAt: storageChannel.createdAt,
deletedAt: storageChannel.deletedAt,
})
.from(storageChannel)
.orderBy(desc(storageChannel.createdAt))
.where(isNull(storageChannel.organizationId)),
]);
const seen = new Set<string>();
return [...orgChannels, ...systemChannels].filter((c) => {
if (seen.has(c.id)) return false;
seen.add(c.id);
return true;
}) as StorageChannel[];
}
+41 -32
View File
@@ -1,40 +1,49 @@
import {SignUpUser} from "@/types/auth";
import {hashPassword} from "better-auth/crypto";
import {db} from "@/db";
import * as drizzleDb from "@/db";
import {User, UserThemeEnum} from "@/db/schema/02_user";
import {assertValidPassword} from "@/utils/password";
"use server";
import { SignUpUser } from "@/types/auth";
import { hashPassword } from "better-auth/crypto";
import { db } from "@/db";
import * as drizzleDb from "@/db";
import { User, UserThemeEnum } from "@/db/schema/02_user";
import { assertValidPassword } from "@/utils/password";
export async function hasUsers(): Promise<boolean> {
const result = await db.select().from(drizzleDb.schemas.user).limit(1);
return result.length > 0;
}
export async function createUserDb(data: SignUpUser): Promise<User> {
assertValidPassword(data.password);
assertValidPassword(data.password);
const now = new Date();
const userId = crypto.randomUUID();
const now = new Date();
const userId = crypto.randomUUID();
const [newUser] = await db.insert(drizzleDb.schemas.user).values({
...data,
id: userId,
name: data.name,
email: data.email,
emailVerified: true,
role: data.role,
createdAt: now,
updatedAt: now,
theme: data.theme as UserThemeEnum,
}).returning();
const [newUser] = await db
.insert(drizzleDb.schemas.user)
.values({
...data,
id: userId,
name: data.name,
email: data.email,
emailVerified: true,
role: data.role,
createdAt: now,
updatedAt: now,
theme: data.theme as UserThemeEnum,
})
.returning();
if (data.password) {
const hashedPassword = await hashPassword(data.password);
await db.insert(drizzleDb.schemas.account).values({
providerId: "credential",
accountId: userId,
userId: userId,
password: hashedPassword,
createdAt: now,
updatedAt: now,
});
}
if (data.password) {
const hashedPassword = await hashPassword(data.password);
await db.insert(drizzleDb.schemas.account).values({
providerId: "credential",
accountId: userId,
userId: userId,
password: hashedPassword,
createdAt: now,
updatedAt: now,
});
}
return newUser
return newUser;
}
+4
View File
@@ -88,6 +88,8 @@ export const env = createEnv({
ALLOWED_GROUP: z.string().optional(),
SKIP_ONBOARDING: z.string().optional().default("false"),
AUTH_EMAIL_PASSWORD_ENABLED: z.string().optional().default("true"),
AUTH_SIGNUP_ENABLED: z.string().optional().default("true"),
AUTH_PASSKEY_ENABLED: z.string().optional().default("false"),
@@ -174,6 +176,8 @@ export const env = createEnv({
ALLOWED_GROUP: process.env.ALLOWED_GROUP,
SKIP_ONBOARDING: process.env.SKIP_ONBOARDING,
AUTH_EMAIL_PASSWORD_ENABLED: process.env.AUTH_EMAIL_PASSWORD_ENABLED,
AUTH_SIGNUP_ENABLED: process.env.AUTH_SIGNUP_ENABLED,
AUTH_PASSKEY_ENABLED: process.env.AUTH_PASSKEY_ENABLED,
@@ -1,6 +1,6 @@
"use server";
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 {eq, and, ne, count, desc} from "drizzle-orm";
import {db} from "@/db";
@@ -11,7 +11,7 @@ import {Badge} from "@/components/ui/badge";
import {truncateWords} from "@/utils/text";
import {useIsMobile} from "@/hooks/use-mobile";
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 = {
data: AgentWith;
@@ -3,10 +3,10 @@
import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card";
import {Server} from "lucide-react";
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 {useQuery} from "@tanstack/react-query";
import {getAgentAction} from "@/features/agents/agents.action";
import {getAgentAction} from "@/features/agents/actions/agents.action";
import {
Accordion,
AccordionContent,
@@ -16,8 +16,8 @@ import {
import {Separator} from "@/components/ui/separator";
import {Badge} from "@/components/ui/badge";
import {CardsWithPagination} from "@/components/common/cards-with-pagination";
import {AgentDatabaseCard} from "@/features/agents/agent-database-card";
import {HealthCheckGraph} from "@/features/database/health-grid";
import {AgentDatabaseCard} from "@/features/agents/components/agent-database-card";
import {HealthCheckGraph} from "@/features/database/components/health-grid";
import {HealthcheckLog} from "@/db/schema/15_healthcheck-log";
type AgentContentPageProps = {
@@ -5,7 +5,7 @@ import {ButtonWithConfirm} from "@/components/common/button-with-confirm";
import {useMutation} from "@tanstack/react-query";
import {useRouter} from "next/navigation";
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";
export type ButtonDeleteAgentProps = {
@@ -7,7 +7,7 @@ import {
DialogTitle,
DialogTrigger,
} 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 {Plus} from "lucide-react";
import {GearIcon} from "@radix-ui/react-icons";
@@ -16,7 +16,7 @@ import {useState} from "react";
import {useRouter} from "next/navigation";
import {OrganizationWithMembers} from "@/db/schema/03_organization";
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";
type AgentDialogProps = {
@@ -15,9 +15,9 @@ import {Button} from "@/components/ui/button";
import {useRouter} from "next/navigation";
import {useMutation, useQueryClient} from "@tanstack/react-query";
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 {createAgentAction, updateAgentAction} from "@/features/agents/agents.action";
import {createAgentAction, updateAgentAction} from "@/features/agents/actions/agents.action";
import {OrganizationWithMembers} from "@/db/schema/03_organization";
export type agentFormProps = {
@@ -8,8 +8,8 @@ import {OrganizationWithMembers} from "@/db/schema/03_organization";
import {MultiSelect} from "@/components/common/multi-select";
import {toast} from "sonner";
import {AgentWith} from "@/db/schema/08_agent";
import {AgentOrganizationSchema, AgentOrganizationType} from "@/features/agents/agent-organizations.schema";
import {updateAgentOrganizationsAction} from "@/features/agents/agent-organizations.action";
import {AgentOrganizationSchema, AgentOrganizationType} from "@/features/agents/schemas/agent-organizations.schema";
import {updateAgentOrganizationsAction} from "@/features/agents/actions/agent-organizations.action";
type AgentOrganisationFormProps = {
@@ -9,7 +9,7 @@ import { Form } from "@/components/ui/form";
import { requestPasswordReset } from "@/lib/auth/auth-client";
import { ButtonWithLoading } from "@/components/common/button-with-loading";
import Link from "next/link";
import { ForgotPasswordSchema, ForgotPasswordType } from "./forgot-password-form.schema";
import { ForgotPasswordSchema, ForgotPasswordType } from "../schemas/forgot-password-form.schema";
import { ArrowLeft } from "lucide-react";
export type ForgotPasswordFormProps = {
@@ -2,7 +2,7 @@
import { toast } from "sonner";
import { useRouter, useSearchParams } from "next/navigation";
import TwoFactorForm from "@/features/profile/2fa-form";
import TwoFactorForm from "@/features/profile/components/2fa-form";
export const GuardForm = () => {
const router = useRouter();
@@ -19,7 +19,7 @@ import { PasswordInput } from "@/components/ui/password-input";
import {
LoginSchema,
type LoginType,
} from "@/features/auth/login-form.schema";
} from "@/features/auth/schemas/login-form.schema";
import { ButtonWithLoading } from "@/components/common/button-with-loading";
import { signIn } from "@/lib/auth/auth-client";
@@ -14,10 +14,10 @@ import {
import {
RegisterSchema,
RegisterType,
} from "@/features/auth/register-form.schema";
} from "@/features/auth/schemas/register-form.schema";
import { signUp } from "@/lib/auth/auth-client";
import { useZodForm } from "@/components/ui/form";
import { CardAuth } from "@/features/layout/card-auth";
import { CardAuth } from "@/features/layout/components/card-auth";
import { FormField } from "@/components/ui/form";
import { FormItem } from "@/components/ui/form";
import { FormLabel } from "@/components/ui/form";
@@ -7,7 +7,7 @@ import {FormControl, FormField, FormItem, FormLabel, useZodForm} from "@/compone
import {Form} from "@/components/ui/form";
import {ButtonWithLoading} from "@/components/common/button-with-loading";
import Link from "next/link";
import {ResetPasswordSchema, ResetPasswordType} from "./reset-password-form.schema";
import {ResetPasswordSchema, ResetPasswordType} from "../schemas/reset-password-form.schema";
import {PasswordStrengthInput} from "@/components/ui/password-input-indicator";
import {useRouter, useSearchParams} from "next/navigation";
import {ArrowLeft} from "lucide-react";
@@ -0,0 +1,104 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { authClient, passkey } from "@/lib/auth/auth-client";
import { Loader2 } from "lucide-react";
import { toast } from "sonner";
import { Icon } from "@iconify/react";
import { AuthProviderConfig } from "@/lib/auth/config";
import Image from "next/image";
import { useRouter } from "next/navigation";
export function SocialAuthButtons({
providers,
}: {
providers: AuthProviderConfig[];
}) {
const socialProviders = providers.filter(
(p) => p.isActive && p.type !== "credential",
);
const router = useRouter();
const [isLoading, setIsLoading] = useState<string | null>(null);
const handleSocialSignIn = async (provider: AuthProviderConfig) => {
setIsLoading(provider.id);
try {
let result;
if (provider.id === "passkey") {
result = await authClient.signIn.passkey({
fetchOptions: {
onSuccess() {
router.push("/dashboard");
},
onError() {
toast.error(
"An error occurred during passkey authentication. Please try again.",
);
},
},
});
} else if (provider.type === "sso") {
result = await authClient.signIn.sso({
providerId: provider.id,
providerType: "oidc",
callbackURL: "/dashboard",
});
} else {
result = await authClient.signIn.social({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
provider: provider.id as any,
callbackURL: "/dashboard",
});
}
if (result?.error) {
toast.error(
"An error occurred while signing in with the provider. Please try again.",
);
} else if (provider.id !== "passkey") {
toast.success("Redirecting to provider...");
}
} catch (err) {
toast.error(
"An error occurred while signing in with the provider. Please try again.",
);
} finally {
setIsLoading(null);
}
};
if (socialProviders.length === 0) return null;
return (
<div className="flex flex-col gap-2 w-full">
{socialProviders.map((provider) => (
<Button
key={provider.id}
variant="outline"
className="w-full gap-2"
onClick={() => handleSocialSignIn(provider)}
disabled={!!isLoading}
>
{isLoading === provider.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : provider.icon.startsWith("/") ||
provider.icon.startsWith("http") ? (
<Image
src={provider.icon}
alt={provider.id || "icon"}
width={16}
height={16}
className="h-4 w-4"
unoptimized={provider.icon.startsWith("http")}
/>
) : (
<Icon icon={provider.icon} className="h-4 w-4" />
)}
<span>{provider.title || provider.name}</span>
</Button>
))}
</div>
);
}

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