mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-07-23 06:45:27 +02:00
Compare commits
15 Commits
v0.29.0
...
feat/scim-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0dbd1039e8 | ||
|
|
f06c9deddf | ||
|
|
b392e58001 | ||
|
|
d9945c0a4f | ||
|
|
f6e2c033ba | ||
|
|
5c787adae1 | ||
|
|
2ba1df1eaa | ||
|
|
e7859395b1 | ||
|
|
6f0ed89ce7 | ||
|
|
4277a509b2 | ||
|
|
f7b576cbf3 | ||
|
|
425fef6e28 | ||
|
|
958372c5f9 | ||
|
|
e7c581476e | ||
|
|
0cae8330e2 |
33
.github/workflows/sync-version.yml
vendored
33
.github/workflows/sync-version.yml
vendored
@@ -3,6 +3,7 @@ name: Sync version to MCP and CLI repos
|
|||||||
on:
|
on:
|
||||||
release:
|
release:
|
||||||
types: [published]
|
types: [published]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
sync-version:
|
sync-version:
|
||||||
@@ -15,18 +16,14 @@ jobs:
|
|||||||
- name: Get version
|
- name: Get version
|
||||||
id: get_version
|
id: get_version
|
||||||
run: |
|
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" >> $GITHUB_OUTPUT
|
||||||
echo "Version: $VERSION"
|
echo "Version: $VERSION"
|
||||||
|
|
||||||
- name: Sync version to MCP repository
|
- name: Sync version to MCP repository
|
||||||
run: |
|
run: |
|
||||||
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/mcp.git mcp-repo
|
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/mcp.git /tmp/mcp-repo
|
||||||
cd mcp-repo
|
cd /tmp/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
|
|
||||||
|
|
||||||
# Regenerate tools from latest OpenAPI spec
|
# Regenerate tools from latest OpenAPI spec
|
||||||
npm install -g pnpm
|
npm install -g pnpm
|
||||||
@@ -34,6 +31,10 @@ jobs:
|
|||||||
pnpm run fetch-openapi
|
pnpm run fetch-openapi
|
||||||
pnpm run generate
|
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.name "Dokploy Bot"
|
||||||
git config user.email "bot@dokploy.com"
|
git config user.email "bot@dokploy.com"
|
||||||
|
|
||||||
@@ -48,22 +49,22 @@ jobs:
|
|||||||
|
|
||||||
- name: Sync version to CLI repository
|
- name: Sync version to CLI repository
|
||||||
run: |
|
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
|
if [ -f package.json ]; then
|
||||||
jq --arg v "${{ steps.get_version.outputs.version }}" '.version = $v' package.json > package.json.tmp
|
jq --arg v "${{ steps.get_version.outputs.version }}" '.version = $v' package.json > package.json.tmp
|
||||||
mv package.json.tmp package.json
|
mv package.json.tmp package.json
|
||||||
fi
|
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.name "Dokploy Bot"
|
||||||
git config user.email "bot@dokploy.com"
|
git config user.email "bot@dokploy.com"
|
||||||
|
|
||||||
|
|||||||
@@ -666,7 +666,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<FormLabel>Custom Entrypoint</FormLabel>
|
<FormLabel>Custom Entrypoint</FormLabel>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
Use custom entrypoint for domina
|
Use custom entrypoint for domain
|
||||||
<br />
|
<br />
|
||||||
"web" and/or "websecure" is used by default.
|
"web" and/or "websecure" is used by default.
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
|
|||||||
291
apps/dokploy/components/dashboard/home/show-home.tsx
Normal file
291
apps/dokploy/components/dashboard/home/show-home.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -166,6 +166,7 @@ export const ShowProjects = () => {
|
|||||||
return (
|
return (
|
||||||
total +
|
total +
|
||||||
(env.applications?.length || 0) +
|
(env.applications?.length || 0) +
|
||||||
|
(env.libsql?.length || 0) +
|
||||||
(env.mariadb?.length || 0) +
|
(env.mariadb?.length || 0) +
|
||||||
(env.mongo?.length || 0) +
|
(env.mongo?.length || 0) +
|
||||||
(env.mysql?.length || 0) +
|
(env.mysql?.length || 0) +
|
||||||
@@ -178,6 +179,7 @@ export const ShowProjects = () => {
|
|||||||
return (
|
return (
|
||||||
total +
|
total +
|
||||||
(env.applications?.length || 0) +
|
(env.applications?.length || 0) +
|
||||||
|
(env.libsql?.length || 0) +
|
||||||
(env.mariadb?.length || 0) +
|
(env.mariadb?.length || 0) +
|
||||||
(env.mongo?.length || 0) +
|
(env.mongo?.length || 0) +
|
||||||
(env.mysql?.length || 0) +
|
(env.mysql?.length || 0) +
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ export const SearchCommand = () => {
|
|||||||
<CommandGroup heading={"Application"} hidden={true}>
|
<CommandGroup heading={"Application"} hidden={true}>
|
||||||
<CommandItem
|
<CommandItem
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
router.push("/dashboard/projects");
|
router.push("/dashboard/home");
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -425,7 +425,7 @@ export const WelcomeSubscription = () => {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (stepper.isLast) {
|
if (stepper.isLast) {
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
push("/dashboard/projects");
|
push("/dashboard/home");
|
||||||
} else {
|
} else {
|
||||||
stepper.next();
|
stepper.next();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
Forward,
|
Forward,
|
||||||
GalleryVerticalEnd,
|
GalleryVerticalEnd,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
|
House,
|
||||||
Key,
|
Key,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
Loader2,
|
Loader2,
|
||||||
@@ -148,6 +149,12 @@ type Menu = {
|
|||||||
// The `isEnabled` function is called to determine if the item should be displayed
|
// The `isEnabled` function is called to determine if the item should be displayed
|
||||||
const MENU: Menu = {
|
const MENU: Menu = {
|
||||||
home: [
|
home: [
|
||||||
|
{
|
||||||
|
isSingle: true,
|
||||||
|
title: "Home",
|
||||||
|
url: "/dashboard/home",
|
||||||
|
icon: House,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
isSingle: true,
|
isSingle: true,
|
||||||
title: "Projects",
|
title: "Projects",
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ export const UserNav = () => {
|
|||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
router.push("/dashboard/projects");
|
router.push("/dashboard/home");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Projects
|
Projects
|
||||||
|
|||||||
236
apps/dokploy/components/proprietary/sso/scim-dialog.tsx
Normal file
236
apps/dokploy/components/proprietary/sso/scim-dialog.tsx
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Copy, KeyRound, Loader2, Plus, Trash2 } from "lucide-react";
|
||||||
|
import { type ReactNode, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { DialogAction } from "@/components/shared/dialog-action";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
import { useUrl } from "@/utils/hooks/use-url";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ScimDialog = ({ children }: Props) => {
|
||||||
|
const utils = api.useUtils();
|
||||||
|
const baseURL = useUrl();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [newProviderId, setNewProviderId] = useState("");
|
||||||
|
const [justCreatedToken, setJustCreatedToken] = useState<{
|
||||||
|
providerId: string;
|
||||||
|
token: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const { data: providers = [], isPending } = api.scim.listProviders.useQuery(
|
||||||
|
undefined,
|
||||||
|
{ enabled: open },
|
||||||
|
);
|
||||||
|
const { mutateAsync: generateToken, isPending: isGenerating } =
|
||||||
|
api.scim.generateToken.useMutation();
|
||||||
|
const { mutateAsync: deleteProvider, isPending: isDeleting } =
|
||||||
|
api.scim.deleteProvider.useMutation();
|
||||||
|
|
||||||
|
const scimUrl = `${baseURL || "{baseURL}"}/api/auth/scim/v2`;
|
||||||
|
|
||||||
|
const handleGenerate = async () => {
|
||||||
|
const providerId = newProviderId.trim().toLowerCase();
|
||||||
|
if (!providerId) return;
|
||||||
|
try {
|
||||||
|
const result = await generateToken({ providerId });
|
||||||
|
setJustCreatedToken({
|
||||||
|
providerId: result.providerId,
|
||||||
|
token: result.scimToken,
|
||||||
|
});
|
||||||
|
setNewProviderId("");
|
||||||
|
await utils.scim.listProviders.invalidate();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(
|
||||||
|
err instanceof Error ? err.message : "Failed to generate SCIM token",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (providerId: string) => {
|
||||||
|
try {
|
||||||
|
await deleteProvider({ providerId });
|
||||||
|
toast.success("SCIM provider removed");
|
||||||
|
await utils.scim.listProviders.invalidate();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(
|
||||||
|
err instanceof Error ? err.message : "Failed to delete SCIM provider",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopy = async (value: string, label: string) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(value);
|
||||||
|
toast.success(`${label} copied`);
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to copy");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenChange = (next: boolean) => {
|
||||||
|
setOpen(next);
|
||||||
|
if (!next) setJustCreatedToken(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-[560px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<KeyRound className="size-5" />
|
||||||
|
SCIM provisioning
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Automatically provision, update, and deactivate users from your
|
||||||
|
identity provider (Okta, Entra ID, etc.). Configure the SCIM endpoint
|
||||||
|
below in your IdP.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
<div className="grid gap-1">
|
||||||
|
<Label className="text-xs font-medium text-muted-foreground">
|
||||||
|
SCIM 2.0 endpoint URL
|
||||||
|
</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="flex-1 break-all rounded-md bg-muted px-2 py-1.5 font-mono text-xs">
|
||||||
|
{scimUrl}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="size-8 shrink-0"
|
||||||
|
onClick={() => handleCopy(scimUrl, "Endpoint URL")}
|
||||||
|
disabled={!baseURL}
|
||||||
|
>
|
||||||
|
<Copy className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{justCreatedToken && (
|
||||||
|
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 p-3">
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
Bearer token for {justCreatedToken.providerId}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
Copy this token now — it will not be shown again. Paste it into
|
||||||
|
your IdP's SCIM configuration.
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 flex items-center gap-2">
|
||||||
|
<p className="flex-1 break-all rounded-md bg-background px-2 py-1.5 font-mono text-xs">
|
||||||
|
{justCreatedToken.token}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="size-8 shrink-0"
|
||||||
|
onClick={() =>
|
||||||
|
handleCopy(justCreatedToken.token, "Bearer token")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Copy className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-medium">
|
||||||
|
Generate token for a new provider
|
||||||
|
</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
value={newProviderId}
|
||||||
|
onChange={(e) => setNewProviderId(e.target.value)}
|
||||||
|
placeholder="okta, entra, jumpcloud..."
|
||||||
|
className="font-mono text-sm"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
void handleGenerate();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleGenerate}
|
||||||
|
disabled={!newProviderId.trim() || isGenerating}
|
||||||
|
>
|
||||||
|
<Plus className="mr-1 size-4" />
|
||||||
|
Generate
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Choose a unique identifier for this IdP connection (lowercase,
|
||||||
|
alphanumeric, dashes).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-medium">Existing providers</Label>
|
||||||
|
{isPending ? (
|
||||||
|
<div className="flex items-center gap-2 justify-center py-4">
|
||||||
|
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||||
|
<span className="text-sm text-muted-foreground">Loading...</span>
|
||||||
|
</div>
|
||||||
|
) : providers.length === 0 ? (
|
||||||
|
<p className="rounded-md border border-dashed bg-muted/30 px-3 py-4 text-center text-sm text-muted-foreground">
|
||||||
|
No SCIM providers configured yet.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col gap-2">
|
||||||
|
{providers.map((provider) => (
|
||||||
|
<li
|
||||||
|
key={provider.id}
|
||||||
|
className="flex items-center gap-2 rounded-md border bg-muted/30 px-3 py-2"
|
||||||
|
>
|
||||||
|
<span className="flex-1 font-mono text-sm">
|
||||||
|
{provider.providerId}
|
||||||
|
</span>
|
||||||
|
<DialogAction
|
||||||
|
title="Remove SCIM provider"
|
||||||
|
description={`Remove "${provider.providerId}"? Existing provisioned users will stay but the IdP will no longer be able to sync.`}
|
||||||
|
type="destructive"
|
||||||
|
onClick={() => handleDelete(provider.providerId)}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-8 shrink-0 text-destructive hover:text-destructive"
|
||||||
|
disabled={isDeleting}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</DialogAction>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => handleOpenChange(false)}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -44,7 +44,7 @@ export function SignInWithSSO({ children }: SignInWithSSOProps) {
|
|||||||
try {
|
try {
|
||||||
const { data, error } = await authClient.signIn.sso({
|
const { data, error } = await authClient.signIn.sso({
|
||||||
email: values.email,
|
email: values.email,
|
||||||
callbackURL: "/dashboard/projects",
|
callbackURL: "/dashboard/home",
|
||||||
});
|
});
|
||||||
if (error) {
|
if (error) {
|
||||||
toast.error(error.message ?? "Failed to sign in with SSO");
|
toast.error(error.message ?? "Failed to sign in with SSO");
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
Eye,
|
Eye,
|
||||||
|
KeyRound,
|
||||||
Loader2,
|
Loader2,
|
||||||
LogIn,
|
LogIn,
|
||||||
Pencil,
|
Pencil,
|
||||||
@@ -34,6 +35,7 @@ import { api } from "@/utils/api";
|
|||||||
import { useUrl } from "@/utils/hooks/use-url";
|
import { useUrl } from "@/utils/hooks/use-url";
|
||||||
import { RegisterOidcDialog } from "./register-oidc-dialog";
|
import { RegisterOidcDialog } from "./register-oidc-dialog";
|
||||||
import { RegisterSamlDialog } from "./register-saml-dialog";
|
import { RegisterSamlDialog } from "./register-saml-dialog";
|
||||||
|
import { ScimDialog } from "./scim-dialog";
|
||||||
|
|
||||||
type ProviderForDetails = {
|
type ProviderForDetails = {
|
||||||
id: string | null;
|
id: string | null;
|
||||||
@@ -169,15 +171,22 @@ export const SSOSettings = () => {
|
|||||||
Users can sign in with their organization's IdP.
|
Users can sign in with their organization's IdP.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<div className="flex flex-wrap gap-2 shrink-0">
|
||||||
variant="outline"
|
<Button
|
||||||
size="sm"
|
variant="outline"
|
||||||
onClick={() => setManageOriginsOpen(true)}
|
size="sm"
|
||||||
className="shrink-0"
|
onClick={() => setManageOriginsOpen(true)}
|
||||||
>
|
>
|
||||||
<Shield className="mr-2 size-4" />
|
<Shield className="mr-2 size-4" />
|
||||||
Manage origins
|
Manage origins
|
||||||
</Button>
|
</Button>
|
||||||
|
<ScimDialog>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<KeyRound className="mr-2 size-4" />
|
||||||
|
Manage SCIM
|
||||||
|
</Button>
|
||||||
|
</ScimDialog>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isPending ? (
|
{isPending ? (
|
||||||
|
|||||||
11
apps/dokploy/drizzle/0166_overjoyed_big_bertha.sql
Normal file
11
apps/dokploy/drizzle/0166_overjoyed_big_bertha.sql
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
CREATE TABLE "scim_provider" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"provider_id" text NOT NULL,
|
||||||
|
"scim_token" text NOT NULL,
|
||||||
|
"organization_id" text,
|
||||||
|
CONSTRAINT "scim_provider_provider_id_unique" UNIQUE("provider_id"),
|
||||||
|
CONSTRAINT "scim_provider_scim_token_unique" UNIQUE("scim_token")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "two_factor" ADD COLUMN "verified" boolean DEFAULT true NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "scim_provider" ADD CONSTRAINT "scim_provider_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
8385
apps/dokploy/drizzle/meta/0166_snapshot.json
Normal file
8385
apps/dokploy/drizzle/meta/0166_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1163,6 +1163,13 @@
|
|||||||
"when": 1775845419261,
|
"when": 1775845419261,
|
||||||
"tag": "0165_abnormal_greymalkin",
|
"tag": "0165_abnormal_greymalkin",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 166,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1776576422440,
|
||||||
|
"tag": "0166_overjoyed_big_bertha",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -46,8 +46,8 @@
|
|||||||
"@ai-sdk/mistral": "^3.0.20",
|
"@ai-sdk/mistral": "^3.0.20",
|
||||||
"@ai-sdk/openai": "^3.0.29",
|
"@ai-sdk/openai": "^3.0.29",
|
||||||
"@ai-sdk/openai-compatible": "^2.0.30",
|
"@ai-sdk/openai-compatible": "^2.0.30",
|
||||||
"@better-auth/api-key": "1.5.4",
|
"@better-auth/api-key": "1.6.5",
|
||||||
"@better-auth/sso": "1.5.4",
|
"@better-auth/sso": "1.6.5",
|
||||||
"@codemirror/autocomplete": "^6.18.6",
|
"@codemirror/autocomplete": "^6.18.6",
|
||||||
"@codemirror/lang-css": "^6.3.1",
|
"@codemirror/lang-css": "^6.3.1",
|
||||||
"@codemirror/lang-json": "^6.0.1",
|
"@codemirror/lang-json": "^6.0.1",
|
||||||
@@ -101,7 +101,7 @@
|
|||||||
"ai": "^6.0.86",
|
"ai": "^6.0.86",
|
||||||
"ai-sdk-ollama": "^3.7.0",
|
"ai-sdk-ollama": "^3.7.0",
|
||||||
"bcrypt": "5.1.1",
|
"bcrypt": "5.1.1",
|
||||||
"better-auth": "1.5.4",
|
"better-auth": "1.6.5",
|
||||||
"bl": "6.0.11",
|
"bl": "6.0.11",
|
||||||
"boxen": "^7.1.1",
|
"boxen": "^7.1.1",
|
||||||
"bullmq": "5.67.3",
|
"bullmq": "5.67.3",
|
||||||
@@ -113,7 +113,7 @@
|
|||||||
"dockerode": "4.0.2",
|
"dockerode": "4.0.2",
|
||||||
"dompurify": "^3.3.3",
|
"dompurify": "^3.3.3",
|
||||||
"dotenv": "16.4.5",
|
"dotenv": "16.4.5",
|
||||||
"drizzle-orm": "0.45.1",
|
"drizzle-orm": "0.45.2",
|
||||||
"drizzle-zod": "0.8.3",
|
"drizzle-zod": "0.8.3",
|
||||||
"fancy-ansi": "^0.1.3",
|
"fancy-ansi": "^0.1.3",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
|
|||||||
@@ -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">
|
<div className="mt-5 flex flex-col justify-center items-center gap-2 sm:flex-row sm:gap-3">
|
||||||
<Link
|
<Link
|
||||||
href="/dashboard/projects"
|
href="/dashboard/home"
|
||||||
className={buttonVariants({
|
className={buttonVariants({
|
||||||
variant: "secondary",
|
variant: "secondary",
|
||||||
className: "flex flex-row gap-2",
|
className: "flex flex-row gap-2",
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ export async function getServerSideProps(ctx: GetServerSidePropsContext) {
|
|||||||
return {
|
return {
|
||||||
redirect: {
|
redirect: {
|
||||||
permanent: false,
|
permanent: false,
|
||||||
destination: "/dashboard/projects",
|
destination: "/dashboard/home",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export async function getServerSideProps(
|
|||||||
return {
|
return {
|
||||||
redirect: {
|
redirect: {
|
||||||
permanent: true,
|
permanent: true,
|
||||||
destination: "/dashboard/projects",
|
destination: "/dashboard/home",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
53
apps/dokploy/pages/dashboard/home.tsx
Normal file
53
apps/dokploy/pages/dashboard/home.tsx
Normal 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(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -96,7 +96,7 @@ export async function getServerSideProps(
|
|||||||
return {
|
return {
|
||||||
redirect: {
|
redirect: {
|
||||||
permanent: true,
|
permanent: true,
|
||||||
destination: "/dashboard/projects",
|
destination: "/dashboard/home",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -122,7 +122,7 @@ export async function getServerSideProps(
|
|||||||
return {
|
return {
|
||||||
redirect: {
|
redirect: {
|
||||||
permanent: false,
|
permanent: false,
|
||||||
destination: "/dashboard/projects",
|
destination: "/dashboard/home",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -509,6 +509,14 @@ const EnvironmentPage = (
|
|||||||
deploy: api.mongo.deploy.useMutation(),
|
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 () => {
|
const handleBulkStart = async () => {
|
||||||
let success = 0;
|
let success = 0;
|
||||||
setIsBulkActionLoading(true);
|
setIsBulkActionLoading(true);
|
||||||
@@ -541,6 +549,9 @@ const EnvironmentPage = (
|
|||||||
case "mongo":
|
case "mongo":
|
||||||
await mongoActions.start.mutateAsync({ mongoId: serviceId });
|
await mongoActions.start.mutateAsync({ mongoId: serviceId });
|
||||||
break;
|
break;
|
||||||
|
case "libsql":
|
||||||
|
await libsqlActions.start.mutateAsync({ libsqlId: serviceId });
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
success++;
|
success++;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -588,6 +599,9 @@ const EnvironmentPage = (
|
|||||||
case "mongo":
|
case "mongo":
|
||||||
await mongoActions.stop.mutateAsync({ mongoId: serviceId });
|
await mongoActions.stop.mutateAsync({ mongoId: serviceId });
|
||||||
break;
|
break;
|
||||||
|
case "libsql":
|
||||||
|
await libsqlActions.stop.mutateAsync({ libsqlId: serviceId });
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
success++;
|
success++;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -664,6 +678,12 @@ const EnvironmentPage = (
|
|||||||
targetEnvironmentId: selectedTargetEnvironment,
|
targetEnvironmentId: selectedTargetEnvironment,
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
case "libsql":
|
||||||
|
await libsqlActions.move.mutateAsync({
|
||||||
|
libsqlId: serviceId,
|
||||||
|
targetEnvironmentId: selectedTargetEnvironment,
|
||||||
|
});
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
await utils.environment.one.invalidate({
|
await utils.environment.one.invalidate({
|
||||||
environmentId,
|
environmentId,
|
||||||
@@ -733,6 +753,11 @@ const EnvironmentPage = (
|
|||||||
mongoId: serviceId,
|
mongoId: serviceId,
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
case "libsql":
|
||||||
|
await libsqlActions.delete.mutateAsync({
|
||||||
|
libsqlId: serviceId,
|
||||||
|
});
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
await utils.environment.one.invalidate({
|
await utils.environment.one.invalidate({
|
||||||
environmentId,
|
environmentId,
|
||||||
@@ -799,6 +824,11 @@ const EnvironmentPage = (
|
|||||||
mongoId: serviceId,
|
mongoId: serviceId,
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
case "libsql":
|
||||||
|
await libsqlActions.deploy.mutateAsync({
|
||||||
|
libsqlId: serviceId,
|
||||||
|
});
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
success++;
|
success++;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1856,7 +1886,7 @@ export async function getServerSideProps(
|
|||||||
return {
|
return {
|
||||||
redirect: {
|
redirect: {
|
||||||
permanent: false,
|
permanent: false,
|
||||||
destination: "/dashboard/projects",
|
destination: "/dashboard/home",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -490,7 +490,7 @@ export async function getServerSideProps(
|
|||||||
return {
|
return {
|
||||||
redirect: {
|
redirect: {
|
||||||
permanent: false,
|
permanent: false,
|
||||||
destination: "/dashboard/projects",
|
destination: "/dashboard/home",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -492,7 +492,7 @@ export async function getServerSideProps(
|
|||||||
return {
|
return {
|
||||||
redirect: {
|
redirect: {
|
||||||
permanent: false,
|
permanent: false,
|
||||||
destination: "/dashboard/projects",
|
destination: "/dashboard/home",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -343,7 +343,7 @@ export async function getServerSideProps(
|
|||||||
return {
|
return {
|
||||||
redirect: {
|
redirect: {
|
||||||
permanent: false,
|
permanent: false,
|
||||||
destination: "/dashboard/projects",
|
destination: "/dashboard/home",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -372,7 +372,7 @@ export async function getServerSideProps(
|
|||||||
return {
|
return {
|
||||||
redirect: {
|
redirect: {
|
||||||
permanent: false,
|
permanent: false,
|
||||||
destination: "/dashboard/projects",
|
destination: "/dashboard/home",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -376,7 +376,7 @@ export async function getServerSideProps(
|
|||||||
return {
|
return {
|
||||||
redirect: {
|
redirect: {
|
||||||
permanent: false,
|
permanent: false,
|
||||||
destination: "/dashboard/projects",
|
destination: "/dashboard/home",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -353,7 +353,7 @@ export async function getServerSideProps(
|
|||||||
return {
|
return {
|
||||||
redirect: {
|
redirect: {
|
||||||
permanent: false,
|
permanent: false,
|
||||||
destination: "/dashboard/projects",
|
destination: "/dashboard/home",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -360,7 +360,7 @@ export async function getServerSideProps(
|
|||||||
return {
|
return {
|
||||||
redirect: {
|
redirect: {
|
||||||
permanent: false,
|
permanent: false,
|
||||||
destination: "/dashboard/projects",
|
destination: "/dashboard/home",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -363,7 +363,7 @@ export async function getServerSideProps(
|
|||||||
return {
|
return {
|
||||||
redirect: {
|
redirect: {
|
||||||
permanent: false,
|
permanent: false,
|
||||||
destination: "/dashboard/projects",
|
destination: "/dashboard/home",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export async function getServerSideProps(
|
|||||||
return {
|
return {
|
||||||
redirect: {
|
redirect: {
|
||||||
permanent: true,
|
permanent: true,
|
||||||
destination: "/dashboard/projects",
|
destination: "/dashboard/home",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export async function getServerSideProps(
|
|||||||
return {
|
return {
|
||||||
redirect: {
|
redirect: {
|
||||||
permanent: true,
|
permanent: true,
|
||||||
destination: "/dashboard/projects",
|
destination: "/dashboard/home",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export async function getServerSideProps(
|
|||||||
return {
|
return {
|
||||||
redirect: {
|
redirect: {
|
||||||
permanent: true,
|
permanent: true,
|
||||||
destination: "/dashboard/projects",
|
destination: "/dashboard/home",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export async function getServerSideProps(
|
|||||||
return {
|
return {
|
||||||
redirect: {
|
redirect: {
|
||||||
permanent: true,
|
permanent: true,
|
||||||
destination: "/dashboard/projects",
|
destination: "/dashboard/home",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export async function getServerSideProps(
|
|||||||
return {
|
return {
|
||||||
redirect: {
|
redirect: {
|
||||||
permanent: true,
|
permanent: true,
|
||||||
destination: "/dashboard/projects",
|
destination: "/dashboard/home",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export async function getServerSideProps(
|
|||||||
return {
|
return {
|
||||||
redirect: {
|
redirect: {
|
||||||
permanent: true,
|
permanent: true,
|
||||||
destination: "/dashboard/projects",
|
destination: "/dashboard/home",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export async function getServerSideProps(
|
|||||||
return {
|
return {
|
||||||
redirect: {
|
redirect: {
|
||||||
permanent: true,
|
permanent: true,
|
||||||
destination: "/dashboard/projects",
|
destination: "/dashboard/home",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export async function getServerSideProps(
|
|||||||
return {
|
return {
|
||||||
redirect: {
|
redirect: {
|
||||||
permanent: true,
|
permanent: true,
|
||||||
destination: "/dashboard/projects",
|
destination: "/dashboard/home",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ export default function Home({ IS_CLOUD }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
toast.success("Logged in successfully");
|
toast.success("Logged in successfully");
|
||||||
router.push("/dashboard/projects");
|
router.push("/dashboard/home");
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("An error occurred while logging in");
|
toast.error("An error occurred while logging in");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -133,7 +133,7 @@ export default function Home({ IS_CLOUD }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
toast.success("Logged in successfully");
|
toast.success("Logged in successfully");
|
||||||
router.push("/dashboard/projects");
|
router.push("/dashboard/home");
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("An error occurred while verifying 2FA code");
|
toast.error("An error occurred while verifying 2FA code");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -163,7 +163,7 @@ export default function Home({ IS_CLOUD }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
toast.success("Logged in successfully");
|
toast.success("Logged in successfully");
|
||||||
router.push("/dashboard/projects");
|
router.push("/dashboard/home");
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("An error occurred while verifying backup code");
|
toast.error("An error occurred while verifying backup code");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -408,7 +408,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||||||
return {
|
return {
|
||||||
redirect: {
|
redirect: {
|
||||||
permanent: true,
|
permanent: true,
|
||||||
destination: "/dashboard/projects",
|
destination: "/dashboard/home",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -437,7 +437,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||||||
return {
|
return {
|
||||||
redirect: {
|
redirect: {
|
||||||
permanent: true,
|
permanent: true,
|
||||||
destination: "/dashboard/projects",
|
destination: "/dashboard/home",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ const Invitation = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
toast.success("Account created successfully");
|
toast.success("Account created successfully");
|
||||||
router.push("/dashboard/projects");
|
router.push("/dashboard/home");
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("An error occurred while creating your account");
|
toast.error("An error occurred while creating your account");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -303,7 +303,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||||||
return {
|
return {
|
||||||
redirect: {
|
redirect: {
|
||||||
permanent: true,
|
permanent: true,
|
||||||
destination: "/dashboard/projects",
|
destination: "/dashboard/home",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import { projectRouter } from "./routers/project";
|
|||||||
import { auditLogRouter } from "./routers/proprietary/audit-log";
|
import { auditLogRouter } from "./routers/proprietary/audit-log";
|
||||||
import { customRoleRouter } from "./routers/proprietary/custom-role";
|
import { customRoleRouter } from "./routers/proprietary/custom-role";
|
||||||
import { licenseKeyRouter } from "./routers/proprietary/license-key";
|
import { licenseKeyRouter } from "./routers/proprietary/license-key";
|
||||||
|
import { scimRouter } from "./routers/proprietary/scim";
|
||||||
import { ssoRouter } from "./routers/proprietary/sso";
|
import { ssoRouter } from "./routers/proprietary/sso";
|
||||||
import { whitelabelingRouter } from "./routers/proprietary/whitelabeling";
|
import { whitelabelingRouter } from "./routers/proprietary/whitelabeling";
|
||||||
import { redirectsRouter } from "./routers/redirects";
|
import { redirectsRouter } from "./routers/redirects";
|
||||||
@@ -93,6 +94,7 @@ export const appRouter = createTRPCRouter({
|
|||||||
organization: organizationRouter,
|
organization: organizationRouter,
|
||||||
licenseKey: licenseKeyRouter,
|
licenseKey: licenseKeyRouter,
|
||||||
sso: ssoRouter,
|
sso: ssoRouter,
|
||||||
|
scim: scimRouter,
|
||||||
whitelabeling: whitelabelingRouter,
|
whitelabeling: whitelabelingRouter,
|
||||||
customRole: customRoleRouter,
|
customRole: customRoleRouter,
|
||||||
auditLog: auditLogRouter,
|
auditLog: auditLogRouter,
|
||||||
|
|||||||
@@ -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
|
search: protectedProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
|
|||||||
78
apps/dokploy/server/api/routers/proprietary/scim.ts
Normal file
78
apps/dokploy/server/api/routers/proprietary/scim.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { db } from "@dokploy/server/db";
|
||||||
|
import { scimProvider } from "@dokploy/server/db/schema";
|
||||||
|
import { requestToHeaders } from "@dokploy/server/index";
|
||||||
|
import { auth } from "@dokploy/server/lib/auth";
|
||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import { and, asc, eq } from "drizzle-orm";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { createTRPCRouter, enterpriseProcedure } from "@/server/api/trpc";
|
||||||
|
|
||||||
|
const providerIdSchema = z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.max(64)
|
||||||
|
.regex(
|
||||||
|
/^[a-z0-9][a-z0-9-]*$/,
|
||||||
|
"Provider ID must be lowercase alphanumeric with optional dashes",
|
||||||
|
);
|
||||||
|
|
||||||
|
export const scimRouter = createTRPCRouter({
|
||||||
|
listProviders: enterpriseProcedure.query(async ({ ctx }) => {
|
||||||
|
const providers = await db.query.scimProvider.findMany({
|
||||||
|
where: eq(scimProvider.organizationId, ctx.session.activeOrganizationId),
|
||||||
|
columns: {
|
||||||
|
id: true,
|
||||||
|
providerId: true,
|
||||||
|
organizationId: true,
|
||||||
|
},
|
||||||
|
orderBy: [asc(scimProvider.providerId)],
|
||||||
|
});
|
||||||
|
return providers;
|
||||||
|
}),
|
||||||
|
generateToken: enterpriseProcedure
|
||||||
|
.input(z.object({ providerId: providerIdSchema }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const existing = await db.query.scimProvider.findFirst({
|
||||||
|
where: eq(scimProvider.providerId, input.providerId),
|
||||||
|
columns: { id: true, organizationId: true },
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "A SCIM provider with this ID already exists",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const result = await auth.generateSCIMToken({
|
||||||
|
body: {
|
||||||
|
providerId: input.providerId,
|
||||||
|
organizationId: ctx.session.activeOrganizationId,
|
||||||
|
},
|
||||||
|
headers: requestToHeaders(ctx.req),
|
||||||
|
});
|
||||||
|
return { scimToken: result.scimToken, providerId: input.providerId };
|
||||||
|
}),
|
||||||
|
deleteProvider: enterpriseProcedure
|
||||||
|
.input(z.object({ providerId: providerIdSchema }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const [deleted] = await db
|
||||||
|
.delete(scimProvider)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(scimProvider.providerId, input.providerId),
|
||||||
|
eq(
|
||||||
|
scimProvider.organizationId,
|
||||||
|
ctx.session.activeOrganizationId,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.returning({ id: scimProvider.id });
|
||||||
|
if (!deleted) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message:
|
||||||
|
"SCIM provider not found or you do not have permission to delete it",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { success: true };
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -1,299 +1,311 @@
|
|||||||
import { relations } from "drizzle-orm";
|
import { relations } from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
boolean,
|
pgTable,
|
||||||
index,
|
text,
|
||||||
integer,
|
timestamp,
|
||||||
pgTable,
|
boolean,
|
||||||
text,
|
integer,
|
||||||
timestamp,
|
index,
|
||||||
uniqueIndex,
|
uniqueIndex,
|
||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
export const user = pgTable("user", {
|
export const user = pgTable("user", {
|
||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
firstName: text("first_name").notNull(),
|
firstName: text("first_name").notNull(),
|
||||||
email: text("email").notNull().unique(),
|
email: text("email").notNull().unique(),
|
||||||
emailVerified: boolean("email_verified").default(false).notNull(),
|
emailVerified: boolean("email_verified").default(false).notNull(),
|
||||||
image: text("image"),
|
image: text("image"),
|
||||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
updatedAt: timestamp("updated_at")
|
updatedAt: timestamp("updated_at")
|
||||||
.defaultNow()
|
.defaultNow()
|
||||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||||
.notNull(),
|
.notNull(),
|
||||||
twoFactorEnabled: boolean("two_factor_enabled").default(false),
|
twoFactorEnabled: boolean("two_factor_enabled").default(false),
|
||||||
role: text("role"),
|
role: text("role"),
|
||||||
ownerId: text("owner_id"),
|
banned: boolean("banned").default(false),
|
||||||
allowImpersonation: boolean("allow_impersonation").default(false),
|
banReason: text("ban_reason"),
|
||||||
lastName: text("last_name").default(""),
|
banExpires: timestamp("ban_expires"),
|
||||||
enableEnterpriseFeatures: boolean("enable_enterprise_features"),
|
ownerId: text("owner_id"),
|
||||||
isValidEnterpriseLicense: boolean("is_valid_enterprise_license"),
|
allowImpersonation: boolean("allow_impersonation").default(false),
|
||||||
|
lastName: text("last_name").default(""),
|
||||||
|
enableEnterpriseFeatures: boolean("enable_enterprise_features"),
|
||||||
|
isValidEnterpriseLicense: boolean("is_valid_enterprise_license"),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const session = pgTable(
|
export const session = pgTable(
|
||||||
"session",
|
"session",
|
||||||
{
|
{
|
||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
expiresAt: timestamp("expires_at").notNull(),
|
expiresAt: timestamp("expires_at").notNull(),
|
||||||
token: text("token").notNull().unique(),
|
token: text("token").notNull().unique(),
|
||||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
updatedAt: timestamp("updated_at")
|
updatedAt: timestamp("updated_at")
|
||||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||||
.notNull(),
|
.notNull(),
|
||||||
ipAddress: text("ip_address"),
|
ipAddress: text("ip_address"),
|
||||||
userAgent: text("user_agent"),
|
userAgent: text("user_agent"),
|
||||||
userId: text("user_id")
|
userId: text("user_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => user.id, { onDelete: "cascade" }),
|
.references(() => user.id, { onDelete: "cascade" }),
|
||||||
activeOrganizationId: text("active_organization_id"),
|
activeOrganizationId: text("active_organization_id"),
|
||||||
},
|
impersonatedBy: text("impersonated_by"),
|
||||||
(table) => [index("session_userId_idx").on(table.userId)],
|
},
|
||||||
|
(table) => [index("session_userId_idx").on(table.userId)],
|
||||||
);
|
);
|
||||||
|
|
||||||
export const account = pgTable(
|
export const account = pgTable(
|
||||||
"account",
|
"account",
|
||||||
{
|
{
|
||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
accountId: text("account_id").notNull(),
|
accountId: text("account_id").notNull(),
|
||||||
providerId: text("provider_id").notNull(),
|
providerId: text("provider_id").notNull(),
|
||||||
userId: text("user_id")
|
userId: text("user_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => user.id, { onDelete: "cascade" }),
|
.references(() => user.id, { onDelete: "cascade" }),
|
||||||
accessToken: text("access_token"),
|
accessToken: text("access_token"),
|
||||||
refreshToken: text("refresh_token"),
|
refreshToken: text("refresh_token"),
|
||||||
idToken: text("id_token"),
|
idToken: text("id_token"),
|
||||||
accessTokenExpiresAt: timestamp("access_token_expires_at"),
|
accessTokenExpiresAt: timestamp("access_token_expires_at"),
|
||||||
refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
|
refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
|
||||||
scope: text("scope"),
|
scope: text("scope"),
|
||||||
password: text("password"),
|
password: text("password"),
|
||||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
updatedAt: timestamp("updated_at")
|
updatedAt: timestamp("updated_at")
|
||||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||||
.notNull(),
|
.notNull(),
|
||||||
},
|
},
|
||||||
(table) => [index("account_userId_idx").on(table.userId)],
|
(table) => [index("account_userId_idx").on(table.userId)],
|
||||||
);
|
);
|
||||||
|
|
||||||
export const verification = pgTable(
|
export const verification = pgTable(
|
||||||
"verification",
|
"verification",
|
||||||
{
|
{
|
||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
identifier: text("identifier").notNull(),
|
identifier: text("identifier").notNull(),
|
||||||
value: text("value").notNull(),
|
value: text("value").notNull(),
|
||||||
expiresAt: timestamp("expires_at").notNull(),
|
expiresAt: timestamp("expires_at").notNull(),
|
||||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
updatedAt: timestamp("updated_at")
|
updatedAt: timestamp("updated_at")
|
||||||
.defaultNow()
|
.defaultNow()
|
||||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||||
.notNull(),
|
.notNull(),
|
||||||
},
|
},
|
||||||
(table) => [index("verification_identifier_idx").on(table.identifier)],
|
(table) => [index("verification_identifier_idx").on(table.identifier)],
|
||||||
);
|
);
|
||||||
|
|
||||||
export const apikey = pgTable(
|
export const apikey = pgTable(
|
||||||
"apikey",
|
"apikey",
|
||||||
{
|
{
|
||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
configId: text("config_id").default("default").notNull(),
|
configId: text("config_id").default("default").notNull(),
|
||||||
name: text("name"),
|
name: text("name"),
|
||||||
start: text("start"),
|
start: text("start"),
|
||||||
referenceId: text("reference_id").notNull(),
|
referenceId: text("reference_id").notNull(),
|
||||||
prefix: text("prefix"),
|
prefix: text("prefix"),
|
||||||
key: text("key").notNull(),
|
key: text("key").notNull(),
|
||||||
refillInterval: integer("refill_interval"),
|
refillInterval: integer("refill_interval"),
|
||||||
refillAmount: integer("refill_amount"),
|
refillAmount: integer("refill_amount"),
|
||||||
lastRefillAt: timestamp("last_refill_at"),
|
lastRefillAt: timestamp("last_refill_at"),
|
||||||
enabled: boolean("enabled").default(true),
|
enabled: boolean("enabled").default(true),
|
||||||
rateLimitEnabled: boolean("rate_limit_enabled").default(true),
|
rateLimitEnabled: boolean("rate_limit_enabled").default(true),
|
||||||
rateLimitTimeWindow: integer("rate_limit_time_window").default(86400000),
|
rateLimitTimeWindow: integer("rate_limit_time_window").default(86400000),
|
||||||
rateLimitMax: integer("rate_limit_max").default(10),
|
rateLimitMax: integer("rate_limit_max").default(10),
|
||||||
requestCount: integer("request_count").default(0),
|
requestCount: integer("request_count").default(0),
|
||||||
remaining: integer("remaining"),
|
remaining: integer("remaining"),
|
||||||
lastRequest: timestamp("last_request"),
|
lastRequest: timestamp("last_request"),
|
||||||
expiresAt: timestamp("expires_at"),
|
expiresAt: timestamp("expires_at"),
|
||||||
createdAt: timestamp("created_at").notNull(),
|
createdAt: timestamp("created_at").notNull(),
|
||||||
updatedAt: timestamp("updated_at").notNull(),
|
updatedAt: timestamp("updated_at").notNull(),
|
||||||
permissions: text("permissions"),
|
permissions: text("permissions"),
|
||||||
metadata: text("metadata"),
|
metadata: text("metadata"),
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [
|
||||||
index("apikey_configId_idx").on(table.configId),
|
index("apikey_configId_idx").on(table.configId),
|
||||||
index("apikey_referenceId_idx").on(table.referenceId),
|
index("apikey_referenceId_idx").on(table.referenceId),
|
||||||
index("apikey_key_idx").on(table.key),
|
index("apikey_key_idx").on(table.key),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
export const ssoProvider = pgTable("sso_provider", {
|
export const ssoProvider = pgTable("sso_provider", {
|
||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
issuer: text("issuer").notNull(),
|
issuer: text("issuer").notNull(),
|
||||||
oidcConfig: text("oidc_config"),
|
oidcConfig: text("oidc_config"),
|
||||||
samlConfig: text("saml_config"),
|
samlConfig: text("saml_config"),
|
||||||
userId: text("user_id").references(() => user.id, { onDelete: "cascade" }),
|
userId: text("user_id").references(() => user.id, { onDelete: "cascade" }),
|
||||||
providerId: text("provider_id").notNull().unique(),
|
providerId: text("provider_id").notNull().unique(),
|
||||||
organizationId: text("organization_id"),
|
organizationId: text("organization_id"),
|
||||||
domain: text("domain").notNull(),
|
domain: text("domain").notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const twoFactor = pgTable(
|
export const twoFactor = pgTable(
|
||||||
"two_factor",
|
"two_factor",
|
||||||
{
|
{
|
||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
secret: text("secret").notNull(),
|
secret: text("secret").notNull(),
|
||||||
backupCodes: text("backup_codes").notNull(),
|
backupCodes: text("backup_codes").notNull(),
|
||||||
userId: text("user_id")
|
userId: text("user_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => user.id, { onDelete: "cascade" }),
|
.references(() => user.id, { onDelete: "cascade" }),
|
||||||
},
|
verified: boolean("verified").default(true),
|
||||||
(table) => [
|
},
|
||||||
index("twoFactor_secret_idx").on(table.secret),
|
(table) => [
|
||||||
index("twoFactor_userId_idx").on(table.userId),
|
index("twoFactor_secret_idx").on(table.secret),
|
||||||
],
|
index("twoFactor_userId_idx").on(table.userId),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
export const organization = pgTable(
|
export const organization = pgTable(
|
||||||
"organization",
|
"organization",
|
||||||
{
|
{
|
||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
slug: text("slug").notNull().unique(),
|
slug: text("slug").notNull().unique(),
|
||||||
logo: text("logo"),
|
logo: text("logo"),
|
||||||
createdAt: timestamp("created_at").notNull(),
|
createdAt: timestamp("created_at").notNull(),
|
||||||
metadata: text("metadata"),
|
metadata: text("metadata"),
|
||||||
},
|
},
|
||||||
(table) => [uniqueIndex("organization_slug_uidx").on(table.slug)],
|
(table) => [uniqueIndex("organization_slug_uidx").on(table.slug)],
|
||||||
);
|
);
|
||||||
|
|
||||||
export const organizationRole = pgTable(
|
export const organizationRole = pgTable(
|
||||||
"organization_role",
|
"organization_role",
|
||||||
{
|
{
|
||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
organizationId: text("organization_id")
|
organizationId: text("organization_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => organization.id, { onDelete: "cascade" }),
|
.references(() => organization.id, { onDelete: "cascade" }),
|
||||||
role: text("role").notNull(),
|
role: text("role").notNull(),
|
||||||
permission: text("permission").notNull(),
|
permission: text("permission").notNull(),
|
||||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
updatedAt: timestamp("updated_at").$onUpdate(
|
updatedAt: timestamp("updated_at").$onUpdate(
|
||||||
() => /* @__PURE__ */ new Date(),
|
() => /* @__PURE__ */ new Date(),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [
|
||||||
index("organizationRole_organizationId_idx").on(table.organizationId),
|
index("organizationRole_organizationId_idx").on(table.organizationId),
|
||||||
index("organizationRole_role_idx").on(table.role),
|
index("organizationRole_role_idx").on(table.role),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
export const member = pgTable(
|
export const member = pgTable(
|
||||||
"member",
|
"member",
|
||||||
{
|
{
|
||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
organizationId: text("organization_id")
|
organizationId: text("organization_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => organization.id, { onDelete: "cascade" }),
|
.references(() => organization.id, { onDelete: "cascade" }),
|
||||||
userId: text("user_id")
|
userId: text("user_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => user.id, { onDelete: "cascade" }),
|
.references(() => user.id, { onDelete: "cascade" }),
|
||||||
role: text("role").default("member").notNull(),
|
role: text("role").default("member").notNull(),
|
||||||
createdAt: timestamp("created_at").notNull(),
|
createdAt: timestamp("created_at").notNull(),
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [
|
||||||
index("member_organizationId_idx").on(table.organizationId),
|
index("member_organizationId_idx").on(table.organizationId),
|
||||||
index("member_userId_idx").on(table.userId),
|
index("member_userId_idx").on(table.userId),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
export const invitation = pgTable(
|
export const invitation = pgTable(
|
||||||
"invitation",
|
"invitation",
|
||||||
{
|
{
|
||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
organizationId: text("organization_id")
|
organizationId: text("organization_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => organization.id, { onDelete: "cascade" }),
|
.references(() => organization.id, { onDelete: "cascade" }),
|
||||||
email: text("email").notNull(),
|
email: text("email").notNull(),
|
||||||
role: text("role"),
|
role: text("role"),
|
||||||
status: text("status").default("pending").notNull(),
|
status: text("status").default("pending").notNull(),
|
||||||
expiresAt: timestamp("expires_at").notNull(),
|
expiresAt: timestamp("expires_at").notNull(),
|
||||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
inviterId: text("inviter_id")
|
inviterId: text("inviter_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => user.id, { onDelete: "cascade" }),
|
.references(() => user.id, { onDelete: "cascade" }),
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [
|
||||||
index("invitation_organizationId_idx").on(table.organizationId),
|
index("invitation_organizationId_idx").on(table.organizationId),
|
||||||
index("invitation_email_idx").on(table.email),
|
index("invitation_email_idx").on(table.email),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const scimProvider = pgTable("scim_provider", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
providerId: text("provider_id").notNull().unique(),
|
||||||
|
scimToken: text("scim_token").notNull().unique(),
|
||||||
|
organizationId: text("organization_id"),
|
||||||
|
});
|
||||||
|
|
||||||
export const userRelations = relations(user, ({ many }) => ({
|
export const userRelations = relations(user, ({ many }) => ({
|
||||||
sessions: many(session),
|
sessions: many(session),
|
||||||
accounts: many(account),
|
accounts: many(account),
|
||||||
ssoProviders: many(ssoProvider),
|
ssoProviders: many(ssoProvider),
|
||||||
twoFactors: many(twoFactor),
|
twoFactors: many(twoFactor),
|
||||||
members: many(member),
|
members: many(member),
|
||||||
invitations: many(invitation),
|
invitations: many(invitation),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const sessionRelations = relations(session, ({ one }) => ({
|
export const sessionRelations = relations(session, ({ one }) => ({
|
||||||
user: one(user, {
|
user: one(user, {
|
||||||
fields: [session.userId],
|
fields: [session.userId],
|
||||||
references: [user.id],
|
references: [user.id],
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const accountRelations = relations(account, ({ one }) => ({
|
export const accountRelations = relations(account, ({ one }) => ({
|
||||||
user: one(user, {
|
user: one(user, {
|
||||||
fields: [account.userId],
|
fields: [account.userId],
|
||||||
references: [user.id],
|
references: [user.id],
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const ssoProviderRelations = relations(ssoProvider, ({ one }) => ({
|
export const ssoProviderRelations = relations(ssoProvider, ({ one }) => ({
|
||||||
user: one(user, {
|
user: one(user, {
|
||||||
fields: [ssoProvider.userId],
|
fields: [ssoProvider.userId],
|
||||||
references: [user.id],
|
references: [user.id],
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const twoFactorRelations = relations(twoFactor, ({ one }) => ({
|
export const twoFactorRelations = relations(twoFactor, ({ one }) => ({
|
||||||
user: one(user, {
|
user: one(user, {
|
||||||
fields: [twoFactor.userId],
|
fields: [twoFactor.userId],
|
||||||
references: [user.id],
|
references: [user.id],
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const organizationRelations = relations(organization, ({ many }) => ({
|
export const organizationRelations = relations(organization, ({ many }) => ({
|
||||||
organizationRoles: many(organizationRole),
|
organizationRoles: many(organizationRole),
|
||||||
members: many(member),
|
members: many(member),
|
||||||
invitations: many(invitation),
|
invitations: many(invitation),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const organizationRoleRelations = relations(
|
export const organizationRoleRelations = relations(
|
||||||
organizationRole,
|
organizationRole,
|
||||||
({ one }) => ({
|
({ one }) => ({
|
||||||
organization: one(organization, {
|
organization: one(organization, {
|
||||||
fields: [organizationRole.organizationId],
|
fields: [organizationRole.organizationId],
|
||||||
references: [organization.id],
|
references: [organization.id],
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const memberRelations = relations(member, ({ one }) => ({
|
export const memberRelations = relations(member, ({ one }) => ({
|
||||||
organization: one(organization, {
|
organization: one(organization, {
|
||||||
fields: [member.organizationId],
|
fields: [member.organizationId],
|
||||||
references: [organization.id],
|
references: [organization.id],
|
||||||
}),
|
}),
|
||||||
user: one(user, {
|
user: one(user, {
|
||||||
fields: [member.userId],
|
fields: [member.userId],
|
||||||
references: [user.id],
|
references: [user.id],
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const invitationRelations = relations(invitation, ({ one }) => ({
|
export const invitationRelations = relations(invitation, ({ one }) => ({
|
||||||
organization: one(organization, {
|
organization: one(organization, {
|
||||||
fields: [invitation.organizationId],
|
fields: [invitation.organizationId],
|
||||||
references: [organization.id],
|
references: [organization.id],
|
||||||
}),
|
}),
|
||||||
user: one(user, {
|
user: one(user, {
|
||||||
fields: [invitation.inviterId],
|
fields: [invitation.inviterId],
|
||||||
references: [user.id],
|
references: [user.id],
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -37,9 +37,10 @@
|
|||||||
"@ai-sdk/mistral": "^3.0.20",
|
"@ai-sdk/mistral": "^3.0.20",
|
||||||
"@ai-sdk/openai": "^3.0.29",
|
"@ai-sdk/openai": "^3.0.29",
|
||||||
"@ai-sdk/openai-compatible": "^2.0.30",
|
"@ai-sdk/openai-compatible": "^2.0.30",
|
||||||
"@better-auth/api-key": "1.5.4",
|
"@better-auth/api-key": "1.6.5",
|
||||||
"@better-auth/sso": "1.5.4",
|
"@better-auth/scim": "^1.6.5",
|
||||||
"@better-auth/utils": "0.3.1",
|
"@better-auth/sso": "1.6.5",
|
||||||
|
"@better-auth/utils": "0.4.0",
|
||||||
"@faker-js/faker": "^8.4.1",
|
"@faker-js/faker": "^8.4.1",
|
||||||
"@octokit/auth-app": "^6.1.3",
|
"@octokit/auth-app": "^6.1.3",
|
||||||
"@octokit/rest": "^20.1.2",
|
"@octokit/rest": "^20.1.2",
|
||||||
@@ -51,15 +52,14 @@
|
|||||||
"ai": "^6.0.86",
|
"ai": "^6.0.86",
|
||||||
"ai-sdk-ollama": "^3.7.0",
|
"ai-sdk-ollama": "^3.7.0",
|
||||||
"bcrypt": "5.1.1",
|
"bcrypt": "5.1.1",
|
||||||
"better-auth": "1.5.4",
|
"better-auth": "1.6.5",
|
||||||
"better-call": "2.0.2",
|
|
||||||
"bl": "6.0.11",
|
"bl": "6.0.11",
|
||||||
"boxen": "^7.1.1",
|
"boxen": "^7.1.1",
|
||||||
"date-fns": "3.6.0",
|
"date-fns": "3.6.0",
|
||||||
"dockerode": "4.0.2",
|
"dockerode": "4.0.2",
|
||||||
"dotenv": "16.4.5",
|
"dotenv": "16.4.5",
|
||||||
"drizzle-dbml-generator": "0.10.0",
|
"drizzle-dbml-generator": "0.10.0",
|
||||||
"drizzle-orm": "0.45.1",
|
"drizzle-orm": "0.45.2",
|
||||||
"drizzle-zod": "0.5.1",
|
"drizzle-zod": "0.5.1",
|
||||||
"lodash": "4.17.21",
|
"lodash": "4.17.21",
|
||||||
"micromatch": "4.0.8",
|
"micromatch": "4.0.8",
|
||||||
|
|||||||
@@ -214,6 +214,7 @@ export const twoFactor = pgTable("two_factor", {
|
|||||||
userId: text("user_id")
|
userId: text("user_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => user.id, { onDelete: "cascade" }),
|
.references(() => user.id, { onDelete: "cascade" }),
|
||||||
|
verified: boolean("verified").notNull().default(true),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const apikey = pgTable("apikey", {
|
export const apikey = pgTable("apikey", {
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export * from "./redis";
|
|||||||
export * from "./registry";
|
export * from "./registry";
|
||||||
export * from "./rollbacks";
|
export * from "./rollbacks";
|
||||||
export * from "./schedule";
|
export * from "./schedule";
|
||||||
|
export * from "./scim";
|
||||||
export * from "./security";
|
export * from "./security";
|
||||||
export * from "./server";
|
export * from "./server";
|
||||||
export * from "./session";
|
export * from "./session";
|
||||||
|
|||||||
22
packages/server/src/db/schema/scim.ts
Normal file
22
packages/server/src/db/schema/scim.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { relations } from "drizzle-orm";
|
||||||
|
import { pgTable, text } from "drizzle-orm/pg-core";
|
||||||
|
import { nanoid } from "nanoid";
|
||||||
|
import { organization } from "./account";
|
||||||
|
|
||||||
|
export const scimProvider = pgTable("scim_provider", {
|
||||||
|
id: text("id")
|
||||||
|
.primaryKey()
|
||||||
|
.$defaultFn(() => nanoid()),
|
||||||
|
providerId: text("provider_id").notNull().unique(),
|
||||||
|
scimToken: text("scim_token").notNull().unique(),
|
||||||
|
organizationId: text("organization_id").references(() => organization.id, {
|
||||||
|
onDelete: "cascade",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const scimProviderRelations = relations(scimProvider, ({ one }) => ({
|
||||||
|
organization: one(organization, {
|
||||||
|
fields: [scimProvider.organizationId],
|
||||||
|
references: [organization.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
52
packages/server/src/lib/auth-cli.ts
Normal file
52
packages/server/src/lib/auth-cli.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { apiKey } from "@better-auth/api-key";
|
||||||
|
import { scim } from "@better-auth/scim";
|
||||||
|
import { sso } from "@better-auth/sso";
|
||||||
|
import { betterAuth } from "better-auth";
|
||||||
|
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||||
|
import { admin, organization, twoFactor } from "better-auth/plugins";
|
||||||
|
import { db } from "../db";
|
||||||
|
import * as schema from "../db/schema";
|
||||||
|
import { ac, adminRole, memberRole, ownerRole } from "./access-control";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal better-auth config used only by `@better-auth/cli` to generate /
|
||||||
|
* inspect database schemas. Must mirror the plugin set in `auth.ts` so the CLI
|
||||||
|
* sees every table each plugin expects.
|
||||||
|
*
|
||||||
|
* Do NOT import this file from the runtime — use `auth.ts` for that.
|
||||||
|
*/
|
||||||
|
export const auth = betterAuth({
|
||||||
|
database: drizzleAdapter(db, {
|
||||||
|
provider: "pg",
|
||||||
|
schema,
|
||||||
|
}),
|
||||||
|
user: {
|
||||||
|
modelName: "user",
|
||||||
|
fields: {
|
||||||
|
name: "firstName",
|
||||||
|
},
|
||||||
|
additionalFields: {
|
||||||
|
role: { type: "string", input: false },
|
||||||
|
ownerId: { type: "string", input: false },
|
||||||
|
allowImpersonation: { type: "boolean", defaultValue: false },
|
||||||
|
lastName: { type: "string", required: false, defaultValue: "" },
|
||||||
|
enableEnterpriseFeatures: { type: "boolean", required: false },
|
||||||
|
isValidEnterpriseLicense: { type: "boolean", required: false },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
apiKey({ enableMetadata: true, references: "user" }),
|
||||||
|
sso(),
|
||||||
|
twoFactor(),
|
||||||
|
organization({
|
||||||
|
ac,
|
||||||
|
roles: { owner: ownerRole, admin: adminRole, member: memberRole },
|
||||||
|
dynamicAccessControl: {
|
||||||
|
enabled: true,
|
||||||
|
maximumRolesPerOrganization: 10,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
scim(),
|
||||||
|
admin(),
|
||||||
|
],
|
||||||
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { IncomingMessage } from "node:http";
|
import type { IncomingMessage } from "node:http";
|
||||||
import { apiKey } from "@better-auth/api-key";
|
import { apiKey } from "@better-auth/api-key";
|
||||||
|
import { scim } from "@better-auth/scim";
|
||||||
import { sso } from "@better-auth/sso";
|
import { sso } from "@better-auth/sso";
|
||||||
import * as bcrypt from "bcrypt";
|
import * as bcrypt from "bcrypt";
|
||||||
import { betterAuth } from "better-auth";
|
import { betterAuth } from "better-auth";
|
||||||
@@ -178,7 +179,8 @@ const { handler, api } = betterAuth({
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const isSSORequest = context?.path.includes("/sso");
|
const isSSORequest = context?.path.includes("/sso");
|
||||||
if (isSSORequest) {
|
const isSCIMRequest = context?.path.includes("/scim");
|
||||||
|
if (isSSORequest || isSCIMRequest) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const isAdminPresent = await db.query.member.findFirst({
|
const isAdminPresent = await db.query.member.findFirst({
|
||||||
@@ -194,6 +196,7 @@ const { handler, api } = betterAuth({
|
|||||||
},
|
},
|
||||||
after: async (user, context) => {
|
after: async (user, context) => {
|
||||||
const isSSORequest = context?.path.includes("/sso");
|
const isSSORequest = context?.path.includes("/sso");
|
||||||
|
const isSCIMRequest = context?.path.includes("/scim");
|
||||||
const isAdminPresent = await db.query.member.findFirst({
|
const isAdminPresent = await db.query.member.findFirst({
|
||||||
where: eq(schema.member.role, "owner"),
|
where: eq(schema.member.role, "owner"),
|
||||||
});
|
});
|
||||||
@@ -229,6 +232,10 @@ const { handler, api } = betterAuth({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isSCIMRequest) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (IS_CLOUD || !isAdminPresent) {
|
if (IS_CLOUD || !isAdminPresent) {
|
||||||
await db.transaction(async (tx) => {
|
await db.transaction(async (tx) => {
|
||||||
const organization = await tx
|
const organization = await tx
|
||||||
@@ -396,7 +403,24 @@ const { handler, api } = betterAuth({
|
|||||||
enableMetadata: true,
|
enableMetadata: true,
|
||||||
references: "user",
|
references: "user",
|
||||||
}),
|
}),
|
||||||
sso(),
|
sso({
|
||||||
|
saml: {
|
||||||
|
enableInResponseToValidation: false,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
scim({
|
||||||
|
beforeSCIMTokenGenerated: async ({ user }) => {
|
||||||
|
const dbUser = await db.query.user.findFirst({
|
||||||
|
where: eq(schema.user.id, user.id),
|
||||||
|
columns: { enableEnterpriseFeatures: true },
|
||||||
|
});
|
||||||
|
if (!dbUser?.enableEnterpriseFeatures) {
|
||||||
|
throw new APIError("FORBIDDEN", {
|
||||||
|
message: "SCIM provisioning requires an enterprise license",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
twoFactor(),
|
twoFactor(),
|
||||||
organization({
|
organization({
|
||||||
ac,
|
ac,
|
||||||
@@ -442,6 +466,9 @@ const _auth = {
|
|||||||
createApiKey: api.createApiKey,
|
createApiKey: api.createApiKey,
|
||||||
registerSSOProvider: api.registerSSOProvider,
|
registerSSOProvider: api.registerSSOProvider,
|
||||||
updateSSOProvider: api.updateSSOProvider,
|
updateSSOProvider: api.updateSSOProvider,
|
||||||
|
generateSCIMToken: api.generateSCIMToken,
|
||||||
|
listSCIMProviderConnections: api.listSCIMProviderConnections,
|
||||||
|
deleteSCIMProviderConnection: api.deleteSCIMProviderConnection,
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AuthType = typeof _auth;
|
export type AuthType = typeof _auth;
|
||||||
|
|||||||
763
pnpm-lock.yaml
generated
763
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user