Compare commits

..
39 Commits
Author SHA1 Message Date
github-actions[bot] 4fadfcfaf2 chore: release 1.15.4 2026-05-21 17:46:00 +00:00
af3df7f72a fix: multiple-webhook-headers and nextcloud talk provider (#296)
* feat(webhook): replace single secret header pair with webhookHeaders array schema

* feat(webhook): iterate webhookHeaders array; keep backward compat for legacy secret fields

* feat(webhook): replace single header pair with dynamic useFieldArray header list

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(webhook): add field labels to header key/value inputs

* fix(webhook): guard reserved headers; align delete button with inputs

* feat(nextcloud): add nextcloud to provider_kind enum

* feat(nextcloud): add ProviderKind entry and HMAC-signed provider

* feat(nextcloud): add Zod schema and form component

* feat(nextcloud): wire nextcloud into form registry and UI provider list

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(nextcloud): register sendNextcloud in provider dispatch map

* fix: multiple webhook headers and nextcloud talk notification provider

* test(webhook): update e2e to use Add Header button and Header Value label

* fix: webhook.ts

---------

Co-authored-by: charles-gauthereau <charles.gauthereau@soluce-technologies.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 19:45:08 +02:00
github-actions[bot] 2cd5bda416 chore: release 1.15.3 2026-05-21 05:51:50 +00:00
965b215224 fix: replace integer column with bigint for size (#295)
Co-authored-by: charles-gauthereau <charles.gauthereau@soluce-technologies.com>
2026-05-21 07:51:08 +02:00
github-actions[bot] 27f6b746e0 chore: release 1.15.2 2026-05-21 05:28:56 +00:00
7d463d7da8 fix: default user from init .env variables (#293)
* fix: default user from init en .env variables

* fix: .env.example

---------

Co-authored-by: charles-gauthereau <charles.gauthereau@soluce-technologies.com>
2026-05-21 07:28:05 +02:00
github-actions[bot] 332540488d chore: release 1.15.1 2026-05-17 08:42:07 +00:00
ab52ef6c27 fix: logger.ts (#288)
* fix: release.yml

* fix: S3ChannelConfigSchema type port mismatch

* chore: package.json

* fix: refactoring logs and adding pino logger for production and dev.

* fix: trusted url error due to better auth update.

* fix: logger.ts

---------

Co-authored-by: charlesgauthereau <charles.gauthereau@soluce-technologies.com>
Co-authored-by: killianlarcher <killian.larcher@soluce-technologies.com>
2026-05-17 10:41:36 +02:00
github-actions[bot] 2d55618898 chore: release 1.15.0 2026-05-17 08:00:16 +00:00
Charles GTEandGitHub 6b3df405eb fix: add-healthcheck (#287)
fix: add-healthcheck
2026-05-17 09:59:38 +02:00
Charles GTE e91ef4c142 fix: docker-compose.prod.yml 2026-05-17 09:52:58 +02:00
Charles GTE 9b504ccd68 fix: proxy.ts 2026-05-17 09:46:03 +02:00
Charles GTE dd3f4411cf chore: add startupProbe and fix probe timing for cold-boot safety 2026-05-17 09:39:11 +02:00
Charles GTE 0ab94b6221 chore: add liveness and readiness probes to Helm deployment 2026-05-17 09:37:28 +02:00
Charles GTE 8477889cea chore: fix healthcheck start_period and clean up curl command 2026-05-17 09:36:49 +02:00
Charles GTE 2eb4a7a0fe chore: add healthcheck probe to prod docker-compose app service 2026-05-17 09:35:01 +02:00
Charles GTE 1f68adda90 feat: add GET /api/health liveness endpoint 2026-05-17 09:33:53 +02:00
Charles GTEandGitHub ffba2c56a3 fix: env
fix: env.mjs [skip-release]
2026-05-15 23:03:19 +02:00
github-actions[bot] 02be78a26e chore: release 1.14.1 2026-05-15 20:42:10 +00:00
Charles GTEandGitHub 711d4613ed fix: log noise #284
fix: logs noise
2026-05-15 22:41:21 +02:00
Charles GTE a5fe5225e1 fix: pin @better-auth/sso and @better-auth/passkey to 1.6.2 to fix build 2026-05-15 22:28:38 +02:00
Charles GTE b752d9437c chore: pnpm 2026-05-15 22:22:21 +02:00
Charles GTE 18df92e048 chore: update packages 2026-05-15 22:18:55 +02:00
Charles GTE 4e66b8ed04 fix: logs 2026-05-15 22:08:14 +02:00
Charles GTEandClaude Sonnet 4.6 4f14d16452 fix: replace console.log with Pino logger in server-side files
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 22:05:13 +02:00
Charles GTE 592588ef76 docs: set LOG_LEVEL=info as dev default in .env.example 2026-05-15 22:01:19 +02:00
Charles GTE 3fec7c3392 docs: document LOG_LEVEL env var in .env.example 2026-05-15 21:59:00 +02:00
Charles GTE 2335caefd7 fix: downgrade cleaningJob logs to debug; fix catch log level 2026-05-15 21:57:56 +02:00
Charles GTE 194eedeaa8 fix: downgrade agent status ping log to debug 2026-05-15 21:57:21 +02:00
Charles GTE ecd09aedb9 fix: cache log level index at module init in loggingMiddleware 2026-05-15 21:56:37 +02:00
Charles GTE b5350b5fe3 fix: .gitignore 2026-05-15 21:55:19 +02:00
Charles GTE ceaafd5531 fix: suppress agent status ping logs below debug level 2026-05-15 21:55:01 +02:00
Charles GTE 654accad4a fix: use nullish coalescing for LOG_LEVEL fallback 2026-05-15 21:53:54 +02:00
Charles GTE 173c7894b4 fix: respect LOG_LEVEL env var in Pino logger 2026-05-15 21:52:51 +02:00
Charles GTE 1ab51d15f9 fix: added mailpit 2026-05-15 21:24:11 +02:00
Charles GTEandGitHub ddce51d2d3 fix: README.md [skip-release] (#280) 2026-05-12 20:03:49 +02:00
Charles GTEandGitHub fc9fbf4357 chore: update the README.md with mssql (#278) [skip-release] 2026-05-11 22:27:19 +02:00
github-actions[bot] 5e8cd7e0e6 chore: release 1.14.0 2026-05-11 20:09:21 +00:00
Charles GTEandGitHub 1eb5fbb983 feat: add support for mssql (#277)
* feat: add support for mssql

* fix: common.ts
2026-05-11 22:08:36 +02:00
60 changed files with 10992 additions and 1953 deletions
+15
View File
@@ -1,6 +1,12 @@
# Environment
NODE_ENV=development
# Logging
# Controls minimum log level. Options: debug | info | warn | error
# Set to "warn" or "error" in production to suppress health-check ping noise.
# Use "debug" to see all request logs including agent status pings.
LOG_LEVEL=info
# Database
DATABASE_URL=postgresql://devuser:changeme@localhost:5433/devdb?schema=public
@@ -9,6 +15,15 @@ PROJECT_NAME="Portabase"
PROJECT_URL=http://localhost:8887
PROJECT_SECRET=
AUTH_DEFAULT_USER_NAME="Portabase Admin"
AUTH_DEFAULT_USER=user@example.com
# Password must contain at least 8 characters
# Password must contain at least 1 number
# Password must contain at least 1 lowercase letter
# Password must contain at least 1 uppercase letter
# Password must contain at least 1 special character
AUTH_DEFAULT_PASSWORD=testPASS123456!
# SMTP (email)
SMTP_HOST=
SMTP_PORT=
+4
View File
@@ -55,3 +55,7 @@ certificates
/playwright/.cache/
/playwright/.auth/
/e2e/local-storage.json
.claude
.codex
/docs
+3 -1
View File
@@ -21,6 +21,8 @@ keywords:
- backups
- postgresql
- mysql
- firebird
- mssql
- mariadb
- devops
- database
@@ -31,5 +33,5 @@ keywords:
- web-ui
- agent
license: Apache-2.0
version: 1.13.1
version: 1.15.4
date-released: '2026-03-02'
+12 -11
View File
@@ -25,6 +25,7 @@
[![MongoDB](https://img.shields.io/badge/-MongoDB-13aa52?logo=mongodb&logoColor=white)](https://www.mongodb.com/)
[![Valkey](https://img.shields.io/badge/Valkey-6284fc?style=flat&logo=Valkey&logoColor=white)](https://valkey.io/)
[![Firebird](https://img.shields.io/badge/Firebird-f55b14?style=flat&logo=Firebird&logoColor=white)](https://firebirdsql.org/)
[![Microsoft SQL Server](https://img.shields.io/badge/Microsoft%20SQL%20Server-CC2927?style=flat&logo=microsoftsqlserver&logoColor=white)](https://www.microsoft.com/en-us/sql-server)
[![Self Hosted](https://img.shields.io/badge/self--hosted-yes-brightgreen)](https://github.com/Portabase/portabase)
[![Open Source](https://img.shields.io/badge/open%20source-❤️-red)](https://github.com/Portabase/portabase)
@@ -65,17 +66,17 @@ You have 4 ways to install Portabase:
## Supported databases
| Engine | Support | Supported Versions | Restore |
|:-------------------|:-----------|:------------------------------|:--------|
| **PostgreSQL** | ✅ Stable | 12, 13, 14, 15, 16, 17 and 18 | Yes |
| **MySQL** | ✅ Stable | 5.7, 8 and 9 | Yes |
| **MariaDB** | ✅ Stable | 10 and 11 | Yes |
| **MongoDB** | ✅ Stable | 4, 5, 6, 7 and 8 | Yes |
| **SQLite** | ✅ Stable | 3.x | Yes |
| **Redis** | ✅ Stable | 2.8+ | No |
| **Valkey** | ✅ Stable | 7.2+ | No |
| **Firebird** | ✅ Stable | 3.0, 4.0, 5.0 | Yes |
| **MSSQL Server** | ❌ Ongoing | - | Yes |
| Engine | Support | Supported Versions | Restore |
|:-------------------|:----------|:------------------------------|:--------|
| **PostgreSQL** | ✅ Stable | 12, 13, 14, 15, 16, 17 and 18 | Yes |
| **MySQL** | ✅ Stable | 5.7, 8 and 9 | Yes |
| **MariaDB** | ✅ Stable | 10 and 11 | Yes |
| **MongoDB** | ✅ Stable | 4, 5, 6, 7 and 8 | Yes |
| **SQLite** | ✅ Stable | 3.x | Yes |
| **Redis** | ✅ Stable | 2.8+ | No |
| **Valkey** | ✅ Stable | 7.2+ | No |
| **Firebird** | ✅ Stable | 3.0, 4.0, 5.0 | Yes |
| **MSSQL Server** | ✅ Stable | 2017, 2019, 2022, Azure SQL | Yes |
See the [Database Servers documentation](https://portabase.io/docs/agent/db) for version-specific backup and restore details.
@@ -1,6 +1,7 @@
import {PageParams} from "@/types/next";
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {db} from "@/db";
import {logger} from "@/lib/logger";
import {notFound} from "next/navigation";
import {SettingsTabs} from "@/components/wrappers/dashboard/admin/settings/settings-tabs";
import {desc, isNull} from "drizzle-orm";
@@ -8,13 +9,15 @@ import * as drizzleDb from "@/db";
import {StorageChannelWith} from "@/db/schema/12_storage-channel";
import {NotificationChannelWith} from "@/db/schema/09_notification-channel";
const log = logger.child({module: "dashboard/admin/settings"});
export default async function RoutePage(props: PageParams<{}>) {
const settings = await db.query.setting.findFirst({
where: (fields, {eq}) => eq(fields.name, "system"),
});
console.log(settings)
log.debug({settings}, "Settings loaded");
const storageChannels = await db.query.storageChannel.findMany({
with: {
@@ -33,7 +36,7 @@ export default async function RoutePage(props: PageParams<{}>) {
}) as NotificationChannelWith[]
if (!settings || !storageChannels || !notificationChannels ) {
if (!settings || !storageChannels || !notificationChannels) {
notFound()
}
@@ -45,7 +48,8 @@ export default async function RoutePage(props: PageParams<{}>) {
</div>
</PageHeader>
<PageContent className="flex flex-col gap-5">
<SettingsTabs storageChannels={storageChannels} notificationChannels={notificationChannels} settings={settings} />
<SettingsTabs storageChannels={storageChannels} notificationChannels={notificationChannels}
settings={settings}/>
</PageContent>
</Page>
);
@@ -30,9 +30,11 @@ export default async function RoutePage(props: PageParams<{}>) {
<PageHeader className="flex flex-col">
<div className="flex justify-between">
<PageTitle className="mb-3">Active users</PageTitle>
<PageActions>
<AdminUserAddModal organizations={organizations}/>
</PageActions>
{isPasswordAuthEnabled && (
<PageActions>
<AdminUserAddModal organizations={organizations}/>
</PageActions>
)}
</div>
</PageHeader>
<PageContent className="flex flex-col gap-5">
-4
View File
@@ -9,8 +9,4 @@ export default async function Index() {
redirect(`/dashboard/home`);
}
redirect("/login");
//Do not delete
// return (
// <Home/>
// );
}
+1 -1
View File
@@ -31,7 +31,7 @@ export async function POST(
) {
try {
const agentId = (await params).agentId
log.info(`Agent ID: ${agentId}`)
log.debug(`Agent ID: ${agentId}`)
const body: Body = await request.json();
const lastContact = new Date();
let message: string
+5
View File
@@ -0,0 +1,5 @@
import { NextResponse } from "next/server";
export async function GET() {
return NextResponse.json({ success: true });
}
+11 -5
View File
@@ -1,10 +1,10 @@
services:
app:
# build:
# context: .
# dockerfile: docker/dockerfile/Dockerfile
# target: prod
image: portabase/portabase:1.7.1
# build:
# context: .
# dockerfile: docker/dockerfile/Dockerfile
# target: prod
image: portabase/portabase:1
ports:
- '8887:80'
environment:
@@ -17,6 +17,12 @@ services:
db:
condition: service_healthy
container_name: portabase-app-prod
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost/api/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 60s
db:
image: postgres:16-alpine
+10 -1
View File
@@ -12,7 +12,7 @@ services:
- POSTGRES_USER=devuser
- POSTGRES_PASSWORD=changeme
healthcheck:
test: ["CMD-SHELL", "pg_isready -U devuser -d devdb"]
test: [ "CMD-SHELL", "pg_isready -U devuser -d devdb" ]
interval: 10s
timeout: 5s
retries: 5
@@ -32,5 +32,14 @@ services:
- ./private/uploads/tmp:/data/uploads/tmp
user: "1000:1000"
mailpit:
image: axllent/mailpit:latest
container_name: portabase-mailpit-dev
hostname: mailpit
restart: unless-stopped
ports:
- "8025:8025"
- "1025:1025"
volumes:
postgres-data:
+4 -2
View File
@@ -31,8 +31,9 @@ const invalidChannelName = "Webhook E2E Invalid";
// await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
// await create(page, "Webhook", optionalChannelName, async (page) => {
// await page.getByLabel(/Webhook URL/).fill(getEnv("E2E_NOTIFICATION_WEBHOOK_URL"));
// await page.getByRole("button", { name: "Add Header" }).click();
// await page.getByLabel(/^Header Name$/).fill(getEnv("E2E_NOTIFICATION_WEBHOOK_SECRET_HEADER"));
// await page.getByLabel(/^Secret Value$/).fill(getEnv("E2E_NOTIFICATION_WEBHOOK_SECRET"));
// await page.getByLabel(/^Header Value$/).fill(getEnv("E2E_NOTIFICATION_WEBHOOK_SECRET"));
// });
// await submit(page);
// await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
@@ -49,8 +50,9 @@ test.describe.serial("Invalid channel", () => {
await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
await create(page, "Webhook", invalidChannelName, async (page) => {
await page.getByLabel(/Webhook URL/).fill("https://webhook.example.com/api/wrong-webhook");
await page.getByRole("button", { name: "Add Header" }).click();
await page.getByLabel(/^Header Name$/).fill(getEnv("E2E_NOTIFICATION_WEBHOOK_SECRET_HEADER"));
await page.getByLabel(/^Secret Value$/).fill("wrong-webhook-secret");
await page.getByLabel(/^Header Value$/).fill("wrong-webhook-secret");
});
await submit(page);
await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
+24
View File
@@ -27,6 +27,30 @@ spec:
volumeMounts:
- name: data
mountPath: /data
startupProbe:
httpGet:
path: /api/health
port: 80
initialDelaySeconds: 10
periodSeconds: 10
failureThreshold: 12
timeoutSeconds: 5
livenessProbe:
httpGet:
path: /api/health
port: 80
initialDelaySeconds: 0
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /api/health
port: 80
initialDelaySeconds: 0
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
volumes:
- name: data
persistentVolumeClaim:
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "portabase",
"version": "1.13.1",
"version": "1.15.4",
"private": true,
"scripts": {
"dev": "next dev --turbopack -p 8887",
@@ -16,8 +16,8 @@
},
"dependencies": {
"@better-auth/core": "1.6.2",
"@better-auth/passkey": "^1.6.2",
"@better-auth/sso": "^1.6.2",
"@better-auth/passkey": "1.6.2",
"@better-auth/sso": "1.6.2",
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",
@@ -128,7 +128,7 @@
"eslint-plugin-tailwindcss": "^3.18.2",
"framer-motion": "^12.38.0",
"node-pty": "^1.1.0",
"postcss": "^8.5.9",
"postcss": "8.5.10",
"release-it": "^19.2.4",
"tailwindcss": "^4.2.2",
"tsx": "^4.21.0",
@@ -136,5 +136,5 @@
"typescript": "^5.9.3",
"zenstack": "2.14.2"
},
"packageManager": "pnpm@10.33.0"
"packageManager": "pnpm@11.1.3"
}
+2038 -1547
View File
File diff suppressed because it is too large Load Diff
+14
View File
@@ -1,3 +1,17 @@
allowBuilds:
'@prisma/client': true
'@prisma/engines': true
argon2: true
bcrypt: true
cpu-features: true
esbuild: true
node-pty: true
prisma: true
protobufjs: true
sharp: true
ssh2: true
unrs-resolver: true
zenstack: true
ignoredBuiltDependencies:
- node-pty
onlyBuiltDependencies:
+1
View File
@@ -71,6 +71,7 @@ function checkRouteExists(pathname: string) {
/^\/api\/tus\/hooks\/?$/,
/^\/api\/events\/?$/,
/^\/api\/config\/?$/,
/^\/api\/health\/?$/,
/^\/api\/google\/drive\/callback\/?$/,
];
return routePatterns.some((pattern) => pattern.test(pathname));
Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

@@ -6,6 +6,7 @@ import {TelegramChannelConfigSchema} from "./providers/notifications/forms/teleg
import {GotifyChannelConfigSchema} from "./providers/notifications/forms/gotify.schema";
import {NtfyChannelConfigSchema} from "./providers/notifications/forms/ntfy.schema";
import {WebhookChannelConfigSchema} from "./providers/notifications/forms/webhook.schema";
import {NextcloudChannelConfigSchema} from "./providers/notifications/forms/nextcloud.schema";
import {S3ChannelConfigSchema} from "./providers/storages/forms/s3.schema";
import {GoogleDriveChannelConfigSchema} from "./providers/storages/forms/google-drive.schema";
import {LocalChannelConfigSchema} from "./providers/storages/forms/local.schema";
@@ -48,6 +49,10 @@ export const NotificationChannelFormSchema = z.discriminatedUnion("provider", [
provider: z.literal("webhook"),
config: WebhookChannelConfigSchema,
}),
BaseChannelFormSchema.extend({
provider: z.literal("nextcloud"),
config: NextcloudChannelConfigSchema,
}),
]);
export const StorageChannelFormSchema = z.discriminatedUnion("provider", [
@@ -0,0 +1,62 @@
import type { UseFormReturn } from "react-hook-form";
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import { PasswordInput } from "@/components/ui/password-input";
type NotifierNextcloudFormProps = {
form: UseFormReturn<any, any, any>;
};
export const NotifierNextcloudForm = ({ form }: NotifierNextcloudFormProps) => {
return (
<>
<Separator className="my-1" />
<FormField
control={form.control}
name="config.nextcloudUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Nextcloud URL *</FormLabel>
<FormControl>
<Input {...field} placeholder="e.g. https://cloud.example.com" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.nextcloudBotToken"
render={({ field }) => (
<FormItem>
<FormLabel>Bot Token *</FormLabel>
<FormControl>
<Input {...field} placeholder="e.g. j3yujpuh" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.nextcloudBotSecret"
render={({ field }) => (
<FormItem>
<FormLabel>Bot Secret *</FormLabel>
<FormControl>
<PasswordInput {...field} placeholder="HMAC signing secret" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
);
};
@@ -0,0 +1,7 @@
import { z } from "zod";
export const NextcloudChannelConfigSchema = z.object({
nextcloudUrl: z.string().url("Must be a valid URL"),
nextcloudBotToken: z.string().min(1, "Bot token is required"),
nextcloudBotSecret: z.string().min(1, "Bot secret is required"),
});
@@ -1,72 +1,122 @@
import { UseFormReturn } from "react-hook-form";
"use client";
import { useFieldArray, UseFormReturn } from "react-hook-form";
import { Plus, Trash2 } from "lucide-react";
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import { PasswordInput } from "@/components/ui/password-input";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
type NotifierWebhookFormProps = {
form: UseFormReturn<any, any, any>;
form: UseFormReturn<any, any, any>;
};
export const NotifierWebhookForm = ({ form }: NotifierWebhookFormProps) => {
return (
<>
<Separator className="my-1" />
<FormField
control={form.control}
name="config.webhookUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Webhook URL *</FormLabel>
<FormControl>
<Input {...field} placeholder="e.g. https://example.com/api/webhook" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex gap-4">
<div className="flex-1">
<FormField
control={form.control}
name="config.webhookSecretHeader"
render={({ field }) => (
<FormItem>
<FormLabel>Header Name</FormLabel>
<FormControl>
<Input {...field} placeholder="e.g. X-Webhook-Secret" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="flex-1">
<FormField
control={form.control}
name="config.webhookSecret"
render={({ field }) => (
<FormItem>
<FormLabel>Secret Value</FormLabel>
<FormControl>
<PasswordInput {...field} placeholder="e.g. webhook-secret" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
<p className="text-xs text-muted-foreground mt-2">
If provided, the secret will be sent in the specified header (defaults
to <code>X-Webhook-Secret</code>).
</p>
</>
);
const { fields, append, remove } = useFieldArray({
control: form.control,
name: "config.webhookHeaders",
});
return (
<>
<Separator className="my-1" />
<FormField
control={form.control}
name="config.webhookUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Webhook URL *</FormLabel>
<FormControl>
<Input
{...field}
placeholder="e.g. https://example.com/api/webhook"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium">Custom Headers</Label>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => append({ key: "", value: "" })}
>
<Plus className="w-4 h-4 mr-2" />
Add Header
</Button>
</div>
{fields.length === 0 && (
<p className="text-xs text-muted-foreground">
No custom headers. Add headers to send with each webhook request.
</p>
)}
<div className="space-y-2">
{fields.map((headerField, index) => (
<div key={headerField.id} className="flex gap-2">
<div className="flex-1">
<FormField
control={form.control}
name={`config.webhookHeaders.${index}.key`}
render={({ field }) => (
<FormItem>
<FormLabel>Header Name</FormLabel>
<FormControl>
<Input
{...field}
placeholder="e.g. X-Api-Key"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="flex-1">
<FormField
control={form.control}
name={`config.webhookHeaders.${index}.value`}
render={({ field }) => (
<FormItem>
<FormLabel>Header Value</FormLabel>
<FormControl>
<Input
{...field}
placeholder="Header value"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="self-end">
<Button
type="button"
variant="outline"
size="icon"
onClick={() => remove(index)}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
))}
</div>
</div>
</>
);
};
@@ -1,7 +1,16 @@
import {z} from "zod";
import { z } from "zod";
export const WebhookChannelConfigSchema = z.object({
webhookUrl: z.string().url("Must be a valid URL"),
webhookSecretHeader: z.string().optional(),
webhookSecret: z.string().optional(),
webhookHeaders: z
.array(
z.object({
key: z.string().min(1, "Header name is required"),
value: z.string(),
}),
)
.optional()
.default([]),
});
export type WebhookChannelConfig = z.infer<typeof WebhookChannelConfigSchema>;
@@ -20,6 +20,9 @@ import {
import {
NotifierWebhookForm
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/forms/webhook.form";
import {
NotifierNextcloudForm
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/forms/nextcloud.form";
import {
notificationProviders,
} from "@/components/wrappers/dashboard/admin/channels/helpers/notification";
@@ -87,6 +90,8 @@ export const renderChannelForm = (provider: string | undefined, form: UseFormRet
return <NotifierNtfyForm form={form}/>;
case "webhook":
return <NotifierWebhookForm form={form}/>;
case "nextcloud":
return <NotifierNextcloudForm form={form}/>;
case "s3":
return <StorageS3Form form={form}/>
case "google-drive":
@@ -13,6 +13,7 @@ export const notificationProviders: ProviderIconTypes[] = [
{value: "gotify", label: "Gotify", icon: GotifyIcon},
{value: "ntfy", label: "ntfy.sh", icon: NtfyIcon},
{value: "webhook", label: "Webhook", icon: WebhookIcon},
{value: "nextcloud", label: "Nextcloud Talk", icon: NextcloudIcon},
{value: "microsoft-teams", label: "Microsoft Teams", icon: MSTeamsIcon, preview: true}
]
@@ -530,3 +531,28 @@ export function MSTeamsIcon(props: SVGProps<SVGSVGElement>) {
</svg>)
}
export function NextcloudIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
xmlSpace="preserve"
viewBox="0 0 512 512"
{...props}
>
<path
d="M512 80.5v352c0 44.2-35.8 80-80 80H80c-44.2 0-80-35.8-80-80v-352C0 36.3 35.8.5 80 .5h352c44.2 0 80 35.8 80 80"
style={{
fillRule: "evenodd",
clipRule: "evenodd",
fill: "#0082c9",
}}
/>
<path
d="M256 128.5c-70.4 0-127.5 57.1-127.5 127.5S185.6 383.5 256 383.5c23.3 0 46.2-6.5 66.1-18.6 15.7 6.2 50.8 24.7 59.1 16.7 8.7-8.3-10.2-47.6-14.8-62.1 11.1-19.3 17-41.1 17-63.4.1-70.5-57-127.6-127.4-127.6m0 48.5c43.7 0 79 35.4 79 79 0 43.7-35.4 79-79 79s-79-35.4-79-79 35.3-79 79-79"
style={{
fill: "#fff",
}}
/>
</svg>
);
}
@@ -35,6 +35,7 @@ export const createUserAction = userAction
email: parsedInput.email,
theme: "dark",
role: "user",
password: ""
};
if (isPasswordAuthEnabled) {
@@ -1,13 +1,16 @@
"use server";
import {userAction} from "@/lib/safe-actions/actions";
import { logger } from "@/lib/logger";
import {z} from "zod";
import {v4 as uuidv4} from "uuid";
import {ServerActionResult} from "@/types/action-type";
import {and, eq, inArray} from "drizzle-orm";
import {eq, inArray} from "drizzle-orm";
import {db} from "@/db";
import * as drizzleDb from "@/db";
const log = logger.child({ module: "dashboard/delete-project.action" });
export const deleteProjectAction = userAction.schema(z.string()).action(async ({parsedInput}): Promise<ServerActionResult<typeof drizzleDb.schemas.project.$inferSelect>> => {
try {
const uuid = uuidv4();
@@ -51,7 +54,7 @@ export const deleteProjectAction = userAction.schema(z.string()).action(async ({
},
};
} catch (error) {
console.log(error);
log.error({ error }, "Failed to archive project");
return {
success: false,
actionError: {
@@ -0,0 +1 @@
ALTER TYPE "public"."dbms_status" ADD VALUE 'mssql';
@@ -0,0 +1,2 @@
ALTER TABLE "backups" ALTER COLUMN "file_size" SET DATA TYPE bigint;--> statement-breakpoint
ALTER TABLE "backup_storage" ALTER COLUMN "size" SET DATA TYPE bigint;
+1
View File
@@ -0,0 +1 @@
ALTER TYPE "public"."provider_kind" ADD VALUE 'nextcloud';
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+21
View File
@@ -379,6 +379,27 @@
"when": 1777204042208,
"tag": "0053_lyrical_union_jack",
"breakpoints": true
},
{
"idx": 54,
"version": "7",
"when": 1778523056918,
"tag": "0054_hesitant_darkhawk",
"breakpoints": true
},
{
"idx": 55,
"version": "7",
"when": 1779342014802,
"tag": "0055_yielding_justin_hammer",
"breakpoints": true
},
{
"idx": 56,
"version": "7",
"when": 1779380470656,
"tag": "0056_lazy_cyclops",
"breakpoints": true
}
]
}
+2 -2
View File
@@ -1,4 +1,4 @@
import {pgTable, text, boolean, timestamp, uuid, integer, pgEnum} from "drizzle-orm/pg-core";
import {pgTable, text, boolean, timestamp, uuid, integer, pgEnum, bigint} from "drizzle-orm/pg-core";
import {Agent, agent, AgentWith} from "./08_agent";
import {Project, project} from "./06_project";
import {relations} from "drizzle-orm";
@@ -37,7 +37,7 @@ export const backup = pgTable(
id: uuid("id").primaryKey().defaultRandom(),
status: statusEnum("status").default("waiting").notNull(),
file: text("file"),
fileSize: integer("file_size"),
fileSize: bigint("file_size", { mode: "number" }),
databaseId: uuid("database_id")
.notNull()
.references(() => database.id, {onDelete: "cascade"}),
+1 -1
View File
@@ -7,7 +7,7 @@ import {z} from "zod";
import {OrganizationInvitation} from "@/db/schema/05_invitation";
export const providerKindEnum = pgEnum('provider_kind', ['slack', 'smtp', 'discord', 'telegram', 'gotify', 'ntfy', 'webhook']);
export const providerKindEnum = pgEnum('provider_kind', ['slack', 'smtp', 'discord', 'telegram', 'gotify', 'ntfy', 'webhook', 'nextcloud']);
export const notificationChannel = pgTable('notification_channel', {
id: uuid("id").defaultRandom().primaryKey(),
+2 -2
View File
@@ -1,4 +1,4 @@
import { pgTable, uuid, text, integer, pgEnum } from "drizzle-orm/pg-core";
import {pgTable, uuid, text, integer, pgEnum, bigint} from "drizzle-orm/pg-core";
import { timestamps } from "@/db/schema/00_common";
import {StorageChannel, storageChannel} from "@/db/schema/12_storage-channel";
import {Backup, backup, Restoration} from "@/db/schema/07_database";
@@ -22,7 +22,7 @@ export const backupStorage = pgTable("backup_storage", {
.references(() => storageChannel.id, { onDelete: "cascade" }),
status: backupStorageStatusEnum("status").notNull().default("pending"),
path: text("path"),
size: integer("size"),
size: bigint("size", { mode: "number" }),
checksum: text("checksum"),
...timestamps,
});
+1 -1
View File
@@ -2,7 +2,7 @@ import {pgEnum} from "drizzle-orm/pg-core";
import {createSelectSchema} from "drizzle-zod";
import {z} from "zod";
export const dbmsEnum = pgEnum("dbms_status", ["postgresql", "mysql", "mariadb", "mongodb", "sqlite", "redis", "valkey", "firebird"]);
export const dbmsEnum = pgEnum("dbms_status", ["postgresql", "mysql", "mariadb", "mongodb", "sqlite", "redis", "valkey", "firebird", "mssql"]);
export const statusEnum = pgEnum("status", ["waiting", "ongoing", "failed", "success"]);
export const typeStorageEnum = pgEnum("type_storage", ["local", "s3"]);
+4
View File
@@ -3,13 +3,17 @@ import {hashPassword} from "better-auth/crypto";
import {db} from "@/db";
import * as drizzleDb from "@/db";
import {User, UserThemeEnum} from "@/db/schema/02_user";
import {assertValidPassword} from "@/utils/password";
export async function createUserDb(data: SignUpUser): Promise<User> {
assertValidPassword(data.password);
const now = new Date();
const userId = crypto.randomUUID();
const [newUser] = await db.insert(drizzleDb.schemas.user).values({
...data,
id: userId,
name: data.name,
email: data.email,
+134 -125
View File
@@ -1,165 +1,174 @@
import { createEnv } from "@t3-oss/env-nextjs";
import {createEnv} from "@t3-oss/env-nextjs";
import path from "path";
import { z } from "zod";
import packageJson from "../package.json" with { type: "json" };
import {z} from "zod";
import packageJson from "../package.json" with {type: "json"};
const { version } = packageJson;
const {version} = packageJson;
export const env = createEnv({
server: {
NEXT_PUBLIC_PROJECT_VERSION: z.string().optional(),
server: {
NEXT_PUBLIC_PROJECT_VERSION: z.string().optional(),
NODE_ENV: z.enum(["development", "production"]).optional(),
NODE_ENV: z.enum(["development", "production"]).optional(),
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
DATABASE_URL: z.string().url().optional(),
DATABASE_URL: z.url().optional(),
PROJECT_NAME: z.string().optional(),
PROJECT_DESCRIPTION: z.string().optional(),
PROJECT_URL: z
.string()
.regex(/^https?:\/\//, "URL must start with http:// or https://"),
PROJECT_SECRET: z.string(),
PROJECT_NAME: z.string().optional(),
PROJECT_DESCRIPTION: z.string().optional(),
PROJECT_URL: z
.string()
.regex(/^https?:\/\//, "URL must start with http:// or https://"),
PROJECT_SECRET: z.string(),
TRUSTED_DOMAINS: z.string().optional(),
TRUSTED_DOMAINS: z.string().optional(),
SMTP_PASSWORD: z.string().optional(),
SMTP_FROM: z.string().optional(),
SMTP_HOST: z.string().optional(),
SMTP_PORT: z.string().optional(),
SMTP_USER: z.string().optional(),
SMTP_PASSWORD: z.string().optional(),
SMTP_FROM: z.string().optional(),
SMTP_HOST: z.string().optional(),
SMTP_PORT: z.string().optional(),
SMTP_USER: z.string().optional(),
AUTH_DEFAULT_USER_NAME: z.string().optional(),
AUTH_DEFAULT_USER: z.string().optional(),
AUTH_DEFAULT_PASSWORD: z.string().optional(),
SMTP_SECURE: z
.enum(["true", "false"])
.transform((val) => val === "true")
.default("true"),
SMTP_SECURE: z
.enum(["true", "false"])
.transform((val) => val === "true")
.default("true"),
AUTH_GOOGLE_ID: z.string().optional(),
AUTH_GOOGLE_SECRET: z.string().optional(),
AUTH_GOOGLE_ID: z.string().optional(),
AUTH_GOOGLE_SECRET: z.string().optional(),
AUTH_GITHUB_ID: z.string().optional(),
AUTH_GITHUB_SECRET: z.string().optional(),
AUTH_GITHUB_ID: z.string().optional(),
AUTH_GITHUB_SECRET: z.string().optional(),
RETENTION_CRON: z
.string()
.default(
process.env.NODE_ENV === "production" ? "0 7 * * *" : "* * * * *",
),
RETENTION_CRON: z
.string()
.default(
process.env.NODE_ENV === "production" ? "0 7 * * *" : "* * * * *",
),
CLEANING_HEALTHCHECK_LOGS_CRON: z
.string()
.default(
process.env.NODE_ENV === "production" ? "0 * * * *" : "* * * * *",
),
CLEANING_HEALTHCHECK_LOGS_CRON: z
.string()
.default(
process.env.NODE_ENV === "production" ? "0 * * * *" : "* * * * *",
),
HEALTHCHECK_CRON: z
.string()
.default(
process.env.NODE_ENV === "production" ? "0 * * * *" : "* * * * *",
),
HEALTHCHECK_CRON: z
.string()
.default(
process.env.NODE_ENV === "production" ? "0 * * * *" : "* * * * *",
),
AUTH_OIDC_ID: z.string().optional().default("oidc"),
AUTH_OIDC_TITLE: z.string().optional(),
AUTH_OIDC_DESC: z.string().optional(),
AUTH_OIDC_ICON: z.string().optional(),
AUTH_OIDC_CLIENT: z.string().optional(),
AUTH_OIDC_SECRET: z.string().optional(),
AUTH_OIDC_ISSUER_URL: z.string().optional(),
AUTH_OIDC_HOST: z.string().optional(),
AUTH_OIDC_SCOPES: z.string().optional(),
AUTH_OIDC_DISCOVERY_ENDPOINT: z.string().optional(),
AUTH_OIDC_JWKS_ENDPOINT: z.string().optional(),
AUTH_OIDC_PKCE: z.string().optional(),
AUTH_OIDC_ID: z.string().optional().default("oidc"),
AUTH_OIDC_TITLE: z.string().optional(),
AUTH_OIDC_DESC: z.string().optional(),
AUTH_OIDC_ICON: z.string().optional(),
AUTH_OIDC_CLIENT: z.string().optional(),
AUTH_OIDC_SECRET: z.string().optional(),
AUTH_OIDC_ISSUER_URL: z.string().optional(),
AUTH_OIDC_HOST: z.string().optional(),
AUTH_OIDC_SCOPES: z.string().optional(),
AUTH_OIDC_DISCOVERY_ENDPOINT: z.string().optional(),
AUTH_OIDC_JWKS_ENDPOINT: z.string().optional(),
AUTH_OIDC_PKCE: z.string().optional(),
AUTH_SOCIAL_ID: z.string().optional().default("social"),
AUTH_SOCIAL_TITLE: z.string().optional(),
AUTH_SOCIAL_DESC: z.string().optional(),
AUTH_SOCIAL_ICON: z.string().optional(),
AUTH_SOCIAL_CLIENT: z.string().optional(),
AUTH_SOCIAL_SECRET: z.string().optional(),
AUTH_SOCIAL_APPLE_APP_BUNDLE_IDENTIFIER: z.string().optional(),
AUTH_SOCIAL_ID: z.string().optional().default("social"),
AUTH_SOCIAL_TITLE: z.string().optional(),
AUTH_SOCIAL_DESC: z.string().optional(),
AUTH_SOCIAL_ICON: z.string().optional(),
AUTH_SOCIAL_CLIENT: z.string().optional(),
AUTH_SOCIAL_SECRET: z.string().optional(),
AUTH_SOCIAL_APPLE_APP_BUNDLE_IDENTIFIER: z.string().optional(),
ALLOWED_GROUP: z.string().optional(),
ALLOWED_GROUP: z.string().optional(),
AUTH_EMAIL_PASSWORD_ENABLED: z.string().optional().default("true"),
AUTH_SIGNUP_ENABLED: z.string().optional().default("true"),
AUTH_PASSKEY_ENABLED: z.string().optional().default("false"),
AUTH_EMAIL_PASSWORD_ENABLED: z.string().optional().default("true"),
AUTH_SIGNUP_ENABLED: z.string().optional().default("true"),
AUTH_PASSKEY_ENABLED: z.string().optional().default("false"),
AUTH_SYNC_OIDC_ROLES_ON_LOGIN: z.enum(["true", "false"]).default("true"),
AUTH_ROLE_MAP: z.string().optional(),
AUTH_DEFAULT_ROLE: z.string().optional().default("pending"),
AUTH_ALLOW_LINKING: z.enum(["true", "false"]).default("true"),
AUTH_ALLOW_UNLINKING: z.enum(["true", "false"]).default("true"),
AUTH_SYNC_OIDC_ROLES_ON_LOGIN: z.enum(["true", "false"]).default("true"),
AUTH_ROLE_MAP: z.string().optional(),
AUTH_DEFAULT_ROLE: z.string().optional().default("pending"),
AUTH_ALLOW_LINKING: z.enum(["true", "false"]).default("true"),
AUTH_ALLOW_UNLINKING: z.enum(["true", "false"]).default("true"),
PRIVATE_PATH: z.string().optional(),
},
client: {
NEXT_PUBLIC_PROJECT_VERSION: z.string().optional(),
},
runtimeEnv: {
NEXT_PUBLIC_PROJECT_VERSION: version || "Unknown Version",
LOG_LEVEL: process.env.LOG_LEVEL,
PRIVATE_PATH: z.string().optional(),
},
client: {
NEXT_PUBLIC_PROJECT_VERSION: z.string().optional(),
},
runtimeEnv: {
NEXT_PUBLIC_PROJECT_VERSION: version || "Unknown Version",
PROJECT_NAME: process.env.PROJECT_NAME,
PROJECT_DESCRIPTION: process.env.PROJECT_DESCRIPTION,
PROJECT_URL: process.env.PROJECT_URL,
PROJECT_SECRET: process.env.PROJECT_SECRET,
PROJECT_NAME: process.env.PROJECT_NAME,
PROJECT_DESCRIPTION: process.env.PROJECT_DESCRIPTION,
PROJECT_URL: process.env.PROJECT_URL,
PROJECT_SECRET: process.env.PROJECT_SECRET,
DATABASE_URL: process.env.DATABASE_URL,
DATABASE_URL: process.env.DATABASE_URL,
TRUSTED_DOMAINS: process.env.TRUSTED_DOMAINS,
TRUSTED_DOMAINS: process.env.TRUSTED_DOMAINS,
SMTP_PASSWORD: process.env.SMTP_PASSWORD,
SMTP_FROM: process.env.SMTP_FROM,
SMTP_HOST: process.env.SMTP_HOST,
SMTP_PORT: process.env.SMTP_PORT,
SMTP_USER: process.env.SMTP_USER,
SMTP_SECURE: process.env.SMTP_SECURE,
SMTP_PASSWORD: process.env.SMTP_PASSWORD,
SMTP_FROM: process.env.SMTP_FROM,
SMTP_HOST: process.env.SMTP_HOST,
SMTP_PORT: process.env.SMTP_PORT,
SMTP_USER: process.env.SMTP_USER,
SMTP_SECURE: process.env.SMTP_SECURE,
RETENTION_CRON: process.env.RETENTION_CRON,
CLEANING_HEALTHCHECK_LOGS_CRON: process.env.CLEANING_HEALTHCHECK_LOGS_CRON,
RETENTION_CRON: process.env.RETENTION_CRON,
CLEANING_HEALTHCHECK_LOGS_CRON: process.env.CLEANING_HEALTHCHECK_LOGS_CRON,
AUTH_OIDC_ID: process.env.AUTH_OIDC_ID,
AUTH_OIDC_TITLE: process.env.AUTH_OIDC_TITLE,
AUTH_OIDC_DESC: process.env.AUTH_OIDC_DESC,
AUTH_OIDC_ICON: process.env.AUTH_OIDC_ICON,
AUTH_OIDC_CLIENT: process.env.AUTH_OIDC_CLIENT,
AUTH_OIDC_SECRET: process.env.AUTH_OIDC_SECRET,
AUTH_OIDC_ISSUER_URL: process.env.AUTH_OIDC_ISSUER_URL,
AUTH_OIDC_HOST: process.env.AUTH_OIDC_HOST,
AUTH_OIDC_SCOPES: process.env.AUTH_OIDC_SCOPES,
AUTH_OIDC_DISCOVERY_ENDPOINT: process.env.AUTH_OIDC_DISCOVERY_ENDPOINT,
AUTH_OIDC_JWKS_ENDPOINT: process.env.AUTH_OIDC_JWKS_ENDPOINT,
AUTH_OIDC_PKCE: process.env.AUTH_OIDC_PKCE,
AUTH_OIDC_ID: process.env.AUTH_OIDC_ID,
AUTH_OIDC_TITLE: process.env.AUTH_OIDC_TITLE,
AUTH_OIDC_DESC: process.env.AUTH_OIDC_DESC,
AUTH_OIDC_ICON: process.env.AUTH_OIDC_ICON,
AUTH_OIDC_CLIENT: process.env.AUTH_OIDC_CLIENT,
AUTH_OIDC_SECRET: process.env.AUTH_OIDC_SECRET,
AUTH_OIDC_ISSUER_URL: process.env.AUTH_OIDC_ISSUER_URL,
AUTH_OIDC_HOST: process.env.AUTH_OIDC_HOST,
AUTH_OIDC_SCOPES: process.env.AUTH_OIDC_SCOPES,
AUTH_OIDC_DISCOVERY_ENDPOINT: process.env.AUTH_OIDC_DISCOVERY_ENDPOINT,
AUTH_OIDC_JWKS_ENDPOINT: process.env.AUTH_OIDC_JWKS_ENDPOINT,
AUTH_OIDC_PKCE: process.env.AUTH_OIDC_PKCE,
AUTH_GOOGLE_ID: process.env.AUTH_GOOGLE_ID,
AUTH_GOOGLE_SECRET: process.env.AUTH_GOOGLE_SECRET,
AUTH_GOOGLE_ID: process.env.AUTH_GOOGLE_ID,
AUTH_GOOGLE_SECRET: process.env.AUTH_GOOGLE_SECRET,
AUTH_GITHUB_ID: process.env.AUTH_GITHUB_ID,
AUTH_GITHUB_SECRET: process.env.AUTH_GITHUB_SECRET,
AUTH_GITHUB_ID: process.env.AUTH_GITHUB_ID,
AUTH_GITHUB_SECRET: process.env.AUTH_GITHUB_SECRET,
AUTH_SOCIAL_ID: process.env.AUTH_SOCIAL_ID,
AUTH_SOCIAL_TITLE: process.env.AUTH_SOCIAL_TITLE,
AUTH_SOCIAL_DESC: process.env.AUTH_SOCIAL_DESC,
AUTH_SOCIAL_ICON: process.env.AUTH_SOCIAL_ICON,
AUTH_SOCIAL_CLIENT: process.env.AUTH_SOCIAL_CLIENT,
AUTH_SOCIAL_SECRET: process.env.AUTH_SOCIAL_SECRET,
AUTH_SOCIAL_ID: process.env.AUTH_SOCIAL_ID,
AUTH_SOCIAL_TITLE: process.env.AUTH_SOCIAL_TITLE,
AUTH_SOCIAL_DESC: process.env.AUTH_SOCIAL_DESC,
AUTH_SOCIAL_ICON: process.env.AUTH_SOCIAL_ICON,
AUTH_SOCIAL_CLIENT: process.env.AUTH_SOCIAL_CLIENT,
AUTH_SOCIAL_SECRET: process.env.AUTH_SOCIAL_SECRET,
ALLOWED_GROUP: process.env.ALLOWED_GROUP,
ALLOWED_GROUP: process.env.ALLOWED_GROUP,
AUTH_EMAIL_PASSWORD_ENABLED: process.env.AUTH_EMAIL_PASSWORD_ENABLED,
AUTH_SIGNUP_ENABLED: process.env.AUTH_SIGNUP_ENABLED,
AUTH_PASSKEY_ENABLED: process.env.AUTH_PASSKEY_ENABLED,
AUTH_EMAIL_PASSWORD_ENABLED: process.env.AUTH_EMAIL_PASSWORD_ENABLED,
AUTH_SIGNUP_ENABLED: process.env.AUTH_SIGNUP_ENABLED,
AUTH_PASSKEY_ENABLED: process.env.AUTH_PASSKEY_ENABLED,
AUTH_SYNC_OIDC_ROLES_ON_LOGIN: process.env.AUTH_SYNC_OIDC_ROLES_ON_LOGIN,
AUTH_SYNC_OIDC_ROLES_ON_LOGIN: process.env.AUTH_SYNC_OIDC_ROLES_ON_LOGIN,
AUTH_ROLE_MAP: process.env.AUTH_ROLE_MAP,
AUTH_ROLE_MAP: process.env.AUTH_ROLE_MAP,
AUTH_ALLOW_LINKING: process.env.AUTH_ALLOW_LINKING,
AUTH_ALLOW_UNLINKING: process.env.AUTH_ALLOW_UNLINKING,
AUTH_ALLOW_LINKING: process.env.AUTH_ALLOW_LINKING,
AUTH_ALLOW_UNLINKING: process.env.AUTH_ALLOW_UNLINKING,
PRIVATE_PATH:
process.env.PRIVATE_PATH || path.join(process.cwd(), "private"),
PRIVATE_PATH:
process.env.PRIVATE_PATH || path.join(process.cwd(), "private"),
},
AUTH_DEFAULT_USER_NAME: process.env.AUTH_DEFAULT_USER_NAME,
AUTH_DEFAULT_USER: process.env.AUTH_DEFAULT_USER,
AUTH_DEFAULT_PASSWORD: process.env.AUTH_DEFAULT_PASSWORD,
},
});
@@ -7,6 +7,7 @@ import {sendTelegram} from "@/features/notifications/providers/telegram";
import {sendGotify} from "@/features/notifications/providers/gotify";
import {sendNtfy} from "@/features/notifications/providers/ntfy";
import {sendWebhook} from "@/features/notifications/providers/webhook";
import {sendNextcloud} from "@/features/notifications/providers/nextcloud";
const handlers: Record<
ProviderKind,
@@ -18,7 +19,8 @@ const handlers: Record<
telegram: sendTelegram,
gotify: sendGotify,
ntfy: sendNtfy,
webhook: sendWebhook
webhook: sendWebhook,
nextcloud: sendNextcloud,
};
export async function dispatchViaProvider(
@@ -0,0 +1,88 @@
import { createHmac, randomBytes } from "crypto";
import type { EventPayload, DispatchResult } from "../types";
type NextcloudConfig = {
nextcloudUrl: string;
nextcloudBotToken: string;
nextcloudBotSecret: string;
};
function formatPayloadData(data: unknown): string {
if (!data) {
return "";
}
if (typeof data === "string") {
return data;
}
try {
return JSON.stringify(data, null, 2);
} catch {
return String(data);
}
}
export async function sendNextcloud(
config: NextcloudConfig,
payload: EventPayload
): Promise<DispatchResult> {
const {
nextcloudUrl,
nextcloudBotToken,
nextcloudBotSecret,
} = config;
const payloadData = formatPayloadData(payload.data);
const messageParts = [
`[${payload.level.toUpperCase()}] ${payload.title}`,
payload.message,
];
if (payloadData) {
messageParts.push(`Payload:\n${payloadData}`);
}
const message = messageParts.join("\n\n");
const random = randomBytes(32).toString("hex");
const signature = createHmac("sha256", nextcloudBotSecret)
.update(random + message)
.digest("hex");
const baseUrl = nextcloudUrl.replace(/\/$/, "");
const res = await fetch(
`${baseUrl}/ocs/v2.php/apps/spreed/api/v1/bot/${nextcloudBotToken}/message`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
"OCS-APIRequest": "true",
"X-Nextcloud-Talk-Bot-Random": random,
"X-Nextcloud-Talk-Bot-Signature": signature,
},
body: JSON.stringify({
message,
}),
}
);
if (!res.ok) {
const err = await res.text();
throw new Error(
`Nextcloud error: ${res.status} ${err}`
);
}
return {
success: true,
provider: "nextcloud",
message: "Sent to Nextcloud Talk",
response: await res.text(),
};
}
@@ -1,24 +1,38 @@
import type {EventPayload, DispatchResult} from '../types';
import type { EventPayload, DispatchResult } from '../types';
type WebhookConfig = {
webhookUrl: string;
webhookHeaders?: { key: string; value: string }[];
webhookSecret?: string;
webhookSecretHeader?: string;
};
export async function sendWebhook(
config: { webhookUrl: string; webhookSecret?: string; webhookSecretHeader?: string },
config: WebhookConfig,
payload: EventPayload
): Promise<DispatchResult> {
const {webhookUrl, webhookSecret, webhookSecretHeader} = config;
const { webhookUrl, webhookHeaders, webhookSecret, webhookSecretHeader } = config;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'User-Agent': 'Portabase-Notifier/1.0'
'User-Agent': 'Portabase-Notifier/1.0',
};
if (webhookSecret) {
// New format: iterate custom headers array
if (webhookHeaders && webhookHeaders.length > 0) {
const RESERVED = new Set(['content-type', 'user-agent']);
for (const { key, value } of webhookHeaders) {
if (key && !RESERVED.has(key.toLowerCase())) headers[key] = value;
}
} else if (webhookSecret) {
// Legacy format: single secret header pair
headers[webhookSecretHeader || 'X-Webhook-Secret'] = webhookSecret;
}
const res = await fetch(webhookUrl, {
method: 'POST',
body: JSON.stringify(payload),
headers: headers,
headers,
});
if (!res.ok) {
+1 -1
View File
@@ -1,4 +1,4 @@
export type ProviderKind = 'slack' | 'smtp' | 'discord' | 'telegram' | 'gotify' | 'ntfy' | 'webhook';
export type ProviderKind = 'slack' | 'smtp' | 'discord' | 'telegram' | 'gotify' | 'ntfy' | 'webhook' | 'nextcloud';
export interface DispatchResult {
success: boolean;
+4 -1
View File
@@ -11,6 +11,9 @@ import {StorageChannel} from "@/db/schema/12_storage-channel";
import {
StorageChannelFormType
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/channel-form.schema";
import { logger } from "@/lib/logger";
const log = logger.child({ module: "features/storages/dispatch" });
export async function dispatchStorage(
@@ -103,7 +106,7 @@ export async function dispatchStorage(
input,
);
console.log(dispatchResult);
log.debug({ result: dispatchResult }, "Storage dispatch result");
return dispatchResult;
} catch (err: any) {
+4 -3
View File
@@ -1,5 +1,6 @@
"use server";
import {userAction} from "@/lib/safe-actions/actions";
import {logger} from "@/lib/logger";
import {z} from "zod";
import {v4 as uuidv4} from "uuid";
import {db} from "@/db";
@@ -10,6 +11,7 @@ import {dispatchStorage} from "@/features/storages/dispatch";
import {StorageInput} from "@/features/storages/types";
import sharp from "sharp";
const log = logger.child({module: "features/upload/upload.action"});
export const uploadUserImageAction = userAction.schema(
z.instanceof(FormData)
@@ -27,7 +29,7 @@ export const uploadUserImageAction = userAction.schema(
const isWebp = fileFormat === "webp";
const compressedBuffer = await sharp(buffer)
.resize({ width: 1024 })
.resize({width: 1024})
.toFormat(isPng ? "png" : isWebp ? "webp" : "jpeg", {
quality: 80,
compressionLevel: isPng ? 9 : undefined
@@ -35,7 +37,6 @@ export const uploadUserImageAction = userAction.schema(
.toBuffer();
const settings = await db.query.setting.findFirst({
where: eq(drizzleDb.schemas.setting.name, "system"),
with: {
@@ -69,7 +70,7 @@ export const uploadUserImageAction = userAction.schema(
}
const result = await dispatchStorage(input, undefined, settings.storageChannel.id);
console.log(result);
log.debug({result}, "Upload dispatch result");
if (!result.success) {
return {
success: false,
+6 -6
View File
@@ -22,7 +22,9 @@ import {passkey} from "@better-auth/passkey";
import {getOidcProviders} from "./oidc";
import {APIError} from "better-auth/api";
import {getOAuthProviders} from "./oauth";
import { logger } from "@/lib/logger";
const log = logger.child({ module: "lib/auth" });
const oidcProviders = getOidcProviders();
@@ -438,9 +440,7 @@ export const auth = betterAuth({
new Date(user.createdAt).getTime();
if (createdAtDiff < 5000) {
console.log(
`Skipping new login email for freshly created user ${user.email}`,
);
log.debug(`Skipping new login email for freshly created user ${user.email}`);
return;
}
@@ -471,12 +471,12 @@ export const auth = betterAuth({
),
});
} catch (error) {
console.log("sendEmail failed", {
log.error({
userId: user.id,
details: "Please Check your env variables or system config",
email: user.email,
error,
});
}, "sendEmail failed");
}
(await auth.$context).internalAdapter.updateUser(user.id, {
@@ -784,7 +784,7 @@ export const getActiveMember = async () => {
return member as MemberWithUser;
} catch (e) {
console.log("err", e);
log.error({ error: e }, "Auth error");
}
};
+3 -2
View File
@@ -1,6 +1,8 @@
import pino, { type Logger } from "pino";
const isProd = process.env.NODE_ENV === "production";
const defaultLevel = isProd ? "info" : "debug";
const level = (process.env.LOG_LEVEL ?? defaultLevel) as pino.Level;
function getLocalTimestamp() {
const date = new Date();
@@ -19,9 +21,8 @@ function getLocalTimestamp() {
}
export const logger: Logger = pino({
level: isProd ? "info" : "debug",
level: level,
base: null,
...(isProd
? {
timestamp: getLocalTimestamp,
+2 -2
View File
@@ -14,7 +14,7 @@ export const backupCleanTask = async () => {
eq(drizzleDb.schemas.backup.status, "ongoing")
)
});
log.info(`Backups to clean: ${backups.length}`);
log.debug(`Backups to clean: ${backups.length}`);
for (const backup of backups) {
await db.update(drizzleDb.schemas.backup).set(withUpdatedAt({
@@ -24,7 +24,7 @@ export const backupCleanTask = async () => {
}
} catch (e: any) {
log.info({name: "backupCleanTask", error: e},`Backup cleanup failed`);
log.error({name: "backupCleanTask", error: e},`Backup cleanup failed`);
throw e;
}
};
+1 -1
View File
@@ -22,7 +22,7 @@ export const retentionJob = cron.schedule(env.RETENTION_CRON, async () => {
export const cleaningJob = cron.schedule("* * * * *", async () => {
try {
log.info({ job: "cron", action: "start", name: "cleaningJob" }, "Cleaning Job started");
log.debug({ job: "cron", action: "start", name: "cleaningJob" }, "Cleaning Job started");
await backupCleanTask();
} catch (err) {
log.error({ job: "cron", name: "cleaningJob", error: err }, "Cleaning Job Error");
+15 -1
View File
@@ -1,8 +1,22 @@
import { NextRequest, NextResponse } from 'next/server';
const LOG_LEVELS = ["debug", "info", "warn", "error"] as const;
type LogLevel = typeof LOG_LEVELS[number];
const raw = (process.env.LOG_LEVEL ?? "info") as LogLevel;
const _idx = LOG_LEVELS.indexOf(raw);
const LEVEL_INDEX = _idx === -1 ? 1 : _idx;
const STATUS_PATH_RE = /\/api\/agent\/[^/]+\/status\/?$/;
export function loggingMiddleware(request: NextRequest) {
if (request.url.includes('/api')) {
console.log(`[API] Received ${request.method} request : ${request.url} at ${new Date()}`);
const isStatusPing = STATUS_PATH_RE.test(request.nextUrl.pathname);
// Status pings are debug-level; all other API calls are info-level.
const requiredIndex = isStatusPing ? 0 : 1;
if (LEVEL_INDEX <= requiredIndex) {
console.log(`[API] Received ${request.method} request : ${request.url} at ${new Date()}`);
}
}
return NextResponse.next();
}
+2 -2
View File
@@ -1,11 +1,11 @@
export type SignUpUser = {
name: string
email: string
password?: string
password: string
callbackURL?: string
role?: string
theme: string
emailVerified?: boolean
}
+4
View File
@@ -78,6 +78,10 @@ export function getFileHeadersBasedOnDbms(dbType: string): Record<string, string
return {
"application/octet-stream": [".backup"],
};
case "mssql":
return {
"application/octet-stream": [".bacpac"],
};
default:
throw new Error(`Unsupported database type: ${dbType}`);
-146
View File
@@ -1,146 +0,0 @@
import { env } from "@/env.mjs";
import { db, makeMigration } from "@/db";
import { eq } from "drizzle-orm";
import * as drizzleDb from "@/db";
import {cleaningHealthcheckLogsJob, cleaningJob, healthcheckAgentAndDatabaseJob, retentionJob} from "@/lib/tasks";
import { generateRSAKeys, getOrCreateMasterKey } from "@/utils/rsa-keys";
import { StorageProviderKind } from "@/features/storages/types";
import {logger} from "@/lib/logger";
import {withUpdatedAt} from "@/db/utils";
const log = logger.child({module: "init"});
export async function init() {
consoleAscii();
log.info("====Init Functions====");
await getOrCreateMasterKey();
await generateRSAKeys();
await makeMigration();
await createDefaultOrganization();
await createSettingsIfNotExist();
log.info("====Initialization completed====");
await setupCronJobs();
if (
(env.AUTH_GOOGLE_ID && env.AUTH_GOOGLE_SECRET) ||
(env.AUTH_GITHUB_ID && env.AUTH_GITHUB_SECRET)
) {
log.warn(
{
deprecated: true,
provider: "oauth_env",
message: "You have set up OAuth credentials in your environment variables, but the format is now different. Please update your environment variables to use the new format. For example, if you were using AUTH_GOOGLE_ID and AUTH_GOOGLE_SECRET, you should now use AUTH_SOCIAL_GOOGLE_CLIENT and AUTH_SOCIAL_GOOGLE_SECRET. Please refer to the documentation for more details. (https://portabase.io/docs/dashboard/auth/oauth2/setup#dynamic-providers)"
},
"Deprecated OAuth environment variables detected",
);
}
}
async function setupCronJobs() {
log.info("==== Setting up Cron Jobs ====");
retentionJob.start();
cleaningJob.start();
cleaningHealthcheckLogsJob.start();
healthcheckAgentAndDatabaseJob.start();
log.info("==== Cron jobs started ====");
}
async function createSettingsIfNotExist() {
await db.transaction(async (tx) => {
const systemSettingsValues = {
name: "system",
smtpPassword: env.SMTP_PASSWORD ?? null,
smtpFrom: env.SMTP_FROM ?? null,
smtpHost: env.SMTP_HOST ?? null,
smtpPort: env.SMTP_PORT ?? null,
smtpUser: env.SMTP_USER ?? null,
smtpSecure: env.SMTP_SECURE ?? false,
};
const [systemSetting] = await tx
.select()
.from(drizzleDb.schemas.setting)
.where(eq(drizzleDb.schemas.setting.name, "system"))
.limit(1);
const [finalSystemSetting] = systemSetting
? await tx
.update(drizzleDb.schemas.setting)
.set(systemSettingsValues)
.where(eq(drizzleDb.schemas.setting.name, "system"))
.returning()
: await tx
.insert(drizzleDb.schemas.setting)
.values(systemSettingsValues)
.returning();
const localStorageValues = {
provider: "local" as StorageProviderKind,
enabled: true,
name: "System",
config: {},
};
const [existingLocalStorage] = await tx
.select()
.from(drizzleDb.schemas.storageChannel)
.where(eq(drizzleDb.schemas.storageChannel.provider, "local"))
.limit(1);
const [localStorage] = existingLocalStorage
? await tx
.update(drizzleDb.schemas.storageChannel)
.set(localStorageValues)
.where(eq(drizzleDb.schemas.storageChannel.provider, "local"))
.returning()
: await tx
.insert(drizzleDb.schemas.storageChannel)
.values(localStorageValues)
.returning();
if (!finalSystemSetting.defaultStorageChannelId) {
await tx
.update(drizzleDb.schemas.setting)
.set(withUpdatedAt({ defaultStorageChannelId: localStorage.id }))
.where(eq(drizzleDb.schemas.setting.id, finalSystemSetting.id));
}
});
}
async function createDefaultOrganization() {
const defaultOrganizationConf = {
slug: "default",
name: "Default Organization",
createdAt: new Date(),
};
const [existing] = await db
.select()
.from(drizzleDb.schemas.organization)
.where(eq(drizzleDb.schemas.organization.slug, "default"))
.limit(1);
if (!existing) {
log.info("==== Creating default Organization... ====");
await db
.insert(drizzleDb.schemas.organization)
.values(defaultOrganizationConf);
}
}
function consoleAscii() {
console.log(
" \n" +
" ____ __ __ \n" +
" / __ \\____ _____/ /_____ _/ /_ ____ _________ \n" +
" / /_/ / __ \\/ ___/ __/ __ / __ \\/ __ / ___/ _ \\ \n" +
" / ____/ /_/ / / / /_/ /_/ / /_/ / /_/ (__ ) __/ \n" +
" /_/ \\____/_/ \\__/\\__,_/_.___/\\__,_/____/\\___/ \n" +
" \n" +
` Community Edition v${env.NEXT_PUBLIC_PROJECT_VERSION} \n ` +
" \n",
);
}
+14
View File
@@ -0,0 +1,14 @@
import {cleaningHealthcheckLogsJob, cleaningJob, healthcheckAgentAndDatabaseJob, retentionJob} from "@/lib/tasks";
import {logger} from "@/lib/logger";
const log = logger.child({module: "init/cron"});
export async function setupCronJobs() {
log.info("==== Setting up Cron Jobs ====");
retentionJob.start();
cleaningJob.start();
cleaningHealthcheckLogsJob.start();
healthcheckAgentAndDatabaseJob.start();
log.info("==== Cron jobs started ====");
}
+50
View File
@@ -0,0 +1,50 @@
import { env } from "@/env.mjs";
import { makeMigration } from "@/db";
import { generateRSAKeys, getOrCreateMasterKey } from "@/utils/rsa-keys";
import {logger} from "@/lib/logger";
import {setupCronJobs} from "@/utils/init/cron";
import {createSettingsIfNotExist} from "@/utils/init/setting";
import {createDefaultOrganization} from "@/utils/init/organization";
import {createDefaultUser} from "@/utils/init/user";
const log = logger.child({module: "init"});
export async function init() {
consoleAscii();
log.info("====Init Functions====");
await getOrCreateMasterKey();
await generateRSAKeys();
await makeMigration();
await createDefaultOrganization();
await createSettingsIfNotExist();
await createDefaultUser();
log.info("====Initialization completed====");
await setupCronJobs();
if (
(env.AUTH_GOOGLE_ID && env.AUTH_GOOGLE_SECRET) ||
(env.AUTH_GITHUB_ID && env.AUTH_GITHUB_SECRET)
) {
log.warn(
{
deprecated: true,
provider: "oauth_env",
message: "You have set up OAuth credentials in your environment variables, but the format is now different. Please update your environment variables to use the new format. For example, if you were using AUTH_GOOGLE_ID and AUTH_GOOGLE_SECRET, you should now use AUTH_SOCIAL_GOOGLE_CLIENT and AUTH_SOCIAL_GOOGLE_SECRET. Please refer to the documentation for more details. (https://portabase.io/docs/dashboard/auth/oauth2/setup#dynamic-providers)"
},
"Deprecated OAuth environment variables detected",
);
}
}
function consoleAscii() {
console.log(
" \n" +
" ____ __ __ \n" +
" / __ \\____ _____/ /_____ _/ /_ ____ _________ \n" +
" / /_/ / __ \\/ ___/ __/ __ / __ \\/ __ / ___/ _ \\ \n" +
" / ____/ /_/ / / / /_/ /_/ / /_/ / /_/ (__ ) __/ \n" +
" /_/ \\____/_/ \\__/\\__,_/_.___/\\__,_/____/\\___/ \n" +
" \n" +
` Community Edition v${env.NEXT_PUBLIC_PROJECT_VERSION} \n ` +
" \n",
);
}
+27
View File
@@ -0,0 +1,27 @@
import {db} from "@/db";
import * as drizzleDb from "@/db";
import {eq} from "drizzle-orm";
import {logger} from "@/lib/logger";
const log = logger.child({module: "init/organization"});
export async function createDefaultOrganization() {
const defaultOrganizationConf = {
slug: "default",
name: "Default Organization",
createdAt: new Date(),
};
const [existing] = await db
.select()
.from(drizzleDb.schemas.organization)
.where(eq(drizzleDb.schemas.organization.slug, "default"))
.limit(1);
if (!existing) {
log.info("==== Creating default Organization... ====");
await db
.insert(drizzleDb.schemas.organization)
.values(defaultOrganizationConf);
}
}
+70
View File
@@ -0,0 +1,70 @@
import {db} from "@/db";
import {env} from "@/env.mjs";
import * as drizzleDb from "@/db";
import {eq} from "drizzle-orm";
import {StorageProviderKind} from "@/features/storages/types";
import {withUpdatedAt} from "@/db/utils";
export async function createSettingsIfNotExist() {
await db.transaction(async (tx) => {
const systemSettingsValues = {
name: "system",
smtpPassword: env.SMTP_PASSWORD ?? null,
smtpFrom: env.SMTP_FROM ?? null,
smtpHost: env.SMTP_HOST ?? null,
smtpPort: env.SMTP_PORT ?? null,
smtpUser: env.SMTP_USER ?? null,
smtpSecure: env.SMTP_SECURE ?? false,
};
const [systemSetting] = await tx
.select()
.from(drizzleDb.schemas.setting)
.where(eq(drizzleDb.schemas.setting.name, "system"))
.limit(1);
const [finalSystemSetting] = systemSetting
? await tx
.update(drizzleDb.schemas.setting)
.set(systemSettingsValues)
.where(eq(drizzleDb.schemas.setting.name, "system"))
.returning()
: await tx
.insert(drizzleDb.schemas.setting)
.values(systemSettingsValues)
.returning();
const localStorageValues = {
provider: "local" as StorageProviderKind,
enabled: true,
name: "System",
config: {},
};
const [existingLocalStorage] = await tx
.select()
.from(drizzleDb.schemas.storageChannel)
.where(eq(drizzleDb.schemas.storageChannel.provider, "local"))
.limit(1);
const [localStorage] = existingLocalStorage
? await tx
.update(drizzleDb.schemas.storageChannel)
.set(localStorageValues)
.where(eq(drizzleDb.schemas.storageChannel.provider, "local"))
.returning()
: await tx
.insert(drizzleDb.schemas.storageChannel)
.values(localStorageValues)
.returning();
if (!finalSystemSetting.defaultStorageChannelId) {
await tx
.update(drizzleDb.schemas.setting)
.set(withUpdatedAt({ defaultStorageChannelId: localStorage.id }))
.where(eq(drizzleDb.schemas.setting.id, finalSystemSetting.id));
}
});
}
+71
View File
@@ -0,0 +1,71 @@
import {db} from "@/db";
import * as drizzleDb from "@/db";
import {count, eq} from "drizzle-orm";
import {logger} from "@/lib/logger";
import type { SignUpUser } from "@/types/auth";
import {env} from "@/env.mjs";
import {createUserDb} from "@/db/services/user";
const log = logger.child({module: "init/user"});
export async function createDefaultUser() {
const hasRequiredEnv =
env.AUTH_DEFAULT_USER &&
env.AUTH_DEFAULT_PASSWORD &&
env.AUTH_DEFAULT_USER_NAME;
if (!hasRequiredEnv) {
log.info(
"Default admin creation skipped: missing environment variables.",
);
return;
}
log.info("Checking default super admin...");
const existingSuperAdmin = await db.query.user.findFirst({
where: eq(drizzleDb.schemas.user.role, "superadmin"),
});
if (existingSuperAdmin) {
log.info(
`CreateDefaultUser skipped: a superadmin already exists (${existingSuperAdmin.email}).`,
);
return;
}
const userData: SignUpUser = {
name: env.AUTH_DEFAULT_USER_NAME!,
email: env.AUTH_DEFAULT_USER!,
password: env.AUTH_DEFAULT_PASSWORD!,
theme: "system",
role: "superadmin",
emailVerified: true,
};
const newUser = await createUserDb(userData);
if (newUser) {
log.info(`Default super admin created (${userData.email}).`);
const defaultOrgSlug = "default";
const defaultOrg = await db.query.organization.findFirst({
where: eq(drizzleDb.schemas.organization.slug, defaultOrgSlug),
});
if (defaultOrg) {
await db.insert(drizzleDb.schemas.member).values({
userId: newUser.id,
organizationId: defaultOrg.id,
role: "owner",
});
} else {
log.warn(
"Default organization not found. Cannot assign member.",
);
}
}
}
+57
View File
@@ -24,4 +24,61 @@ export function generateValidPassword(length = 12) {
}
return passwordChars.join('');
}
export interface PasswordValidationResult {
valid: boolean;
errors: string[];
score: number;
}
const PASSWORD_RULES = [
{
key: "minLength",
regex: /.{8,}/,
message: "Password must contain at least 8 characters",
},
{
key: "number",
regex: /[0-9]/,
message: "Password must contain at least 1 number",
},
{
key: "lowercase",
regex: /[a-z]/,
message: "Password must contain at least 1 lowercase letter",
},
{
key: "uppercase",
regex: /[A-Z]/,
message: "Password must contain at least 1 uppercase letter",
},
{
key: "specialChar",
regex: /[^a-zA-Z0-9]/,
message: "Password must contain at least 1 special character",
},
];
export function validatePassword(
password: string,
): PasswordValidationResult {
const failedRules = PASSWORD_RULES.filter(
(rule) => !rule.regex.test(password),
);
return {
valid: failedRules.length === 0,
errors: failedRules.map((r) => r.message),
score: PASSWORD_RULES.length - failedRules.length,
};
}
export function assertValidPassword(password: string) {
const result = validatePassword(password);
if (!result.valid) {
throw new Error(result.errors.join(", "));
}
}