feat: adding system settings page. And did some refactoring.

This commit is contained in:
charlesgauthereau
2026-01-17 22:26:44 +01:00
parent e63e62d3e0
commit 5b980fb382
24 changed files with 238 additions and 161 deletions
+2
View File
@@ -46,7 +46,9 @@ function Calendar({ className, classNames, showOutsideDays = true, ...props }: R
...classNames,
}}
components={{
// @ts-ignore
IconLeft: ({ className, children: _children, ...props }) => <ChevronLeftIcon className={cn("size-4", className)} {...props} />,
// @ts-ignore
IconRight: ({ className, children: _children, ...props }) => <ChevronRightIcon className={cn("size-4", className)} {...props} />,
}}
{...props}
@@ -109,7 +109,6 @@ export const ButtonWithLoading = ({
{isPending && <Loader2 className="mr-2 animate-spin" size={16} />}
{children && children}
<>{icon ? icon : null}</>
{/*{icon && <span className="ml-2">{icon}</span>}*/}
</Button>
);
};
@@ -13,16 +13,14 @@ import {
DialogHeader,
DialogTitle
} from "@/components/ui/dialog";
import {authClient} from "@/lib/auth/auth-client";
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select";
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
import {MemberRoleType} from "@/types/common";
import {MemberWithUser} from "@/db/schema/03_organization";
import {updateMemberRoleAction} from "@/components/wrappers/dashboard/settings/update-member.action";
import {RoleSchemaMember} from "@/components/wrappers/dashboard/settings/member.schema";
import {
updateMemberRoleAdminAction
} from "@/components/wrappers/dashboard/admin/organizations/organization/details/role-member.action";
import {RoleSchemaMember} from "@/components/wrappers/dashboard/organization/settings/member.schema";
type OrganizationMemberChangeRoleModalProps = {
open: boolean;
@@ -3,11 +3,11 @@ import {userAction} from "@/lib/safe-actions/actions";
import {z} from "zod";
import {ServerActionResult} from "@/types/action-type";
import {Member} from "better-auth/plugins";
import {RoleSchemaMember} from "@/components/wrappers/dashboard/settings/member.schema";
import {db as dbClient} from "@/db";
import * as drizzleDb from "@/db";
import {and, eq} from "drizzle-orm";
import {withUpdatedAt} from "@/db/utils";
import {RoleSchemaMember} from "@/components/wrappers/dashboard/organization/settings/member.schema";
export const updateMemberRoleAdminAction = userAction.schema(
@@ -1,49 +0,0 @@
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card";
import {Button} from "@/components/ui/button";
import {Download} from "lucide-react";
import {getFileUrlPresignedLocal} from "@/features/upload/private/upload.action";
import {toast} from "sonner";
export type AdminSettingsTabProps = {};
export const AdminSettingsSection = (props: AdminSettingsTabProps) => {
const handleDownloadKey = async () => {
let url: string = "";
const data = await getFileUrlPresignedLocal({dir: "private/keys/", fileName: "server_public.pem"})
if (data?.data?.success) {
url = data.data.value ?? "";
} else {
// @ts-ignore
const errorMessage = data?.data?.actionError?.message || "Failed to get file!";
toast.error(errorMessage);
}
window.open(url, "_self");
};
return (
<div className="flex flex-col gap-y-4 h-full py-4">
<Card>
<CardHeader>
<CardTitle>Instance settings</CardTitle>
<CardDescription>Manage portabase settings</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">Download Public Key</p>
<p className="text-xs text-muted-foreground">
Used for encrypting communications with this instance.
</p>
</div>
<Button onClick={handleDownloadKey} variant="outline" size="sm">
<Download className="h-4 w-4 mr-2"/>
Download
</Button>
</div>
</CardContent>
</Card>
</div>
);
};
@@ -0,0 +1,61 @@
"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 "@/components/wrappers/dashboard/admin/settings/email/settings-email-section";
import {SettingsStorageSection} from "@/components/wrappers/dashboard/admin/settings/storage/settings-storage-section";
import {StorageChannelWith} from "@/db/schema/12_storage-channel";
export type SettingsTabsProps = {
settings: Setting
storageChannels: StorageChannelWith[]
};
export const SettingsTabs = ({settings, storageChannels}: 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);
}, [searchParams]);
const handleChangeTab = (value: string) => {
router.push(`?tab=${value}`);
};
return (
<div className="h-full mt-3">
<Tabs className="h-full" value={tab} onValueChange={handleChangeTab}>
<TabsList className='bg-background rounded-none border-b p-0 min-w-48'>
<TabsTrigger
value="email"
className='bg-background data-[state=active]:border-primary dark:data-[state=active]:border-primary h-full rounded-none border-0 border-b-2 border-transparent data-[state=active]:shadow-none'
>
Email
</TabsTrigger>
<TabsTrigger
value="storage"
className='bg-background data-[state=active]:border-primary dark:data-[state=active]:border-primary h-full rounded-none border-0 border-b-2 border-transparent data-[state=active]:shadow-none'
>
Default storage
</TabsTrigger>
</TabsList>
<TabsContent className="h-full" value="email">
<SettingsEmailSection settings={settings}/>
</TabsContent>
<TabsContent className="h-full" value="storage">
<SettingsStorageSection storageChannels={storageChannels} settings={settings}/>
</TabsContent>
</Tabs>
</div>
);
};
@@ -1,106 +1,102 @@
"use client"
import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert";
import {Info, ShieldCheck} from "lucide-react";
import {Switch} from "@/components/ui/switch";
import {Label} from "@/components/ui/label";
import {StorageS3Form} from "@/components/wrappers/dashboard/admin/settings/storage/storage-s3/storage-s3-form";
import {useState} from "react";
import {Info} from "lucide-react";
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
import {useMutation} from "@tanstack/react-query";
import {checkConnexionToS3} from "@/features/upload/public/upload.action";
import {toast} from "sonner";
import {useRouter} from "next/navigation";
import {Setting} from "@/db/schema/01_setting";
import {Form, FormField, FormItem, useZodForm} from "@/components/ui/form";
import {StorageChannelWith} from "@/db/schema/12_storage-channel";
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select";
import {useMutation} from "@tanstack/react-query";
import {
DefaultStorageSchema,
DefaultStorageType
} from "@/components/wrappers/dashboard/admin/settings/storage/settings-storage.schema";
import {getChannelIcon} from "@/components/wrappers/dashboard/admin/channels/helpers/common";
import {
updateStorageSettingsAction
} from "@/components/wrappers/dashboard/admin/settings/storage/storage-s3/s3-form.action";
import {Setting} from "@/db/schema/01_setting";
import {S3FormType} from "@/components/wrappers/dashboard/admin/settings/storage/storage-s3/s3-form.schema";
} from "@/components/wrappers/dashboard/admin/settings/storage/settings-storage.action";
import {toast} from "sonner";
export type SettingsStorageSectionProps = {
settings: Setting;
storageChannels: StorageChannelWith[];
};
export const SettingsStorageSection = (props: SettingsStorageSectionProps) => {
export const SettingsStorageSection = ({settings, storageChannels}: SettingsStorageSectionProps) => {
const router = useRouter();
const form = useZodForm({
schema: DefaultStorageSchema,
defaultValues: {
storageChannelId: settings.defaultStorageChannelId ?? undefined
}
});
const mutation = useMutation({
mutationFn: async () => {
const result = await checkConnexionToS3();
if (result.error) {
toast.error("An error occured during the connexion !");
mutationFn: async (values: DefaultStorageType) => {
const result = await updateStorageSettingsAction({name: "system", data: values})
const inner = result?.data;
if (inner?.success) {
toast.success(inner.actionSuccess?.message);
router.refresh();
} else {
toast.success("Connexion succeed!");
toast.error(inner?.actionError?.message);
}
},
}
});
const [isSwitched, setIsSwitched] = useState<boolean>(props.settings.storage !== "local");
const updateMutation = useMutation({
mutationFn: () => updateStorageSettingsAction({name: "system", data: {storage: isSwitched ? "s3" : "local"}}),
onSuccess: () => {
toast.success(`Settings updated successfully.`);
router.refresh();
},
onError: () => {
toast.error(`An error occurred while updating settings information.`);
},
});
const HandleSwitchStorage = async () => {
setIsSwitched(!isSwitched);
await updateMutation.mutateAsync();
};
const extractS3FormValues = (settings: Setting): S3FormType | undefined => {
if (!settings.s3EndPointUrl) return undefined;
return {
s3EndPointUrl: settings.s3EndPointUrl,
s3AccessKeyId: settings.s3AccessKeyId!,
s3SecretAccessKey: settings.s3SecretAccessKey!,
S3BucketName: settings.S3BucketName!,
};
};
return (
<div className="flex flex-col h-full">
<Alert className="mt-3">
<Info className="h-4 w-4"/>
<AlertTitle>Informations</AlertTitle>
<AlertDescription>
Actually you can only store you data in one place : s3 compatible or in local. For exemple you
cannot choose to store images in one place
and backups files in another.
</AlertDescription>
The default storage channel will be used by default to store your backups if no storage policy is
configured at the database level. </AlertDescription>
</Alert>
<div className="flex flex-col h-full py-4 ">
<div className="flex items-center justify-between space-x-2">
<div className="flex items-center space-x-2">
<Label htmlFor="storage-mode">Storage Mode (Local/s3 compatible)</Label>
<Switch
checked={isSwitched}
onCheckedChange={async () => {
await HandleSwitchStorage();
}}
id="storage-mode"
<Form
form={form}
onSubmit={async (values) => {
await mutation.mutateAsync(values);
}}
>
<div className="flex items-center gap-3">
<FormField
control={form.control}
name="storageChannelId"
render={({field}) => (
<FormItem className="flex items-center justify-center">
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger className="w-90 h-full mb-0">
<SelectValue placeholder="Select a default storage channel"/>
</SelectTrigger>
<SelectContent>
{storageChannels.map((channel) => (
<SelectItem key={channel.id} value={channel.id}>
<div className="flex items-center gap-2">
{getChannelIcon(channel.provider)}
<span className="font-medium">{channel.name}</span>
<span
className="text-[9px] uppercase bg-secondary px-1.5 py-0.5 rounded">
{channel.provider}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</FormItem>
)}
/>
<ButtonWithLoading type="submit">
Confirm
</ButtonWithLoading>
</div>
<div>
<ButtonWithLoading
size={"default"}
disabled={!isSwitched}
isPending={mutation.isPending}
onClick={async () => {
await mutation.mutateAsync();
}}
icon={<ShieldCheck/>}>Test connexion</ButtonWithLoading>
</div>
</div>
{isSwitched && (
<div className="mt-5">
<StorageS3Form defaultValues={extractS3FormValues(props.settings)}/>
</div>
)}
</Form>
</div>
</div>
);
@@ -0,0 +1,50 @@
"use server"
import {userAction} from "@/lib/safe-actions/actions";
import {db} from "@/db";
import * as drizzleDb from "@/db";
import {eq} from "drizzle-orm";
import {ServerActionResult} from "@/types/action-type";
import {Setting} from "@/db/schema/01_setting";
import {z} from "zod";
import {DefaultStorageSchema} from "@/components/wrappers/dashboard/admin/settings/storage/settings-storage.schema";
export const updateStorageSettingsAction = userAction
.schema(
z.object({
name: z.string(),
data: DefaultStorageSchema,
})
)
.action(async ({parsedInput}): Promise<ServerActionResult<Setting>> => {
const {name, data} = parsedInput;
try {
const [updatedSettings] = await db
.update(drizzleDb.schemas.setting)
.set({
defaultStorageChannelId: data.storageChannelId,
})
.where(eq(drizzleDb.schemas.setting.name, name))
.returning();
return {
success: true,
value: updatedSettings,
actionSuccess: {
message: "Settings successfully updated",
},
};
} catch (error) {
return {
success: false,
actionError: {
message: "Failed update settings.",
status: 500,
cause: error instanceof Error ? error.message : "Unknown error",
},
};
}
});
@@ -0,0 +1,7 @@
import { z } from "zod";
export const DefaultStorageSchema = z.object({
storageChannelId: z.string(),
});
export type DefaultStorageType = z.infer<typeof DefaultStorageSchema>;
@@ -41,10 +41,6 @@ export const ChannelPoliciesModal = ({icon, kind, database, channels, organizati
const activeAlertPolicies = database.alertPolicies?.filter((policy) => channelsIds.includes(policy.notificationChannelId));
const activeStoragePolicies = database.storagePolicies?.filter((policy) => channelsIds.includes(policy.storageChannelId));
console.log(channels);
console.log(database.storagePolicies);
console.log("activeAlertPolicies", activeAlertPolicies);
console.log("activeStoragePolicies", activeStoragePolicies);
const activePolicies = kind === "notification" ? activeAlertPolicies : activeStoragePolicies;
@@ -43,6 +43,7 @@ export const AdvancedCronSelect = ({
</Label>
{!isAdvanced ? (
<Select
// @ts-ignore
id={id}
className="col-span-4"
value={defaultValue}
@@ -13,8 +13,8 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { updateMemberRoleAction } from "@/components/wrappers/dashboard/settings/update-member.action";
import { RoleSchemaMember } from "@/components/wrappers/dashboard/settings/member.schema";
import {updateMemberRoleAction} from "@/components/wrappers/dashboard/organization/settings/update-member.action";
import {RoleSchemaMember} from "@/components/wrappers/dashboard/organization/settings/member.schema";
export const organizationMemberColumns: ColumnDef<MemberWithUser>[] = [
{
@@ -1,6 +1,8 @@
import {DataTable} from "@/components/wrappers/common/table/data-table";
import {MemberWithUser, OrganizationWithMembers} from "@/db/schema/03_organization";
import {organizationMemberColumns} from "@/components/wrappers/dashboard/settings/columns-organization-members";
import {
organizationMemberColumns
} from "@/components/wrappers/dashboard/organization/settings/columns-organization-members";
interface SettingsOrganizationMembersTableProps {
organization: OrganizationWithMembers
@@ -4,8 +4,8 @@ import {z} from "zod";
import {auth} from "@/lib/auth/auth";
import {ServerActionResult} from "@/types/action-type";
import {Member} from "better-auth/plugins";
import {RoleSchemaMember} from "@/components/wrappers/dashboard/settings/member.schema";
import {headers} from "next/headers";
import {RoleSchemaMember} from "@/components/wrappers/dashboard/organization/settings/member.schema";
export const updateMemberRoleAction = userAction.schema(
@@ -4,18 +4,18 @@ import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs";
import {useEffect, useState} from "react";
import {useRouter, useSearchParams} from "next/navigation";
import {MemberWithUser, OrganizationWithMembers} from "@/db/schema/03_organization";
import {
SettingsOrganizationMembersTable
} from "@/components/wrappers/dashboard/settings/settings-organization-members-table";
import {
OrganizationNotifiersTab
} from "@/components/wrappers/dashboard/organization/tabs/organization-notifiers-tab/organization-notifiers-tab";
import {NotificationChannel} from "@/db/schema/09_notification-channel";
import {useOrganizationPermissions} from "@/hooks/use-organization-permissions";
import {StorageChannel} from "@/db/schema/12_storage-channel";
import {
SettingsOrganizationMembersTable
} from "@/components/wrappers/dashboard/organization/settings/settings-organization-members-table";
import {
OrganizationNotifiersTab
} from "@/components/wrappers/dashboard/organization/tabs/organization-channels-tab/organization-notifiers-tab";
import {
OrganizationStoragesTab
} from "@/components/wrappers/dashboard/organization/tabs/organization-notifiers-tab/organization-storages-tab";
import {StorageChannel} from "@/db/schema/12_storage-channel";
} from "@/components/wrappers/dashboard/organization/tabs/organization-channels-tab/organization-storages-tab";
export type OrganizationTabsProps = {
organization: OrganizationWithMembers;
+12 -8
View File
@@ -27,18 +27,25 @@ export async function storeBackupFiles(
? [{
id: null,
storageChannelId: settings.storageChannel.id,
enabled: true
enabled: settings.storageChannel.enabled,
}]
: [];
console.log(database.storagePolicies);
const enabledPolicies = database.storagePolicies?.filter(p => p.enabled) ?? [];
const policies = (database.storagePolicies?.filter(p => p.enabled) || defaultPolicy);
console.log("Policies", policies);
const policies = enabledPolicies.length > 0
? enabledPolicies
: defaultPolicy;
console.debug("Policies", policies);
if (!policies.length) {
await db
.update(drizzleDb.schemas.backup)
.set(withUpdatedAt({
status: "failed",
}))
.where(eq(drizzleDb.schemas.backup.id, backup.id));
return [];
}
@@ -65,7 +72,6 @@ export async function storeBackupFiles(
data: {path, file},
};
// const result = await dispatchStorage(input, policy.id);
let result: StorageResult;
try {
@@ -93,8 +99,6 @@ export async function storeBackupFiles(
})
);
console.log(results);
const backupStatus = results.some(r => r.success) ? "success" : "failed";
await db