mirror of
https://github.com/Portabase/portabase.git
synced 2026-07-14 11:16:13 +02:00
chore: checkpoint before features reorganization
This commit is contained in:
+1
-5
@@ -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"
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 "Connect a storage" step first.
|
||||
Go back and configure storages in the "Connect an external storage" step first.
|
||||
</p>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -23,7 +23,6 @@ export type OnboardingSecurityData = {
|
||||
|
||||
export type OnboardingPreferencesData = {
|
||||
theme: "light" | "dark";
|
||||
avatarUrl?: string;
|
||||
};
|
||||
|
||||
export type OnboardingOrgData = {
|
||||
|
||||
@@ -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",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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" } },
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -1,2 +1,2 @@
|
||||
export type MemberRole = "member" | "admin" | "owner";
|
||||
export type MemberRoleType = MemberRole | MemberRole[];
|
||||
export type MemberRoleType = MemberRole | MemberRole[];
|
||||
|
||||
+4
-4
@@ -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
@@ -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
@@ -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
@@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user