Compare commits

...

19 Commits

Author SHA1 Message Date
Mauricio Siu
98a586478e chore: bump version to v0.29.1 in package.json 2026-04-19 12:07:02 -06:00
Mauricio Siu
13248c8d8a Merge pull request #4257 from colocated/fix/4256-preview-deployment-too-many-args
fix: preview deployments broken on v0.29.0 — postgres 100-arg limit
2026-04-19 12:06:17 -06:00
Jack
54417ca8e7 fix: limit application columns in findPreviewDeploymentById to avoid postgres 100-arg limit
Closes #4256
2026-04-19 11:14:47 +01:00
Mauricio Siu
b392e58001 Merge pull request #4244 from Dokploy/feat/dashboard-home
feat: add dashboard home page
2026-04-17 22:40:50 -06:00
Mauricio Siu
d9945c0a4f style: update ShowHome component layout for improved responsiveness
- Adjusted the Card component to have a minimum height of 85vh for better visual consistency.
- Ensured the inner div has a full height to enhance the layout structure.
2026-04-17 22:24:08 -06:00
autofix-ci[bot]
f6e2c033ba [autofix.ci] apply automated fixes 2026-04-18 04:18:44 +00:00
Mauricio Siu
5c787adae1 feat: implement homeStats query for dashboard overview
- Replace individual project and server queries with a consolidated homeStats query to streamline data retrieval for the dashboard.
- Update the ShowHome component to utilize homeStats for displaying project, environment, application, and service counts, along with their status breakdown.
- Enhance data handling for user permissions to ensure accurate statistics based on user access levels.
2026-04-17 22:18:14 -06:00
Mauricio Siu
2ba1df1eaa feat: refine home page and fix libsql in bulk actions
- Home: 4 KPI cards (projects, services, deploys/7d, status list),
  server column with icon in recent deployments, empty state with
  icon, dashboard card frame to match other pages.
- Include libsql in project services count sort.
- Fix bulk actions in environment page: libsql was missing from
  start, stop, move, delete and deploy handlers.
2026-04-17 22:11:04 -06:00
autofix-ci[bot]
e7859395b1 [autofix.ci] apply automated fixes 2026-04-18 03:37:12 +00:00
Mauricio Siu
6f0ed89ce7 feat: add dashboard home page with overview and recent deployments
Adds a new /dashboard/home landing with welcome header, KPI cards
(deploys/24h, build, CPU, memory) and a recent deployments list.

Home is now the post-login landing and the destination for permission
fallback redirects across the app. Projects remains accessible from
the sidebar.
2026-04-17 21:36:37 -06:00
Mauricio Siu
4277a509b2 Merge pull request #4241 from sancho1952007/patch-1
style: Fix typo in custom entrypoint description
2026-04-17 21:06:48 -06:00
Sancho Godinho
f7b576cbf3 Fix typo in custom entrypoint description 2026-04-18 04:23:15 +05:30
Mauricio Siu
425fef6e28 fix: remove 'v' prefix from version in synchronization workflow
Update the version retrieval command in the GitHub Actions workflow to strip the 'v' prefix from the version number in package.json. This change ensures that the version format is consistent for downstream processes.
2026-04-17 14:49:14 -06:00
Mauricio Siu
958372c5f9 chore: update paths in version synchronization workflow for MCP and CLI repositories
Modify the GitHub Actions workflow to clone the MCP and CLI repositories into temporary directories instead of the current directory. This change improves the organization of the workflow and ensures that the latest OpenAPI specification is correctly referenced during the synchronization process.
2026-04-17 14:46:20 -06:00
Mauricio Siu
e7c581476e feat: add workflow dispatch trigger to version synchronization workflow
Enhance the GitHub Actions workflow by adding a workflow_dispatch trigger, allowing manual execution of the version synchronization process. This provides greater flexibility in managing version updates for MCP and CLI repositories.
2026-04-17 14:44:04 -06:00
Mauricio Siu
0cae8330e2 chore: adjust version bump timing in synchronization workflow
Update the GitHub Actions workflow to bump the version in package.json after installing dependencies, ensuring that the version is not overwritten by pnpm install. This change enhances the reliability of version synchronization for both MCP and CLI repositories.
2026-04-17 14:42:14 -06:00
Mauricio Siu
4a271c11e7 Merge pull request #4239 from Dokploy/feat/resend-verification-email-on-signin
feat: resend verification email on sign-in and improve template
2026-04-17 14:02:01 -06:00
Mauricio Siu
fda367b2c5 fix: update logger configuration to disable in production environment
Change the logger's disabled property to be dependent on the NODE_ENV variable, ensuring logging is disabled in production for improved performance and security.
2026-04-17 14:01:46 -06:00
Mauricio Siu
ea1238b1d1 feat: resend verification email on sign-in and improve email template
- Add `sendOnSignIn: true` to emailVerification config so unverified users
  receive a new verification email when they attempt to sign in
- Create styled verification email template matching the invoice email design
- Extract `sendVerificationEmail` helper to keep auth.ts clean
- Show friendly message on login when email is not verified
2026-04-17 13:59:50 -06:00
40 changed files with 731 additions and 68 deletions

View File

@@ -3,6 +3,7 @@ name: Sync version to MCP and CLI repos
on:
release:
types: [published]
workflow_dispatch:
jobs:
sync-version:
@@ -15,55 +16,55 @@ jobs:
- name: Get version
id: get_version
run: |
VERSION=$(jq -r .version apps/dokploy/package.json)
VERSION=$(jq -r .version apps/dokploy/package.json | sed 's/^v//')
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Version: $VERSION"
- name: Sync version to MCP repository
run: |
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/mcp.git mcp-repo
cd mcp-repo
# Bump version
jq --arg v "${{ steps.get_version.outputs.version }}" '.version = $v' package.json > package.json.tmp
mv package.json.tmp package.json
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/mcp.git /tmp/mcp-repo
cd /tmp/mcp-repo
# Regenerate tools from latest OpenAPI spec
npm install -g pnpm
pnpm install
pnpm run fetch-openapi
pnpm run generate
# Bump version after install so pnpm install doesn't overwrite it
jq --arg v "${{ steps.get_version.outputs.version }}" '.version = $v' package.json > package.json.tmp
mv package.json.tmp package.json
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
git add -A
git commit -m "chore: bump version to ${{ steps.get_version.outputs.version }}" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
-m "Release: ${{ github.event.release.html_url }}" \
--allow-empty
git push
- name: Sync version to CLI repository
run: |
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/cli.git cli-repo
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/cli.git /tmp/cli-repo
cd cli-repo
cd /tmp/cli-repo
# Bump version
# Copy latest openapi spec and regenerate commands
cp ${{ github.workspace }}/openapi.json ./openapi.json
npm install -g pnpm
pnpm install
pnpm run generate
# Bump version after install so pnpm install doesn't overwrite it
if [ -f package.json ]; then
jq --arg v "${{ steps.get_version.outputs.version }}" '.version = $v' package.json > package.json.tmp
mv package.json.tmp package.json
fi
# Copy latest openapi spec and regenerate commands
cp ../openapi.json ./openapi.json
npm install -g pnpm
pnpm install
pnpm run generate
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"

View File

@@ -666,7 +666,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
<div className="space-y-0.5">
<FormLabel>Custom Entrypoint</FormLabel>
<FormDescription>
Use custom entrypoint for domina
Use custom entrypoint for domain
<br />
"web" and/or "websecure" is used by default.
</FormDescription>

View File

@@ -0,0 +1,291 @@
import { formatDistanceToNow } from "date-fns";
import { ArrowRight, Rocket, Server } from "lucide-react";
import Link from "next/link";
import { useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { api } from "@/utils/api";
type DeploymentStatus = "idle" | "running" | "done" | "error";
const statusDotClass: Record<string, string> = {
done: "bg-emerald-500",
running: "bg-amber-500",
error: "bg-red-500",
idle: "bg-muted-foreground/40",
};
function getServiceInfo(d: any) {
const app = d.application;
const comp = d.compose;
const serverName: string =
d.server?.name ?? app?.server?.name ?? comp?.server?.name ?? "Dokploy";
if (app?.environment?.project && app.environment) {
return {
name: app.name as string,
environment: app.environment.name as string,
projectName: app.environment.project.name as string,
serverName,
href: `/dashboard/project/${app.environment.project.projectId}/environment/${app.environment.environmentId}/services/application/${app.applicationId}`,
};
}
if (comp?.environment?.project && comp.environment) {
return {
name: comp.name as string,
environment: comp.environment.name as string,
projectName: comp.environment.project.name as string,
serverName,
href: `/dashboard/project/${comp.environment.project.projectId}/environment/${comp.environment.environmentId}/services/compose/${comp.composeId}`,
};
}
return null;
}
function StatCard({
label,
value,
delta,
}: {
label: string;
value: string;
delta?: string;
}) {
return (
<div className="rounded-xl border bg-background p-5 min-h-[140px] flex flex-col justify-between">
<span className="text-xs uppercase tracking-wider text-muted-foreground">
{label}
</span>
<div className="flex flex-col gap-1">
<span className="text-3xl font-semibold tracking-tight">{value}</span>
{delta && (
<span className="text-xs text-muted-foreground">{delta}</span>
)}
</div>
</div>
);
}
function StatusListCard({
label,
items,
}: {
label: string;
items: { dotClass: string; label: string; count: number }[];
}) {
return (
<div className="rounded-xl border bg-background p-5 min-h-[140px] flex flex-col gap-3">
<span className="text-xs uppercase tracking-wider text-muted-foreground">
{label}
</span>
<ul className="flex flex-col gap-1.5">
{items.map((item) => (
<li key={item.label} className="flex items-center gap-2.5 text-sm">
<span
className={`size-2 rounded-full shrink-0 ${item.dotClass}`}
aria-hidden
/>
<span className="font-semibold tabular-nums w-8">{item.count}</span>
<span className="text-muted-foreground">{item.label}</span>
</li>
))}
</ul>
</div>
);
}
export const ShowHome = () => {
const { data: auth } = api.user.get.useQuery();
const { data: homeStats } = api.project.homeStats.useQuery();
const { data: permissions } = api.user.getPermissions.useQuery();
const canReadDeployments = !!permissions?.deployment.read;
const { data: deployments } = api.deployment.allCentralized.useQuery(
undefined,
{
enabled: canReadDeployments,
refetchInterval: 10000,
},
);
const firstName = auth?.user?.firstName?.trim();
const totals = homeStats ?? {
projects: 0,
environments: 0,
applications: 0,
compose: 0,
databases: 0,
services: 0,
};
const statusBreakdown = homeStats?.status ?? {
running: 0,
error: 0,
idle: 0,
};
const recentDeployments = useMemo(() => {
if (!deployments) return [];
return [...deployments]
.sort(
(a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
)
.slice(0, 10);
}, [deployments]);
const deployStats = useMemo(() => {
const now = Date.now();
const weekMs = 7 * 24 * 60 * 60 * 1000;
const lastStart = now - weekMs;
const prevStart = now - 2 * weekMs;
const last: NonNullable<typeof deployments> = [];
const prev: NonNullable<typeof deployments> = [];
for (const d of deployments ?? []) {
const t = new Date(d.createdAt).getTime();
if (t >= lastStart) last.push(d);
else if (t >= prevStart) prev.push(d);
}
const lastCount = last.length;
const prevCount = prev.length;
let delta: string | undefined;
if (prevCount > 0) {
const pct = Math.round(((lastCount - prevCount) / prevCount) * 100);
delta = `${pct >= 0 ? "+" : ""}${pct}% vs prev 7d`;
} else if (lastCount > 0) {
delta = "no prior data";
} else {
delta = "no activity yet";
}
return { value: String(lastCount), delta };
}, [deployments]);
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl min-h-[85vh]">
<div className="rounded-xl bg-background shadow-md p-6 flex flex-col gap-6 h-full">
<div className="flex flex-col gap-6 sm:flex-row sm:items-end sm:justify-between">
<h1 className="text-3xl font-semibold tracking-tight">
{firstName ? `Welcome back, ${firstName}` : "Welcome back"}
</h1>
<Button asChild variant="secondary" className="w-fit">
<Link href="/dashboard/projects">
Go to projects
<ArrowRight className="size-4" />
</Link>
</Button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard
label="Projects"
value={String(totals.projects)}
delta={`${totals.environments} ${totals.environments === 1 ? "environment" : "environments"}`}
/>
<StatCard
label="Services"
value={String(totals.services)}
delta={`${totals.applications} apps · ${totals.compose} compose · ${totals.databases} db`}
/>
<StatCard
label="Deploys / 7d"
value={deployStats.value}
delta={deployStats.delta}
/>
<StatusListCard
label="Status"
items={[
{
dotClass: "bg-emerald-500",
label: "running",
count: statusBreakdown.running,
},
{
dotClass: "bg-red-500",
label: "errored",
count: statusBreakdown.error,
},
{
dotClass: "bg-muted-foreground/40",
label: "idle",
count: statusBreakdown.idle,
},
]}
/>
</div>
<div className="rounded-xl border bg-background">
<div className="flex items-center justify-between px-5 py-4 border-b">
<div className="flex items-center gap-2">
<Rocket className="size-4 text-muted-foreground" />
<h2 className="text-sm font-semibold">Recent deployments</h2>
</div>
{canReadDeployments && (
<Link
href="/dashboard/deployments"
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
view all
</Link>
)}
</div>
{!canReadDeployments ? (
<div className="min-h-[400px] flex flex-col items-center justify-center gap-3 text-center text-sm text-muted-foreground p-10">
<Rocket className="size-8 opacity-40" />
<span>You do not have permission to view deployments.</span>
</div>
) : recentDeployments.length === 0 ? (
<div className="min-h-[400px] flex flex-col items-center justify-center gap-3 text-center text-sm text-muted-foreground p-10">
<Rocket className="size-8 opacity-40" />
<span>No deployments yet.</span>
</div>
) : (
<ul className="divide-y">
{recentDeployments.map((d) => {
const info = getServiceInfo(d);
if (!info) return null;
const status = (d.status ?? "idle") as DeploymentStatus;
return (
<li key={d.deploymentId}>
<Link
href={info.href}
className="flex items-center gap-4 px-5 py-4 hover:bg-muted/40 transition-colors"
>
<span
className={`size-2 rounded-full shrink-0 ${statusDotClass[status] ?? statusDotClass.idle}`}
aria-hidden
/>
<div className="flex flex-col min-w-0 flex-1">
<span className="text-sm truncate">{info.name}</span>
<span className="text-xs text-muted-foreground truncate">
{info.projectName} · {info.environment}
</span>
</div>
<span className="text-xs text-muted-foreground w-36 hidden lg:flex items-center justify-end gap-1.5 truncate">
<Server className="size-3 shrink-0" />
<span className="truncate">{info.serverName}</span>
</span>
<span className="text-xs text-muted-foreground w-20 text-right hidden sm:inline">
{status}
</span>
<span className="text-xs text-muted-foreground w-24 text-right hidden md:inline">
{formatDistanceToNow(new Date(d.createdAt), {
addSuffix: true,
})}
</span>
<span className="text-xs text-muted-foreground hover:text-foreground transition-colors">
logs
</span>
</Link>
</li>
);
})}
</ul>
)}
</div>
</div>
</Card>
</div>
);
};

View File

@@ -166,6 +166,7 @@ export const ShowProjects = () => {
return (
total +
(env.applications?.length || 0) +
(env.libsql?.length || 0) +
(env.mariadb?.length || 0) +
(env.mongo?.length || 0) +
(env.mysql?.length || 0) +
@@ -178,6 +179,7 @@ export const ShowProjects = () => {
return (
total +
(env.applications?.length || 0) +
(env.libsql?.length || 0) +
(env.mariadb?.length || 0) +
(env.mongo?.length || 0) +
(env.mysql?.length || 0) +

View File

@@ -167,7 +167,7 @@ export const SearchCommand = () => {
<CommandGroup heading={"Application"} hidden={true}>
<CommandItem
onSelect={() => {
router.push("/dashboard/projects");
router.push("/dashboard/home");
setOpen(false);
}}
>

View File

@@ -425,7 +425,7 @@ export const WelcomeSubscription = () => {
onClick={() => {
if (stepper.isLast) {
setIsOpen(false);
push("/dashboard/projects");
push("/dashboard/home");
} else {
stepper.next();
}

View File

@@ -19,6 +19,7 @@ import {
Forward,
GalleryVerticalEnd,
GitBranch,
House,
Key,
KeyRound,
Loader2,
@@ -148,6 +149,12 @@ type Menu = {
// The `isEnabled` function is called to determine if the item should be displayed
const MENU: Menu = {
home: [
{
isSingle: true,
title: "Home",
url: "/dashboard/home",
icon: House,
},
{
isSingle: true,
title: "Projects",

View File

@@ -80,7 +80,7 @@ export const UserNav = () => {
<DropdownMenuItem
className="cursor-pointer"
onClick={() => {
router.push("/dashboard/projects");
router.push("/dashboard/home");
}}
>
Projects

View File

@@ -44,7 +44,7 @@ export function SignInWithSSO({ children }: SignInWithSSOProps) {
try {
const { data, error } = await authClient.signIn.sso({
email: values.email,
callbackURL: "/dashboard/projects",
callbackURL: "/dashboard/home",
});
if (error) {
toast.error(error.message ?? "Failed to sign in with SSO");

View File

@@ -1,6 +1,6 @@
{
"name": "dokploy",
"version": "v0.29.0",
"version": "v0.29.1",
"private": true,
"license": "Apache-2.0",
"type": "module",

View File

@@ -53,7 +53,7 @@ export default function Custom404({ statusCode, error }: Props) {
<div className="mt-5 flex flex-col justify-center items-center gap-2 sm:flex-row sm:gap-3">
<Link
href="/dashboard/projects"
href="/dashboard/home"
className={buttonVariants({
variant: "secondary",
className: "flex flex-row gap-2",

View File

@@ -102,7 +102,7 @@ export async function getServerSideProps(ctx: GetServerSidePropsContext) {
return {
redirect: {
permanent: false,
destination: "/dashboard/projects",
destination: "/dashboard/home",
},
};
}

View File

@@ -24,7 +24,7 @@ export async function getServerSideProps(
return {
redirect: {
permanent: true,
destination: "/dashboard/projects",
destination: "/dashboard/home",
},
};
}

View File

@@ -0,0 +1,53 @@
import { validateRequest } from "@dokploy/server/lib/auth";
import { createServerSideHelpers } from "@trpc/react-query/server";
import type { GetServerSidePropsContext } from "next";
import type { ReactElement } from "react";
import superjson from "superjson";
import { ShowHome } from "@/components/dashboard/home/show-home";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { appRouter } from "@/server/api/root";
const Home = () => {
return <ShowHome />;
};
export default Home;
Home.getLayout = (page: ReactElement) => {
return <DashboardLayout>{page}</DashboardLayout>;
};
export async function getServerSideProps(ctx: GetServerSidePropsContext) {
const { req, res } = ctx;
const { user, session } = await validateRequest(req);
if (!user) {
return {
redirect: {
permanent: true,
destination: "/",
},
};
}
const helpers = createServerSideHelpers({
router: appRouter,
ctx: {
req: req as any,
res: res as any,
db: null as any,
session: session as any,
user: user as any,
},
transformer: superjson,
});
await helpers.settings.isCloud.prefetch();
await helpers.user.get.prefetch();
return {
props: {
trpcState: helpers.dehydrate(),
},
};
}

View File

@@ -96,7 +96,7 @@ export async function getServerSideProps(
return {
redirect: {
permanent: true,
destination: "/dashboard/projects",
destination: "/dashboard/home",
},
};
}
@@ -122,7 +122,7 @@ export async function getServerSideProps(
return {
redirect: {
permanent: false,
destination: "/dashboard/projects",
destination: "/dashboard/home",
},
};
}

View File

@@ -509,6 +509,14 @@ const EnvironmentPage = (
deploy: api.mongo.deploy.useMutation(),
};
const libsqlActions = {
start: api.libsql.start.useMutation(),
stop: api.libsql.stop.useMutation(),
move: api.libsql.move.useMutation(),
delete: api.libsql.remove.useMutation(),
deploy: api.libsql.deploy.useMutation(),
};
const handleBulkStart = async () => {
let success = 0;
setIsBulkActionLoading(true);
@@ -541,6 +549,9 @@ const EnvironmentPage = (
case "mongo":
await mongoActions.start.mutateAsync({ mongoId: serviceId });
break;
case "libsql":
await libsqlActions.start.mutateAsync({ libsqlId: serviceId });
break;
}
success++;
} catch {
@@ -588,6 +599,9 @@ const EnvironmentPage = (
case "mongo":
await mongoActions.stop.mutateAsync({ mongoId: serviceId });
break;
case "libsql":
await libsqlActions.stop.mutateAsync({ libsqlId: serviceId });
break;
}
success++;
} catch {
@@ -664,6 +678,12 @@ const EnvironmentPage = (
targetEnvironmentId: selectedTargetEnvironment,
});
break;
case "libsql":
await libsqlActions.move.mutateAsync({
libsqlId: serviceId,
targetEnvironmentId: selectedTargetEnvironment,
});
break;
}
await utils.environment.one.invalidate({
environmentId,
@@ -733,6 +753,11 @@ const EnvironmentPage = (
mongoId: serviceId,
});
break;
case "libsql":
await libsqlActions.delete.mutateAsync({
libsqlId: serviceId,
});
break;
}
await utils.environment.one.invalidate({
environmentId,
@@ -799,6 +824,11 @@ const EnvironmentPage = (
mongoId: serviceId,
});
break;
case "libsql":
await libsqlActions.deploy.mutateAsync({
libsqlId: serviceId,
});
break;
}
success++;
} catch (error) {
@@ -1856,7 +1886,7 @@ export async function getServerSideProps(
return {
redirect: {
permanent: false,
destination: "/dashboard/projects",
destination: "/dashboard/home",
},
};
}

View File

@@ -490,7 +490,7 @@ export async function getServerSideProps(
return {
redirect: {
permanent: false,
destination: "/dashboard/projects",
destination: "/dashboard/home",
},
};
}

View File

@@ -492,7 +492,7 @@ export async function getServerSideProps(
return {
redirect: {
permanent: false,
destination: "/dashboard/projects",
destination: "/dashboard/home",
},
};
}

View File

@@ -343,7 +343,7 @@ export async function getServerSideProps(
return {
redirect: {
permanent: false,
destination: "/dashboard/projects",
destination: "/dashboard/home",
},
};
}

View File

@@ -372,7 +372,7 @@ export async function getServerSideProps(
return {
redirect: {
permanent: false,
destination: "/dashboard/projects",
destination: "/dashboard/home",
},
};
}

View File

@@ -376,7 +376,7 @@ export async function getServerSideProps(
return {
redirect: {
permanent: false,
destination: "/dashboard/projects",
destination: "/dashboard/home",
},
};
}

View File

@@ -353,7 +353,7 @@ export async function getServerSideProps(
return {
redirect: {
permanent: false,
destination: "/dashboard/projects",
destination: "/dashboard/home",
},
};
}

View File

@@ -360,7 +360,7 @@ export async function getServerSideProps(
return {
redirect: {
permanent: false,
destination: "/dashboard/projects",
destination: "/dashboard/home",
},
};
}

View File

@@ -363,7 +363,7 @@ export async function getServerSideProps(
return {
redirect: {
permanent: false,
destination: "/dashboard/projects",
destination: "/dashboard/home",
},
};
}

View File

@@ -18,7 +18,7 @@ export async function getServerSideProps(
return {
redirect: {
permanent: true,
destination: "/dashboard/projects",
destination: "/dashboard/home",
},
};
}

View File

@@ -35,7 +35,7 @@ export async function getServerSideProps(
return {
redirect: {
permanent: true,
destination: "/dashboard/projects",
destination: "/dashboard/home",
},
};
}

View File

@@ -24,7 +24,7 @@ export async function getServerSideProps(
return {
redirect: {
permanent: true,
destination: "/dashboard/projects",
destination: "/dashboard/home",
},
};
}

View File

@@ -28,7 +28,7 @@ export async function getServerSideProps(
return {
redirect: {
permanent: true,
destination: "/dashboard/projects",
destination: "/dashboard/home",
},
};
}

View File

@@ -24,7 +24,7 @@ export async function getServerSideProps(
return {
redirect: {
permanent: true,
destination: "/dashboard/projects",
destination: "/dashboard/home",
},
};
}

View File

@@ -45,7 +45,7 @@ export async function getServerSideProps(
return {
redirect: {
permanent: true,
destination: "/dashboard/projects",
destination: "/dashboard/home",
},
};
}

View File

@@ -46,7 +46,7 @@ export async function getServerSideProps(
return {
redirect: {
permanent: true,
destination: "/dashboard/projects",
destination: "/dashboard/home",
},
};
}

View File

@@ -24,7 +24,7 @@ export async function getServerSideProps(
return {
redirect: {
permanent: true,
destination: "/dashboard/projects",
destination: "/dashboard/home",
},
};
}

View File

@@ -82,6 +82,16 @@ export default function Home({ IS_CLOUD }: Props) {
});
if (error) {
const isEmailNotVerified =
error.code === "EMAIL_NOT_VERIFIED" ||
error.message?.toLowerCase().includes("email not verified");
if (isEmailNotVerified) {
const msg =
"Your email is not verified. We've sent a new verification link to your email.";
toast.info(msg);
setError(msg);
return;
}
toast.error(error.message);
setError(error.message || "An error occurred while logging in");
return;
@@ -96,7 +106,7 @@ export default function Home({ IS_CLOUD }: Props) {
}
toast.success("Logged in successfully");
router.push("/dashboard/projects");
router.push("/dashboard/home");
} catch {
toast.error("An error occurred while logging in");
} finally {
@@ -123,7 +133,7 @@ export default function Home({ IS_CLOUD }: Props) {
}
toast.success("Logged in successfully");
router.push("/dashboard/projects");
router.push("/dashboard/home");
} catch {
toast.error("An error occurred while verifying 2FA code");
} finally {
@@ -153,7 +163,7 @@ export default function Home({ IS_CLOUD }: Props) {
}
toast.success("Logged in successfully");
router.push("/dashboard/projects");
router.push("/dashboard/home");
} catch {
toast.error("An error occurred while verifying backup code");
} finally {
@@ -398,7 +408,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
return {
redirect: {
permanent: true,
destination: "/dashboard/projects",
destination: "/dashboard/home",
},
};
}
@@ -427,7 +437,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
return {
redirect: {
permanent: true,
destination: "/dashboard/projects",
destination: "/dashboard/home",
},
};
}

View File

@@ -139,7 +139,7 @@ const Invitation = ({
});
toast.success("Account created successfully");
router.push("/dashboard/projects");
router.push("/dashboard/home");
} catch {
toast.error("An error occurred while creating your account");
}

View File

@@ -303,7 +303,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
return {
redirect: {
permanent: true,
destination: "/dashboard/projects",
destination: "/dashboard/home",
},
};
}

View File

@@ -487,6 +487,148 @@ export const projectRouter = createTRPCRouter({
},
),
homeStats: protectedProcedure.query(async ({ ctx }) => {
const isPrivileged = ctx.user.role === "owner" || ctx.user.role === "admin";
let accessedProjects: string[] = [];
let accessedEnvironments: string[] = [];
let accessedServices: string[] = [];
if (!isPrivileged) {
const member = await findMemberByUserId(
ctx.user.id,
ctx.session.activeOrganizationId,
);
accessedProjects = member.accessedProjects;
accessedEnvironments = member.accessedEnvironments;
accessedServices = member.accessedServices;
if (accessedProjects.length === 0) {
return {
projects: 0,
environments: 0,
applications: 0,
compose: 0,
databases: 0,
services: 0,
status: { running: 0, error: 0, idle: 0 },
};
}
}
const projectIdFilter = isPrivileged
? eq(projects.organizationId, ctx.session.activeOrganizationId)
: and(
sql`${projects.projectId} IN (${sql.join(
accessedProjects.map((id) => sql`${id}`),
sql`, `,
)})`,
eq(projects.organizationId, ctx.session.activeOrganizationId),
);
const environmentFilter = isPrivileged
? undefined
: accessedEnvironments.length === 0
? sql`false`
: sql`${environments.environmentId} IN (${sql.join(
accessedEnvironments.map((envId) => sql`${envId}`),
sql`, `,
)})`;
const applyFilter = (col: AnyPgColumn) =>
isPrivileged ? undefined : buildServiceFilter(col, accessedServices);
const rows = await db.query.projects.findMany({
where: projectIdFilter,
columns: { projectId: true },
with: {
environments: {
where: environmentFilter,
columns: { environmentId: true },
with: {
applications: {
where: applyFilter(applications.applicationId),
columns: { applicationStatus: true },
},
compose: {
where: applyFilter(compose.composeId),
columns: { composeStatus: true },
},
libsql: {
where: applyFilter(libsql.libsqlId),
columns: { applicationStatus: true },
},
mariadb: {
where: applyFilter(mariadb.mariadbId),
columns: { applicationStatus: true },
},
mongo: {
where: applyFilter(mongo.mongoId),
columns: { applicationStatus: true },
},
mysql: {
where: applyFilter(mysql.mysqlId),
columns: { applicationStatus: true },
},
postgres: {
where: applyFilter(postgres.postgresId),
columns: { applicationStatus: true },
},
redis: {
where: applyFilter(redis.redisId),
columns: { applicationStatus: true },
},
},
},
},
});
let applicationsCount = 0;
let composeCount = 0;
let databasesCount = 0;
let environmentsCount = 0;
const status = { running: 0, error: 0, idle: 0 };
const bump = (s?: string | null) => {
if (s === "done") status.running++;
else if (s === "error") status.error++;
else status.idle++;
};
for (const project of rows) {
for (const env of project.environments) {
environmentsCount++;
applicationsCount += env.applications.length;
composeCount += env.compose.length;
databasesCount +=
env.libsql.length +
env.mariadb.length +
env.mongo.length +
env.mysql.length +
env.postgres.length +
env.redis.length;
for (const a of env.applications) bump(a.applicationStatus);
for (const c of env.compose) bump(c.composeStatus);
for (const s of env.libsql) bump(s.applicationStatus);
for (const s of env.mariadb) bump(s.applicationStatus);
for (const s of env.mongo) bump(s.applicationStatus);
for (const s of env.mysql) bump(s.applicationStatus);
for (const s of env.postgres) bump(s.applicationStatus);
for (const s of env.redis) bump(s.applicationStatus);
}
}
return {
projects: rows.length,
environments: environmentsCount,
applications: applicationsCount,
compose: composeCount,
databases: databasesCount,
services: applicationsCount + composeCount + databasesCount,
status,
};
}),
search: protectedProcedure
.input(
z.object({

View File

@@ -0,0 +1,104 @@
import {
Body,
Button,
Container,
Head,
Heading,
Html,
Img,
Link,
Preview,
Section,
Tailwind,
Text,
} from "@react-email/components";
export type TemplateProps = {
userName: string;
verificationUrl: string;
};
export const VerifyEmailTemplate = ({
userName = "User",
verificationUrl = "https://app.dokploy.com/verify",
}: TemplateProps) => {
const previewText = "Verify your email address to get started with Dokploy";
return (
<Html>
<Head />
<Preview>{previewText}</Preview>
<Tailwind
config={{
theme: {
extend: {
colors: {
brand: "#007291",
},
},
},
}}
>
<Body className="bg-[#f4f4f5] my-auto mx-auto font-sans">
<Container className="my-[40px] mx-auto max-w-[520px]">
{/* Header */}
<Section className="bg-[#09090b] rounded-t-xl px-[40px] py-[32px] text-center">
<Img
src="https://raw.githubusercontent.com/Dokploy/website/refs/heads/main/apps/docs/public/logo-dokploy-blackpng.png"
width="190"
height="120"
alt="Dokploy"
className="my-0 mx-auto"
/>
</Section>
{/* Body */}
<Section className="bg-white px-[40px] py-[32px]">
<Heading className="text-[#09090b] text-[22px] font-semibold m-0 mb-[8px]">
Verify Your Email
</Heading>
<Text className="text-[#71717a] text-[14px] leading-[22px] m-0 mb-[24px]">
Hello {userName}, thank you for signing up for Dokploy. Please
verify your email address to activate your account.
</Text>
{/* CTA Button */}
<Section className="text-center mb-[24px]">
<Button
href={verificationUrl}
className="bg-[#09090b] rounded-lg text-white text-[14px] font-semibold no-underline text-center px-[24px] py-[12px]"
>
Verify Email Address
</Button>
</Section>
<Text className="text-[#a1a1aa] text-[13px] leading-[20px] m-0 text-center mb-[16px]">
If the button above doesn't work, copy and paste the following
link into your browser:
</Text>
<Text className="text-[#71717a] text-[12px] leading-[18px] m-0 text-center break-all">
{verificationUrl}
</Text>
</Section>
{/* Footer */}
<Section className="bg-[#fafafa] rounded-b-xl px-[40px] py-[24px] text-center border-t border-solid border-[#e4e4e7]">
<Text className="text-[#a1a1aa] text-[12px] leading-[18px] m-0">
This is an automated email from{" "}
<Link
href="https://dokploy.com"
className="text-[#71717a] underline"
>
Dokploy Cloud
</Link>
. If you didn't create an account, you can safely ignore this
email.
</Text>
</Section>
</Container>
</Body>
</Tailwind>
</Html>
);
};
export default VerifyEmailTemplate;

View File

@@ -21,7 +21,10 @@ import {
updateWebServerSettings,
} from "../services/web-server-settings";
import { getHubSpotUTK, submitToHubSpot } from "../utils/tracking/hubspot";
import { sendEmail } from "../verification/send-verification-email";
import {
sendEmail,
sendVerificationEmail,
} from "../verification/send-verification-email";
import { getPublicIpWithFallback } from "../wss/utils";
import { ac, adminRole, memberRole, ownerRole } from "./access-control";
@@ -106,14 +109,13 @@ const { handler, api } = betterAuth({
emailVerification: {
sendOnSignUp: true,
autoSignInAfterVerification: true,
sendOnSignIn: true,
sendVerificationEmail: async ({ user, url }) => {
if (IS_CLOUD) {
await sendEmail({
await sendVerificationEmail({
userName: user.name || "User",
email: user.email,
subject: "Verify your email",
text: `
<p>Click the link to verify your email: <a href="${url}">Verify Email</a></p>
`,
verificationUrl: url,
});
}
},

View File

@@ -30,13 +30,9 @@ export const findPreviewDeploymentById = async (
with: {
domain: true,
application: {
with: {
server: true,
environment: {
with: {
project: true,
},
},
columns: {
applicationId: true,
serverId: true,
},
},
},

View File

@@ -1,4 +1,7 @@
import { renderAsync } from "@react-email/components";
import VerifyEmailTemplate from "../emails/emails/verify-email";
import { sendEmailNotification } from "../utils/notifications/utils";
export const sendEmail = async ({
email,
subject,
@@ -26,3 +29,25 @@ export const sendEmail = async ({
return true;
};
export const sendVerificationEmail = async ({
userName,
email,
verificationUrl,
}: {
userName: string;
email: string;
verificationUrl: string;
}) => {
const html = await renderAsync(
VerifyEmailTemplate({
userName: userName || "User",
verificationUrl,
}),
);
await sendEmail({
email,
subject: "Verify your email",
text: html,
});
};