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