chore: checkpoint before features reorganization

This commit is contained in:
Théo LAGACHE
2026-06-23 16:00:22 +02:00
parent 7c9b38b1eb
commit 87fe60f82a
34 changed files with 930 additions and 933 deletions
+1 -5
View File
@@ -1,11 +1,7 @@
{
"extends": [
"next/core-web-vitals",
"plugin:tailwindcss/recommended"
],
"extends": ["next/core-web-vitals", "plugin:tailwindcss/recommended"],
"rules": {
"react/no-unescaped-entities": "off",
"react-hooks/rules-of-hooks": "off"
}
}
-51
View File
@@ -1,51 +0,0 @@
import { ImageResponse } from "next/og";
import { NextRequest, NextResponse } from "next/server";
import { getSettings } from "@/db/services/setting";
export const runtime = "nodejs";
const AVATAR_COLORS = [
"#4f46e5",
"#7c3aed",
"#e11d48",
"#ea580c",
"#d97706",
"#059669",
"#0891b2",
"#52525b",
];
export async function GET(request: NextRequest) {
const settings = await getSettings();
if (settings?.avatarMode && settings.avatarMode !== "internal") {
return new NextResponse(null, { status: 404 });
}
const { searchParams } = new URL(request.url);
const initials = (searchParams.get("initials") ?? "?")
.slice(0, 2)
.toUpperCase();
const color = searchParams.get("color") ?? AVATAR_COLORS[0];
return new ImageResponse(
<div
style={{
width: 80,
height: 80,
borderRadius: "50%",
backgroundColor: color,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "white",
fontSize: 28,
fontWeight: 700,
fontFamily: "sans-serif",
letterSpacing: "-0.5px",
}}
>
{initials}
</div>,
{ width: 80, height: 80 },
);
}
+86 -68
View File
@@ -10,77 +10,95 @@ import { AuthProviderConfig } from "@/lib/auth/config";
import Image from "next/image";
import { useRouter } from "next/navigation";
export function SocialAuthButtons({ providers }: { providers: AuthProviderConfig[] }) {
const socialProviders = providers.filter((p) => p.isActive && p.type !== "credential");
const router = useRouter();
export function SocialAuthButtons({
providers,
}: {
providers: AuthProviderConfig[];
}) {
const socialProviders = providers.filter(
(p) => p.isActive && p.type !== "credential",
);
const router = useRouter();
const [isLoading, setIsLoading] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState<string | null>(null);
const handleSocialSignIn = async (provider: AuthProviderConfig) => {
setIsLoading(provider.id);
try {
let result;
if (provider.id === "passkey") {
result = await authClient.signIn.passkey({
fetchOptions: {
onSuccess() {
router.push("/dashboard");
},
onError() {
toast.error("An error occurred during passkey authentication. Please try again.");
},
},
});
} else if (provider.type === "sso") {
result = await authClient.signIn.sso({
providerId: provider.id,
providerType: "oidc",
callbackURL: "/dashboard",
});
console.log(result);
} else {
result = await authClient.signIn.social({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
provider: provider.id as any,
callbackURL: "/dashboard",
});
}
const handleSocialSignIn = async (provider: AuthProviderConfig) => {
setIsLoading(provider.id);
try {
let result;
if (provider.id === "passkey") {
result = await authClient.signIn.passkey({
fetchOptions: {
onSuccess() {
router.push("/dashboard");
},
onError() {
toast.error(
"An error occurred during passkey authentication. Please try again.",
);
},
},
});
} else if (provider.type === "sso") {
result = await authClient.signIn.sso({
providerId: provider.id,
providerType: "oidc",
callbackURL: "/dashboard",
});
} else {
result = await authClient.signIn.social({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
provider: provider.id as any,
callbackURL: "/dashboard",
});
}
if (result?.error) {
toast.error("An error occurred while signing in with the provider. Please try again.");
} else if (provider.id !== "passkey") {
toast.success("Redirecting to provider...");
}
} catch (err) {
toast.error("An error occurred while signing in with the provider. Please try again.");
} finally {
setIsLoading(null);
}
};
if (result?.error) {
toast.error(
"An error occurred while signing in with the provider. Please try again.",
);
} else if (provider.id !== "passkey") {
toast.success("Redirecting to provider...");
}
} catch (err) {
toast.error(
"An error occurred while signing in with the provider. Please try again.",
);
} finally {
setIsLoading(null);
}
};
if (socialProviders.length === 0) return null;
if (socialProviders.length === 0) return null;
return (
<div className="flex flex-col gap-2 w-full">
{socialProviders.map((provider) => (
<Button key={provider.id} variant="outline" className="w-full gap-2" onClick={() => handleSocialSignIn(provider)} disabled={!!isLoading}>
{isLoading === provider.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : provider.icon.startsWith("/") || provider.icon.startsWith("http") ? (
<Image
src={provider.icon}
alt={provider.id || "icon"}
width={16}
height={16}
className="h-4 w-4"
unoptimized={provider.icon.startsWith("http")}
/>
) : (
<Icon icon={provider.icon} className="h-4 w-4" />
)}
<span>{provider.title || provider.name}</span>
</Button>
))}
</div>
);
return (
<div className="flex flex-col gap-2 w-full">
{socialProviders.map((provider) => (
<Button
key={provider.id}
variant="outline"
className="w-full gap-2"
onClick={() => handleSocialSignIn(provider)}
disabled={!!isLoading}
>
{isLoading === provider.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : provider.icon.startsWith("/") ||
provider.icon.startsWith("http") ? (
<Image
src={provider.icon}
alt={provider.id || "icon"}
width={16}
height={16}
className="h-4 w-4"
unoptimized={provider.icon.startsWith("http")}
/>
) : (
<Icon icon={provider.icon} className="h-4 w-4" />
)}
<span>{provider.title || provider.name}</span>
</Button>
))}
</div>
);
}
+1 -1
View File
@@ -37,7 +37,7 @@ export const LoggedInButtonClient = ({ user, sessions, currentSession, accounts,
>
<SidebarMenuButton type="button" className="h-auto justify-between py-2" data-testid="profile-dropdown">
<div className="flex items-center gap-2">
<Avatar className="size-6">
<Avatar key={avatarUrl ?? "fallback"} className="size-6">
<AvatarFallback>{(user.name?.[0] ?? user.email?.[0] ?? "?").toUpperCase()}</AvatarFallback>
{avatarUrl && <AvatarImage src={avatarUrl} />}
</Avatar>
@@ -27,7 +27,7 @@ export const StorageSection = ({ initial, storages, onSave, isPending }: Storage
}
noChannelsMessage={
<p className="text-xs text-muted-foreground">
Go back and configure storages in the &quot;Connect a storage&quot; step first.
Go back and configure storages in the &quot;Connect an external storage&quot; step first.
</p>
}
/>
+27 -12
View File
@@ -107,14 +107,16 @@ export async function resolveOnboardingState(): Promise<ResolvedOnboardingState>
organizationId: n.organizationId ?? null,
}));
const storages = storageChannels.map((s) => ({
id: s.id,
provider: s.provider,
label: s.provider,
name: s.name,
config: (s.config as Record<string, unknown>) ?? {},
organizationId: s.organizationId ?? null,
}));
const storages = storageChannels
.filter((s) => s.provider !== "local")
.map((s) => ({
id: s.id,
provider: s.provider,
label: s.provider,
name: s.name,
config: (s.config as Record<string, unknown>) ?? {},
organizationId: s.organizationId ?? null,
}));
const defaults = {
notifierId: settings?.defaultNotificationChannelId ?? undefined,
@@ -168,6 +170,10 @@ export async function resolveOnboardingState(): Promise<ResolvedOnboardingState>
const hasAgents = agents && agents.length > 0;
const defaultsConfigured =
!!settings?.defaultNotificationChannelId &&
!!settings?.defaultStorageChannelId;
if (project) {
if (!hasAgents) {
if (notifiers.length === 0) {
@@ -178,8 +184,12 @@ export async function resolveOnboardingState(): Promise<ResolvedOnboardingState>
meta.resumeStepId = "storage";
return { stepId: "storage", flowData: fullData };
}
meta.resumeStepId = "agent-create";
return { stepId: "agent-create", flowData: fullData };
if (defaultsConfigured) {
meta.resumeStepId = "agent-create";
return { stepId: "agent-create", flowData: fullData };
}
meta.resumeStepId = "defaults";
return { stepId: "defaults", flowData: fullData };
}
const agentHasPinged = !!agents[0]?.lastContact;
@@ -215,6 +225,11 @@ export async function resolveOnboardingState(): Promise<ResolvedOnboardingState>
return { stepId: "storage", flowData: fullData };
}
meta.resumeStepId = "agent-create";
return { stepId: "agent-create", flowData: fullData };
if (defaultsConfigured) {
meta.resumeStepId = "agent-create";
return { stepId: "agent-create", flowData: fullData };
}
meta.resumeStepId = "defaults";
return { stepId: "defaults", flowData: fullData };
}
+5 -1
View File
@@ -105,7 +105,11 @@ export const onboardingSteps: OnboardingStep[] = [
id: "agent-key",
component: StepAgentKey,
isSkippable: false,
nextStep: "agent-waiting",
nextStep: (ctx: any) => {
const agents = (ctx.flowData?.agents as any[]) || [];
const isConnected = agents.some((a) => a.connected);
return isConnected ? "project-create" : "agent-waiting";
},
},
{
id: "agent-waiting",
@@ -100,7 +100,9 @@ export const StepDbSettings = () => {
storagePolicies: settings.storagePolicies,
});
succeededIds.push(targetId);
} catch {}
} catch (err) {
console.error("Failed to apply settings to database", targetId, err);
}
}
if (succeededIds.length > 0) {
+68 -43
View File
@@ -2,6 +2,7 @@
import { useState } from "react";
import { useOnboarding } from "@onboardjs/react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import {
@@ -46,61 +47,85 @@ export const StepDefaults = () => {
);
const selectNotifier = async (value: string) => {
const prev = notifierId;
setNotifierId(value);
await updateNotificationSettingsAction({
name: "system",
data: { notificationChannelId: value },
});
await updateContext({
flowData: {
...state?.context.flowData,
defaults: { notifierId: value, storageId, avatarMode },
},
});
try {
await updateNotificationSettingsAction({
name: "system",
data: { notificationChannelId: value },
});
await updateContext({
flowData: {
...state?.context.flowData,
defaults: { notifierId: value, storageId, avatarMode, dicebearStyle },
},
});
} catch {
setNotifierId(prev);
toast.error("Failed to save default notifier");
}
};
const selectStorage = async (value: string) => {
const prev = storageId;
setStorageId(value);
await updateStorageSettingsAction({
name: "system",
data: { storageChannelId: value, encryption: false },
});
await updateContext({
flowData: {
...state?.context.flowData,
defaults: { notifierId, storageId: value, avatarMode },
},
});
try {
await updateStorageSettingsAction({
name: "system",
data: { storageChannelId: value, encryption: false },
});
await updateContext({
flowData: {
...state?.context.flowData,
defaults: { notifierId, storageId: value, avatarMode, dicebearStyle },
},
});
} catch {
setStorageId(prev);
toast.error("Failed to save default storage");
}
};
const selectAvatarMode = async (mode: AvatarMode) => {
const prev = avatarMode;
setAvatarMode(mode);
await updateAvatarModeAction({
name: "system",
avatarMode: mode,
dicebearStyle,
});
await updateContext({
flowData: {
...state?.context.flowData,
defaults: { notifierId, storageId, avatarMode: mode, dicebearStyle },
},
});
try {
await updateAvatarModeAction({
name: "system",
avatarMode: mode,
dicebearStyle,
});
await updateContext({
flowData: {
...state?.context.flowData,
defaults: { notifierId, storageId, avatarMode: mode, dicebearStyle },
},
});
} catch {
setAvatarMode(prev);
toast.error("Failed to save avatar mode");
}
};
const selectDicebearStyle = async (style: string) => {
const prev = dicebearStyle;
setDicebearStyle(style);
await updateAvatarModeAction({
name: "system",
avatarMode: "dicebear",
dicebearStyle: style,
});
await updateContext({
flowData: {
...state?.context.flowData,
defaults: { notifierId, storageId, avatarMode, dicebearStyle: style },
},
});
try {
await updateAvatarModeAction({
name: "system",
avatarMode: "dicebear",
dicebearStyle: style,
});
await updateContext({
flowData: {
...state?.context.flowData,
defaults: { notifierId, storageId, avatarMode, dicebearStyle: style },
},
});
} catch {
setDicebearStyle(prev);
toast.error("Failed to save avatar style");
}
};
const onContinue = async () => {
@@ -218,7 +243,7 @@ export const StepDefaults = () => {
<AvatarModeSelector value={avatarMode} onChange={selectAvatarMode} />
{avatarMode === "dicebear" && (
<DicebearStylePicker value={dicebearStyle} onChange={selectDicebearStyle} />
<DicebearStylePicker value={dicebearStyle} onChange={selectDicebearStyle} maxHeight="max-h-56" />
)}
<Button type="button" onClick={onContinue}>
+1 -1
View File
@@ -47,7 +47,7 @@ export const StepLogin = () => {
if (session?.user) {
router.push("/welcome");
}
}, [session?.user?.id]);
}, [session?.user, router]);
const form = useZodForm({ schema: LoginSchema });
@@ -2,64 +2,35 @@
import { useOnboarding } from "@onboardjs/react";
import { useTheme } from "next-themes";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { authClient } from "@/lib/auth/auth-client";
import type { OnboardingAccountData } from "@/features/onboarding/types";
import { ThemeKey, ThemeSelector } from "@/components/common/theme-selector";
const AVATAR_COLORS = [
{ name: "indigo", hex: "#4f46e5" },
{ name: "violet", hex: "#7c3aed" },
{ name: "rose", hex: "#e11d48" },
{ name: "orange", hex: "#ea580c" },
{ name: "amber", hex: "#d97706" },
{ name: "emerald", hex: "#059669" },
{ name: "cyan", hex: "#0891b2" },
{ name: "zinc", hex: "#52525b" },
];
export const StepPreferences = () => {
const { next, updateContext, state } = useOnboarding();
const { theme: currentTheme, setTheme } = useTheme();
const preferences = state?.context.flowData.preferences ?? {
theme: (currentTheme ?? "system") as ThemeKey,
};
const account = state?.context.flowData.account as
| OnboardingAccountData
| undefined;
const initials = account
? `${account.firstName[0] ?? ""}${account.lastName[0] ?? ""}`.toUpperCase()
: "PB";
const selectedAvatarUrl = preferences.avatarUrl;
const selectAvatar = async (color: string) => {
const url = `/api/avatar?initials=${initials}&color=${encodeURIComponent(color)}`;
await updateContext({
flowData: {
...state?.context.flowData,
preferences: { ...preferences, avatarUrl: url },
},
});
};
const selectTheme = async (theme: ThemeKey) => {
setTheme(theme);
await authClient.updateUser({ theme });
await updateContext({
flowData: {
...state?.context.flowData,
preferences: { ...preferences, theme },
},
});
try {
await authClient.updateUser({ theme });
await updateContext({
flowData: {
...state?.context.flowData,
preferences: { ...preferences, theme },
},
});
} catch {
toast.error("Failed to save theme preference");
setTheme(preferences.theme as ThemeKey);
}
};
const onContinue = async () => {
if (selectedAvatarUrl) {
await authClient.updateUser({ image: selectedAvatarUrl });
}
await next();
};
@@ -77,37 +48,6 @@ export const StepPreferences = () => {
<ThemeSelector value={preferences.theme} onSelect={selectTheme} />
</div>
<div className="flex flex-col gap-2">
<p className="text-sm font-medium">Avatar</p>
<div className="flex flex-wrap gap-2">
{AVATAR_COLORS.map((c) => {
const url = `/api/avatar?initials=${initials}&color=${encodeURIComponent(c.hex)}`;
const isSelected = selectedAvatarUrl === url;
return (
<button
key={c.name}
type="button"
onClick={() => selectAvatar(c.hex)}
className={cn(
"rounded-full overflow-hidden transition-all shrink-0",
isSelected
? "ring-2 ring-primary ring-offset-2 ring-offset-background"
: "opacity-70 hover:opacity-100",
)}
>
<img
src={url}
alt={c.name}
width={40}
height={40}
className="size-10 rounded-full"
/>
</button>
);
})}
</div>
</div>
<Button type="button" onClick={onContinue}>
Continue
</Button>
@@ -135,9 +135,9 @@ export const StepStorage = () => {
return (
<div className="flex flex-col gap-4">
<div>
<h1 className="text-2xl font-semibold">Connect a storage</h1>
<h1 className="text-2xl font-semibold">Connect an external storage</h1>
<p className="text-sm text-muted-foreground mt-1">
Optional choose where backups and files are stored.
Optional link an S3-compatible bucket or other external storage to keep your backups safe and off-instance.
</p>
</div>
-1
View File
@@ -23,7 +23,6 @@ export type OnboardingSecurityData = {
export type OnboardingPreferencesData = {
theme: "light" | "dark";
avatarUrl?: string;
};
export type OnboardingOrgData = {
+81 -80
View File
@@ -1,100 +1,101 @@
"use client";
import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar";
import {UploadIcon} from "lucide-react";
import {toast} from "sonner";
import {uploadUserImageAction} from "@/features/upload/upload.action";
import {useMutation} from "@tanstack/react-query";
import {updateImageUserAction} from "@/features/profile/avatar.action";
import {useRouter} from "next/navigation";
import {User} from "@/db/schema/02_user";
import React, {ChangeEvent} from "react";
import type {AvatarMode} from "@/features/onboarding/types";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { UploadIcon } from "lucide-react";
import { toast } from "sonner";
import { uploadUserImageAction } from "@/features/upload/upload.action";
import { useMutation } from "@tanstack/react-query";
import { updateImageUserAction } from "@/features/profile/avatar.action";
import { useRouter } from "next/navigation";
import { User } from "@/db/schema/02_user";
import React, { ChangeEvent } from "react";
import type { AvatarMode } from "@/features/onboarding/types";
export type AvatarWithUploadProps = {
user: User;
avatarMode?: AvatarMode;
avatarUrl?: string;
user: User;
avatarMode?: AvatarMode;
avatarUrl?: string;
};
export const AvatarWithUpload = (props: AvatarWithUploadProps) => {
const user = props.user;
const canUpload = !props.avatarMode || props.avatarMode === "internal";
const src = props.avatarUrl ?? user.image ?? undefined;
const router = useRouter();
const user = props.user;
const canUpload = !props.avatarMode || props.avatarMode === "internal";
const src = props.avatarUrl ?? user.image ?? undefined;
const router = useRouter();
const submitImage = useMutation({
mutationFn: async (file: File) => {
const formData = new FormData();
formData.set("file", file);
const result = await uploadUserImageAction(formData);
const submitImage = useMutation({
mutationFn: async (file: File) => {
const formData = new FormData();
formData.set("file", file);
const result = await uploadUserImageAction(formData);
const inner = result?.data;
const inner = result?.data;
if (inner?.success) {
if (inner?.success) {
const updateUser = await updateImageUserAction(inner.value ?? "");
const dataUser = updateUser?.data;
const updateUser = await updateImageUserAction(inner.value ?? "");
const dataUser = updateUser?.data;
if (updateUser?.serverError || !dataUser) {
toast.error(updateUser?.serverError);
return;
}
toast.success(inner.actionSuccess?.message);
router.refresh();
} else {
toast.error(inner?.actionError?.message);
}
},
});
const handleImageUpload = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
const allowedFormats = ["image/jpeg", "image/png", "image/webp"];
if (!allowedFormats.includes(file.type)) {
toast.error("Only JPG, PNG or WebP images are allowed.");
return;
if (updateUser?.serverError || !dataUser) {
toast.error(updateUser?.serverError);
return;
}
const maxSizeInMB = 5;
if (file.size > maxSizeInMB * 1024 * 1024) {
toast.error(`Image must be smaller than ${maxSizeInMB}MB.`);
return;
}
toast.success(inner.actionSuccess?.message);
router.refresh();
} else {
toast.error(inner?.actionError?.message);
}
},
});
submitImage.mutate(file);
};
const handleImageUpload = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
const allowedFormats = ["image/jpeg", "image/png", "image/webp"];
if (!allowedFormats.includes(file.type)) {
toast.error("Only JPG, PNG or WebP images are allowed.");
return;
}
return (
<div className="relative ">
const maxSizeInMB = 5;
if (file.size > maxSizeInMB * 1024 * 1024) {
toast.error(`Image must be smaller than ${maxSizeInMB}MB.`);
return;
}
<Avatar className="w-24 h-24 lg:w-32 lg:h-32 border-4 border-muted/20">
<AvatarImage className="object-cover" src={src}/>
<AvatarFallback className="text-3xl">{(user.name?.charAt(0) ?? user.email?.charAt(0) ?? "?").toUpperCase()}</AvatarFallback>
</Avatar>
submitImage.mutate(file);
};
{canUpload && (
<div
onClick={() => {
const fileInput = document.createElement("input");
fileInput.type = "file";
fileInput.accept = ".jpg,.jpeg,.png,.webp";
fileInput.onchange = (e: Event) =>
handleImageUpload(e as unknown as React.ChangeEvent<HTMLInputElement>);
fileInput.click();
}}
className="cursor-pointer absolute inset-0 flex justify-center items-center opacity-0 transition-opacity hover:opacity-30 hover:bg-gray-500 hover:bg-opacity-50 rounded-full w-24 h-24 lg:w-32 lg:h-32"
>
<UploadIcon className="w-12 h-12 lg:w-16 lg:h-16 text-primary"/>
</div>
)}
return (
<div className="relative ">
<Avatar
key={src}
className="w-24 h-24 lg:w-32 lg:h-32 border-4 border-muted/20"
>
{src && <AvatarImage className="object-cover" src={src} />}
<AvatarFallback className="text-3xl">
{(user.name?.charAt(0) ?? user.email?.charAt(0) ?? "?").toUpperCase()}
</AvatarFallback>
</Avatar>
{canUpload && (
<div
onClick={() => {
const fileInput = document.createElement("input");
fileInput.type = "file";
fileInput.accept = ".jpg,.jpeg,.png,.webp";
fileInput.onchange = (e: Event) =>
handleImageUpload(
e as unknown as React.ChangeEvent<HTMLInputElement>,
);
fileInput.click();
}}
className="cursor-pointer absolute inset-0 flex justify-center items-center opacity-0 transition-opacity hover:opacity-30 hover:bg-gray-500 hover:bg-opacity-50 rounded-full w-24 h-24 lg:w-32 lg:h-32"
>
<UploadIcon className="w-12 h-12 lg:w-16 lg:h-16 text-primary" />
</div>
);
)}
</div>
);
};
@@ -28,9 +28,9 @@ export const AvatarModeSelector = ({ value, onChange, disabled }: Props) => (
key={mode.value}
type="button"
disabled={disabled}
onClick={() => onChange(mode.value)}
onClick={() => { if (!isActive) onChange(mode.value); }}
className={cn(
"flex flex-col gap-2 rounded-xl border-2 p-3 text-left transition-all hover:bg-accent/50 disabled:opacity-50",
"flex flex-col gap-3 rounded-xl border-2 p-4 text-left transition-all hover:bg-accent/50 disabled:opacity-50",
isActive ? "border-primary bg-primary/5" : "border-muted/40",
)}
>
+1 -1
View File
@@ -42,7 +42,7 @@ export const SettingsAvatarSection = ({ settings }: Props) => {
};
return (
<div className="flex flex-col gap-6 max-w-2xl">
<div className="flex flex-col gap-6">
<div>
<h2 className="text-lg font-semibold">Avatar</h2>
<p className="text-sm text-muted-foreground mt-1">
@@ -10,9 +10,10 @@ type Props = {
value: string;
onChange: (style: string) => void;
disabled?: boolean;
maxHeight?: string;
};
export const DicebearStylePicker = ({ value, onChange, disabled }: Props) => {
export const DicebearStylePicker = ({ value, onChange, disabled, maxHeight = "max-h-96" }: Props) => {
const dicebearState = useDiceBearStyles();
return (
@@ -43,7 +44,7 @@ export const DicebearStylePicker = ({ value, onChange, disabled }: Props) => {
)}
{dicebearState.status === "success" && (
<div className="max-h-52 overflow-y-auto scrollbar-hide">
<div className={cn("overflow-y-auto scrollbar-hide", maxHeight)}>
<div className="grid grid-cols-3 sm:grid-cols-5 gap-2">
{dicebearState.styles.map((style) => {
const isActive = value === style;
@@ -52,7 +53,7 @@ export const DicebearStylePicker = ({ value, onChange, disabled }: Props) => {
key={style}
type="button"
disabled={disabled}
onClick={() => onChange(style)}
onClick={() => { if (!isActive) onChange(style); }}
title={style}
className={cn(
"flex flex-col items-center gap-1 rounded-lg border-2 p-1.5 transition-all hover:bg-accent/50 disabled:opacity-50",
+35 -53
View File
@@ -1,33 +1,29 @@
"use client";
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs";
import {useEffect, useState} from "react";
import {useRouter, useSearchParams} from "next/navigation";
import {Setting} from "@/db/schema/01_setting";
import {SettingsEmailSection} from "@/features/settings/email-section";
import {SettingsStorageSection} from "@/features/settings/storage-section";
import {StorageChannelWith} from "@/db/schema/12_storage-channel";
import {AlarmClock, MailboxIcon, Save, UserCircle} from "lucide-react";
import {
SettingsNotificationSection
} from "@/features/settings/notification-section";
import {NotificationChannelWith} from "@/db/schema/09_notification-channel";
import {SettingsAvatarSection} from "@/features/settings/avatar-section";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Setting } from "@/db/schema/01_setting";
import { SettingsEmailSection } from "@/features/settings/email-section";
import { SettingsStorageSection } from "@/features/settings/storage-section";
import { StorageChannelWith } from "@/db/schema/12_storage-channel";
import { AlarmClock, MailboxIcon, Save, UserCircle } from "lucide-react";
import { SettingsNotificationSection } from "@/features/settings/notification-section";
import { NotificationChannelWith } from "@/db/schema/09_notification-channel";
import { SettingsAvatarSection } from "@/features/settings/avatar-section";
export type SettingsTabsProps = {
settings: Setting
storageChannels: StorageChannelWith[],
settings: Setting;
storageChannels: StorageChannelWith[];
notificationChannels: NotificationChannelWith[];
};
export const SettingsTabs = ({settings, storageChannels, notificationChannels}: SettingsTabsProps) => {
export const SettingsTabs = ({ settings, storageChannels, notificationChannels }: SettingsTabsProps) => {
const router = useRouter();
const searchParams = useSearchParams();
const [tab, setTab] = useState<string>(() => searchParams.get("tab") ?? "email");
useEffect(() => {
const newTab = searchParams.get("tab") ?? "email";
setTab(newTab);
@@ -37,65 +33,51 @@ export const SettingsTabs = ({settings, storageChannels, notificationChannels}:
router.push(`?tab=${value}`);
};
const tabs = [
{
name: 'System Email',
value: 'email',
name: "System Email",
value: "email",
icon: MailboxIcon,
content: (
<SettingsEmailSection settings={settings}/>
)
content: <SettingsEmailSection settings={settings} />,
},
{
name: 'Storage',
value: 'storage',
name: "Storage",
value: "storage",
icon: Save,
content: (
<SettingsStorageSection storageChannels={storageChannels} settings={settings}/>
)
content: <SettingsStorageSection storageChannels={storageChannels} settings={settings} />,
},
{
name: 'Notification',
value: 'notification',
name: "Notification",
value: "notification",
icon: AlarmClock,
content: (
<SettingsNotificationSection notificationChannels={notificationChannels} settings={settings}/>
)
content: <SettingsNotificationSection notificationChannels={notificationChannels} settings={settings} />,
},
{
name: 'Avatar',
value: 'avatar',
name: "Avatar",
value: "avatar",
icon: UserCircle,
content: (
<SettingsAvatarSection settings={settings}/>
)
}
]
content: <SettingsAvatarSection settings={settings} />,
},
];
return (
<div className="h-full mt-3">
<Tabs className="h-full gap-4" value={tab} onValueChange={handleChangeTab}>
<TabsList className="w-full sm:w-fit">
{tabs.map(({icon: Icon, name, value}) => (
<TabsTrigger key={value} value={value} className='flex items-center gap-1 px-2.5 sm:px-3 flex-1 sm:flex-none'>
<Icon className="size-4 shrink-0"/>
<TabsList className="w-full">
{tabs.map(({ icon: Icon, name, value }) => (
<TabsTrigger key={value} value={value} className="w-full flex items-center gap-1.5">
<Icon className="size-4 shrink-0" />
<span className="hidden sm:inline">{name}</span>
</TabsTrigger>
))}
</TabsList>
{tabs.map(tab => (
<TabsContent key={tab.value} value={tab.value} className="h-full">
{tab.content}
{tabs.map((t) => (
<TabsContent key={t.value} value={t.value} className="h-full">
{t.content}
</TabsContent>
))}
</Tabs>
</div>
);
};
+141 -87
View File
@@ -1,108 +1,162 @@
export interface GitHubRelease {
tag_name: string;
html_url: string;
prerelease: boolean;
name: string;
body: string;
tag_name: string;
html_url: string;
prerelease: boolean;
name: string;
body: string;
}
type ParsedVersion = {
major: number;
minor: number;
patch: number;
rc?: number;
major: number;
minor: number;
patch: number;
rc?: number;
};
function parseVersion(version: string): ParsedVersion {
const match = version.match(/^v?(\d+)\.(\d+)\.(\d+)(?:-rc\.(\d+))?$/);
if (!match) return {
major: 0,
minor: 0,
patch: 0,
rc: undefined,
const match = version.match(/^v?(\d+)\.(\d+)\.(\d+)(?:-rc\.(\d+))?$/);
if (!match)
return {
major: 0,
minor: 0,
patch: 0,
rc: undefined,
};
return {
major: Number(match[1]),
minor: Number(match[2]),
patch: Number(match[3]),
rc: match[4] ? Number(match[4]) : undefined,
};
return {
major: Number(match[1]),
minor: Number(match[2]),
patch: Number(match[3]),
rc: match[4] ? Number(match[4]) : undefined,
};
}
const findLatestVersion = (currentVersion: string, releases: GitHubRelease[]): GitHubRelease | null => {
const cleanCurrentVersion = currentVersion.replace(/^v/, "");
const isStable = /^\d+\.\d+\.\d+$/.test(cleanCurrentVersion);
const isRc = /^\d+\.\d+\.\d+-rc\.\d+$/.test(cleanCurrentVersion);
const findLatestVersion = (
currentVersion: string,
releases: GitHubRelease[],
): GitHubRelease | null => {
const cleanCurrentVersion = currentVersion.replace(/^v/, "");
const isStable = /^\d+\.\d+\.\d+$/.test(cleanCurrentVersion);
const isRc = /^\d+\.\d+\.\d+-rc\.\d+$/.test(cleanCurrentVersion);
const currentParsedVersion = parseVersion(cleanCurrentVersion);
const currentParsedVersion = parseVersion(cleanCurrentVersion);
if (isRc) {
const latestStableVersion = releases.find(r => !r.prerelease);
const latestRcVersion = releases.find(r => r.prerelease && (r.tag_name.includes("rc") || (r.name && r.name.includes("rc"))));
if (isRc) {
const latestStableVersion = releases.find((r) => !r.prerelease);
const latestRcVersion = releases.find(
(r) =>
r.prerelease &&
(r.tag_name.includes("rc") || (r.name && r.name.includes("rc"))),
);
if (latestStableVersion) {
const versionStr = latestStableVersion.tag_name || latestStableVersion.name;
const latestStableParsedVersion = parseVersion(versionStr);
if (latestStableParsedVersion.major > currentParsedVersion.major) return latestStableVersion;
if (latestStableParsedVersion.major === currentParsedVersion.major && latestStableParsedVersion.minor > currentParsedVersion.minor) return latestStableVersion;
if (latestStableParsedVersion.major === currentParsedVersion.major && latestStableParsedVersion.minor === currentParsedVersion.minor && latestStableParsedVersion.patch >= currentParsedVersion.patch) return latestStableVersion;
}
if (latestRcVersion) {
const versionStr = latestRcVersion.tag_name || latestRcVersion.name;
const latestRcParsedVersion = parseVersion(versionStr);
if (latestRcParsedVersion.major > currentParsedVersion.major) return latestRcVersion;
if (latestRcParsedVersion.major === currentParsedVersion.major && latestRcParsedVersion.minor > currentParsedVersion.minor) return latestRcVersion;
if (latestRcParsedVersion.major === currentParsedVersion.major && latestRcParsedVersion.minor === currentParsedVersion.minor && latestRcParsedVersion.patch > currentParsedVersion.patch) return latestRcVersion;
if (latestRcParsedVersion.major === currentParsedVersion.major && latestRcParsedVersion.minor === currentParsedVersion.minor && latestRcParsedVersion.patch === currentParsedVersion.patch &&
latestRcParsedVersion.rc !== undefined && currentParsedVersion.rc !== undefined && latestRcParsedVersion.rc > currentParsedVersion.rc) return latestRcVersion;
}
} else if (isStable) {
const latestStableVersion = releases.find(r => !r.prerelease);
if (latestStableVersion) {
const versionStr = latestStableVersion.tag_name || latestStableVersion.name;
const latestStableParsedVersion = parseVersion(versionStr);
if (latestStableParsedVersion.major > currentParsedVersion.major) return latestStableVersion;
if (latestStableParsedVersion.major === currentParsedVersion.major && latestStableParsedVersion.minor > currentParsedVersion.minor) return latestStableVersion;
if (latestStableParsedVersion.major === currentParsedVersion.major && latestStableParsedVersion.minor === currentParsedVersion.minor && latestStableParsedVersion.patch > currentParsedVersion.patch) return latestStableVersion;
}
if (latestStableVersion) {
const versionStr =
latestStableVersion.tag_name || latestStableVersion.name;
const latestStableParsedVersion = parseVersion(versionStr);
if (latestStableParsedVersion.major > currentParsedVersion.major)
return latestStableVersion;
if (
latestStableParsedVersion.major === currentParsedVersion.major &&
latestStableParsedVersion.minor > currentParsedVersion.minor
)
return latestStableVersion;
if (
latestStableParsedVersion.major === currentParsedVersion.major &&
latestStableParsedVersion.minor === currentParsedVersion.minor &&
latestStableParsedVersion.patch >= currentParsedVersion.patch
)
return latestStableVersion;
}
if (latestRcVersion) {
const versionStr = latestRcVersion.tag_name || latestRcVersion.name;
const latestRcParsedVersion = parseVersion(versionStr);
if (latestRcParsedVersion.major > currentParsedVersion.major)
return latestRcVersion;
if (
latestRcParsedVersion.major === currentParsedVersion.major &&
latestRcParsedVersion.minor > currentParsedVersion.minor
)
return latestRcVersion;
if (
latestRcParsedVersion.major === currentParsedVersion.major &&
latestRcParsedVersion.minor === currentParsedVersion.minor &&
latestRcParsedVersion.patch > currentParsedVersion.patch
)
return latestRcVersion;
if (
latestRcParsedVersion.major === currentParsedVersion.major &&
latestRcParsedVersion.minor === currentParsedVersion.minor &&
latestRcParsedVersion.patch === currentParsedVersion.patch &&
latestRcParsedVersion.rc !== undefined &&
currentParsedVersion.rc !== undefined &&
latestRcParsedVersion.rc > currentParsedVersion.rc
)
return latestRcVersion;
}
} else if (isStable) {
const latestStableVersion = releases.find((r) => !r.prerelease);
if (latestStableVersion) {
const versionStr =
latestStableVersion.tag_name || latestStableVersion.name;
const latestStableParsedVersion = parseVersion(versionStr);
if (latestStableParsedVersion.major > currentParsedVersion.major)
return latestStableVersion;
if (
latestStableParsedVersion.major === currentParsedVersion.major &&
latestStableParsedVersion.minor > currentParsedVersion.minor
)
return latestStableVersion;
if (
latestStableParsedVersion.major === currentParsedVersion.major &&
latestStableParsedVersion.minor === currentParsedVersion.minor &&
latestStableParsedVersion.patch > currentParsedVersion.patch
)
return latestStableVersion;
}
}
return null;
};
export const getNewRelease = async (
currentVersion: string,
): Promise<GitHubRelease | null> => {
try {
const response = await fetch(
"https://api.github.com/repos/Portabase/portabase/releases",
);
if (!response.ok) {
return null;
}
const releases: GitHubRelease[] = await response.json();
return findLatestVersion(currentVersion, releases);
} catch (error) {
console.error("Failed to fetch latest release", error);
return null;
}
};
export const getNewRelease = async (currentVersion: string): Promise<GitHubRelease | null> => {
try {
const response = await fetch("https://api.github.com/repos/Portabase/portabase/releases");
if (!response.ok) {
return null;
}
const releases: GitHubRelease[] = await response.json();
return findLatestVersion(currentVersion, releases);
} catch (error) {
console.error("Failed to fetch latest release", error);
return null;
}
};
export const getNewAgentRelease = async (currentVersion: string): Promise<GitHubRelease | null> => {
try {
const response = await fetch("https://api.github.com/repos/Portabase/agent-rust/releases", {
next: { revalidate: 3600 }
});
if (!response.ok) {
return null;
}
const releases: GitHubRelease[] = await response.json();
return findLatestVersion(currentVersion, releases);
} catch (error) {
console.error("Failed to fetch latest agent release", error);
return null;
export const getNewAgentRelease = async (
currentVersion: string,
): Promise<GitHubRelease | null> => {
try {
const response = await fetch(
"https://api.github.com/repos/Portabase/agent-rust/releases",
{
next: { revalidate: 3600 },
},
);
if (!response.ok) {
return null;
}
const releases: GitHubRelease[] = await response.json();
return findLatestVersion(currentVersion, releases);
} catch (error) {
console.error("Failed to fetch latest agent release", error);
return null;
}
};
+36 -34
View File
@@ -5,43 +5,45 @@ const defaultLevel = isProd ? "info" : "debug";
const level = (process.env.LOG_LEVEL ?? defaultLevel) as pino.Level;
function getLocalTimestamp() {
const date = new Date();
const date = new Date();
const formatted = new Intl.DateTimeFormat("en-US", {
timeZone: process.env.TZ,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit"
}).format(date).replace(" ", "T");
const formatted = new Intl.DateTimeFormat("en-US", {
timeZone: process.env.TZ,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
})
.format(date)
.replace(" ", "T");
return `,"time":"${formatted}"`;
return `,"time":"${formatted}"`;
}
export const logger: Logger = pino({
level: level,
base: null,
...(isProd
? {
timestamp: getLocalTimestamp,
formatters: {
level(label) {
return { level: label.toUpperCase() };
}
}
}
: {
timestamp: getLocalTimestamp,
transport: {
target: "pino-pretty",
options: {
colorize: true,
translateTime: "yyyy-mm-dd HH:MM:ss",
ignore: "pid,hostname",
levelFirst: true
}
}
})
level: level,
base: null,
...(isProd
? {
timestamp: getLocalTimestamp,
formatters: {
level(label) {
return { level: label.toUpperCase() };
},
},
}
: {
timestamp: getLocalTimestamp,
transport: {
target: "pino-pretty",
options: {
colorize: true,
translateTime: "yyyy-mm-dd HH:MM:ss",
ignore: "pid,hostname",
levelFirst: true,
},
},
}),
});
+27 -24
View File
@@ -1,30 +1,33 @@
import {db} from "@/db";
import {and, eq, isNotNull, isNull} from "drizzle-orm";
import { db } from "@/db";
import { and, eq, isNotNull } from "drizzle-orm";
import * as drizzleDb from "@/db";
import {withUpdatedAt} from "@/db/utils";
import {logger} from "@/lib/logger";
import { withUpdatedAt } from "@/db/utils";
import { logger } from "@/lib/logger";
const log = logger.child({module: "tasks/cleaning"});
const log = logger.child({ module: "tasks/cleaning" });
export const backupCleanTask = async () => {
try {
const backups = await db.query.backup.findMany({
where: and(
isNotNull(drizzleDb.schemas.backup.deletedAt),
eq(drizzleDb.schemas.backup.status, "ongoing")
)
});
log.debug(`Backups to clean: ${backups.length}`);
try {
const backups = await db.query.backup.findMany({
where: and(
isNotNull(drizzleDb.schemas.backup.deletedAt),
eq(drizzleDb.schemas.backup.status, "ongoing"),
),
});
log.debug(`Backups to clean: ${backups.length}`);
for (const backup of backups) {
await db.update(drizzleDb.schemas.backup).set(withUpdatedAt({
status: "failed",
}))
.where(eq(drizzleDb.schemas.backup.id, backup.id));
}
} catch (e: any) {
log.error({name: "backupCleanTask", error: e},`Backup cleanup failed`);
throw e;
for (const backup of backups) {
await db
.update(drizzleDb.schemas.backup)
.set(
withUpdatedAt({
status: "failed",
}),
)
.where(eq(drizzleDb.schemas.backup.id, backup.id));
}
};
} catch (e: any) {
log.error({ name: "backupCleanTask", error: e }, `Backup cleanup failed`);
throw e;
}
};
+44 -45
View File
@@ -1,58 +1,57 @@
import {db} from "@/db";
import {enforceRetentionCount} from "@/lib/tasks/database/retention-count";
import {enforceRetentionDays} from "@/lib/tasks/database/retention-days";
import {enforceRetentionGFS} from "@/lib/tasks/database/retention-gsf";
import {retentionPolicy} from "@/db/schema/07_database";
import {isNull} from "drizzle-orm";
import { db } from "@/db";
import { enforceRetentionCount } from "@/lib/tasks/database/retention-count";
import { enforceRetentionDays } from "@/lib/tasks/database/retention-days";
import { enforceRetentionGFS } from "@/lib/tasks/database/retention-gsf";
import { retentionPolicy } from "@/db/schema/07_database";
import { isNull } from "drizzle-orm";
import * as drizzleDb from "@/db";
import {logger} from "@/lib/logger";
import { logger } from "@/lib/logger";
const log = logger.child({module: "tasks/database"});
const log = logger.child({ module: "tasks/database" });
export const retentionCleanTask = async () => {
try {
const databases = await db.query.database.findMany({
with: {
retentionPolicy: true,
backups: {
where: isNull(drizzleDb.schemas.backup.deletedAt),
},
},
});
log.info(`Retention databases number: ${databases.length}`);
for (const db of databases) {
if (!db.retentionPolicy) continue;
await enforceRetention(db.id, db.retentionPolicy);
}
} catch (e: any) {
log.error({error: e},"Retention cleanup failed");
throw e;
try {
const databases = await db.query.database.findMany({
with: {
retentionPolicy: true,
backups: {
where: isNull(drizzleDb.schemas.backup.deletedAt),
},
},
});
log.info(`Retention databases number: ${databases.length}`);
for (const db of databases) {
if (!db.retentionPolicy) continue;
await enforceRetention(db.id, db.retentionPolicy);
}
} catch (e: any) {
log.error({ error: e }, "Retention cleanup failed");
throw e;
}
};
export async function enforceRetention(
databaseId: string,
policy: typeof retentionPolicy.$inferSelect
databaseId: string,
policy: typeof retentionPolicy.$inferSelect,
) {
;
log.info({name: "enforceRetention"},`Retention started for ${databaseId}`);
log.info({ name: "enforceRetention" }, `Retention started for ${databaseId}`);
switch (policy.type) {
case "count":
await enforceRetentionCount(databaseId, policy.count ?? 7);
break;
switch (policy.type) {
case "count":
await enforceRetentionCount(databaseId, policy.count ?? 7);
break;
case "days":
await enforceRetentionDays(databaseId, policy.days ?? 30);
break;
case "days":
await enforceRetentionDays(databaseId, policy.days ?? 30);
break;
case "gfs":
await enforceRetentionGFS(databaseId, {
daily: policy.gfsDaily ?? 7,
weekly: policy.gfsWeekly ?? 4,
monthly: policy.gfsMonthly ?? 12,
yearly: policy.gfsYearly ?? 3,
});
break;
}
case "gfs":
await enforceRetentionGFS(databaseId, {
daily: policy.gfsDaily ?? 7,
weekly: policy.gfsWeekly ?? 4,
monthly: policy.gfsMonthly ?? 12,
yearly: policy.gfsYearly ?? 3,
});
break;
}
}
+96 -89
View File
@@ -1,97 +1,104 @@
"use server"
import {action} from "@/lib/safe-actions/actions";
import {z} from "zod";
import {ServerActionResult} from "@/types/action-type";
import {Backup} from "@/db/schema/07_database";
import {db} from "@/db";
"use server";
import { action } from "@/lib/safe-actions/actions";
import { z } from "zod";
import { ServerActionResult } from "@/types/action-type";
import { Backup } from "@/db/schema/07_database";
import { db } from "@/db";
import * as drizzleDb from "@/db";
import {and, eq} from "drizzle-orm";
import {withUpdatedAt} from "@/db/utils";
import type {StorageInput} from "@/features/storages/storages.types";
import {dispatchStorage} from "@/features/storages/storages.dispatch";
import { and, eq } from "drizzle-orm";
import { withUpdatedAt } from "@/db/utils";
import type { StorageInput } from "@/features/storages/storages.types";
import { dispatchStorage } from "@/features/storages/storages.dispatch";
export const deleteBackupCronAction = action
.schema(
z.object({
backupId: z.string(),
databaseId: z.string(),
})
)
.action(async ({parsedInput}): Promise<ServerActionResult<Backup>> => {
try {
.schema(
z.object({
backupId: z.string(),
databaseId: z.string(),
}),
)
.action(async ({ parsedInput }): Promise<ServerActionResult<Backup>> => {
try {
const backup = await db.query.backup.findFirst({
where: and(
eq(drizzleDb.schemas.backup.id, parsedInput.backupId),
eq(drizzleDb.schemas.backup.databaseId, parsedInput.databaseId),
),
});
const backup = await db.query.backup.findFirst({
where: and(eq(drizzleDb.schemas.backup.id, parsedInput.backupId), eq(drizzleDb.schemas.backup.databaseId, parsedInput.databaseId))
});
if (!backup) {
return {
success: false,
actionError: {
message: "Backup not found.",
status: 404,
messageParams: { backupId: parsedInput.backupId },
},
};
}
const backupStorages = await db.query.backupStorage.findMany({
where: eq(
drizzleDb.schemas.backupStorage.backupId,
parsedInput.backupId,
),
});
if (!backup) {
return {
success: false,
actionError: {
message: "Backup not found.",
status: 404,
messageParams: {backupId: parsedInput.backupId},
},
}
}
for (const backupStorage of backupStorages) {
await db
.update(drizzleDb.schemas.backupStorage)
.set(
withUpdatedAt({
deletedAt: new Date(),
}),
)
.where(eq(drizzleDb.schemas.backupStorage.id, backupStorage.id));
const backupStorages = await db.query.backupStorage.findMany({
where: eq(drizzleDb.schemas.backupStorage.backupId, parsedInput.backupId),
});
for (const backupStorage of backupStorages) {
await db
.update(drizzleDb.schemas.backupStorage)
.set(withUpdatedAt({
deletedAt: new Date(),
}))
.where(eq(drizzleDb.schemas.backupStorage.id, backupStorage.id))
if (backupStorage.status != "success" || !backupStorage.path) {
continue;
}
const input: StorageInput = {
action: "delete",
data: {
path: backupStorage.path,
},
};
await dispatchStorage(input, undefined, backupStorage.storageChannelId);
}
await db
.update(drizzleDb.schemas.backup)
.set(withUpdatedAt({
deletedAt: new Date(),
status: backup.status == "ongoing" ? "failed" : backup.status
}))
.where(and(eq(drizzleDb.schemas.backup.id, parsedInput.backupId), eq(drizzleDb.schemas.backup.databaseId, parsedInput.databaseId)))
return {
success: true,
actionSuccess: {
message: `Backup deleted successfully (${parsedInput.backupId}).`,
},
};
} catch (error) {
console.error(error);
return {
success: false,
actionError: {
message: `Failed to delete backup(${parsedInput.backupId}).`,
status: 500,
cause: error instanceof Error ? error.message : "Unknown error",
messageParams: {message: "Error deleting the backup"},
},
};
if (backupStorage.status != "success" || !backupStorage.path) {
continue;
}
});
const input: StorageInput = {
action: "delete",
data: {
path: backupStorage.path,
},
};
await dispatchStorage(input, undefined, backupStorage.storageChannelId);
}
await db
.update(drizzleDb.schemas.backup)
.set(
withUpdatedAt({
deletedAt: new Date(),
status: backup.status == "ongoing" ? "failed" : backup.status,
}),
)
.where(
and(
eq(drizzleDb.schemas.backup.id, parsedInput.backupId),
eq(drizzleDb.schemas.backup.databaseId, parsedInput.databaseId),
),
);
return {
success: true,
actionSuccess: {
message: `Backup deleted successfully (${parsedInput.backupId}).`,
},
};
} catch (error) {
console.error(error);
return {
success: false,
actionError: {
message: `Failed to delete backup(${parsedInput.backupId}).`,
status: 500,
cause: error instanceof Error ? error.message : "Unknown error",
messageParams: { message: "Error deleting the backup" },
},
};
}
});
+1 -1
View File
@@ -1,4 +1,4 @@
import { createTwc } from "react-twc";
import { cn } from "./utils";
export const twx = createTwc({ compose: cn});
export const twx = createTwc({ compose: cn });
+3 -3
View File
@@ -1,6 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
return twMerge(clsx(inputs));
}
+9 -6
View File
@@ -1,8 +1,11 @@
import {NextResponse} from "next/server";
import { NextResponse } from "next/server";
export const errorHandler = (error: any) => {
return new NextResponse(
JSON.stringify({ message: 'An error occurred while processing your request.', status: 500 }),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
);
};
return new NextResponse(
JSON.stringify({
message: "An error occurred while processing your request.",
status: 500,
}),
{ status: 500, headers: { "Content-Type": "application/json" } },
);
};
+13 -11
View File
@@ -1,7 +1,8 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from "@/lib/logger";
import { NextRequest, NextResponse } from "next/server";
const LOG_LEVELS = ["debug", "info", "warn", "error"] as const;
type LogLevel = typeof LOG_LEVELS[number];
type LogLevel = (typeof LOG_LEVELS)[number];
const raw = (process.env.LOG_LEVEL ?? "info") as LogLevel;
const _idx = LOG_LEVELS.indexOf(raw);
@@ -10,13 +11,14 @@ const LEVEL_INDEX = _idx === -1 ? 1 : _idx;
const STATUS_PATH_RE = /\/api\/agent\/[^/]+\/status\/?$/;
export function loggingMiddleware(request: NextRequest) {
if (request.url.includes('/api')) {
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()}`);
}
if (request.url.includes("/api")) {
const isStatusPing = STATUS_PATH_RE.test(request.nextUrl.pathname);
const requiredIndex = isStatusPing ? 0 : 1;
if (LEVEL_INDEX <= requiredIndex) {
logger.info(
`[API] Received ${request.method} request : ${request.url} at ${new Date()}`,
);
}
return NextResponse.next();
}
}
return NextResponse.next();
}
+23 -20
View File
@@ -2,30 +2,33 @@ import { SafeActionResult } from "next-safe-action";
import { ZodString } from "zod";
export type ServerActionResult<T> =
| { success: true; value?: T; actionSuccess?: ActionSuccessMessage }
| {
success: false;
actionError?: ActionErrorMessage;
};
| { success: true; value?: T; actionSuccess?: ActionSuccessMessage }
| {
success: false;
actionError?: ActionErrorMessage;
};
export type ActionErrorMessage = {
message?: string;
status?: number;
cause?: string;
messageParams?: Record<string, string | number>;
message?: string;
status?: number;
cause?: string;
messageParams?: Record<string, string | number>;
};
export type ActionSuccessMessage = { message?: string; messageParams?: Record<string, string | number> };
export type ActionSuccessMessage = {
message?: string;
messageParams?: Record<string, string | number>;
};
export type ActionResult<T> = SafeActionResult<
string,
ZodString,
readonly [],
{
formErrors: string[];
fieldErrors: {};
},
readonly [],
ServerActionResult<T>,
object
string,
ZodString,
readonly [],
{
formErrors: string[];
fieldErrors: {};
},
readonly [],
ServerActionResult<T>,
object
>;
+13 -14
View File
@@ -1,17 +1,16 @@
export type SignUpUser = {
name: string
email: string
password: string
callbackURL?: string
role?: string
theme: string
emailVerified?: boolean
}
name: string;
email: string;
password: string;
callbackURL?: string;
role?: string;
theme: string;
emailVerified?: boolean;
};
export type BetterAuthError = {
code?: string;
message?: string;
status: number;
statusText: string;
};
code?: string;
message?: string;
status: number;
statusText: string;
};
+1 -1
View File
@@ -1,2 +1,2 @@
export type MemberRole = "member" | "admin" | "owner";
export type MemberRoleType = MemberRole | MemberRole[];
export type MemberRoleType = MemberRole | MemberRole[];
+4 -4
View File
@@ -1,9 +1,9 @@
export type LayoutParams<T extends Record<string, string | string[]>> = {
children: React.ReactNode;
params: T;
children: React.ReactNode;
params: T;
};
export type PageParams<T extends Record<string, string | string[]>> = {
params: Promise<T>;
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
params: Promise<T>;
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
};
+78 -76
View File
@@ -1,89 +1,91 @@
import {MemberWithUser, Organization, OrganizationWithMembersAndUsers} from "@/db/schema/03_organization";
import {OrganizationMember} from "@/db/schema/04_member";
import {OrganizationInvitation} from "@/db/schema/05_invitation";
import {User} from "@/db/schema/02_user";
import {
MemberWithUser,
Organization,
OrganizationWithMembersAndUsers,
} from "@/db/schema/03_organization";
import { OrganizationMember } from "@/db/schema/04_member";
import { OrganizationInvitation } from "@/db/schema/05_invitation";
import { User } from "@/db/schema/02_user";
export function buildOrganizationWithMembers(
rows: {
organization: Organization;
member: OrganizationMember | null;
invitation: OrganizationInvitation | null;
user: User | null;
}[]
rows: {
organization: Organization;
member: OrganizationMember | null;
invitation: OrganizationInvitation | null;
user: User | null;
}[],
): OrganizationWithMembersAndUsers | null {
if (rows.length === 0) return null;
if (rows.length === 0) return null;
const org = rows[0].organization;
const org = rows[0].organization;
const invitations: OrganizationInvitation[] = rows
.filter((r) => r.invitation)
.map((r) => ({
...r.invitation!,
}));
const invitations: OrganizationInvitation[] = rows
.filter(r => r.invitation)
.map(r => ({
...r.invitation!,
}));
const members: MemberWithUser[] = rows
.filter((r) => r.member && r.user)
.map((r) => ({
...r.member!,
user: r.user!,
}));
const members: MemberWithUser[] = rows
.filter(r => r.member && r.user)
.map(r => ({
...r.member!,
user: r.user!,
}));
return {
...org,
invitations,
members,
};
return {
...org,
invitations,
members,
};
}
export function getFileExtension(dbType: string) {
switch (dbType) {
case "postgresql":
return ".dump";
case "mysql":
return ".sql";
default:
return ".dump";
}
switch (dbType) {
case "postgresql":
return ".dump";
case "mysql":
return ".sql";
default:
return ".dump";
}
}
export function getFileHeadersBasedOnDbms(dbType: string): Record<string, string[]> {
switch (dbType) {
case "postgresql":
return {
"application/octet-stream": [".dump"],
};
case "mysql":
case "mariadb":
return {
"application/sql": [".sql"],
"application/x-sql": [".sql"],
};
case "mongodb":
return {
"application/gzip": [".archive.gz"],
};
case "firebird":
return {
"application/octet-stream": [".fbk"],
};
case "valkey":
case "redis":
return {
"application/octet-stream": [".rdb"],
};
case "sqlite":
return {
"application/octet-stream": [".backup"],
};
case "mssql":
return {
"application/octet-stream": [".bacpac"],
};
default:
throw new Error(`Unsupported database type: ${dbType}`);
}
export function getFileHeadersBasedOnDbms(
dbType: string,
): Record<string, string[]> {
switch (dbType) {
case "postgresql":
return {
"application/octet-stream": [".dump"],
};
case "mysql":
case "mariadb":
return {
"application/sql": [".sql"],
"application/x-sql": [".sql"],
};
case "mongodb":
return {
"application/gzip": [".archive.gz"],
};
case "firebird":
return {
"application/octet-stream": [".fbk"],
};
case "valkey":
case "redis":
return {
"application/octet-stream": [".rdb"],
};
case "sqlite":
return {
"application/octet-stream": [".backup"],
};
case "mssql":
return {
"application/octet-stream": [".bacpac"],
};
default:
throw new Error(`Unsupported database type: ${dbType}`);
}
}
+59 -61
View File
@@ -1,70 +1,68 @@
import {db} from "@/db";
import {env} from "@/env.mjs";
import { db } from "@/db";
import { env } from "@/env.mjs";
import * as drizzleDb from "@/db";
import {eq} from "drizzle-orm";
import {StorageProviderKind} from "@/features/storages/storages.types";
import {withUpdatedAt} from "@/db/utils";
import { eq } from "drizzle-orm";
import { StorageProviderKind } from "@/features/storages/storages.types";
import { withUpdatedAt } from "@/db/utils";
export async function createSettingsIfNotExist() {
await db.transaction(async (tx) => {
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 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 [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 [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 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 [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();
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));
}
});
}
if (!finalSystemSetting.defaultStorageChannelId) {
await tx
.update(drizzleDb.schemas.setting)
.set(withUpdatedAt({ defaultStorageChannelId: localStorage.id }))
.where(eq(drizzleDb.schemas.setting.id, finalSystemSetting.id));
}
});
}
+51 -58
View File
@@ -1,71 +1,64 @@
import {db} from "@/db";
import { db } from "@/db";
import * as drizzleDb from "@/db";
import {count, eq} from "drizzle-orm";
import {logger} from "@/lib/logger";
import { 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";
import { env } from "@/env.mjs";
import { createUserDb } from "@/db/services/user";
const log = logger.child({module: "init/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;
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;
}
if (!hasRequiredEnv) {
log.info(
"Default admin creation skipped: missing environment variables.",
);
return;
}
log.info("Checking default super admin...");
log.info("Checking default super admin...");
const existingSuperAdmin = await db.query.user.findFirst({
where: eq(drizzleDb.schemas.user.role, "superadmin"),
});
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 (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.",
);
}
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.");
}
}
}