mirror of
https://github.com/Portabase/portabase.git
synced 2026-07-14 11:16:13 +02:00
feat: adding system settings page. And did some refactoring.
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+1
-3
@@ -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;
|
||||
|
||||
+1
-1
@@ -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>
|
||||
|
||||
|
||||
);
|
||||
};
|
||||
+71
-75
@@ -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}
|
||||
|
||||
+2
-2
@@ -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>[] = [
|
||||
{
|
||||
+3
-1
@@ -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
|
||||
+1
-1
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user