mirror of
https://github.com/Portabase/portabase.git
synced 2026-07-14 11:16:13 +02:00
feat/onboarding (#337)
* feat(onboarding): add cookie helper, types and mock data * feat(onboarding): add stepper, contextual preview and shell * feat(onboarding): add sso-gate step * feat(onboarding): add account-info step with real signup * feat(onboarding): add security step * feat(onboarding): add preferences step * fix(onboarding): validate avatar upload size/type and handle FileReader errors * feat(onboarding): add org-create step * feat(onboarding): add invite-members step * feat(onboarding): add notifier step reusing real provider list * feat(onboarding): add storage step reusing real provider list * feat(onboarding): add defaults step * feat(onboarding): add agent-create step * feat(onboarding): add agent-waiting step with simulated ping * feat(onboarding): add project-create step * feat(onboarding): add db-settings step with per-db repeat * feat(onboarding): add finish step with confetti * feat(onboarding): wire full step graph * feat(onboarding): add /welcome route * feat(onboarding): gate landing, auth and dashboard routes behind onboarding cookie * fix(onboarding): call next() in finish step so onFlowComplete fires and cookie is written * docs: add onboarding channel config + style refactor spec * docs: add conditional step skip when no agents to spec * docs(onboarding): add implementation plan for channel config flow and style refactor * feat(onboarding): extend OnboardingChannel with name and config fields * feat(onboarding): add two-phase config flow and org-combobox style to notifier step * fix(onboarding): prevent duplicate notifier providers and guard Continue button * feat(onboarding): add two-phase config flow and org-combobox style to storage step * style(onboarding): restyle sso-gate provider buttons to org-combobox card style * style(onboarding): restyle security method choice to org-combobox card style * style(onboarding): restyle theme toggles in preferences step to org-combobox card style * style(onboarding): restyle DB toggle buttons in project-create step to org-combobox card style * feat(onboarding): skip agent-waiting/project-create/db-settings when no agents created * feat(onboarding): update type system — meta, firstName/lastName, org.id, databases * feat(onboarding): add settings.onboarding column + markOnboardingDoneAction * feat(onboarding): add resolveOnboardingState server function * fix(onboarding): remove unused imports from onboarding-state.ts * style(onboarding): add branding to shell, fix progress bar full width * feat(onboarding): replace preview panel with stepper checklist * feat(onboarding): add step-login replacing sso-gate * fix(onboarding): step-login — conform to global mutation pattern, handle SSO errors * feat(onboarding): step-account-info — firstName/lastName, conditional password, findOrCreate * feat(onboarding): avatar API route (next/og) + AvatarPicker in preferences step * feat(onboarding): step-security reads passkeyEnabled from flowData.meta * feat(onboarding): step-org-create calls real createOrganizationAction * fix(onboarding): step-org-create — simplify error handling, throw on empty name * feat(onboarding): notifier + storage steps call real actions + findOrCreate on back * feat(onboarding): step-agent-create calls real createAgentAction + findOrCreate * feat(onboarding): step-agent-waiting polls real agent lastContact via useQuery * feat(onboarding): add step-agent-key with edgeKey generation via server action * feat(onboarding): step-project-create uses real action + real DBs from flowData * feat(onboarding): wire steps — add login + agent-key, remove sso-gate * feat(onboarding): page.tsx uses resolveOnboardingState — server-driven resume * chore(onboarding): delete mock/cookie/preview/sso-gate files — replaced by real implementation * feat(onboarding): add emailPasswordEnabled to meta, remove logoDataUrl from org * feat(onboarding): DB-driven resume — query notifiers/storages, check agent lastContact * feat(onboarding): agent-waiting step is now skippable * feat(onboarding): login — auto-advance if session, callbackURL on SSO, hide email/pw when disabled, empty state * fix(onboarding): step-login — add next to useEffect deps, default emailPasswordEnabled to false * fix(onboarding): verify session after passkey registration, fallback to signIn.passkey() * feat(onboarding): theme applied dynamically via setTheme, avatar saved to user.image * feat(onboarding): remove org logo upload — orgs have no logo * feat(onboarding): show Next button in shell when navigating backwards * docs: add onboarding refactor design spec * docs: add onboarding refactor implementation plan * refactor(onboarding): extract useCreateAgent and useDeleteAgent hooks * refactor(onboarding): extract useCreateOrg and useCreateProject hooks * fix(onboarding): trim project name, standardize error narrowing in org/project hooks * fix(onboarding): use narrowed createData.value in use-create-org * refactor(onboarding): extract useUpdateAccount hook * refactor(onboarding): extract useAddNotifier and useRemoveNotifier hooks * refactor(onboarding): extract useAddStorage and useRemoveStorage hooks * refactor(onboarding): extract useAgentStatus and useGenerateEdgeKey hooks * refactor(onboarding): extract useMarkOnboardingDone hook, remove .gitkeep files * docs: add onboarding theme persistence & progress bar fix spec * docs: add onboarding theme & progress bar implementation plan * fix(onboarding): persist theme to db on selection in StepPreferences * fix(onboarding): recalculate progress bar from full STEP_ORDER position * fix(theme): sync user theme from DB on all routes, not only dashboard * fix(theme): use current theme as fallback in preferences, prevent session override of localStorage * fix(onboarding): correct resume steps and skip agent-waiting flash when agent is connected * fix(onboarding): properly resolve onboarding state, remove redirect trap in step-defaults, block waiting flash * fix(onboarding): resume at db-settings instead of finish if no database exists for the project * fix(onboarding): redirect to agent-create if project exists but no agent is found * fix(onboarding): allow going from agent-create to project-create if a project already exists * fix(onboarding): skip intermediate agent steps when going back from project-create to agent-create * fix(onboarding): skip default step when going back from agent-create if no notifiers and storages exist * feat(onboarding): allow user to choose between passkey and password during registration if both are enabled * feat(onboarding): replace tabs with single form and multiple buttons for auth choice * style(onboarding): improve empty auth state UI with lucide icon and better layout * fix(onboarding): completely hide back button on the first step instead of disabling it * fix(onboarding): skip default step if no notifiers and storages exist when advancing from storage step * fix(onboarding): correctly show select placeholder when default value does not exist in the list * refactor(onboarding): remove duplicate generate-edge-key action and use core utility directly * feat(onboarding): compute edgeKey directly on the server side to avoid lazy loading * fix(onboarding): skip db-settings step if no database is found on the agent * fix(onboarding): do not skip db settings if agent is connected but no databases found * refactor(onboarding): skip db settings entirely if no database is selected in project create * feat(onboarding): auto-save database selections on project create instantly * style(onboarding): replace button opacity flash with specific spinner animation on db select * feat(onboarding): extend OnboardingDbSettings with retention, schedule, and policy fields * feat(onboarding): add applyOnboardingDbSettingsAction server action * feat(onboarding): add BackupScheduleSelector component * feat(onboarding): rewrite step-db-settings with three-level phase navigation and full policy persistence * fix(onboarding): wire applyMutation.mutateAsync everywhere and stabilize list keys * docs(sdd): append hotfix summary for applyMutation and list keys * fix(onboarding): address final review findings - atomicity, scheduling guard, apply-all recovery, client validation * add: onboarding * fix(migration): merge * fix(migration): apply for onboarding * fix(settings): make avatar selector, dicebear picker and tab bar responsive * chore: checkpoint before features reorganization * Task 2: Add migration script for features directory reorganization * refactor(agents): reorganize into actions/components/schemas/hooks * refactor(auth): reorganize into actions/components/schemas * refactor(channel): reorganize into components/schemas, nest notifications+storages under components * refactor(database): reorganize into actions/components/schemas * refactor(layout): move all components to components/ * refactor(logs): reorganize into components/types * refactor(migration): reorganize into actions/components * refactor(notifications): reorganize into components/types/utils * refactor(organizations): reorganize into actions/components/schemas/hooks/utils * refactor(profile): reorganize into actions/components/schemas * refactor(projects): reorganize into actions/components/schemas * refactor(settings): reorganize into actions/components/schemas/hooks * refactor(statistics): reorganize into components/utils * refactor(storages): reorganize into types/utils * refactor(theme): move all components to components/ * refactor(updates): reorganize into components/hooks * refactor(upload): move action to actions/ * refactor(users): reorganize into actions/components/schemas * fix: update broken imports after features reorganization (double-quote relative imports, channel subdir absolute paths) * fix: .gitignore * fix: onboarding * fix: removed dead files * feat: backfill onboarding flag for instances with existing data * fix: persist onboarding=true on boot when SKIP_ONBOARDING is set * feat: remove avatarMode/dicebearStyle from DB schema and types * feat: simplify resolveAvatarUrl — custom image first, gravatar fallback * feat: allow avatar upload regardless of mode * feat: remove avatarMode from layout and profile prop chains * feat: remove avatar mode admin tab and supporting files * feat: remove avatar mode selector from onboarding defaults step * fix: remove preferences onboarding step and fix routing * fix: remove stale preferences entry from onboarding checklist * feat: add avatar reset button and explanatory tooltip on profile page * fix: move avatar tooltip to top-right badge overlay on avatar * fix: use small destructive button for avatar removal * fix * Update step-defaults.tsx * fix: default dev password for keycloak refer to env * fix * fix(migration): refactoring * fix: api agent helpers.ts * Update .env.example --------- Co-authored-by: Théo LAGACHE <theo.lagache@soluce-technologies.com> Co-authored-by: charles-gauthereau <charles.gauthereau@soluce-technologies.com>
This commit is contained in:
co-authored by
Théo LAGACHE
charles-gauthereau
parent
40d3228db6
commit
40247efddd
@@ -0,0 +1,150 @@
|
||||
"use client"
|
||||
|
||||
import {Pencil, Plus} from "lucide-react";
|
||||
|
||||
import {
|
||||
Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger
|
||||
} from "@/components/ui/dialog";
|
||||
import {Button} from "@/components/ui/button";
|
||||
import {OrganizationWithMembers} from "@/db/schema/03_organization";
|
||||
import {NotificationChannelWith} from "@/db/schema/09_notification-channel";
|
||||
import {useIsMobile} from "@/hooks/use-mobile";
|
||||
import {useEffect, useState} from "react";
|
||||
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs";
|
||||
import {StorageChannelWith} from "@/db/schema/12_storage-channel";
|
||||
import {ChannelForm} from "@/features/channel/components/channel-form";
|
||||
import {ChannelKind, getChannelTextBasedOnKind} from "@/features/channel/components/channels-helpers";
|
||||
import {
|
||||
ChannelOrganisationForm
|
||||
} from "@/features/organizations/components/channels-organization-form";
|
||||
|
||||
|
||||
type ChannelAddModalProps = {
|
||||
channel?: NotificationChannelWith | StorageChannelWith
|
||||
organization?: OrganizationWithMembers;
|
||||
open?: boolean;
|
||||
onOpenChangeAction?: (open: boolean) => void;
|
||||
adminView?: boolean;
|
||||
organizations?: OrganizationWithMembers[]
|
||||
trigger?: boolean;
|
||||
kind: ChannelKind;
|
||||
}
|
||||
|
||||
|
||||
export const ChannelAddEditModal = ({
|
||||
organization,
|
||||
channel,
|
||||
open = false,
|
||||
onOpenChangeAction,
|
||||
adminView,
|
||||
organizations,
|
||||
trigger = true,
|
||||
kind
|
||||
}: ChannelAddModalProps) => {
|
||||
const isMobile = useIsMobile();
|
||||
const [openInternal, setOpen] = useState(open);
|
||||
const isLocalSystem = channel?.provider == "local";
|
||||
|
||||
const isCreate = !Boolean(channel);
|
||||
|
||||
useEffect(() => {
|
||||
setOpen(open);
|
||||
}, [open])
|
||||
|
||||
|
||||
const channelText = getChannelTextBasedOnKind(kind)
|
||||
|
||||
|
||||
return (
|
||||
<Dialog open={openInternal} onOpenChange={(state) => {
|
||||
onOpenChangeAction?.(state);
|
||||
setOpen(state);
|
||||
}}>
|
||||
{trigger && (
|
||||
<DialogTrigger asChild>
|
||||
{isCreate ?
|
||||
<Button>
|
||||
<Plus/>{!isMobile && `Add ${channelText} channel`}
|
||||
</Button>
|
||||
:
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
>
|
||||
<Pencil className="h-4 w-4"/>
|
||||
</Button>
|
||||
}
|
||||
</DialogTrigger>
|
||||
)}
|
||||
<DialogContent onOpenAutoFocus={(e) => e.preventDefault()}>
|
||||
<DialogHeader>
|
||||
<DialogTitle> {isCreate ? "Add" : "Edit"} {channelText} Channel</DialogTitle>
|
||||
<DialogDescription>
|
||||
Configure your {channelText.toLowerCase()} channel preferences.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div>
|
||||
<>
|
||||
{!isLocalSystem ? (
|
||||
<>
|
||||
{adminView ?
|
||||
<Tabs className="flex flex-col flex-1" defaultValue="configuration">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="configuration">Configuration</TabsTrigger>
|
||||
<TabsTrigger value="organizations">Organizations</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent className="h-full justify-between" value="configuration">
|
||||
<ChannelForm
|
||||
kind={kind}
|
||||
adminView={adminView}
|
||||
defaultValues={channel}
|
||||
organization={organization}
|
||||
onSuccessAction={() => {
|
||||
onOpenChangeAction?.(false)
|
||||
setOpen(false);
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent className="h-full justify-between" value="organizations">
|
||||
<ChannelOrganisationForm
|
||||
defaultValues={channel}
|
||||
kind={kind}
|
||||
organizations={organizations}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
:
|
||||
<>
|
||||
<ChannelForm
|
||||
kind={kind}
|
||||
adminView={adminView}
|
||||
defaultValues={channel}
|
||||
organization={organization}
|
||||
onSuccessAction={() => {
|
||||
onOpenChangeAction?.(false)
|
||||
setOpen(false);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
</>
|
||||
)
|
||||
|
||||
:
|
||||
|
||||
<>
|
||||
<ChannelOrganisationForm
|
||||
defaultValues={channel}
|
||||
kind={kind}
|
||||
organizations={organizations}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
|
||||
|
||||
</>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { NotificationChannelWith } from "@/db/schema/09_notification-channel";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { OrganizationWithMembers } from "@/db/schema/03_organization";
|
||||
import { truncateWords } from "@/utils/text";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { StorageChannelWith } from "@/db/schema/12_storage-channel";
|
||||
import { EditChannelButton } from "@/features/channel/components/channel-edit-button";
|
||||
import {
|
||||
ChannelKind,
|
||||
getChannelIcon,
|
||||
} from "@/features/channel/components/channels-helpers";
|
||||
import { DeleteChannelButton } from "@/features/channel/components/channel-delete-button";
|
||||
|
||||
export type ChannelCardProps = {
|
||||
data: NotificationChannelWith | StorageChannelWith;
|
||||
organization?: OrganizationWithMembers;
|
||||
organizations?: OrganizationWithMembers[];
|
||||
adminView?: boolean;
|
||||
kind?: ChannelKind;
|
||||
defaultStorageChannelId?: string | null | undefined;
|
||||
};
|
||||
|
||||
export const ChannelCard = (props: ChannelCardProps) => {
|
||||
const { data, organization, kind, defaultStorageChannelId } = props;
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const isDefaultSystemStorage = defaultStorageChannelId === data.id;
|
||||
|
||||
const isOwned = data.organizationId ? true : !organization;
|
||||
const isLocalSystem = data.provider === "local";
|
||||
|
||||
return (
|
||||
<div className="block transition-all duration-200 rounded-xl">
|
||||
<Card className="flex flex-row justify-between p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-md bg-secondary border border-border">
|
||||
{getChannelIcon(data.provider)}
|
||||
</div>
|
||||
<div
|
||||
className={`h-2 w-2 rounded-full ${data.enabled ? "bg-green-600" : "bg-muted"}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-start w-full">
|
||||
<div className="flex flex-col items-start md:flex-row md:items-center gap-2 ">
|
||||
<h3 className="font-medium text-foreground">
|
||||
{isMobile ? truncateWords(data.name, 2) : data.name}
|
||||
</h3>
|
||||
<Badge variant="secondary" className="text-xs font-mono">
|
||||
{data.provider}
|
||||
</Badge>
|
||||
{isDefaultSystemStorage && (
|
||||
<Badge variant="secondary" className="text-xs font-mono">
|
||||
default
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{kind && (
|
||||
<div className="flex items-center gap-2">
|
||||
{isOwned && (
|
||||
<>
|
||||
<EditChannelButton
|
||||
organizations={props.organizations}
|
||||
adminView={props.adminView}
|
||||
organization={organization}
|
||||
channel={data}
|
||||
kind={kind}
|
||||
/>
|
||||
{!isLocalSystem && (
|
||||
<DeleteChannelButton
|
||||
kind={kind}
|
||||
organizationId={organization?.id}
|
||||
channelId={data.id}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
import {ButtonWithConfirm} from "@/components/common/button-with-confirm";
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
import {useRouter} from "next/navigation";
|
||||
import {Trash2} from "lucide-react";
|
||||
import {
|
||||
removeNotificationChannelAction,
|
||||
} from "@/features/channel/components/notifications/channel.action";
|
||||
import {toast} from "sonner";
|
||||
import {ChannelKind, getChannelTextBasedOnKind} from "@/features/channel/components/channels-helpers";
|
||||
import {
|
||||
removeStorageChannelAction
|
||||
} from "@/features/channel/components/storages/channel.action";
|
||||
|
||||
export type DeleteChannelButtonProps = {
|
||||
channelId: string;
|
||||
kind: ChannelKind;
|
||||
organizationId?: string;
|
||||
};
|
||||
|
||||
export const DeleteChannelButton = ({channelId, organizationId, kind}: DeleteChannelButtonProps) => {
|
||||
const router = useRouter();
|
||||
const channelText = getChannelTextBasedOnKind(kind)
|
||||
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
|
||||
const result = kind === "notification" ? await removeNotificationChannelAction({
|
||||
organizationId,
|
||||
notificationChannelId: channelId
|
||||
}) : await removeStorageChannelAction({organizationId, id: channelId})
|
||||
const inner = result?.data;
|
||||
|
||||
if (inner?.success) {
|
||||
toast.success(inner.actionSuccess?.message);
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(inner?.actionError?.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<ButtonWithConfirm
|
||||
title={`Delete ${channelText.toLowerCase()} channel`}
|
||||
description={`Are you sure you want to remove this ${channelText.toLowerCase()} channel ? This action cannot be undone and will delete all alert policies related to this channel !`}
|
||||
button={{
|
||||
main: {
|
||||
size: "icon",
|
||||
variant: "ghost",
|
||||
icon: <Trash2 color="red"/>,
|
||||
},
|
||||
confirm: {
|
||||
className: "w-full",
|
||||
text: "Delete",
|
||||
icon: <Trash2/>,
|
||||
variant: "destructive",
|
||||
onClick: () => {
|
||||
mutation.mutate();
|
||||
},
|
||||
},
|
||||
cancel: {
|
||||
className: "w-full",
|
||||
text: "Cancel",
|
||||
icon: <Trash2/>,
|
||||
variant: "outline",
|
||||
},
|
||||
}}
|
||||
isPending={mutation.isPending}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
import {useRouter} from "next/navigation";
|
||||
import {NotificationChannelWith} from "@/db/schema/09_notification-channel";
|
||||
import {Switch} from "@/components/ui/switch";
|
||||
import {toast} from "sonner";
|
||||
import {useState} from "react";
|
||||
import {OrganizationWithMembers} from "@/db/schema/03_organization";
|
||||
import {StorageChannelWith} from "@/db/schema/12_storage-channel";
|
||||
import {ChannelKind} from "@/features/channel/components/channels-helpers";
|
||||
import {
|
||||
updateNotificationChannelAction
|
||||
} from "@/features/channel/components/notifications/channel.action";
|
||||
import {ChannelAddEditModal} from "@/features/channel/components/channel-add-edit-modal";
|
||||
import {
|
||||
updateStorageChannelAction
|
||||
} from "@/features/channel/components/storages/channel.action";
|
||||
|
||||
export type EditChannelButtonProps = {
|
||||
channel: NotificationChannelWith | StorageChannelWith;
|
||||
organization?: OrganizationWithMembers;
|
||||
organizations?: OrganizationWithMembers[];
|
||||
adminView?: boolean;
|
||||
kind: ChannelKind;
|
||||
};
|
||||
|
||||
export const EditChannelButton = ({
|
||||
organizations,
|
||||
adminView = false,
|
||||
channel,
|
||||
organization,
|
||||
kind
|
||||
}: EditChannelButtonProps) => {
|
||||
const router = useRouter();
|
||||
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
||||
const isLocalSystem = channel.provider == "local";
|
||||
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (value: boolean) => {
|
||||
|
||||
|
||||
const payload = {
|
||||
data: {
|
||||
name: channel.name,
|
||||
provider: channel.provider,
|
||||
config: channel.config as Record<string, any>,
|
||||
enabled: value
|
||||
},
|
||||
id: channel.id
|
||||
};
|
||||
|
||||
let result: any;
|
||||
if (kind == "notification") {
|
||||
// @ts-expect-error — payload type varies between notification and storage
|
||||
result = await updateNotificationChannelAction(payload)
|
||||
} else if (kind == "storage") {
|
||||
// @ts-expect-error — payload type varies between notification and storage
|
||||
result = await updateStorageChannelAction(payload)
|
||||
} else {
|
||||
toast.error("An error occurred while updating storage channel")
|
||||
return;
|
||||
}
|
||||
const inner = result?.data;
|
||||
|
||||
if (inner?.success) {
|
||||
toast.success(inner.actionSuccess?.message);
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(inner?.actionError?.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
{!isLocalSystem && (
|
||||
<Switch checked={channel.enabled} onCheckedChange={async () => {
|
||||
await mutation.mutateAsync(!channel.enabled)
|
||||
}}/>
|
||||
)}
|
||||
<ChannelAddEditModal
|
||||
kind={kind}
|
||||
organizations={organizations}
|
||||
adminView={adminView}
|
||||
organization={organization}
|
||||
channel={channel}
|
||||
open={isAddModalOpen}
|
||||
onOpenChangeAction={setIsAddModalOpen}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,254 @@
|
||||
"use client";
|
||||
|
||||
import {useRouter} from "next/navigation";
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
import {Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form";
|
||||
import {ButtonWithLoading} from "@/components/common/button-with-loading";
|
||||
import {Input} from "@/components/ui/input";
|
||||
|
||||
import {
|
||||
addStorageChannelAction, updateStorageChannelAction
|
||||
} from "@/features/channel/components/storages/channel.action";
|
||||
import {toast} from "sonner";
|
||||
import {OrganizationWithMembers} from "@/db/schema/03_organization";
|
||||
import {Button} from "@/components/ui/button";
|
||||
import {NotificationChannelWith} from "@/db/schema/09_notification-channel";
|
||||
import {useEffect} from "react";
|
||||
import {cn} from "@/lib/utils";
|
||||
import {Card} from "@/components/ui/card";
|
||||
import {ArrowLeft} from "lucide-react";
|
||||
import {StorageChannelWith} from "@/db/schema/12_storage-channel";
|
||||
import {
|
||||
NotificationChannelFormSchema, NotificationChannelFormType, StorageChannelFormSchema, StorageChannelFormType
|
||||
} from "@/features/channel/schemas/channel-form.schema";
|
||||
import {
|
||||
ChannelKind,
|
||||
renderChannelForm
|
||||
} from "@/features/channel/components/channels-helpers";
|
||||
import {storageProviders} from "@/features/channel/components/channels-storage-helper";
|
||||
import {notificationProviders} from "@/features/channel/components/channels-notification-helper";
|
||||
import {
|
||||
ChannelTestButton
|
||||
} from "@/features/channel/components/channel-test-button";
|
||||
import {
|
||||
addNotificationChannelAction, updateNotificationChannelAction
|
||||
} from "@/features/channel/components/notifications/channel.action";
|
||||
|
||||
|
||||
type NotifierFormProps = {
|
||||
onSuccessAction?: () => void;
|
||||
organization?: OrganizationWithMembers;
|
||||
defaultValues?: NotificationChannelWith | StorageChannelWith
|
||||
adminView?: boolean
|
||||
kind: ChannelKind
|
||||
};
|
||||
|
||||
export const ChannelForm = ({onSuccessAction, organization, defaultValues, kind}: NotifierFormProps) => {
|
||||
|
||||
const router = useRouter();
|
||||
const isCreate = !Boolean(defaultValues);
|
||||
|
||||
const form = useZodForm({
|
||||
schema: kind == "notification" ? NotificationChannelFormSchema : StorageChannelFormSchema,
|
||||
// @ts-expect-error — defaultValues type is a union of notification/storage channel types
|
||||
defaultValues: {...defaultValues},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
form.reset(defaultValues ? {...defaultValues} : {enabled: true});
|
||||
}, [defaultValues]);
|
||||
|
||||
const mutationAddNotificationChannel = useMutation({
|
||||
mutationFn: async (values: NotificationChannelFormType | StorageChannelFormType) => {
|
||||
|
||||
const payload = {
|
||||
data: values,
|
||||
...(organization && {organizationId: organization.id}),
|
||||
...((defaultValues && {id: defaultValues.id}))
|
||||
};
|
||||
|
||||
|
||||
let result: any;
|
||||
|
||||
if (kind === "notification") {
|
||||
// @ts-expect-error — payload type varies between notification and storage
|
||||
result = isCreate ? await addNotificationChannelAction(payload) : await updateNotificationChannelAction(payload);
|
||||
} else if (kind === "storage") {
|
||||
// @ts-expect-error — payload type varies between notification and storage
|
||||
result = isCreate ? await addStorageChannelAction(payload) : await updateStorageChannelAction(payload);
|
||||
} else {
|
||||
toast.error("An error occurred");
|
||||
return;
|
||||
}
|
||||
|
||||
const inner = result?.data;
|
||||
|
||||
if (inner?.success) {
|
||||
toast.success(inner.actionSuccess?.message);
|
||||
isCreate && onSuccessAction?.();
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(inner?.actionError?.message);
|
||||
isCreate && onSuccessAction?.();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const provider = form.watch("provider");
|
||||
const channelTypes = kind == "notification" ? notificationProviders : storageProviders
|
||||
const selectedProviderDetails = channelTypes.find(t => t.value === provider);
|
||||
|
||||
if (isCreate && !provider) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4 py-4">
|
||||
{channelTypes.filter(p => p.value != "local").map((type) => {
|
||||
const Icon = type.icon;
|
||||
return (
|
||||
<Card
|
||||
key={type.value}
|
||||
className={cn(
|
||||
"relative flex flex-col items-center justify-center gap-3 p-4 transition-all border",
|
||||
type.preview
|
||||
? "cursor-not-allowed bg-muted/60 border-border text-muted-foreground"
|
||||
: "cursor-pointer bg-background hover:bg-accent/40 hover:border-primary/50"
|
||||
)}
|
||||
onClick={() => {
|
||||
if (type.preview) return;
|
||||
form.setValue("provider", type.value as any);
|
||||
form.setValue("config", {});
|
||||
}}
|
||||
>
|
||||
|
||||
{type.preview && (
|
||||
<div
|
||||
className="absolute bottom-0 right-0 overflow-hidden w-20 h-20 pointer-events-none">
|
||||
<div
|
||||
className="absolute bottom-0 right-0 w-38 h-5 flex items-center justify-center
|
||||
transform -rotate-45 translate-x-14 -translate-y-4"
|
||||
style={{backgroundColor: "#FE6702"}}
|
||||
>
|
||||
<span className="text-white text-[7px] pr-3 font-medium text-center w-full">COMING SOON</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"h-10 w-10 rounded-full flex items-center justify-center transition-colors",
|
||||
type.preview
|
||||
? "bg-muted"
|
||||
: "bg-accent text-accent-foreground"
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
className={cn(
|
||||
"h-6 w-6",
|
||||
type.preview ? "text-muted-foreground" : "text-foreground"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"font-medium text-sm text-center leading-tight",
|
||||
type.preview ? "text-muted-foreground" : "text-foreground"
|
||||
)}
|
||||
>
|
||||
{type.label}
|
||||
</span>
|
||||
</Card>
|
||||
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Form
|
||||
form={form}
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={async (values) => {
|
||||
await mutationAddNotificationChannel.mutateAsync(values);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-2 p-3 bg-secondary/30 rounded-lg border border-border">
|
||||
{selectedProviderDetails && (
|
||||
<div
|
||||
className="h-10 w-10 bg-background rounded-full flex items-center justify-center border border-border shadow-sm">
|
||||
<selectedProviderDetails.icon className="h-5 w-5"/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">Configuring {selectedProviderDetails?.label}</p>
|
||||
<p className="text-xs text-muted-foreground">{isCreate ? "New Channel" : "Edit Channel"}</p>
|
||||
</div>
|
||||
{isCreate && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
form.setValue("provider", undefined as any);
|
||||
form.setValue("config", undefined);
|
||||
}}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-2"/>
|
||||
Change
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Channel Name *</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder={`e.g. ${selectedProviderDetails?.label} Channel`}
|
||||
value={field.value ?? ""}/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="provider"
|
||||
render={({field}) => (
|
||||
<input type="hidden" {...field} value={field.value || ""}/>
|
||||
)}
|
||||
/>
|
||||
|
||||
{renderChannelForm(provider, form)}
|
||||
|
||||
<div className="flex justify-between mt-4">
|
||||
<div>
|
||||
{(!isCreate || kind == "storage") && (
|
||||
<ChannelTestButton
|
||||
kind={kind}
|
||||
organizationId={organization?.id}
|
||||
channel={form.getValues()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
onSuccessAction?.();
|
||||
form.reset();
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<ButtonWithLoading isPending={mutationAddNotificationChannel.isPending}>
|
||||
{isCreate ? "Add" : "Save"} Channel
|
||||
</ButtonWithLoading>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,92 @@
|
||||
"use client"
|
||||
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
import {Send, ShieldCheck} from "lucide-react";
|
||||
import {toast} from "sonner";
|
||||
|
||||
import {Button} from "@/components/ui/button";
|
||||
import {NotificationChannel} from "@/db/schema/09_notification-channel";
|
||||
import {dispatchNotification} from "@/features/notifications/utils/notifications.dispatch";
|
||||
import {EventPayload} from "@/features/notifications/types";
|
||||
import {useIsMobile} from "@/hooks/use-mobile";
|
||||
import {cn} from "@/lib/utils";
|
||||
import {StorageChannel} from "@/db/schema/12_storage-channel";
|
||||
import {ChannelKind} from "@/features/channel/components/channels-helpers";
|
||||
import type {StorageInput} from "@/features/storages/types";
|
||||
import {dispatchStorage} from "@/features/storages/utils/storages.dispatch";
|
||||
|
||||
|
||||
type NotifierTestChannelButtonProps = {
|
||||
channel: NotificationChannel | StorageChannel;
|
||||
organizationId?: string;
|
||||
kind: ChannelKind;
|
||||
}
|
||||
|
||||
export const ChannelTestButton = ({channel, organizationId, kind}: NotifierTestChannelButtonProps) => {
|
||||
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (kind === "notification") {
|
||||
const payload: EventPayload = {
|
||||
title: 'Test Channel',
|
||||
message: `We are testing channel ${channel.name}`,
|
||||
level: 'info',
|
||||
};
|
||||
const result = await dispatchNotification(payload, undefined, channel.id, organizationId);
|
||||
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
} else {
|
||||
toast.error("An error occurred while testing the notification channel, check your configuration");
|
||||
}
|
||||
} else if (kind === "storage") {
|
||||
const input: StorageInput = {
|
||||
action: "ping",
|
||||
};
|
||||
const result = await dispatchStorage(input, undefined, undefined, channel);
|
||||
|
||||
if (result.success) {
|
||||
toast.success("Successfully connected to storage channel");
|
||||
} else {
|
||||
const responseText = result.response ? `(${result.response})` : "";
|
||||
toast.error(`An error occurred while testing the storage channel, check your configuration ${responseText}`);
|
||||
}
|
||||
} else {
|
||||
toast.error("Not yet supported");
|
||||
}
|
||||
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
onClick={() => mutation.mutateAsync()}
|
||||
disabled={mutation.isPending}
|
||||
className="bg-green-600 hover:bg-green-700 text-white font-medium shadow-sm transition-all"
|
||||
>
|
||||
{mutation.isPending ? (
|
||||
<>
|
||||
<div
|
||||
className={cn(" h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white", !isMobile && "mr-2")}/>
|
||||
{!isMobile && `Sending...`}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-row justify-center items-center">
|
||||
{kind === "notification" ?
|
||||
<>
|
||||
<Send className={cn("h-4 w-4", !isMobile && "mr-2")}/>{!isMobile && ` Test Channel`}
|
||||
</>
|
||||
:
|
||||
<>
|
||||
<ShieldCheck className={cn("h-4 w-4", !isMobile && "mr-2")}/>{!isMobile && ` Test Storage`}
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import {UseFormReturn} from "react-hook-form";
|
||||
import {
|
||||
NotifierSmtpForm
|
||||
} from "@/features/channel/components/notifications/smtp.form";
|
||||
import {
|
||||
NotifierSlackForm
|
||||
} from "@/features/channel/components/notifications/slack.form";
|
||||
import {
|
||||
NotifierDiscordForm
|
||||
} from "@/features/channel/components/notifications/discord.form";
|
||||
import {
|
||||
NotifierTelegramForm
|
||||
} from "@/features/channel/components/notifications/telegram.form";
|
||||
import {
|
||||
NotifierGotifyForm
|
||||
} from "@/features/channel/components/notifications/gotify.form";
|
||||
import {
|
||||
NotifierNtfyForm
|
||||
} from "@/features/channel/components/notifications/ntfy.form";
|
||||
import {
|
||||
NotifierWebhookForm
|
||||
} from "@/features/channel/components/notifications/webhook.form";
|
||||
import {
|
||||
NotifierNextcloudForm
|
||||
} from "@/features/channel/components/notifications/nextcloud.form";
|
||||
import {
|
||||
NotifierPushoverForm
|
||||
} from "@/features/channel/components/notifications/pushover.form";
|
||||
import {
|
||||
notificationProviders,
|
||||
} from "@/features/channel/components/channels-notification-helper";
|
||||
import {
|
||||
NotifierTeamsForm
|
||||
} from "@/features/channel/components/notifications/teams.form";
|
||||
import {storageProviders} from "@/features/channel/components/channels-storage-helper";
|
||||
import {ForwardRefExoticComponent, JSX, RefAttributes, SVGProps} from "react";
|
||||
import {LucideProps} from "lucide-react";
|
||||
import {
|
||||
StorageS3Form
|
||||
} from "@/features/channel/components/storages/s3.form";
|
||||
import {
|
||||
StorageGoogleDriveForm
|
||||
} from "@/features/channel/components/storages/google-drive/google-drive.form";
|
||||
import {
|
||||
StorageBlobForm
|
||||
} from "@/features/channel/components/storages/az-blob.form";
|
||||
|
||||
export type ChannelKind = "notification" | "storage";
|
||||
|
||||
export function getChannelTextBasedOnKind(kind: ChannelKind) {
|
||||
switch (kind) {
|
||||
case "notification":
|
||||
return "Notification";
|
||||
case "storage":
|
||||
return "Storage";
|
||||
default:
|
||||
return "Notification";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export type ProviderIconTypes = {
|
||||
value: string
|
||||
label: string
|
||||
icon: ForwardRefExoticComponent<Omit<LucideProps, "ref"> & RefAttributes<SVGSVGElement>>
|
||||
preview?: boolean
|
||||
} | {
|
||||
value: string
|
||||
label: string
|
||||
icon: (props: SVGProps<SVGSVGElement>) => JSX.Element
|
||||
preview?: boolean
|
||||
}
|
||||
|
||||
export const providerIcons: ProviderIconTypes[] = [
|
||||
...notificationProviders,
|
||||
...storageProviders,
|
||||
];
|
||||
|
||||
|
||||
export const getChannelIcon = (type: string) => {
|
||||
const Icon = providerIcons.find((t) => t.value === type)?.icon
|
||||
return Icon ? <Icon className="h-4 w-4"/> : null
|
||||
}
|
||||
|
||||
|
||||
export const renderChannelForm = (provider: string | undefined, form: UseFormReturn<any>) => {
|
||||
switch (provider) {
|
||||
case "smtp":
|
||||
return <NotifierSmtpForm form={form}/>;
|
||||
case "slack":
|
||||
return <NotifierSlackForm form={form}/>;
|
||||
case "discord":
|
||||
return <NotifierDiscordForm form={form}/>;
|
||||
case "telegram":
|
||||
return <NotifierTelegramForm form={form}/>;
|
||||
case "gotify":
|
||||
return <NotifierGotifyForm form={form}/>;
|
||||
case "ntfy":
|
||||
return <NotifierNtfyForm form={form}/>;
|
||||
case "webhook":
|
||||
return <NotifierWebhookForm form={form}/>;
|
||||
case "nextcloud":
|
||||
return <NotifierNextcloudForm form={form}/>;
|
||||
case "teams":
|
||||
return <NotifierTeamsForm form={form}/>;
|
||||
case "pushover":
|
||||
return <NotifierPushoverForm form={form}/>;
|
||||
case "s3":
|
||||
return <StorageS3Form form={form}/>
|
||||
case "google-drive":
|
||||
return <StorageGoogleDriveForm form={form}/>
|
||||
case "blob":
|
||||
return <StorageBlobForm form={form}/>
|
||||
case "local":
|
||||
return <></>
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,576 @@
|
||||
import {Mail} from "lucide-react";
|
||||
import type { SVGProps } from 'react';
|
||||
import {ProviderIconTypes} from "@/features/channel/components/channels-helpers";
|
||||
|
||||
|
||||
|
||||
|
||||
export const notificationProviders: ProviderIconTypes[] = [
|
||||
{value: "smtp", label: "Email", icon: Mail},
|
||||
{value: "slack", label: "Slack", icon: SlackIcon},
|
||||
{value: "discord", label: "Discord", icon: DiscordIcon},
|
||||
{value: "telegram", label: "Telegram", icon: TelegramIcon},
|
||||
{value: "gotify", label: "Gotify", icon: GotifyIcon},
|
||||
{value: "ntfy", label: "ntfy.sh", icon: NtfyIcon},
|
||||
{value: "webhook", label: "Webhook", icon: WebhookIcon},
|
||||
{value: "nextcloud", label: "Nextcloud Talk", icon: NextcloudIcon},
|
||||
{value: "teams", label: "Microsoft Teams", icon: MSTeamsIcon},
|
||||
{value: "pushover", label: "Pushover", icon: PushoverIcon},
|
||||
]
|
||||
|
||||
|
||||
export function SlackIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (<svg xmlns="http://www.w3.org/2000/svg" width={256} height={256} viewBox="0 0 256 256" {...props}><path fill="#e01e5a" d="M53.841 161.32c0 14.832-11.987 26.82-26.819 26.82S.203 176.152.203 161.32c0-14.831 11.987-26.818 26.82-26.818H53.84zm13.41 0c0-14.831 11.987-26.818 26.819-26.818s26.819 11.987 26.819 26.819v67.047c0 14.832-11.987 26.82-26.82 26.82c-14.83 0-26.818-11.988-26.818-26.82z"></path><path fill="#36c5f0" d="M94.07 53.638c-14.832 0-26.82-11.987-26.82-26.819S79.239 0 94.07 0s26.819 11.987 26.819 26.819v26.82zm0 13.613c14.832 0 26.819 11.987 26.819 26.819s-11.987 26.819-26.82 26.819H26.82C11.987 120.889 0 108.902 0 94.069c0-14.83 11.987-26.818 26.819-26.818z"></path><path fill="#2eb67d" d="M201.55 94.07c0-14.832 11.987-26.82 26.818-26.82s26.82 11.988 26.82 26.82s-11.988 26.819-26.82 26.819H201.55zm-13.41 0c0 14.832-11.988 26.819-26.82 26.819c-14.831 0-26.818-11.987-26.818-26.82V26.82C134.502 11.987 146.489 0 161.32 0s26.819 11.987 26.819 26.819z"></path><path fill="#ecb22e" d="M161.32 201.55c14.832 0 26.82 11.987 26.82 26.818s-11.988 26.82-26.82 26.82c-14.831 0-26.818-11.988-26.818-26.82V201.55zm0-13.41c-14.831 0-26.818-11.988-26.818-26.82c0-14.831 11.987-26.818 26.819-26.818h67.25c14.832 0 26.82 11.987 26.82 26.819s-11.988 26.819-26.82 26.819z"></path></svg>);
|
||||
}
|
||||
|
||||
export function DiscordIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (<svg xmlns="http://www.w3.org/2000/svg" width={256} height={199} viewBox="0 0 256 199" {...props}><path fill="#5865f2" d="M216.856 16.597A208.5 208.5 0 0 0 164.042 0c-2.275 4.113-4.933 9.645-6.766 14.046q-29.538-4.442-58.533 0c-1.832-4.4-4.55-9.933-6.846-14.046a207.8 207.8 0 0 0-52.855 16.638C5.618 67.147-3.443 116.4 1.087 164.956c22.169 16.555 43.653 26.612 64.775 33.193A161 161 0 0 0 79.735 175.3a136.4 136.4 0 0 1-21.846-10.632a109 109 0 0 0 5.356-4.237c42.122 19.702 87.89 19.702 129.51 0a132 132 0 0 0 5.355 4.237a136 136 0 0 1-21.886 10.653c4.006 8.02 8.638 15.67 13.873 22.848c21.142-6.58 42.646-16.637 64.815-33.213c5.316-56.288-9.08-105.09-38.056-148.36M85.474 135.095c-12.645 0-23.015-11.805-23.015-26.18s10.149-26.2 23.015-26.2s23.236 11.804 23.015 26.2c.02 14.375-10.148 26.18-23.015 26.18m85.051 0c-12.645 0-23.014-11.805-23.014-26.18s10.148-26.2 23.014-26.2c12.867 0 23.236 11.804 23.015 26.2c0 14.375-10.148 26.18-23.015 26.18"></path></svg>);
|
||||
}
|
||||
|
||||
export function TelegramIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (<svg xmlns="http://www.w3.org/2000/svg" width={256} height={256} viewBox="0 0 256 256" {...props}><defs><linearGradient id="SVGuySfwdaH" x1="50%" x2="50%" y1="0%" y2="100%"><stop offset="0%" stopColor="#2aabee"></stop><stop offset="100%" stopColor="#229ed9"></stop></linearGradient></defs><path fill="url(#SVGuySfwdaH)" d="M128 0C94.06 0 61.48 13.494 37.5 37.49A128.04 128.04 0 0 0 0 128c0 33.934 13.5 66.514 37.5 90.51C61.48 242.506 94.06 256 128 256s66.52-13.494 90.5-37.49c24-23.996 37.5-56.576 37.5-90.51s-13.5-66.514-37.5-90.51C194.52 13.494 161.94 0 128 0"></path><path fill="#fff" d="M57.94 126.648q55.98-24.384 74.64-32.152c35.56-14.786 42.94-17.354 47.76-17.441c1.06-.017 3.42.245 4.96 1.49c1.28 1.05 1.64 2.47 1.82 3.467c.16.996.38 3.266.2 5.038c-1.92 20.24-10.26 69.356-14.5 92.026c-1.78 9.592-5.32 12.808-8.74 13.122c-7.44.684-13.08-4.912-20.28-9.63c-11.26-7.386-17.62-11.982-28.56-19.188c-12.64-8.328-4.44-12.906 2.76-20.386c1.88-1.958 34.64-31.748 35.26-34.45c.08-.338.16-1.598-.6-2.262c-.74-.666-1.84-.438-2.64-.258c-1.14.256-19.12 12.152-54 35.686c-5.1 3.508-9.72 5.218-13.88 5.128c-4.56-.098-13.36-2.584-19.9-4.708c-8-2.606-14.38-3.984-13.82-8.41c.28-2.304 3.46-4.662 9.52-7.072"></path></svg>);
|
||||
}
|
||||
|
||||
|
||||
export function NtfyIcon(props: SVGProps<SVGSVGElement>) {
|
||||
|
||||
return ( <svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlSpace="preserve"
|
||||
id="Layer_1"
|
||||
x={0}
|
||||
y={0}
|
||||
viewBox="0 0 512 512"
|
||||
{...props}
|
||||
>
|
||||
<style>{".st1{fill:#fff}"}</style>
|
||||
<linearGradient
|
||||
id="SVGID_1_"
|
||||
x1={115.045}
|
||||
x2={142.93}
|
||||
y1={512.747}
|
||||
y2={491.741}
|
||||
gradientTransform="matrix(15.8584 0 0 -15.8584 -1816.246 8200.18)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop
|
||||
offset={0}
|
||||
style={{
|
||||
stopColor: "#348878",
|
||||
}}
|
||||
/>
|
||||
<stop
|
||||
offset={1}
|
||||
style={{
|
||||
stopColor: "#56bda8",
|
||||
}}
|
||||
/>
|
||||
</linearGradient>
|
||||
<path
|
||||
d="M441.8 64.8H84.7c-24.5 0-44.6 19-44.6 42.1l.3 285.3-6.7 38.4 95.6-28.8h312.4c24.5 0 44.6-19 44.6-42.1V106.9c.1-23.2-20-42.1-44.5-42.1"
|
||||
style={{
|
||||
fill: "url(#SVGID_1_)",
|
||||
}}
|
||||
/>
|
||||
<path
|
||||
d="M78.1 32.7C40.9 32.7 9 62 9 99.2l.4 311.2-9.4 69 127.1-33.8H443c37.2 0 69.1-29.3 69.1-66.5V99.2c0-37.2-31.9-66.5-69-66.5zm0 46.9H443c13.1 0 22.1 9.5 22.1 19.6V379c0 10-9 19.8-22.1 19.6H120.2l-64.6 19.5.7-3.8-.4-315.1c0-10.1 9.1-19.6 22.2-19.6"
|
||||
className="st1"
|
||||
/>
|
||||
<path
|
||||
d="M116.4 310.9v-34.8l73.1-38.7c2.4-1.2 4.6-2.1 6.8-2.7 2.3-.8 4.5-1.4 6.3-1.8l6-.6V230q-3-.3-6-1.2-2.85-.6-6.3-1.5c-2.2-.8-4.5-1.8-6.8-3l-73.1-38.7v-35.1l113 61.5v37.4z"
|
||||
className="st1"
|
||||
/>
|
||||
<path
|
||||
d="M110.5 139.7v50.4l76.6 40.6.5.2-.5.2-76.6 40.6v50.1l8.2-4.5L235.1 254v-46.4zm11.8 21.7 101.2 55.1V245l-101.2 55.1v-19.4l69.6-36.8.1-.1c1.9-1 3.8-1.7 5.8-2.3h.1l.2-.1c2.1-.7 3.9-1.2 5.5-1.6l11.1-1.1v-15.2l-5.4-.5c-1.7-.2-3.5-.5-5.1-1.1h-.2l-.2-.1c-2-.4-3.9-.9-5.9-1.4-2-.7-4-1.6-5.9-2.6l-.1-.1-69.6-36.7zm152.3 137.4h120.7v32.6H274.6z"
|
||||
className="st1"
|
||||
/>
|
||||
<path
|
||||
d="M268.7 292.1v46h132.4v-46zm11.7 13.4h109v19.2h-109z"
|
||||
className="st1"
|
||||
/>
|
||||
</svg>)
|
||||
|
||||
}
|
||||
|
||||
|
||||
export function GotifyIcon(props: SVGProps<SVGSVGElement>) {
|
||||
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlSpace="preserve"
|
||||
id="Layer_2"
|
||||
x={0}
|
||||
y={0}
|
||||
viewBox="46.6 113.46 377.2 307.81"
|
||||
{...props}
|
||||
>
|
||||
<style>
|
||||
{
|
||||
".st0{fill:#ddcba2}.st0,.st1,.st2,.st4,.st5{stroke:#000;stroke-width:2;stroke-linecap:round;stroke-miterlimit:10}.st1{fill:#71caee}.st2{fill:#fff}.st4{fill:#f0f0f0}.st5{fill:none}"
|
||||
}
|
||||
</style>
|
||||
<switch>
|
||||
<foreignObject
|
||||
width={1}
|
||||
height={1}
|
||||
x={0}
|
||||
y={0}
|
||||
requiredExtensions="http://ns.adobe.com/AdobeIllustrator/10.0/"
|
||||
/>
|
||||
<g>
|
||||
<path
|
||||
d="M317.7 376.2c6.2-1.7 15.8 0 19.5 3.2 5.5 4.8 4.9 20.9 1.1 29-.8 1.7-1.8 3.4-3.2 4.4s-3.4 1.2-4.7 0-1.7-3.3-1.9-5.2c-.2-3.1-.2-6.2.3-9.2.2-1.3.2-2.9-.2-4.2-.6-2.2-2.5-3.1-4.5-3.5-3.4-.8-14.3-.7-19.5-.7-.8 0 1.5-9.1 2-9.7 2.6-2.9 7.5-3.1 11.1-4.1z"
|
||||
className="st0"
|
||||
/>
|
||||
<path
|
||||
d="m258.9 119.7-9-2.7c-4.6-1.4-9.2-2.8-14-2.5-2.8.2-6.1 1.3-6.9 4-.6 2-1.6 7.3-1.3 7.9 1.5 3.4 13.9 6.7 18.3 6.7"
|
||||
className="st1"
|
||||
/>
|
||||
<path d="M392.6 177.9c-1.4 1.4-2.2 3.5-2.5 5.5-.2 1.4-.1 3 .5 4.3s1.8 2.3 3.1 3c1.3.6 2.8.9 4.3.9 1.1 0 2.3-.1 3.1-.9.6-.7.8-1.6.9-2.5.2-2.3-.1-4.7-.9-6.9-.4-1.1-.9-2.3-1.8-3.1-1.7-1.8-4.5-2.2-6.4-.5-.1 0-.2.1-.3.2" />
|
||||
<path
|
||||
d="M358.5 164.2c-1-1 0-2.7 1-3.7 5.8-5.2 15.1-4.6 21.8-.6 10.9 6.6 15.6 19.9 17.2 32.5.6 5.2.9 10.6-.5 15.7s-4.6 9.9-9.3 12.1c-1.1.5-2.3.9-3.4.5s-1.9-1.8-1.2-2.8c-9.4-13.6-19-26.8-20.9-43.2-.5-4.1-1.8-7.4-4.7-10.5z"
|
||||
className="st2"
|
||||
/>
|
||||
<path
|
||||
d="M134.7 328.4c-5.1-3.1-9.9-6.6-14.3-10.6-1.3-1.2-2.6-2.5-2.6-4.3 0-1.2.6-2.2 1.2-3.2.8-1.4 1.7-2.8 2.5-4.1 1.1-1.8 2.9-3.9 4.9-3.2.9.3 1.5 1.1 2 1.8 2.4 3.3 4.9 6.6 7.3 9.8 1.5 2 3.7 4.3 6.1 3.5"
|
||||
className="st1"
|
||||
/>
|
||||
<path
|
||||
d="M209.6 133c33.2-18 77.8-19.6 111.5-8.7 24.3 7.9 43.4 26.7 53.3 50 8.7 20.6 10.5 43.6 8.1 65.7-4.4 40.2-20.2 77.9-40.3 112.6-11.1 19-21.8 36-40.5 48.5-36.8 24.6-87.2 22.1-128.4 11.5-19.9-5.1-39.7-17.3-47.2-37.3-4.8-12.8-4.2-27.6 1.5-40 11.6-24.8 43.2-38.4 45.6-67.9.7-8.7-1.6-17.3-3.6-25.7-5.6-23.4-8.9-45.8 1.4-68.7 8.1-17.7 21.9-31 38.6-40z"
|
||||
className="st1"
|
||||
/>
|
||||
<path
|
||||
d="M189.8 151.4c-5.4-5.2-11.9-8.8-19-10.3-2.2-.5-4.7-.7-6.9.7-1.8 1.2-3.1 3.3-4.2 5.3-1.6 3-3 6.2-4.1 9.4-.4 1.2-.6 2.5 0 3.5.3.6.9.9 1.5 1.2 8.1 4.2 16.8 7.1 25.5 9.8"
|
||||
className="st1"
|
||||
/>
|
||||
<path
|
||||
d="M183.7 158.7c-2.5-1.8-16.8-12.1-18.7-4.8-.4 1.6.5 3.9 1.5 4.8"
|
||||
className="st1"
|
||||
/>
|
||||
<path
|
||||
d="M264.5 174.9c-.5.5-.9 1-1.3 1.6-9 11.6-12 27.9-9.3 42.1 1.7 9 5.9 17.9 13.2 23.4 19.3 14.6 51.5 13.5 68.4-1.5 24.4-21.7 13-67.6-14-78.8-17.6-7.2-43.7-1.6-57 13.2zm117.6 62.2c1.4-.1 2.9-.1 4.3.1.3 0 .7.1 1 .4.2.3.4.7.5 1.1 1 3.9.5 8.2.1 12.4-.1.9-.2 1.8-.6 2.6-1 2.1-3.1 2.7-4.7 2.7-.1 0-.2 0-.3-.1-.3-.2-.3-.7-.2-1.2.3-5.9-.1-11.9-.1-18q0 .15 0 0z"
|
||||
className="st2"
|
||||
/>
|
||||
<path
|
||||
d="M378.7 236.8c-1.4.4-2.5 2-2.8 4.4-.5 4.4-.7 8.9-.5 13.4 0 .9.1 1.9.5 2.4.2.3.5.4.8.4 1.6.3 4.1-.6 5.6-1 0 0 0-5.2-.1-8s-.1-6.1-.2-8.9v-2.2c.1-.7-2.6-.7-3.3-.5z"
|
||||
className="st2"
|
||||
/>
|
||||
<path
|
||||
d="M358.3 231.8c-.3 2.2.1 4.7 1.7 7.4 2.6 4.4 7 6.1 11.9 5.8 8.9-.6 25.3-5.4 27.5-15.7.6-3-.3-6.1-2.2-8.5-6.2-7.8-17.8-5.7-25.6-2-5.9 2.7-12.4 7-13.3 13z"
|
||||
className="st0"
|
||||
/>
|
||||
<path
|
||||
d="M386.4 208.6c2.2 1.4 3.7 3.8 4 7 .3 3.6-1.4 7.5-5 8.8-2.9 1.1-6.2.6-9.1-.4s-5.8-2.8-6.8-5.7c-.7-2-.3-4.3.7-6.1 1.1-1.8 2.8-3.2 4.7-4.1 3.9-1.8 8.4-1.6 11.5.5z"
|
||||
style={{
|
||||
fill: "#888e93",
|
||||
stroke: "#000",
|
||||
strokeWidth: 2,
|
||||
strokeLinecap: "round",
|
||||
strokeMiterlimit: 10,
|
||||
}}
|
||||
/>
|
||||
<path
|
||||
d="M414.7 262.6c2.4.6 4.8 2.1 5.6 4.4s.1 4.9-1.6 6.7-4.2 2.5-6.6 2.5c-.8 0-1.7-.1-2.4-.5-2.5-1.1-3.5-4-4.2-6.6-1.8-6.8 3.6-7.8 9.2-6.5z"
|
||||
className="st0"
|
||||
/>
|
||||
<path
|
||||
d="M267.1 284.7c2.3-4.5 141.3-36.2 144.7-31.6 3.4 4.5 15.8 88.2 9 90.4-6.8 2.3-119.8 37.3-126.6 35s-29.4-89.3-27.1-93.8z"
|
||||
className="st4"
|
||||
/>
|
||||
<path
|
||||
d="M294.2 378.5s54.3-74.6 59.9-76.9c5.7-2.3 67.3 41.3 67.3 41.3"
|
||||
className="st5"
|
||||
/>
|
||||
<path
|
||||
d="M267 287.7s86 38.8 91.6 36.6c5.7-2.3 53.1-71.2 53.1-71.2"
|
||||
className="st4"
|
||||
/>
|
||||
<linearGradient
|
||||
id="SVGID_1_"
|
||||
x1={131.119}
|
||||
x2={103.259}
|
||||
y1={373.573}
|
||||
y2={396.951}
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop
|
||||
offset={0}
|
||||
style={{
|
||||
stopColor: "#71caee",
|
||||
}}
|
||||
/>
|
||||
<stop
|
||||
offset={0.041}
|
||||
style={{
|
||||
stopColor: "#83cae2",
|
||||
}}
|
||||
/>
|
||||
<stop
|
||||
offset={0.121}
|
||||
style={{
|
||||
stopColor: "#9fcace",
|
||||
}}
|
||||
/>
|
||||
<stop
|
||||
offset={0.211}
|
||||
style={{
|
||||
stopColor: "#b6cbbe",
|
||||
}}
|
||||
/>
|
||||
<stop
|
||||
offset={0.314}
|
||||
style={{
|
||||
stopColor: "#c7cbb1",
|
||||
}}
|
||||
/>
|
||||
<stop
|
||||
offset={0.438}
|
||||
style={{
|
||||
stopColor: "#d4cba8",
|
||||
}}
|
||||
/>
|
||||
<stop
|
||||
offset={0.606}
|
||||
style={{
|
||||
stopColor: "#dbcba3",
|
||||
}}
|
||||
/>
|
||||
<stop
|
||||
offset={1}
|
||||
style={{
|
||||
stopColor: "#ddcba2",
|
||||
}}
|
||||
/>
|
||||
</linearGradient>
|
||||
<path
|
||||
d="M132.8 375.6c-3.5 3.8-7.3 7.8-13 9.2-4.6 1.2-10 .2-13.6-2.3-1.4-1-2.6-2.2-4-3.2-1.5-1-3.4-1.7-5.3-1.3-2.7.5-4.1 3.1-3.6 5.3 2 8.8 17 15.6 27.5 15.5 9 0 19-4.6 21.4-11.8"
|
||||
style={{
|
||||
fill: "url(#SVGID_1_)",
|
||||
}}
|
||||
/>
|
||||
<path
|
||||
d="M132.8 375.6c-3.5 3.8-7.3 7.8-13 9.2-4.6 1.2-10 .2-13.6-2.3-1.4-1-2.6-2.2-4-3.2-1.5-1-3.4-1.7-5.3-1.3-2.7.5-4.1 3.1-3.6 5.3 2 8.8 17 15.6 27.5 15.5 9 0 19-4.6 21.4-11.8"
|
||||
className="st5"
|
||||
/>
|
||||
<linearGradient
|
||||
id="SVGID_2_"
|
||||
x1={265.033}
|
||||
x2={275.301}
|
||||
y1={280.295}
|
||||
y2={302.314}
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop
|
||||
offset={0}
|
||||
style={{
|
||||
stopColor: "#71caee",
|
||||
}}
|
||||
/>
|
||||
<stop
|
||||
offset={0.041}
|
||||
style={{
|
||||
stopColor: "#83cae2",
|
||||
}}
|
||||
/>
|
||||
<stop
|
||||
offset={0.121}
|
||||
style={{
|
||||
stopColor: "#9fcace",
|
||||
}}
|
||||
/>
|
||||
<stop
|
||||
offset={0.211}
|
||||
style={{
|
||||
stopColor: "#b6cbbe",
|
||||
}}
|
||||
/>
|
||||
<stop
|
||||
offset={0.314}
|
||||
style={{
|
||||
stopColor: "#c7cbb1",
|
||||
}}
|
||||
/>
|
||||
<stop
|
||||
offset={0.438}
|
||||
style={{
|
||||
stopColor: "#d4cba8",
|
||||
}}
|
||||
/>
|
||||
<stop
|
||||
offset={0.606}
|
||||
style={{
|
||||
stopColor: "#dbcba3",
|
||||
}}
|
||||
/>
|
||||
<stop
|
||||
offset={1}
|
||||
style={{
|
||||
stopColor: "#ddcba2",
|
||||
}}
|
||||
/>
|
||||
</linearGradient>
|
||||
<path
|
||||
d="M261.9 283.5c-.1 4.2 4.3 7.3 8.4 7.6s8.2-1.3 12.2-2.6c1.4-.4 2.9-.8 4.2-.2 1.8.9 2.7 4.1 1.8 5.9s-3.4 3.5-5.3 4.4c-6.5 3-12.9 3.6-19.9 2-5.3-1.2-11.3-4.3-13-13.5"
|
||||
style={{
|
||||
fill: "url(#SVGID_2_)",
|
||||
}}
|
||||
/>
|
||||
<path
|
||||
d="M261.9 283.5c-.1 4.2 4.3 7.3 8.4 7.6s8.2-1.3 12.2-2.6c1.4-.4 2.9-.8 4.2-.2 1.8.9 2.7 4.1 1.8 5.9s-3.4 3.5-5.3 4.4c-6.5 3-12.9 3.6-19.9 2-5.3-1.2-11.3-4.3-13-13.5"
|
||||
className="st5"
|
||||
/>
|
||||
<path d="M318.4 198.4c-2-.3-4.1.1-5.9 1.3-3.2 2.1-4.7 6.2-4.7 9.9 0 1.9.4 3.8 1.4 5.3 1.2 1.7 3.1 2.9 5.2 3.4 3.4.8 8.2.7 10.5-2.5 1-1.5 1.4-3.3 1.5-5.1.5-5.7-1.8-11.4-8-12.3" />
|
||||
<path
|
||||
d="M320.4 203.3c.9.3 1.7.8 2.1 1.7.4.8.4 1.7.3 2.5-.1 1-.6 2-1.5 2.7-.7.5-1.7.7-2.6.5s-1.7-.8-2.2-1.6c-1.1-1.6-.9-4.4.9-5.5.9-.4 2-.6 3-.3"
|
||||
style={{
|
||||
fill: "#fff",
|
||||
}}
|
||||
/>
|
||||
<linearGradient
|
||||
id="SVGID_3_"
|
||||
x1={-18.456}
|
||||
x2={110.382}
|
||||
y1={252.286}
|
||||
y2={252.286}
|
||||
gradientTransform="rotate(-5.297 -26.409 -856.73)scale(.99997)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop
|
||||
offset={0}
|
||||
style={{
|
||||
stopColor: "#333",
|
||||
stopOpacity: 0,
|
||||
}}
|
||||
/>
|
||||
<stop
|
||||
offset={0.477}
|
||||
style={{
|
||||
stopColor: "#575756",
|
||||
stopOpacity: 0.2,
|
||||
}}
|
||||
/>
|
||||
<stop
|
||||
offset={0.995}
|
||||
style={{
|
||||
stopColor: "#333",
|
||||
stopOpacity: 0.001911551,
|
||||
}}
|
||||
/>
|
||||
<stop
|
||||
offset={1}
|
||||
style={{
|
||||
stopColor: "#333",
|
||||
stopOpacity: 0,
|
||||
}}
|
||||
/>
|
||||
</linearGradient>
|
||||
<path
|
||||
d="M81.6 222.4s56.5 68.4 123.9 42.4l6.6-29.7C150 272.2 82.8 214.3 82.8 214.3z"
|
||||
style={{
|
||||
fill: "url(#SVGID_3_)",
|
||||
}}
|
||||
/>
|
||||
<linearGradient
|
||||
id="SVGID_4_"
|
||||
x1={110.198}
|
||||
x2={283.737}
|
||||
y1={262.493}
|
||||
y2={262.493}
|
||||
gradientTransform="matrix(.9991 .04164 -.04164 .9991 -36.323 1.051)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop
|
||||
offset={0}
|
||||
style={{
|
||||
stopColor: "#333",
|
||||
stopOpacity: 0,
|
||||
}}
|
||||
/>
|
||||
<stop
|
||||
offset={0.477}
|
||||
style={{
|
||||
stopColor: "#575756",
|
||||
stopOpacity: 0.2,
|
||||
}}
|
||||
/>
|
||||
<stop
|
||||
offset={0.995}
|
||||
style={{
|
||||
stopColor: "#333",
|
||||
stopOpacity: 0.001911551,
|
||||
}}
|
||||
/>
|
||||
<stop
|
||||
offset={1}
|
||||
style={{
|
||||
stopColor: "#333",
|
||||
stopOpacity: 0,
|
||||
}}
|
||||
/>
|
||||
</linearGradient>
|
||||
<path
|
||||
d="M64.2 234.9s63.1 101.5 157.8 78.9l14.2-38.5c-89.5 38.3-168.9-51-168.9-51z"
|
||||
style={{
|
||||
fill: "url(#SVGID_4_)",
|
||||
}}
|
||||
/>
|
||||
<linearGradient
|
||||
id="SVGID_5_"
|
||||
x1={218.967}
|
||||
x2={428.745}
|
||||
y1={263.594}
|
||||
y2={263.594}
|
||||
gradientTransform="rotate(9.265 -38.832 -826.002)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop
|
||||
offset={0}
|
||||
style={{
|
||||
stopColor: "#333",
|
||||
stopOpacity: 0,
|
||||
}}
|
||||
/>
|
||||
<stop
|
||||
offset={0.477}
|
||||
style={{
|
||||
stopColor: "#575756",
|
||||
stopOpacity: 0.2,
|
||||
}}
|
||||
/>
|
||||
<stop
|
||||
offset={0.995}
|
||||
style={{
|
||||
stopColor: "#333",
|
||||
stopOpacity: 0.001911551,
|
||||
}}
|
||||
/>
|
||||
<stop
|
||||
offset={1}
|
||||
style={{
|
||||
stopColor: "#333",
|
||||
stopOpacity: 0,
|
||||
}}
|
||||
/>
|
||||
</linearGradient>
|
||||
<path
|
||||
d="M46.6 251.5s61.1 130.9 178 117.5l22.6-44.2c-113 33-195.3-85.7-195.3-85.7z"
|
||||
style={{
|
||||
fill: "url(#SVGID_5_)",
|
||||
}}
|
||||
/>
|
||||
</g>
|
||||
</switch>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function WebhookIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={2500}
|
||||
height={2448}
|
||||
fillRule="evenodd"
|
||||
strokeLinejoin="round"
|
||||
strokeMiterlimit={2}
|
||||
clipRule="evenodd"
|
||||
viewBox="-6.74 -6.74 223.55 218.799"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fill="#7547db"
|
||||
d="M111.134 71.906a21 21 0 0 1-1.324.042c-11.314 0-20.5-9.186-20.5-20.5s9.186-20.5 20.5-20.5 20.5 9.186 20.5 20.5a20.4 20.4 0 0 1-4.582 12.913l22.66 41.655a51 51 0 0 1 17.422-3.068c28.148 0 51 22.852 51 51s-22.852 51-51 51a51.2 51.2 0 0 1-28.036-8.373 7.303 7.303 0 0 1-1.359-11.105c.015-.016.031-.033.061-.036a7.665 7.665 0 0 1 9.736-1.228 36.06 36.06 0 0 0 19.598 5.792c19.897 0 36.05-16.153 36.05-36.05s-16.153-36.05-36.05-36.05c-6.89 0-16.669 3.014-22.548 6.745a2.637 2.637 0 0 1-3.899-.845zm-9.412 88.614a51 51 0 0 1-6.054 16.62c-14.074 24.378-45.29 32.743-69.667 18.669S-6.74 150.518 7.333 126.14a51.2 51.2 0 0 1 21.27-20.094 7.3 7.3 0 0 1 6.167-.211 7.3 7.3 0 0 1 4.13 4.587c.006.022.012.043 0 .07a7.665 7.665 0 0 1-3.804 9.047 36.05 36.05 0 0 0-14.816 14.076c-9.948 17.23-4.036 39.297 13.195 49.246 17.231 9.948 39.298 4.035 49.246-13.196 3.445-5.967 5.724-15.943 5.433-22.9a2.632 2.632 0 0 1 2.681-2.953l59.054 1.499q.292-.59.626-1.168c5.657-9.798 18.205-13.161 28.003-7.504 9.8 5.658 13.161 18.206 7.504 28.004s-18.205 13.16-28.004 7.503a20.4 20.4 0 0 1-8.892-10.424zM76.97 90.555A51 51 0 0 1 65.601 77c-14.074-24.377-5.71-55.594 18.667-69.667S139.862 1.624 153.935 26a51.2 51.2 0 0 1 6.768 28.466 7.306 7.306 0 0 1-8.938 6.73c-.022-.005-.044-.011-.062-.035a7.664 7.664 0 0 1-5.932-7.817 36.04 36.04 0 0 0-4.783-19.87c-9.948-17.23-32.014-23.143-49.245-13.195S68.599 52.294 78.548 69.525c3.445 5.967 10.945 12.929 17.115 16.155a2.635 2.635 0 0 1 1.218 3.799l-30.826 50.393q.366.548.7 1.125c5.656 9.799 2.294 22.347-7.505 28.004s-22.346 2.295-28.003-7.504-2.295-22.346 7.503-28.003a20.4 20.4 0 0 1 13.474-2.488z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
export function MSTeamsIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (<svg xmlns="http://www.w3.org/2000/svg" width={256} height={256} viewBox="0 0 256 239" {...props}>
|
||||
<defs>
|
||||
<path id="logosMicrosoftTeams0"
|
||||
d="M136.93 64.476v12.8a32.674 32.674 0 0 1-5.953-.952a38.698 38.698 0 0 1-26.79-22.742h21.848c6.008.022 10.872 4.887 10.895 10.894Z"
|
||||
opacity=".2"/>
|
||||
</defs>
|
||||
<defs>
|
||||
<linearGradient id="logosMicrosoftTeams1" x1="17.372%" x2="82.628%" y1="-6.51%" y2="106.51%">
|
||||
<stop offset="0%" stop-color="#5A62C3"/>
|
||||
<stop offset="50%" stop-color="#4D55BD"/>
|
||||
<stop offset="100%" stop-color="#3940AB"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path fill="#5059C9"
|
||||
d="M178.563 89.302h66.125c6.248 0 11.312 5.065 11.312 11.312v60.231c0 22.96-18.613 41.574-41.573 41.574h-.197c-22.96.003-41.576-18.607-41.579-41.568V95.215a5.912 5.912 0 0 1 5.912-5.913Z"/>
|
||||
<circle cx="223.256" cy="50.605" r="26.791" fill="#5059C9"/>
|
||||
<circle cx="139.907" cy="38.698" r="38.698" fill="#7B83EB"/>
|
||||
<path fill="#7B83EB"
|
||||
d="M191.506 89.302H82.355c-6.173.153-11.056 5.276-10.913 11.449v68.697c-.862 37.044 28.445 67.785 65.488 68.692c37.043-.907 66.35-31.648 65.489-68.692v-68.697c.143-6.173-4.74-11.296-10.913-11.449Z"/>
|
||||
<path
|
||||
d="M142.884 89.302v96.268a10.96 10.96 0 0 1-6.787 10.062c-1.3.55-2.697.833-4.108.833H76.68c-.774-1.965-1.488-3.93-2.084-5.953a72.509 72.509 0 0 1-3.155-21.076v-68.703c-.143-6.163 4.732-11.278 10.895-11.43h60.547Z"
|
||||
opacity=".1"/>
|
||||
<path
|
||||
d="M136.93 89.302v102.222c0 1.411-.283 2.808-.833 4.108a10.96 10.96 0 0 1-10.062 6.787H79.48c-1.012-1.965-1.965-3.93-2.798-5.954a59.049 59.049 0 0 1-2.084-5.953a72.508 72.508 0 0 1-3.155-21.076v-68.703c-.143-6.163 4.732-11.278 10.895-11.43h54.593Z"
|
||||
opacity=".2"/>
|
||||
<path
|
||||
d="M136.93 89.302v90.315c-.045 5.998-4.896 10.85-10.895 10.895H74.597a72.508 72.508 0 0 1-3.155-21.076v-68.703c-.143-6.163 4.732-11.278 10.895-11.43h54.593Z"
|
||||
opacity=".2"/>
|
||||
<path
|
||||
d="M130.977 89.302v90.315c-.046 5.998-4.897 10.85-10.895 10.895H74.597a72.508 72.508 0 0 1-3.155-21.076v-68.703c-.143-6.163 4.732-11.278 10.895-11.43h48.64Z"
|
||||
opacity=".2"/>
|
||||
<path
|
||||
d="M142.884 58.523v18.753c-1.012.06-1.965.12-2.977.12c-1.012 0-1.965-.06-2.977-.12a32.674 32.674 0 0 1-5.953-.952a38.698 38.698 0 0 1-26.791-22.742a33.082 33.082 0 0 1-1.905-5.954h29.708c6.007.023 10.872 4.887 10.895 10.895Z"
|
||||
opacity=".1"/>
|
||||
<use href="#logosMicrosoftTeams0" opacity=".2"/>
|
||||
<use href="#logosMicrosoftTeams0" opacity=".2"/>
|
||||
<path d="M130.977 64.476v11.848a38.698 38.698 0 0 1-26.791-22.743h15.896c6.008.023 10.872 4.888 10.895 10.895Z"
|
||||
opacity=".2"/>
|
||||
<path fill="url(#logosMicrosoftTeams1)"
|
||||
d="M10.913 53.581h109.15c6.028 0 10.914 4.886 10.914 10.913v109.151c0 6.027-4.886 10.913-10.913 10.913H10.913C4.886 184.558 0 179.672 0 173.645V64.495C0 58.466 4.886 53.58 10.913 53.58Z"/>
|
||||
<path fill="#FFF" d="M94.208 95.125h-21.82v59.416H58.487V95.125H36.769V83.599h57.439z"/>
|
||||
</svg>)
|
||||
}
|
||||
|
||||
export function NextcloudIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlSpace="preserve"
|
||||
viewBox="0 0 512 512"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M512 80.5v352c0 44.2-35.8 80-80 80H80c-44.2 0-80-35.8-80-80v-352C0 36.3 35.8.5 80 .5h352c44.2 0 80 35.8 80 80"
|
||||
style={{
|
||||
fillRule: "evenodd",
|
||||
clipRule: "evenodd",
|
||||
fill: "#0082c9",
|
||||
}}
|
||||
/>
|
||||
<path
|
||||
d="M256 128.5c-70.4 0-127.5 57.1-127.5 127.5S185.6 383.5 256 383.5c23.3 0 46.2-6.5 66.1-18.6 15.7 6.2 50.8 24.7 59.1 16.7 8.7-8.3-10.2-47.6-14.8-62.1 11.1-19.3 17-41.1 17-63.4.1-70.5-57-127.6-127.4-127.6m0 48.5c43.7 0 79 35.4 79 79 0 43.7-35.4 79-79 79s-79-35.4-79-79 35.3-79 79-79"
|
||||
style={{
|
||||
fill: "#fff",
|
||||
}}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function PushoverIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 100 100"
|
||||
{...props}
|
||||
>
|
||||
<rect width="100" height="100" rx="22" fill="#249DF1"/>
|
||||
<path
|
||||
d="M28 72V28h16.5c5.5 0 9.8 1.3 12.8 3.8 3 2.5 4.5 6 4.5 10.4 0 4.5-1.5 8-4.5 10.5-3 2.5-7.3 3.8-12.8 3.8H38V72H28zm10-23.8h6c2.8 0 4.9-.6 6.3-1.9 1.4-1.3 2.1-3.1 2.1-5.4 0-2.3-.7-4-2.1-5.3-1.4-1.3-3.5-1.9-6.3-1.9H38v14.5z"
|
||||
fill="white"
|
||||
/>
|
||||
<circle cx="72" cy="68" r="8" fill="white" opacity="0.9"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
import { NotificationChannelWith } from "@/db/schema/09_notification-channel";
|
||||
import { CardsWithPagination } from "@/components/common/cards-with-pagination";
|
||||
import { useState } from "react";
|
||||
import { EmptyStatePlaceholder } from "@/components/common/empty-state-placeholder";
|
||||
import { OrganizationWithMembers } from "@/db/schema/03_organization";
|
||||
import { StorageChannelWith } from "@/db/schema/12_storage-channel";
|
||||
import { ChannelCard } from "@/features/channel/components/channel-card";
|
||||
import { ChannelAddEditModal } from "@/features/channel/components/channel-add-edit-modal";
|
||||
import {
|
||||
ChannelKind,
|
||||
getChannelTextBasedOnKind,
|
||||
} from "@/features/channel/components/channels-helpers";
|
||||
|
||||
type ChannelsSectionProps = {
|
||||
channels: NotificationChannelWith[] | StorageChannelWith[];
|
||||
organizations: OrganizationWithMembers[];
|
||||
kind: ChannelKind;
|
||||
defaultStorageChannelId?: string | null | undefined;
|
||||
};
|
||||
|
||||
export const ChannelsSection = ({
|
||||
organizations,
|
||||
channels,
|
||||
kind,
|
||||
defaultStorageChannelId,
|
||||
}: ChannelsSectionProps) => {
|
||||
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
||||
const channelText = getChannelTextBasedOnKind(kind);
|
||||
const hasChannels = channels.length > 0;
|
||||
|
||||
return (
|
||||
<div className="h-full">
|
||||
<ChannelAddEditModal
|
||||
kind={kind}
|
||||
open={isAddModalOpen}
|
||||
onOpenChangeAction={setIsAddModalOpen}
|
||||
adminView={false}
|
||||
trigger={false}
|
||||
/>
|
||||
{hasChannels ? (
|
||||
<div className="h-full">
|
||||
<CardsWithPagination
|
||||
data={channels}
|
||||
cardItem={ChannelCard}
|
||||
cardsPerPage={8}
|
||||
numberOfColumns={2}
|
||||
adminView={true}
|
||||
organizations={organizations}
|
||||
kind={kind}
|
||||
defaultStorageChannelId={defaultStorageChannelId}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyStatePlaceholder
|
||||
text={`No ${channelText} channels configured yet`}
|
||||
onClick={() => {
|
||||
setIsAddModalOpen(true);
|
||||
}}
|
||||
className="h-full"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
import {Server} from "lucide-react";
|
||||
import type {SVGProps} from "react";
|
||||
import {ProviderIconTypes} from "@/features/channel/components/channels-helpers";
|
||||
|
||||
export const storageProviders: ProviderIconTypes[] = [
|
||||
{value: "local", label: "Local", icon: Server},
|
||||
{value: "s3", label: "S3", icon: S3Icon},
|
||||
{value: "google-drive", label: "Google Drive", icon: GoogleDriveIcon},
|
||||
{value: "blob", label: "Azure Blob Storage", icon: BlobIcon},
|
||||
{value: "gcs", label: "Google Cloud Storage", icon: GCSIcon, preview: true},
|
||||
]
|
||||
|
||||
export function S3Icon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={256} height={199} {...props} viewBox="0 0 24 24">
|
||||
<path fill="currentColor"
|
||||
d="M13.207.006a2.16 2.16 0 0 0-1.62.582a2.15 2.15 0 0 0-.095 3.035l3.408 3.55a3.042 3.042 0 0 1-.663 4.688l-.463.239V7.285a15.42 15.42 0 0 0-8.018 10.487v.017l6.549-3.328v7.621L13.779 24V13.682l.897-.463a4.443 4.443 0 0 0 1.22-7.03l-3.37-3.525a.75.75 0 0 1 .037-1.055a.75.75 0 0 1 1.056.038l.467.486l-.006.006l4.07 4.244a.057.057 0 0 0 .082 0a.06.06 0 0 0 0-.07l-3.14-5.143l-.149.143l.149-.145C14.494.393 13.829.054 13.207.006m-.902 9.865v2.994l-4.152 2.149a14 14 0 0 1 2.767-3.928a14 14 0 0 1 1.385-1.215"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function GoogleDriveIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={256} height={199} {...props} viewBox="0 0 256 229"><path fill="#0066da" d="m19.354 196.034l11.29 19.5c2.346 4.106 5.718 7.332 9.677 9.678q17.009-21.591 23.68-33.137q6.77-11.717 16.641-36.655q-26.604-3.502-40.32-3.502q-13.165 0-40.322 3.502c0 4.545 1.173 9.09 3.519 13.196z"/><path fill="#ea4335" d="M215.681 225.212c3.96-2.346 7.332-5.572 9.677-9.677l4.692-8.064l22.434-38.855a26.57 26.57 0 0 0 3.518-13.196q-27.315-3.502-40.247-3.502q-13.899 0-40.248 3.502q9.754 25.075 16.422 36.655q6.724 11.683 23.752 33.137"/><path fill="#00832d" d="M128.001 73.311q19.68-23.768 27.125-36.655q5.996-10.377 13.196-33.137C164.363 1.173 159.818 0 155.126 0h-54.25C96.184 0 91.64 1.32 87.68 3.519q9.16 26.103 15.544 37.154q7.056 12.213 24.777 32.638"/><path fill="#2684fc" d="M175.36 155.42H80.642l-40.32 69.792c3.958 2.346 8.503 3.519 13.195 3.519h148.968c4.692 0 9.238-1.32 13.196-3.52z"/><path fill="#00ac47" d="M128.001 73.311L87.681 3.52c-3.96 2.346-7.332 5.571-9.678 9.677L3.519 142.224A26.57 26.57 0 0 0 0 155.42h80.642z"/><path fill="#ffba00" d="m215.242 77.71l-37.243-64.514c-2.345-4.106-5.718-7.331-9.677-9.677l-40.32 69.792l47.358 82.109h80.496c0-4.546-1.173-9.09-3.519-13.196z"/></svg>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
export function BlobIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" x="0px" y="0px" width={256} height={199} {...props} viewBox="0 0 48 48">
|
||||
<path fill="#2979ff"
|
||||
d="M32.66,7H15.34c-0.715,0-1.375,0.381-1.732,1l-8.66,15c-0.357,0.619-0.357,1.381,0,2l8.66,15c0.357,0.619,1.018,1,1.732,1H32.66c0.715,0,1.375-0.381,1.732-1l8.66-15c0.357-0.619,0.357-1.381,0-2l-8.66-15C34.035,7.381,33.375,7,32.66,7z"></path>
|
||||
<path fill="#fff"
|
||||
d="M32,35H16c-0.552,0-1-0.448-1-1V14c0-0.552,0.448-1,1-1h13l4,4v17C33,34.552,32.552,35,32,35z"></path>
|
||||
<path fill="#b1cfff" d="M29,13v3c0,0.552,0.448,1,1,1h3L29,13z"></path>
|
||||
<path fill="#03a9f4"
|
||||
d="M22.265 23.831H20.93V19.58l-1.312.383v-1.011l2.524-.873h.122V23.831zM28.702 21.48c0 .416-.049.776-.146 1.081-.097.303-.234.556-.411.756-.176.2-.387.349-.632.446-.245.097-.515.146-.81.146-.298 0-.57-.049-.816-.146-.246-.097-.457-.246-.633-.446-.176-.2-.315-.453-.413-.756-.099-.305-.147-.665-.147-1.081v-1.051c0-.413.049-.773.146-1.078.097-.305.234-.558.411-.758.176-.2.387-.349.632-.446C26.126 18.049 26.397 18 26.695 18c.295 0 .565.049.811.146.246.097.458.246.635.446.176.2.313.453.412.758.099.305.149.665.149 1.078V21.48zM27.37 20.26c0-.229-.016-.421-.047-.579-.032-.155-.076-.283-.134-.381-.058-.097-.129-.167-.212-.209-.083-.042-.176-.063-.282-.063-.105 0-.2.021-.283.063-.083.042-.153.112-.209.209-.057.097-.1.225-.13.381-.03.158-.045.35-.045.579v1.383c0 .234.014.431.045.589.03.158.075.286.133.381.058.097.129.166.213.208.084.042.179.063.284.063.103 0 .195-.021.278-.063.083-.042.154-.111.212-.208.058-.095.101-.223.132-.381.03-.158.046-.354.046-.589V20.26zM23.308 29.571c0 .416-.049.776-.146 1.081-.097.303-.234.556-.411.756-.176.2-.387.349-.632.446C21.874 31.951 21.604 32 21.309 32c-.298 0-.57-.049-.816-.146-.246-.097-.457-.246-.633-.446-.176-.2-.315-.453-.413-.756-.099-.305-.147-.665-.147-1.081V28.52c0-.413.049-.773.146-1.078.097-.305.234-.558.411-.758.176-.2.387-.349.632-.446.245-.097.516-.146.814-.146.295 0 .565.049.811.146.246.097.458.246.635.446.176.2.313.453.412.758s.149.665.149 1.078V29.571zM21.977 28.35c0-.229-.016-.421-.047-.579-.032-.155-.076-.283-.134-.381-.058-.097-.129-.167-.212-.209-.083-.042-.176-.063-.282-.063s-.2.021-.283.063c-.083.042-.153.112-.209.209-.057.097-.1.225-.13.381-.03.158-.045.35-.045.579v1.383c0 .234.014.431.045.589.03.158.075.286.133.381.058.097.129.166.213.208s.179.063.284.063c.103 0 .195-.021.278-.063.083-.042.154-.111.212-.208.058-.095.101-.223.132-.381.03-.158.046-.354.046-.589V28.35zM27.659 31.921h-1.335V27.67l-1.312.383v-1.011l2.524-.873h.122V31.921z"></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function GCSIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
id="standard_product_icon"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 512 512"
|
||||
{...props}
|
||||
>
|
||||
<g id="bounding_box">
|
||||
<rect width="512" height="512" fill="none"/>
|
||||
</g>
|
||||
<g id="art">
|
||||
<path
|
||||
fill="#34a853"
|
||||
d="M442,277.9H70c-8.8,0-16,7.2-16,16v148.1c0,8.8,7.2,16,16,16h107.2c1.5.5,3.1.7,4.8.7s3.3-.3,4.8-.7h255.2c8.8,0,16-7.2,16-16v-148.1c0-8.8-7.2-16-16-16ZM86,309.9h80v116.1h-80v-116.1ZM426,425.9h-228v-116.1h228v116.1Z"
|
||||
/>
|
||||
<path
|
||||
fill="#fbbc04"
|
||||
d="M442,54H70c-8.8,0-16,7.2-16,16v148.8c0,8.8,7.2,16,16,16h372c8.8,0,16-7.2,16-16V70c0-8.8-7.2-16-16-16ZM86,86h80v116.8h-80v-116.8ZM426,202.8h-228v-116.8h228v116.8Z"
|
||||
/>
|
||||
<path
|
||||
fill="#ea4335"
|
||||
d="M442,234.8h-16V86H54v-16c0-8.8,7.2-16,16-16h372c8.8,0,16,7.2,16,16v148.8c0,8.8-7.2,16-16,16Z"
|
||||
/>
|
||||
<path
|
||||
fill="#4285f4"
|
||||
d="M442,457.9h-16v-148.1H54v-16c0-8.8,7.2-16,16-16h372c8.8,0,16,7.2,16,16v148.1c0,8.8-7.2,16-16,16Z"
|
||||
/>
|
||||
<circle fill="#ea4335" cx="349" cy="144.4" r="37"/>
|
||||
<circle fill="#4285f4" cx="349" cy="367.9" r="37"/>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
"use server";
|
||||
|
||||
import { z } from "zod";
|
||||
import { ServerActionResult } from "@/types/action-type";
|
||||
import * as drizzleDb from "@/db";
|
||||
import { userAction } from "@/lib/safe-actions/actions";
|
||||
import { NotificationChannel } from "@/db/schema/09_notification-channel";
|
||||
import { db } from "@/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { withUpdatedAt } from "@/db/utils";
|
||||
import { NotificationChannelFormSchema } from "@/features/channel/schemas/channel-form.schema";
|
||||
|
||||
export const addNotificationChannelAction = userAction
|
||||
.schema(
|
||||
z.object({
|
||||
organizationId: z.string().optional(),
|
||||
data: NotificationChannelFormSchema,
|
||||
}),
|
||||
)
|
||||
.action(
|
||||
async ({
|
||||
parsedInput,
|
||||
}): Promise<ServerActionResult<NotificationChannel>> => {
|
||||
const { organizationId, data } = parsedInput;
|
||||
try {
|
||||
const [channel] = await db
|
||||
.insert(drizzleDb.schemas.notificationChannel)
|
||||
.values({
|
||||
provider: data.provider,
|
||||
name: data.name,
|
||||
config: data.config,
|
||||
enabled: data.enabled ?? true,
|
||||
organizationId: organizationId ?? null,
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (organizationId) {
|
||||
await db
|
||||
.insert(drizzleDb.schemas.organizationNotificationChannel)
|
||||
.values({
|
||||
organizationId,
|
||||
notificationChannelId: channel.id,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
value: {
|
||||
...channel,
|
||||
config: channel.config as JSON,
|
||||
},
|
||||
actionSuccess: {
|
||||
message: "Notification channel has been successfully created.",
|
||||
messageParams: { notificationChannelId: channel.id },
|
||||
},
|
||||
};
|
||||
} catch (_error) {
|
||||
const error = _error;
|
||||
return {
|
||||
success: false,
|
||||
actionError: {
|
||||
message: "Failed to create notification channel.",
|
||||
status: 500,
|
||||
cause: error instanceof Error ? error.message : "Unknown error",
|
||||
messageParams: { notificationChannelId: "" },
|
||||
},
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const removeNotificationChannelAction = userAction
|
||||
.schema(
|
||||
z.object({
|
||||
organizationId: z.string().optional(),
|
||||
notificationChannelId: z.string(),
|
||||
}),
|
||||
)
|
||||
.action(
|
||||
async ({
|
||||
parsedInput,
|
||||
}): Promise<ServerActionResult<NotificationChannel>> => {
|
||||
const { organizationId, notificationChannelId } = parsedInput;
|
||||
|
||||
try {
|
||||
if (organizationId) {
|
||||
await db
|
||||
.delete(drizzleDb.schemas.organizationNotificationChannel)
|
||||
.where(
|
||||
and(
|
||||
eq(
|
||||
drizzleDb.schemas.organizationNotificationChannel
|
||||
.organizationId,
|
||||
organizationId,
|
||||
),
|
||||
eq(
|
||||
drizzleDb.schemas.organizationNotificationChannel
|
||||
.notificationChannelId,
|
||||
notificationChannelId,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const [deletedChannel] = await db
|
||||
.delete(drizzleDb.schemas.notificationChannel)
|
||||
.where(
|
||||
eq(drizzleDb.schemas.notificationChannel.id, notificationChannelId),
|
||||
)
|
||||
.returning();
|
||||
|
||||
if (!deletedChannel) {
|
||||
return {
|
||||
success: false,
|
||||
actionError: {
|
||||
message: "Notification channel not found.",
|
||||
status: 404,
|
||||
messageParams: { notificationChannelId: notificationChannelId },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
value: {
|
||||
...deletedChannel,
|
||||
config: deletedChannel.config as JSON,
|
||||
},
|
||||
actionSuccess: {
|
||||
message: "Notification channel has been successfully removed.",
|
||||
messageParams: { notificationChannelId: notificationChannelId },
|
||||
},
|
||||
};
|
||||
} catch (_error) {
|
||||
const error = _error;
|
||||
return {
|
||||
success: false,
|
||||
actionError: {
|
||||
message: "Failed to remove notification channel.",
|
||||
status: 500,
|
||||
cause: error instanceof Error ? error.message : "Unknown error",
|
||||
messageParams: { notificationChannelId: notificationChannelId },
|
||||
},
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const updateNotificationChannelAction = userAction
|
||||
.schema(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
data: NotificationChannelFormSchema,
|
||||
}),
|
||||
)
|
||||
.action(
|
||||
async ({
|
||||
parsedInput,
|
||||
}): Promise<ServerActionResult<NotificationChannel>> => {
|
||||
const { id, data } = parsedInput;
|
||||
|
||||
try {
|
||||
const [channel] = await db
|
||||
.update(drizzleDb.schemas.notificationChannel)
|
||||
.set(
|
||||
withUpdatedAt({
|
||||
provider: data.provider,
|
||||
name: data.name,
|
||||
config: data.config,
|
||||
enabled: data.enabled ?? true,
|
||||
}),
|
||||
)
|
||||
.where(eq(drizzleDb.schemas.notificationChannel.id, id))
|
||||
.returning();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
value: {
|
||||
...channel,
|
||||
config: channel.config as JSON,
|
||||
},
|
||||
actionSuccess: {
|
||||
message: `Notification channel "${channel.name}" has been successfully updated.`,
|
||||
messageParams: { id: channel.id },
|
||||
},
|
||||
};
|
||||
} catch (_error) {
|
||||
const error = _error;
|
||||
return {
|
||||
success: false,
|
||||
actionError: {
|
||||
message: "Failed to update notification channel.",
|
||||
status: 500,
|
||||
cause: error instanceof Error ? error.message : "Unknown error",
|
||||
messageParams: { id: "" },
|
||||
},
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,38 @@
|
||||
import { UseFormReturn } from "react-hook-form";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
|
||||
type NotifierDiscordFormProps = {
|
||||
form: UseFormReturn<any, any, any>;
|
||||
};
|
||||
|
||||
export const NotifierDiscordForm = ({ form }: NotifierDiscordFormProps) => {
|
||||
return (
|
||||
<>
|
||||
<Separator className="my-1" />
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.discordWebhook"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Discord Webhook URL *</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput
|
||||
{...field}
|
||||
placeholder="e.g. https://discord.com/api/webhooks/..."
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
import {z} from "zod";
|
||||
|
||||
export const DiscordChannelConfigSchema = z.object({
|
||||
discordWebhook: z.string().url("Must be a valid URL"),
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import type {EventPayload, DispatchResult} from '@/features/notifications/types';
|
||||
|
||||
export async function sendDiscord(
|
||||
config: { discordWebhook: string },
|
||||
payload: EventPayload
|
||||
): Promise<DispatchResult> {
|
||||
const {discordWebhook: webhookUrl} = config;
|
||||
|
||||
const embeds = [
|
||||
{
|
||||
title: `[${payload.level.toUpperCase()}] ${payload.title}`,
|
||||
description: payload.message,
|
||||
color: payload.level === 'critical' ? 15158332 : payload.level === 'warning' ? 16776960 : 3447003,
|
||||
fields: payload.data ? [
|
||||
{
|
||||
name: 'Data',
|
||||
value: `
|
||||
${JSON.stringify(payload.data, null, 2).substring(0, 1000)}
|
||||
`,
|
||||
inline: false
|
||||
}
|
||||
] : [],
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
];
|
||||
|
||||
const body = {
|
||||
embeds,
|
||||
};
|
||||
|
||||
const res = await fetch(webhookUrl, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
throw new Error(`Discord error: ${res.status} ${err}`);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
provider: 'discord',
|
||||
message: 'Sent to Discord',
|
||||
response: res.statusText,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { UseFormReturn } from "react-hook-form";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
|
||||
type NotifierGotifyFormProps = {
|
||||
form: UseFormReturn<any, any, any>;
|
||||
};
|
||||
|
||||
export const NotifierGotifyForm = ({ form }: NotifierGotifyFormProps) => {
|
||||
return (
|
||||
<>
|
||||
<Separator className="my-1" />
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.gotifyServerUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Gotify Server URL *</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="e.g. https://gotify.example.com" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.gotifyAppToken"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Application Token *</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput {...field} placeholder="e.g. gotify-app-token" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import {z} from "zod";
|
||||
|
||||
export const GotifyChannelConfigSchema = z.object({
|
||||
gotifyServerUrl: z.string().url("Must be a valid URL"),
|
||||
gotifyAppToken: z.string().min(1, "App token is required"),
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import type {EventPayload, DispatchResult} from '@/features/notifications/types';
|
||||
|
||||
export async function sendGotify(
|
||||
config: { gotifyServerUrl: string; gotifyAppToken: string },
|
||||
payload: EventPayload
|
||||
): Promise<DispatchResult> {
|
||||
const {gotifyServerUrl, gotifyAppToken} = config;
|
||||
|
||||
const baseUrl = gotifyServerUrl.replace(/\/$/, "");
|
||||
|
||||
const body = {
|
||||
title: `[${payload.level.toUpperCase()}] ${payload.title}`,
|
||||
message: `${payload.message}\n\n${payload.data ? `Data:\n${JSON.stringify(payload.data, null, 2)}` : ''}`,
|
||||
priority: payload.level === 'critical' ? 8 : payload.level === 'warning' ? 5 : 2,
|
||||
};
|
||||
|
||||
const res = await fetch(`${baseUrl}/message?token=${gotifyAppToken}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
throw new Error(`Gotify error: ${res.status} ${err}`);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
provider: 'gotify',
|
||||
message: 'Sent to Gotify',
|
||||
response: await res.json(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
"use server"
|
||||
import type {ProviderKind, EventPayload, DispatchResult} from '@/features/notifications/types';
|
||||
import {sendSlack} from './slack';
|
||||
import {sendSmtp} from './smtp';
|
||||
import {sendDiscord} from "@/features/channel/components/notifications/discord";
|
||||
import {sendTelegram} from "@/features/channel/components/notifications/telegram";
|
||||
import {sendGotify} from "@/features/channel/components/notifications/gotify";
|
||||
import {sendNtfy} from "@/features/channel/components/notifications/ntfy";
|
||||
import {sendWebhook} from "@/features/channel/components/notifications/webhook";
|
||||
import {sendNextcloud} from "@/features/channel/components/notifications/nextcloud";
|
||||
import {sendPushover} from "@/features/channel/components/notifications/pushover";
|
||||
import {sendTeams} from "@/features/channel/components/notifications/teams"
|
||||
|
||||
const handlers: Record<
|
||||
ProviderKind,
|
||||
(config: any, payload: EventPayload) => Promise<DispatchResult>
|
||||
> = {
|
||||
slack: sendSlack,
|
||||
smtp: sendSmtp,
|
||||
discord: sendDiscord,
|
||||
telegram: sendTelegram,
|
||||
gotify: sendGotify,
|
||||
ntfy: sendNtfy,
|
||||
webhook: sendWebhook,
|
||||
nextcloud: sendNextcloud,
|
||||
teams: sendTeams,
|
||||
pushover: sendPushover,
|
||||
};
|
||||
|
||||
export async function dispatchViaProvider(
|
||||
kind: ProviderKind,
|
||||
config: any,
|
||||
payload: EventPayload,
|
||||
channelId: string
|
||||
): Promise<DispatchResult> {
|
||||
const handler = handlers[kind];
|
||||
if (!handler) {
|
||||
return {
|
||||
success: false,
|
||||
channelId,
|
||||
provider: kind,
|
||||
error: `Unsupported provider: ${kind}`,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
return await handler(config, payload);
|
||||
} catch (err: any) {
|
||||
return {
|
||||
success: false,
|
||||
channelId,
|
||||
provider: kind,
|
||||
error: err.message || 'Unknown error',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { UseFormReturn } from "react-hook-form";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
|
||||
type NotifierNextcloudFormProps = {
|
||||
form: UseFormReturn<any, any, any>;
|
||||
};
|
||||
|
||||
export const NotifierNextcloudForm = ({ form }: NotifierNextcloudFormProps) => {
|
||||
return (
|
||||
<>
|
||||
<Separator className="my-1" />
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.nextcloudUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Nextcloud URL *</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="e.g. https://cloud.example.com" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.nextcloudBotToken"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Bot Token *</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="e.g. j3yujpuh" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.nextcloudBotSecret"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Bot Secret *</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput {...field} placeholder="HMAC signing secret" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const NextcloudChannelConfigSchema = z.object({
|
||||
nextcloudUrl: z.string().url("Must be a valid URL"),
|
||||
nextcloudBotToken: z.string().min(1, "Bot token is required"),
|
||||
nextcloudBotSecret: z.string().min(1, "Bot secret is required"),
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { createHmac, randomBytes } from "crypto";
|
||||
import type { EventPayload, DispatchResult } from '@/features/notifications/types';
|
||||
|
||||
type NextcloudConfig = {
|
||||
nextcloudUrl: string;
|
||||
nextcloudBotToken: string;
|
||||
nextcloudBotSecret: string;
|
||||
};
|
||||
|
||||
function formatPayloadData(data: unknown): string {
|
||||
if (!data) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (typeof data === "string") {
|
||||
return data;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(data, null, 2);
|
||||
} catch {
|
||||
return String(data);
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendNextcloud(
|
||||
config: NextcloudConfig,
|
||||
payload: EventPayload
|
||||
): Promise<DispatchResult> {
|
||||
const {
|
||||
nextcloudUrl,
|
||||
nextcloudBotToken,
|
||||
nextcloudBotSecret,
|
||||
} = config;
|
||||
|
||||
const payloadData = formatPayloadData(payload.data);
|
||||
|
||||
const messageParts = [
|
||||
`[${payload.level.toUpperCase()}] ${payload.title}`,
|
||||
payload.message,
|
||||
];
|
||||
|
||||
if (payloadData) {
|
||||
messageParts.push(`Payload:\n${payloadData}`);
|
||||
}
|
||||
|
||||
const message = messageParts.join("\n\n");
|
||||
|
||||
const random = randomBytes(32).toString("hex");
|
||||
|
||||
const signature = createHmac("sha256", nextcloudBotSecret)
|
||||
.update(random + message)
|
||||
.digest("hex");
|
||||
|
||||
const baseUrl = nextcloudUrl.replace(/\/$/, "");
|
||||
|
||||
const res = await fetch(
|
||||
`${baseUrl}/ocs/v2.php/apps/spreed/api/v1/bot/${nextcloudBotToken}/message`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
"OCS-APIRequest": "true",
|
||||
"X-Nextcloud-Talk-Bot-Random": random,
|
||||
"X-Nextcloud-Talk-Bot-Signature": signature,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
|
||||
throw new Error(
|
||||
`Nextcloud error: ${res.status} ${err}`
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
provider: "nextcloud",
|
||||
message: "Sent to Nextcloud Talk",
|
||||
response: await res.text(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import {UseFormReturn} from "react-hook-form";
|
||||
import {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form";
|
||||
import {Input} from "@/components/ui/input";
|
||||
import {Separator} from "@/components/ui/separator";
|
||||
import {PasswordInput} from "@/components/ui/password-input";
|
||||
|
||||
type NotifierNtfyFormProps = {
|
||||
form: UseFormReturn<any, any, any>
|
||||
}
|
||||
|
||||
export const NotifierNtfyForm = ({form}: NotifierNtfyFormProps) => {
|
||||
return (
|
||||
<>
|
||||
<Separator className="my-1"/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.ntfyTopic"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Topic Name *</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="e.g. team-alerts"/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.ntfyServerUrl"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Server URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="e.g. https://ntfy.example.com"/>
|
||||
</FormControl>
|
||||
<p className="text-xs text-muted-foreground">Leave empty to use the official ntfy.sh server.</p>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.ntfyToken"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Access Token</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput {...field} placeholder="e.g. tk_xxxxx"/>
|
||||
</FormControl>
|
||||
<p className="text-xs text-muted-foreground">Only required for protected topics or self-hosted instances with auth.</p>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Separator className="my-1"/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.ntfyUsername"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Basic Auth Username</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="e.g. notifier-user"/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.ntfyPassword"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Basic Auth Password</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput placeholder="e.g. notifier-password" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import {z} from "zod";
|
||||
|
||||
export const NtfyChannelConfigSchema = z.object({
|
||||
ntfyTopic: z.string().min(1, "Topic is required"),
|
||||
ntfyServerUrl: z.string().url("Must be a valid URL").optional().or(z.literal('')),
|
||||
ntfyToken: z.string().optional(),
|
||||
ntfyUsername: z.string().optional(),
|
||||
ntfyPassword: z.string().optional(),
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import type {EventPayload, DispatchResult} from '@/features/notifications/types';
|
||||
|
||||
const getPriority = (level?: string): number => {
|
||||
switch (level) {
|
||||
case 'critical': return 5;
|
||||
case 'error': return 4;
|
||||
case 'warning': return 3;
|
||||
case 'info':
|
||||
default: return 2;
|
||||
}
|
||||
};
|
||||
|
||||
const formatData = (data: any): string => {
|
||||
if (!data || typeof data !== 'object') return '';
|
||||
return '\n\nDetails:\n' + Object.entries(data)
|
||||
.map(([key, value]) => {
|
||||
const stringVal = value !== null && typeof value === 'object' ? JSON.stringify(value) : String(value);
|
||||
return `- ${key}: ${stringVal}`;
|
||||
})
|
||||
.join('\n');
|
||||
};
|
||||
|
||||
const getTags = (level?: string): string[] => {
|
||||
const baseTags = ['floppy_disk'];
|
||||
|
||||
if (level === 'critical') baseTags.push('rotating_light');
|
||||
else if (level === 'error') baseTags.push('x');
|
||||
else if (level === 'warning') baseTags.push('warning');
|
||||
else baseTags.push('information_source');
|
||||
|
||||
return baseTags;
|
||||
};
|
||||
|
||||
export async function sendNtfy(
|
||||
config: { ntfyServerUrl?: string; ntfyTopic: string; ntfyToken?: string, ntfyUsername?: string, ntfyPassword?: string },
|
||||
payload: EventPayload
|
||||
): Promise<DispatchResult> {
|
||||
const {ntfyServerUrl, ntfyTopic, ntfyToken, ntfyUsername, ntfyPassword} = config;
|
||||
|
||||
const baseUrl = (ntfyServerUrl || "https://ntfy.sh").replace(/\/$/, "");
|
||||
|
||||
const body = {
|
||||
topic: ntfyTopic,
|
||||
title: payload.title,
|
||||
message: payload.message + formatData(payload.data),
|
||||
priority: getPriority(payload.level),
|
||||
tags: getTags(payload.level),
|
||||
};
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
if (ntfyToken) {
|
||||
headers['Authorization'] = `Bearer ${ntfyToken}`;
|
||||
}
|
||||
|
||||
if (ntfyUsername && ntfyPassword) {
|
||||
const credentials = btoa(`${ntfyUsername}:${ntfyPassword}`);
|
||||
headers['Authorization'] = `Basic ${credentials}`;
|
||||
}
|
||||
|
||||
const res = await fetch(`${baseUrl}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
headers: headers,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
throw new Error(`ntfy error: ${res.status} ${err}`);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
provider: 'ntfy',
|
||||
message: 'Sent to ntfy',
|
||||
response: await res.json(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import {UseFormReturn} from "react-hook-form";
|
||||
import {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form";
|
||||
import {Input} from "@/components/ui/input";
|
||||
import {Separator} from "@/components/ui/separator";
|
||||
import {PasswordInput} from "@/components/ui/password-input";
|
||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select";
|
||||
import {PUSHOVER_PRIORITIES} from "@/features/channel/components/notifications/pushover.schema";
|
||||
|
||||
type NotifierPushoverFormProps = {
|
||||
form: UseFormReturn<any, any, any>;
|
||||
};
|
||||
|
||||
export const NotifierPushoverForm = ({form}: NotifierPushoverFormProps) => {
|
||||
return (
|
||||
<>
|
||||
<Separator className="my-1"/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.pushoverUserKey"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>User Key *</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput {...field} placeholder="Your 30-character Pushover user key"/>
|
||||
</FormControl>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Found at the top of your Pushover dashboard at pushover.net.
|
||||
</p>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.pushoverApiToken"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>App API Token *</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput {...field} placeholder="Your 30-character application API token"/>
|
||||
</FormControl>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Create an application at pushover.net/apps to get an API token.
|
||||
</p>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Separator className="my-1"/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.pushoverPriority"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Message Priority</FormLabel>
|
||||
<Select onValueChange={field.onChange} value={field.value ?? "0"}>
|
||||
<FormControl>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select priority"/>
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{PUSHOVER_PRIORITIES.map((p) => (
|
||||
<SelectItem key={p.value} value={p.value}>
|
||||
{p.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Emergency priority repeats every 60 seconds until acknowledged (max 1 hour).
|
||||
</p>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.pushoverDevice"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Device Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} value={field.value ?? ""} placeholder="e.g. iphone (leave empty for all devices)"/>
|
||||
</FormControl>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Target a specific registered device. Leave empty to send to all devices.
|
||||
</p>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import {z} from "zod";
|
||||
|
||||
export const PUSHOVER_PRIORITIES = [
|
||||
{value: "-2", label: "Lowest - no sound, no vibration, no alert"},
|
||||
{value: "-1", label: "Low - quiet notification, no sound"},
|
||||
{value: "0", label: "Normal - default sound and alert"},
|
||||
{value: "1", label: "High - bypass quiet hours"},
|
||||
{value: "2", label: "Emergency - repeat until acknowledged"},
|
||||
] as const;
|
||||
|
||||
export const PushoverChannelConfigSchema = z.object({
|
||||
pushoverUserKey: z.string().min(30, "User key must be 30 characters").max(30, "User key must be 30 characters"),
|
||||
pushoverApiToken: z.string().min(30, "API token must be 30 characters").max(30, "API token must be 30 characters"),
|
||||
pushoverPriority: z.enum(["-2", "-1", "0", "1", "2"]).default("0"),
|
||||
pushoverDevice: z.string().max(25, "Device name must be at most 25 characters").optional().or(z.literal("")),
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import type {EventPayload, DispatchResult} from '@/features/notifications/types';
|
||||
|
||||
export async function sendPushover(
|
||||
config: {
|
||||
pushoverUserKey: string;
|
||||
pushoverApiToken: string;
|
||||
pushoverPriority: string;
|
||||
pushoverDevice?: string;
|
||||
},
|
||||
payload: EventPayload
|
||||
): Promise<DispatchResult> {
|
||||
const {pushoverUserKey, pushoverApiToken, pushoverDevice} = config;
|
||||
const priority = parseInt(config.pushoverPriority ?? "0", 10);
|
||||
|
||||
const rawTitle = `[${payload.level.toUpperCase()}] ${payload.title}`;
|
||||
const rawMessage = payload.data
|
||||
? `${payload.message}\n\nData:\n${JSON.stringify(payload.data)}`
|
||||
: payload.message;
|
||||
|
||||
const body: Record<string, string | number> = {
|
||||
token: pushoverApiToken,
|
||||
user: pushoverUserKey,
|
||||
title: rawTitle.length > 250 ? rawTitle.slice(0, 249) + "…" : rawTitle,
|
||||
message: rawMessage.length > 1024 ? rawMessage.slice(0, 1023) + "…" : rawMessage,
|
||||
priority,
|
||||
};
|
||||
|
||||
if (pushoverDevice) {
|
||||
body.device = pushoverDevice;
|
||||
}
|
||||
|
||||
// Emergency priority requires retry + expire
|
||||
if (priority === 2) {
|
||||
body.retry = 60;
|
||||
body.expire = 3600;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 10_000);
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch("https://api.pushover.net/1/messages.json", {
|
||||
method: "POST",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
|
||||
const json = await res.json().catch(() => null);
|
||||
|
||||
if (!res.ok) {
|
||||
const errors = json?.errors?.join(", ") ?? res.statusText;
|
||||
throw new Error(`Pushover error ${res.status}: ${errors}`);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
provider: "pushover",
|
||||
message: "Sent via Pushover",
|
||||
response: json,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { UseFormReturn } from "react-hook-form";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
|
||||
type NotifierSmtpFormProps = {
|
||||
form: UseFormReturn<any, any, any>;
|
||||
};
|
||||
|
||||
export const NotifierSlackForm = ({ form }: NotifierSmtpFormProps) => {
|
||||
return (
|
||||
<>
|
||||
<Separator className="my-1" />
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.slackWebhook"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Slack Webhook URL *</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput
|
||||
{...field}
|
||||
placeholder="e.g. https://hooks.slack.com/services/..."
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
import {z} from "zod";
|
||||
|
||||
export const SlackChannelConfigSchema = z.object({
|
||||
slackWebhook: z.string().url("Must be a valid URL"),
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import type {EventPayload, DispatchResult} from '@/features/notifications/types';
|
||||
|
||||
export async function sendSlack(
|
||||
config: { slackWebhook: string },
|
||||
payload: EventPayload
|
||||
): Promise<DispatchResult> {
|
||||
const {slackWebhook: webhookUrl} = config;
|
||||
|
||||
const text = `*[${payload.level}] ${payload.title}*\n${payload.message}`;
|
||||
const blocks = [
|
||||
{
|
||||
type: 'section',
|
||||
text: {type: 'mrkdwn', text: `*[${payload.level.toUpperCase()}] ${payload.title}*`},
|
||||
},
|
||||
{type: 'section', text: {type: 'mrkdwn', text: payload.message}},
|
||||
payload.data
|
||||
? {
|
||||
type: 'context',
|
||||
elements: [{type: 'mrkdwn', text: `*Data:* \`\`\`${JSON.stringify(payload.data, null, 2)}\`\`\``}],
|
||||
}
|
||||
: null,
|
||||
].filter(Boolean);
|
||||
|
||||
const body = {
|
||||
text,
|
||||
blocks,
|
||||
};
|
||||
|
||||
const res = await fetch(webhookUrl, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
throw new Error(`Slack error: ${res.status} ${err}`);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
provider: 'slack',
|
||||
message: 'Sent to Slack',
|
||||
response: await res.text(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form";
|
||||
import {Input} from "@/components/ui/input";
|
||||
import {UseFormReturn} from "react-hook-form";
|
||||
import {Separator} from "@/components/ui/separator";
|
||||
import {PasswordInput} from "@/components/ui/password-input";
|
||||
|
||||
|
||||
type NotifierSmtpFormProps = {
|
||||
form: UseFormReturn<any, any, any>
|
||||
}
|
||||
|
||||
|
||||
export const NotifierSmtpForm = ({form}: NotifierSmtpFormProps) => {
|
||||
return (
|
||||
<>
|
||||
<Separator className="my-1"/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.host"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>SMTP Host *</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="e.g. smtp.example.com" {...field} value={field.value ?? ""}/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.port"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>SMTP Port *</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="e.g. 587" type="number" {...field} value={field.value ?? ""}/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.user"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Username *</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="e.g. noreply@example.com" {...field} value={field.value ?? ""}/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.password"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password *</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.from"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>From Email *</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={`e.g. "Portabase" <noreply@example.com>`} {...field}
|
||||
value={field.value ?? ""}/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.to"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>To Email *</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={"e.g. alerts@example.com, team@example.com"} {...field}
|
||||
value={field.value ?? ""}/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import {z} from "zod";
|
||||
|
||||
export const SmtpChannelConfigSchema = z.object({
|
||||
host: z.string().min(1, "Host is required"),
|
||||
port: z.coerce.number().min(1, "Port is required"),
|
||||
user: z.string().min(1, "User is required"),
|
||||
password: z.string().min(1, "Password is required"),
|
||||
from: z.string().min(1, "From is required"),
|
||||
to: z.string().min(1, "To is required"),
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
"use server";
|
||||
import { render } from "@react-email/render";
|
||||
import nodemailer from "nodemailer";
|
||||
import type { EventPayload, DispatchResult } from '@/features/notifications/types';
|
||||
import EmailNotification from "@/components/emails/email-notification";
|
||||
|
||||
export async function sendSmtp(
|
||||
config: {
|
||||
host: string;
|
||||
port: number;
|
||||
secure: boolean;
|
||||
user: string;
|
||||
password: string;
|
||||
from: string;
|
||||
to: string | string[];
|
||||
},
|
||||
payload: EventPayload
|
||||
): Promise<DispatchResult> {
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
pool: true,
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
secure: config.secure,
|
||||
auth: { user: config.user, pass: config.password },
|
||||
});
|
||||
|
||||
await transporter.verify();
|
||||
|
||||
|
||||
const info = await transporter.sendMail({
|
||||
from: config.from,
|
||||
to: Array.isArray(config.to) ? config.to.join(", ") : config.to,
|
||||
subject: `[${payload.level.toUpperCase()}] ${payload.title}`,
|
||||
html: await render(EmailNotification({
|
||||
payload: payload
|
||||
})),
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
provider: "smtp",
|
||||
message: `Email sent: ${config.to}`,
|
||||
response: info,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { UseFormReturn } from "react-hook-form";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
|
||||
type NotifierTeamsFormProps = {
|
||||
form: UseFormReturn<any, any, any>;
|
||||
};
|
||||
|
||||
export const NotifierTeamsForm = ({ form }: NotifierTeamsFormProps) => {
|
||||
return (
|
||||
<>
|
||||
<Separator className="my-1" />
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.teamsWebhook"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Teams Webhook URL *</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput
|
||||
{...field}
|
||||
placeholder="e.g. https://xxxxx.logic.azure.com:443/xxxxx"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
import {z} from "zod";
|
||||
|
||||
export const TeamsChannelConfigSchema = z.object({
|
||||
teamsWebhook: z.string().url("Must be a valid URL"),
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { EventPayload, DispatchResult } from '@/features/notifications/types';
|
||||
|
||||
|
||||
|
||||
export async function sendTeams(
|
||||
config: { teamsWebhook: string },
|
||||
payload: EventPayload
|
||||
): Promise<DispatchResult> {
|
||||
const { teamsWebhook: webhookUrl } = config;
|
||||
const levelColor: Record<string, string> = {
|
||||
critical: 'attention',
|
||||
warning: 'warning',
|
||||
info: 'good',
|
||||
};
|
||||
const accentColor = levelColor[payload.level] ?? 'default';
|
||||
const bodyItems: object[] = [
|
||||
{
|
||||
type: 'TextBlock',
|
||||
text: `**[${payload.level.toUpperCase()}] ${payload.title}**`,
|
||||
wrap: true,
|
||||
color: accentColor,
|
||||
size: 'Medium',
|
||||
weight: 'Bolder',
|
||||
},
|
||||
{
|
||||
type: 'TextBlock',
|
||||
text: payload.message,
|
||||
wrap: true,
|
||||
},
|
||||
];
|
||||
|
||||
if (payload.data) {
|
||||
bodyItems.push(
|
||||
{
|
||||
type: 'TextBlock',
|
||||
text: '**Data:**',
|
||||
wrap: true,
|
||||
weight: 'Bolder',
|
||||
},
|
||||
{
|
||||
type: 'TextBlock',
|
||||
text: JSON.stringify(payload.data, null, 2).substring(0, 1000),
|
||||
wrap: true,
|
||||
fontType: 'Monospace',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const adaptiveCard = {
|
||||
type: 'AdaptiveCard',
|
||||
$schema: 'http://adaptivecards.io/schemas/adaptive-card.json',
|
||||
version: '1.2',
|
||||
body: bodyItems,
|
||||
};
|
||||
|
||||
const body = {
|
||||
type: 'message',
|
||||
attachments: [
|
||||
{
|
||||
contentType: 'application/vnd.microsoft.card.adaptive',
|
||||
contentUrl: null,
|
||||
content: adaptiveCard,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 10_000);
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(webhookUrl, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
signal: controller.signal,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
throw new Error(`Teams error: ${res.status} ${err}`);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
provider: 'teams',
|
||||
message: 'Sent to Microsoft Teams',
|
||||
response: res.statusText,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { UseFormReturn } from "react-hook-form";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
|
||||
type NotifierTelegramFormProps = {
|
||||
form: UseFormReturn<any, any, any>;
|
||||
};
|
||||
|
||||
export const NotifierTelegramForm = ({ form }: NotifierTelegramFormProps) => {
|
||||
return (
|
||||
<>
|
||||
<Separator className="my-1" />
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.telegramBotToken"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Telegram Bot Token *</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput
|
||||
{...field}
|
||||
placeholder="e.g. 123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.telegramChatId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Telegram Chat ID *</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="e.g. -100123456789 or 123456789" />
|
||||
</FormControl>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
You must start the conversation with the bot first (
|
||||
<strong>/start</strong>). <br />
|
||||
For groups, add the bot to the group. <br />
|
||||
You can use{" "}
|
||||
<a
|
||||
href="https://t.me/userinfobot"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
@userinfobot
|
||||
</a>{" "}
|
||||
to find your ID.
|
||||
</p>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.telegramTopicId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Telegram Topic ID</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="e.g. 123456" />
|
||||
</FormControl>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Unique identifier for the target message thread (topic) of the
|
||||
forum; for forum supergroups only
|
||||
</p>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const TelegramChannelConfigSchema = z
|
||||
.object({
|
||||
telegramBotToken: z.string().min(1, "Bot token is required"),
|
||||
telegramChatId: z.string().min(1, "Chat ID is required"),
|
||||
telegramTopicId: z.string().optional(),
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.telegramTopicId && !data.telegramChatId) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: "Chat ID is required when a Topic ID is provided",
|
||||
path: ["telegramChatId"],
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { EventPayload, DispatchResult } from '@/features/notifications/types';
|
||||
|
||||
export async function sendTelegram(
|
||||
config: {
|
||||
telegramBotToken: string;
|
||||
telegramChatId: string;
|
||||
telegramTopicId?: string;
|
||||
},
|
||||
payload: EventPayload,
|
||||
): Promise<DispatchResult> {
|
||||
const { telegramBotToken, telegramChatId, telegramTopicId } = config;
|
||||
|
||||
// Helper to escape HTML characters
|
||||
const escapeHtml = (unsafe: string) => {
|
||||
return unsafe
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
};
|
||||
|
||||
const title = escapeHtml(payload.title);
|
||||
const message = escapeHtml(payload.message);
|
||||
const level = escapeHtml(payload.level.toUpperCase());
|
||||
const dataString = payload.data
|
||||
? escapeHtml(JSON.stringify(payload.data, null, 2).substring(0, 1000))
|
||||
: "";
|
||||
|
||||
const text = `<b>${title}</b>\n\n${message}\n\nLevel: <code>${level}</code>${payload.data ? `\n\nData:\n<pre>${dataString}</pre>` : ""}`;
|
||||
|
||||
const body: Record<string, any> = {
|
||||
chat_id: telegramChatId,
|
||||
text,
|
||||
parse_mode: "HTML",
|
||||
};
|
||||
|
||||
if (telegramTopicId) {
|
||||
body.message_thread_id = Number(telegramTopicId);
|
||||
}
|
||||
|
||||
const res = await fetch(
|
||||
`https://api.telegram.org/bot${telegramBotToken}/sendMessage`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
throw new Error(`Telegram error: ${res.status} ${err}`);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
provider: "telegram",
|
||||
message: "Sent to Telegram",
|
||||
response: await res.text(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
"use client";
|
||||
|
||||
import { useFieldArray, UseFormReturn } from "react-hook-form";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
type NotifierWebhookFormProps = {
|
||||
form: UseFormReturn<any, any, any>;
|
||||
};
|
||||
|
||||
export const NotifierWebhookForm = ({ form }: NotifierWebhookFormProps) => {
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control: form.control,
|
||||
name: "config.webhookHeaders",
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Separator className="my-1" />
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.webhookUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Webhook URL *</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="e.g. https://example.com/api/webhook"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-medium">Custom Headers</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => append({ key: "", value: "" })}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Add Header
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{fields.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
No custom headers. Add headers to send with each webhook request.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{fields.map((headerField, index) => (
|
||||
<div key={headerField.id} className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`config.webhookHeaders.${index}.key`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Header Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="e.g. X-Api-Key"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`config.webhookHeaders.${index}.value`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Header Value</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="Header value"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="self-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const WebhookChannelConfigSchema = z.object({
|
||||
webhookUrl: z.string().url("Must be a valid URL"),
|
||||
webhookHeaders: z
|
||||
.array(
|
||||
z.object({
|
||||
key: z.string().min(1, "Header name is required"),
|
||||
value: z.string(),
|
||||
}),
|
||||
)
|
||||
.optional()
|
||||
.default([]),
|
||||
});
|
||||
|
||||
export type WebhookChannelConfig = z.infer<typeof WebhookChannelConfigSchema>;
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { EventPayload, DispatchResult } from '@/features/notifications/types';
|
||||
|
||||
type WebhookConfig = {
|
||||
webhookUrl: string;
|
||||
webhookHeaders?: { key: string; value: string }[];
|
||||
webhookSecret?: string;
|
||||
webhookSecretHeader?: string;
|
||||
};
|
||||
|
||||
export async function sendWebhook(
|
||||
config: WebhookConfig,
|
||||
payload: EventPayload
|
||||
): Promise<DispatchResult> {
|
||||
const { webhookUrl, webhookHeaders, webhookSecret, webhookSecretHeader } = config;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'Portabase-Notifier/1.0',
|
||||
};
|
||||
|
||||
// New format: iterate custom headers array
|
||||
if (webhookHeaders && webhookHeaders.length > 0) {
|
||||
const RESERVED = new Set(['content-type', 'user-agent']);
|
||||
for (const { key, value } of webhookHeaders) {
|
||||
if (key && !RESERVED.has(key.toLowerCase())) headers[key] = value;
|
||||
}
|
||||
} else if (webhookSecret) {
|
||||
// Legacy format: single secret header pair
|
||||
headers[webhookSecretHeader || 'X-Webhook-Secret'] = webhookSecret;
|
||||
}
|
||||
|
||||
const res = await fetch(webhookUrl, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
throw new Error(`Webhook error: ${res.status} ${err}`);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
provider: 'webhook',
|
||||
message: 'Sent to Webhook',
|
||||
response: await res.text(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { UseFormReturn } from "react-hook-form";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
type StorageBlobFormProps = {
|
||||
form: UseFormReturn<any, any, any>;
|
||||
};
|
||||
|
||||
export const StorageBlobForm = ({ form }: StorageBlobFormProps) => {
|
||||
return (
|
||||
<>
|
||||
<Separator className="my-1" />
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.accountName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Account Name *</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="e.g. mystorageaccount" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.accountKey"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Account Key</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput {...field} placeholder="e.g. base64-encoded-key" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.connectionString"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Connection String</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput {...field} placeholder="e.g. DefaultEndpointsProtocol=https;..." />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.containerName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Container Name *</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="e.g. backups-prod" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.endpointUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Endpoint URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="e.g. https://myaccount.blob.core.windows.net" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import {z} from "zod";
|
||||
|
||||
export const BlobChannelConfigSchema = z.object({
|
||||
accountName: z.string().min(1, "Account name is required"),
|
||||
accountKey: z.string().optional(),
|
||||
connectionString: z.string().optional(),
|
||||
containerName: z.string().min(1, "Container name is required"),
|
||||
endpointUrl: z.preprocess(
|
||||
(v) => (v === "" ? undefined : v),
|
||||
z.string().url("Endpoint URL must be a valid URL").optional(),
|
||||
),
|
||||
}).refine(
|
||||
(data) => data.accountKey || data.connectionString,
|
||||
{message: "Either account key or connection string is required"}
|
||||
);
|
||||
@@ -0,0 +1,185 @@
|
||||
import {BlobSASPermissions, BlobServiceClient, StorageSharedKeyCredential} from "@azure/storage-blob";
|
||||
import {
|
||||
StorageCopyInput,
|
||||
StorageDeleteInput,
|
||||
StorageGetInput,
|
||||
StorageMetaData,
|
||||
StorageResult,
|
||||
StorageUploadInput
|
||||
} from '@/features/storages/types';
|
||||
import {Readable} from "node:stream";
|
||||
|
||||
type BlobConfig = {
|
||||
accountName: string;
|
||||
accountKey?: string;
|
||||
connectionString?: string;
|
||||
containerName: string;
|
||||
endpointUrl?: string;
|
||||
};
|
||||
|
||||
async function getBlobClient(config: BlobConfig) {
|
||||
if (config.connectionString) {
|
||||
return BlobServiceClient.fromConnectionString(config.connectionString);
|
||||
}
|
||||
|
||||
const url = config.endpointUrl ?? `https://${config.accountName}.blob.core.windows.net`;
|
||||
const credential = new StorageSharedKeyCredential(config.accountName, config.accountKey!);
|
||||
return new BlobServiceClient(url, credential);
|
||||
}
|
||||
|
||||
const BASE_DIR = "";
|
||||
|
||||
|
||||
async function ensureContainer(config: BlobConfig) {
|
||||
const client = await getBlobClient(config);
|
||||
const containerClient = client.getContainerClient(config.containerName);
|
||||
await containerClient.createIfNotExists();
|
||||
}
|
||||
|
||||
export async function uploadBlob(
|
||||
config: BlobConfig,
|
||||
input: { data: StorageUploadInput, metadata?: StorageMetaData }
|
||||
): Promise<StorageResult> {
|
||||
const client = await getBlobClient(config);
|
||||
await ensureContainer(config);
|
||||
|
||||
const key = `${BASE_DIR}${input.data.path}`;
|
||||
const file = input.data.file;
|
||||
|
||||
let uploadStream: Readable;
|
||||
if (Buffer.isBuffer(file) || file instanceof Uint8Array) {
|
||||
uploadStream = Readable.from(file);
|
||||
} else if ((file as any).pipe) {
|
||||
uploadStream = file;
|
||||
} else {
|
||||
return {success: false, provider: "blob", error: "Unsupported file type for streaming upload"};
|
||||
}
|
||||
|
||||
try {
|
||||
const containerClient = client.getContainerClient(config.containerName);
|
||||
const blockBlobClient = containerClient.getBlockBlobClient(key);
|
||||
await blockBlobClient.uploadStream(uploadStream);
|
||||
} catch (err: any) {
|
||||
return {success: false, provider: "blob", error: err.message};
|
||||
}
|
||||
|
||||
return {success: true, provider: "blob"};
|
||||
}
|
||||
|
||||
|
||||
export async function getBlob(
|
||||
config: BlobConfig,
|
||||
input: { data: StorageGetInput, metadata: StorageMetaData }
|
||||
): Promise<StorageResult> {
|
||||
const client = await getBlobClient(config);
|
||||
const key = `${BASE_DIR}${input.data.path}`;
|
||||
|
||||
const containerClient = client.getContainerClient(config.containerName);
|
||||
const blockBlobClient = containerClient.getBlockBlobClient(key);
|
||||
|
||||
if (!(await blockBlobClient.exists())) {
|
||||
return {success: false, provider: "blob", error: "File not found"};
|
||||
}
|
||||
|
||||
const downloadResponse = await blockBlobClient.download();
|
||||
const fileStream = downloadResponse.readableStreamBody as unknown as Readable;
|
||||
|
||||
let presignedUrl: string | undefined;
|
||||
if (input.data.signedUrl) {
|
||||
presignedUrl = await blockBlobClient.generateSasUrl({
|
||||
expiresOn: new Date(Date.now() + (input.data.expiresInSeconds ?? 60) * 1000),
|
||||
permissions: BlobSASPermissions.parse("r"),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
provider: "blob",
|
||||
file: fileStream,
|
||||
url: presignedUrl,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
export async function deleteBlob(config: BlobConfig, input: {
|
||||
data: StorageDeleteInput,
|
||||
metadata?: StorageMetaData
|
||||
}): Promise<StorageResult> {
|
||||
const client = await getBlobClient(config);
|
||||
const key = `${BASE_DIR}${input.data.path}`;
|
||||
|
||||
try {
|
||||
const containerClient = client.getContainerClient(config.containerName);
|
||||
const blockBlobClient = containerClient.getBlockBlobClient(key);
|
||||
await blockBlobClient.delete();
|
||||
return {success: true, provider: "blob"};
|
||||
} catch (err: any) {
|
||||
return {success: false, provider: "blob", error: err.message};
|
||||
}
|
||||
}
|
||||
|
||||
export async function pingBlob(config: BlobConfig): Promise<StorageResult> {
|
||||
try {
|
||||
const client = await getBlobClient(config);
|
||||
const containerClient = client.getContainerClient(config.containerName);
|
||||
const exists = await containerClient.exists();
|
||||
if (!exists) return {
|
||||
success: false,
|
||||
provider: "blob",
|
||||
response: "Container does not exist"
|
||||
};
|
||||
const key = `${BASE_DIR}ping-${Date.now()}-${Math.random().toString(36).slice(2)}.txt`;
|
||||
const blockBlobClient = containerClient.getBlockBlobClient(key);
|
||||
try {
|
||||
await blockBlobClient.upload(Buffer.from("ping"), 4);
|
||||
await blockBlobClient.download();
|
||||
} finally {
|
||||
await blockBlobClient.delete().catch(() => undefined);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
provider: "blob",
|
||||
response: "Blob storage OK"
|
||||
};
|
||||
} catch (err: any) {
|
||||
return {
|
||||
success: false,
|
||||
provider: "blob",
|
||||
response: err.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export async function copyBlob(
|
||||
config: BlobConfig,
|
||||
input: {
|
||||
data: StorageCopyInput,
|
||||
},
|
||||
): Promise<StorageResult> {
|
||||
const client = await getBlobClient(config);
|
||||
await ensureContainer(config);
|
||||
|
||||
const sourceKey = `${BASE_DIR}${input.data.from}`;
|
||||
const destinationKey = `${BASE_DIR}${input.data.to}`;
|
||||
|
||||
try {
|
||||
const containerClient = client.getContainerClient(config.containerName);
|
||||
const sourceBlob = containerClient.getBlockBlobClient(sourceKey);
|
||||
const destBlob = containerClient.getBlockBlobClient(destinationKey);
|
||||
const poller = await destBlob.beginCopyFromURL(sourceBlob.url);
|
||||
await poller.pollUntilDone();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
provider: "blob",
|
||||
};
|
||||
} catch (err: any) {
|
||||
return {
|
||||
success: false,
|
||||
provider: "blob",
|
||||
error: err.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
"use server";
|
||||
|
||||
import { z } from "zod";
|
||||
import { ServerActionResult } from "@/types/action-type";
|
||||
import * as drizzleDb from "@/db";
|
||||
import { userAction } from "@/lib/safe-actions/actions";
|
||||
import { db } from "@/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { withUpdatedAt } from "@/db/utils";
|
||||
import { StorageChannelFormSchema } from "@/features/channel/schemas/channel-form.schema";
|
||||
import { StorageChannel } from "@/db/schema/12_storage-channel";
|
||||
|
||||
export const addStorageChannelAction = userAction
|
||||
.schema(
|
||||
z.object({
|
||||
organizationId: z.string().optional(),
|
||||
data: StorageChannelFormSchema,
|
||||
}),
|
||||
)
|
||||
.action(
|
||||
async ({ parsedInput }): Promise<ServerActionResult<StorageChannel>> => {
|
||||
const { organizationId, data } = parsedInput;
|
||||
|
||||
try {
|
||||
const [channel] = await db
|
||||
.insert(drizzleDb.schemas.storageChannel)
|
||||
.values({
|
||||
provider: data.provider,
|
||||
name: data.name,
|
||||
config: data.config,
|
||||
enabled: data.enabled ?? true,
|
||||
organizationId: organizationId ?? null,
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (organizationId) {
|
||||
await db.insert(drizzleDb.schemas.organizationStorageChannel).values({
|
||||
organizationId,
|
||||
storageChannelId: channel.id,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
value: {
|
||||
...channel,
|
||||
config: channel.config as JSON,
|
||||
},
|
||||
actionSuccess: {
|
||||
message: "Storage channel has been successfully created.",
|
||||
messageParams: { id: channel.id },
|
||||
},
|
||||
};
|
||||
} catch (_error) {
|
||||
const error = _error;
|
||||
return {
|
||||
success: false,
|
||||
actionError: {
|
||||
message: "Failed to create storage channel.",
|
||||
status: 500,
|
||||
cause: error instanceof Error ? error.message : "Unknown error",
|
||||
messageParams: { id: "" },
|
||||
},
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const removeStorageChannelAction = userAction
|
||||
.schema(
|
||||
z.object({
|
||||
organizationId: z.string().optional(),
|
||||
id: z.string(),
|
||||
}),
|
||||
)
|
||||
.action(
|
||||
async ({ parsedInput }): Promise<ServerActionResult<StorageChannel>> => {
|
||||
const { organizationId, id } = parsedInput;
|
||||
|
||||
try {
|
||||
if (organizationId) {
|
||||
await db
|
||||
.delete(drizzleDb.schemas.organizationStorageChannel)
|
||||
.where(
|
||||
and(
|
||||
eq(
|
||||
drizzleDb.schemas.organizationStorageChannel.organizationId,
|
||||
organizationId,
|
||||
),
|
||||
eq(
|
||||
drizzleDb.schemas.organizationStorageChannel.storageChannelId,
|
||||
id,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const [deletedChannel] = await db
|
||||
.delete(drizzleDb.schemas.storageChannel)
|
||||
.where(eq(drizzleDb.schemas.storageChannel.id, id))
|
||||
.returning();
|
||||
|
||||
if (!deletedChannel) {
|
||||
return {
|
||||
success: false,
|
||||
actionError: {
|
||||
message: "Storage channel not found.",
|
||||
status: 404,
|
||||
messageParams: { id: id },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
value: {
|
||||
...deletedChannel,
|
||||
config: deletedChannel.config as JSON,
|
||||
},
|
||||
actionSuccess: {
|
||||
message: "Storage channel has been successfully removed.",
|
||||
messageParams: { id: id },
|
||||
},
|
||||
};
|
||||
} catch (_error) {
|
||||
const error = _error;
|
||||
return {
|
||||
success: false,
|
||||
actionError: {
|
||||
message: "Failed to remove storage channel.",
|
||||
status: 500,
|
||||
cause: error instanceof Error ? error.message : "Unknown error",
|
||||
messageParams: { id: id },
|
||||
},
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const updateStorageChannelAction = userAction
|
||||
.schema(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
data: StorageChannelFormSchema,
|
||||
}),
|
||||
)
|
||||
.action(
|
||||
async ({ parsedInput }): Promise<ServerActionResult<StorageChannel>> => {
|
||||
const { id, data } = parsedInput;
|
||||
|
||||
try {
|
||||
const [channel] = await db
|
||||
.update(drizzleDb.schemas.storageChannel)
|
||||
.set(
|
||||
withUpdatedAt({
|
||||
provider: data.provider,
|
||||
name: data.name,
|
||||
config: data.config,
|
||||
enabled: data.enabled ?? true,
|
||||
}),
|
||||
)
|
||||
.where(eq(drizzleDb.schemas.storageChannel.id, id))
|
||||
.returning();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
value: {
|
||||
...channel,
|
||||
config: channel.config as JSON,
|
||||
},
|
||||
actionSuccess: {
|
||||
message: `Storage channel "${channel.name}" has been successfully updated.`,
|
||||
messageParams: { id: channel.id },
|
||||
},
|
||||
};
|
||||
} catch (_error) {
|
||||
const error = _error;
|
||||
return {
|
||||
success: false,
|
||||
actionError: {
|
||||
message: "Failed to update storage channel.",
|
||||
status: 500,
|
||||
cause: error instanceof Error ? error.message : "Unknown error",
|
||||
messageParams: { id: "" },
|
||||
},
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,52 @@
|
||||
"use server"
|
||||
import {userAction} from "@/lib/safe-actions/actions";
|
||||
import {z} from "zod";
|
||||
import {ServerActionResult} from "@/types/action-type";
|
||||
|
||||
|
||||
export const googleDriveRefreshTokenAction = userAction.schema(
|
||||
z.object({
|
||||
code: z.string(),
|
||||
clientId: z.string(),
|
||||
clientSecret: z.string(),
|
||||
redirectUri: z.string(),
|
||||
})).action(async ({parsedInput}): Promise<ServerActionResult<string>> => {
|
||||
const {code, clientId, clientSecret, redirectUri} = parsedInput;
|
||||
|
||||
try {
|
||||
|
||||
const tokenRes = await fetch("https://oauth2.googleapis.com/token", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
code: code,
|
||||
grant_type: "authorization_code",
|
||||
redirect_uri: redirectUri,
|
||||
}),
|
||||
});
|
||||
|
||||
const tokens = await tokenRes.json();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
value: tokens.refresh_token,
|
||||
actionSuccess: {
|
||||
message: "Refresh token successfully fetched",
|
||||
messageParams: {code: code},
|
||||
},
|
||||
};
|
||||
}catch {
|
||||
return {
|
||||
success: false,
|
||||
actionError: {
|
||||
message: "An error occurred",
|
||||
status: 404,
|
||||
messageParams: {code: code},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
"use client";
|
||||
|
||||
import {UseFormReturn} from "react-hook-form";
|
||||
import {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form";
|
||||
import {Input} from "@/components/ui/input";
|
||||
import {Separator} from "@/components/ui/separator";
|
||||
import {Button} from "@/components/ui/button";
|
||||
import {PasswordInput} from "@/components/ui/password-input";
|
||||
import {
|
||||
googleDriveRefreshTokenAction
|
||||
} from "@/features/channel/components/storages/google-drive/google-drive-refresh.action";
|
||||
import {toast} from "sonner";
|
||||
|
||||
type StorageGoogleDriveFormProps = {
|
||||
form: UseFormReturn<any>;
|
||||
};
|
||||
|
||||
export const StorageGoogleDriveForm = ({form}: StorageGoogleDriveFormProps) => {
|
||||
const refreshToken = form.watch("config.refreshToken");
|
||||
const isConnected = Boolean(refreshToken);
|
||||
|
||||
const handleConnect = () => {
|
||||
const clientId = form.getValues("config.clientId");
|
||||
const clientSecret = form.getValues("config.clientSecret");
|
||||
const redirectUri = `${window.location.origin}/api/google/drive/callback`;
|
||||
|
||||
if (!clientId || !clientSecret) {
|
||||
form.setError("config.clientId", {message: "Client ID and Secret are required"});
|
||||
return;
|
||||
}
|
||||
|
||||
const scope = encodeURIComponent("https://www.googleapis.com/auth/drive.file");
|
||||
const oauthUrl =
|
||||
`https://accounts.google.com/o/oauth2/v2/auth` +
|
||||
`?client_id=${clientId}` +
|
||||
`&redirect_uri=${encodeURIComponent(redirectUri)}` +
|
||||
`&response_type=code` +
|
||||
`&scope=${scope}` +
|
||||
`&access_type=offline` +
|
||||
`&prompt=consent`;
|
||||
|
||||
const oauthWindow = window.open(oauthUrl, "_blank", "width=500,height=600");
|
||||
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
if (!oauthWindow || oauthWindow.closed) {
|
||||
clearInterval(interval);
|
||||
return;
|
||||
}
|
||||
|
||||
if (oauthWindow.location.href.startsWith(redirectUri)) {
|
||||
const code = new URL(oauthWindow.location.href).searchParams.get("code");
|
||||
if (!code) return;
|
||||
|
||||
const result = await googleDriveRefreshTokenAction({
|
||||
code,
|
||||
clientId,
|
||||
clientSecret,
|
||||
redirectUri,
|
||||
});
|
||||
const inner = result?.data;
|
||||
|
||||
if (inner?.success) {
|
||||
toast.success(inner.actionSuccess?.message);
|
||||
|
||||
if (!inner.value) {
|
||||
form.setError("config", {message: "OAuth succeeded but no refresh token returned"});
|
||||
return;
|
||||
}
|
||||
|
||||
form.setValue("config.refreshToken", inner.value, {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
});
|
||||
|
||||
} else {
|
||||
toast.error(inner?.actionError?.message);
|
||||
}
|
||||
|
||||
|
||||
oauthWindow.close();
|
||||
clearInterval(interval);
|
||||
}
|
||||
} catch {
|
||||
// Ignore cross-origin errors until redirect
|
||||
}
|
||||
}, 500);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Separator className="my-1"/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.clientId"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Client ID *</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="e.g. xxxx.apps.googleusercontent.com"/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.clientSecret"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Client Secret *</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput {...field} />
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.folderId"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Folder ID *</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="e.g. 1AbCdEfGhIjKlMnOpQrStUvWxYz"/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<input type="hidden" {...form.register("config.refreshToken")} />
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button type="button" variant={"secondary"} className="hover:cursor-pointer" onClick={handleConnect}>
|
||||
{isConnected ? "Reconnect Google Drive" : "Connect Google Drive"}
|
||||
</Button>
|
||||
|
||||
{isConnected && (
|
||||
<span className="text-sm text-green-600 font-medium">
|
||||
Google Drive connected
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
import {z} from "zod";
|
||||
|
||||
export const GoogleDriveChannelConfigSchema = z.object({
|
||||
clientId: z.string().min(1, "Client ID is required"),
|
||||
clientSecret: z.string().min(1, "Client Secret is required"),
|
||||
folderId: z.string().min(1, "Folder ID is required"),
|
||||
refreshToken: z.string().optional(),
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import {drive_v3, google} from "googleapis";
|
||||
import {GoogleDriveConfig} from "@/features/channel/components/storages/google-drive/types";
|
||||
import Drive = drive_v3.Drive;
|
||||
import {getServerUrl} from "@/utils/get-server-url";
|
||||
|
||||
export async function getGoogleDriveClient(config: GoogleDriveConfig): Promise<Drive> {
|
||||
const baseUrl = getServerUrl();
|
||||
|
||||
const oauth2Client = new google.auth.OAuth2(
|
||||
config.clientId,
|
||||
config.clientSecret,
|
||||
baseUrl
|
||||
);
|
||||
|
||||
oauth2Client.setCredentials({
|
||||
refresh_token: config.refreshToken
|
||||
});
|
||||
|
||||
return google.drive({
|
||||
version: "v3",
|
||||
auth: oauth2Client
|
||||
});
|
||||
}
|
||||
|
||||
export async function findFileByName(
|
||||
drive: drive_v3.Drive,
|
||||
name: string,
|
||||
folderId: string
|
||||
): Promise<string | null> {
|
||||
const res = await drive.files.list({
|
||||
q: `name='${name}' and '${folderId}' in parents and trashed=false`,
|
||||
fields: "files(id)",
|
||||
pageSize: 1,
|
||||
supportsAllDrives: true,
|
||||
includeItemsFromAllDrives: true,
|
||||
});
|
||||
|
||||
return res.data.files?.[0]?.id ?? null;
|
||||
}
|
||||
|
||||
|
||||
export async function ensureFolderPath(client: any, path: string, rootFolderId: string): Promise<string> {
|
||||
const parts = path.split("/").filter(Boolean); // ["backups", "project-1"]
|
||||
let parentId = rootFolderId;
|
||||
|
||||
for (const part of parts) {
|
||||
const res = await client.files.list({
|
||||
q: `'${parentId}' in parents and name='${part}' and mimeType='application/vnd.google-apps.folder' and trashed=false`,
|
||||
fields: "files(id, name)",
|
||||
supportsAllDrives: true,
|
||||
includeItemsFromAllDrives: true,
|
||||
});
|
||||
|
||||
if (res.data.files && res.data.files.length > 0) {
|
||||
parentId = res.data.files[0].id!;
|
||||
} else {
|
||||
const folder = await client.files.create({
|
||||
requestBody: {
|
||||
name: part,
|
||||
mimeType: "application/vnd.google-apps.folder",
|
||||
parents: [parentId],
|
||||
},
|
||||
fields: "id",
|
||||
supportsAllDrives: true,
|
||||
});
|
||||
parentId = folder.data.id!;
|
||||
}
|
||||
}
|
||||
|
||||
return parentId;
|
||||
}
|
||||
|
||||
|
||||
export async function resolveFilePath(client: any, fullPath: string, rootFolderId: string): Promise<string | null> {
|
||||
const parts = fullPath.split("/").filter(Boolean);
|
||||
const fileName = parts.pop()!;
|
||||
let parentId = rootFolderId;
|
||||
|
||||
for (const part of parts) {
|
||||
const res = await client.files.list({
|
||||
q: `'${parentId}' in parents and name='${part}' and mimeType='application/vnd.google-apps.folder' and trashed=false`,
|
||||
fields: "files(id, name)",
|
||||
supportsAllDrives: true,
|
||||
includeItemsFromAllDrives: true,
|
||||
});
|
||||
if (res.data.files && res.data.files.length > 0) {
|
||||
parentId = res.data.files[0].id!;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const fileRes = await client.files.list({
|
||||
q: `'${parentId}' in parents and name='${fileName}' and trashed=false`,
|
||||
fields: "files(id, name)",
|
||||
supportsAllDrives: true,
|
||||
includeItemsFromAllDrives: true,
|
||||
});
|
||||
|
||||
return fileRes.data.files?.[0]?.id || null;
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
"use server"
|
||||
import {
|
||||
StorageCopyInput,
|
||||
StorageDeleteInput,
|
||||
StorageGetInput,
|
||||
StorageMetaData,
|
||||
StorageResult,
|
||||
StorageUploadInput
|
||||
} from '@/features/storages/types';
|
||||
import {GoogleDriveConfig} from "@/features/channel/components/storages/google-drive/types";
|
||||
import {
|
||||
ensureFolderPath,
|
||||
findFileByName,
|
||||
getGoogleDriveClient, resolveFilePath
|
||||
} from "@/features/channel/components/storages/google-drive/helpers";
|
||||
import {Readable} from "node:stream";
|
||||
import {generateFileUrl} from "@/features/storages/utils/storages.helpers";
|
||||
|
||||
export async function uploadGoogleDrive(
|
||||
config: GoogleDriveConfig,
|
||||
input: { data: StorageUploadInput, metadata?: StorageMetaData },
|
||||
): Promise<StorageResult> {
|
||||
const client = await getGoogleDriveClient(config);
|
||||
|
||||
const fullPath = input.data.path;
|
||||
const pathParts = fullPath.split("/").filter(Boolean);
|
||||
const fileName = pathParts.pop()!;
|
||||
const folderPath = pathParts.join("/");
|
||||
|
||||
const folderId = folderPath
|
||||
? await ensureFolderPath(client, folderPath, config.folderId)
|
||||
: config.folderId;
|
||||
|
||||
const existing = await findFileByName(client, fileName, folderId);
|
||||
if (existing) return {success: false, provider: "google-drive", error: "File already exists"};
|
||||
|
||||
let fileStream: Readable;
|
||||
const file = input.data.file;
|
||||
if (Buffer.isBuffer(file) || file instanceof Uint8Array) {
|
||||
fileStream = Readable.from(file);
|
||||
} else if ((file as any).pipe) {
|
||||
fileStream = file as Readable;
|
||||
} else {
|
||||
throw new Error("Unsupported file type for streaming upload");
|
||||
}
|
||||
|
||||
await client.files.create({
|
||||
requestBody: {name: fileName, parents: [folderId]},
|
||||
media: {body: fileStream},
|
||||
fields: "id",
|
||||
supportsAllDrives: true,
|
||||
});
|
||||
|
||||
|
||||
if (input.data.url) {
|
||||
const url = await generateFileUrl(input);
|
||||
if (!url) {
|
||||
return {
|
||||
success: false,
|
||||
provider: "google-drive",
|
||||
response: "Unable to get url file"
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
provider: 'google-drive',
|
||||
url: url
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
provider: 'google-drive',
|
||||
};
|
||||
|
||||
|
||||
}
|
||||
|
||||
export async function getGoogleDrive(
|
||||
config: GoogleDriveConfig,
|
||||
input: { data: StorageGetInput, metadata: StorageMetaData },
|
||||
): Promise<StorageResult> {
|
||||
const client = await getGoogleDriveClient(config);
|
||||
|
||||
const fileId = await resolveFilePath(client, input.data.path, config.folderId);
|
||||
if (!fileId) return {success: false, provider: "google-drive", error: "File not found"};
|
||||
|
||||
const res = await client.files.get(
|
||||
{fileId, alt: "media", supportsAllDrives: true},
|
||||
{responseType: "stream"}
|
||||
);
|
||||
|
||||
const stream = res.data as Readable;
|
||||
|
||||
|
||||
if (input.data.signedUrl) {
|
||||
const url = await generateFileUrl(input);
|
||||
|
||||
if (!url) {
|
||||
return {
|
||||
success: false,
|
||||
provider: "google-drive",
|
||||
response: "Unable to get url"
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
provider: "google-drive",
|
||||
file: stream,
|
||||
url: url,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
provider: "google-drive",
|
||||
file: stream,
|
||||
};
|
||||
}
|
||||
|
||||
export async function deleteGoogleDrive(
|
||||
config: GoogleDriveConfig,
|
||||
input: { data: StorageDeleteInput, metadata?: StorageMetaData },
|
||||
): Promise<StorageResult> {
|
||||
const client = await getGoogleDriveClient(config);
|
||||
const fileId = await resolveFilePath(client, input.data.path, config.folderId);
|
||||
if (!fileId) return {success: false, provider: "google-drive", error: "File not found"};
|
||||
|
||||
await client.files.delete({fileId, supportsAllDrives: true});
|
||||
|
||||
return {success: true, provider: "google-drive"};
|
||||
}
|
||||
|
||||
export async function pingGoogleDrive(config: GoogleDriveConfig): Promise<StorageResult> {
|
||||
try {
|
||||
const drive = await getGoogleDriveClient(config);
|
||||
const name = `ping-${Date.now()}.txt`;
|
||||
const buffer = Buffer.from("ping");
|
||||
|
||||
const file = await drive.files.create({
|
||||
requestBody: {name, parents: [config.folderId]},
|
||||
media: {mimeType: "text/plain", body: Readable.from(buffer)},
|
||||
fields: "id",
|
||||
supportsAllDrives: true,
|
||||
});
|
||||
|
||||
await drive.files.get({fileId: file.data.id!, supportsAllDrives: true});
|
||||
await drive.files.delete({fileId: file.data.id!, supportsAllDrives: true});
|
||||
|
||||
return {success: true, provider: "google-drive", response: "Google Drive storage OK"};
|
||||
} catch (err: any) {
|
||||
return {success: false, provider: "google-drive", response: err.message};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export async function copyGoogleDrive(
|
||||
config: GoogleDriveConfig,
|
||||
input: {
|
||||
data: StorageCopyInput,
|
||||
metadata?: StorageMetaData;
|
||||
},
|
||||
): Promise<StorageResult> {
|
||||
const client = await getGoogleDriveClient(config);
|
||||
|
||||
const sourceFileId = await resolveFilePath(
|
||||
client,
|
||||
input.data.from,
|
||||
config.folderId,
|
||||
);
|
||||
|
||||
if (!sourceFileId) {
|
||||
return {
|
||||
success: false,
|
||||
provider: "google-drive",
|
||||
error: "Source file not found",
|
||||
};
|
||||
}
|
||||
|
||||
const fullPath = input.data.to;
|
||||
const parts = fullPath.split("/").filter(Boolean);
|
||||
const fileName = parts.pop()!;
|
||||
const folderPath = parts.join("/");
|
||||
|
||||
const folderId = folderPath
|
||||
? await ensureFolderPath(client, folderPath, config.folderId)
|
||||
: config.folderId;
|
||||
|
||||
try {
|
||||
const copied = await client.files.copy({
|
||||
fileId: sourceFileId,
|
||||
requestBody: {
|
||||
name: fileName,
|
||||
parents: [folderId],
|
||||
},
|
||||
fields: "id",
|
||||
supportsAllDrives: true,
|
||||
});
|
||||
|
||||
const newFileId = copied.data.id;
|
||||
|
||||
if (!newFileId) {
|
||||
return {
|
||||
success: false,
|
||||
provider: "google-drive",
|
||||
error: "Copy failed (no file id returned)",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
provider: "google-drive",
|
||||
};
|
||||
} catch (err: any) {
|
||||
return {
|
||||
success: false,
|
||||
provider: "google-drive",
|
||||
error: err.message || "Copy failed",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
|
||||
export type GoogleDriveConfig = {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
refreshToken: string;
|
||||
folderId: string;
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
import {
|
||||
StorageProviderKind,
|
||||
StorageInput,
|
||||
StorageResult,
|
||||
} from '@/features/storages/types';
|
||||
|
||||
import {uploadLocal, getLocal, deleteLocal, pingLocal, copyLocal} from './local';
|
||||
import {copyS3, deleteS3, getS3, pingS3, uploadS3} from "@/features/channel/components/storages/s3";
|
||||
import {copyBlob, deleteBlob, getBlob, pingBlob, uploadBlob} from "@/features/channel/components/storages/az-blob";
|
||||
import {
|
||||
copyGoogleDrive,
|
||||
deleteGoogleDrive,
|
||||
getGoogleDrive,
|
||||
pingGoogleDrive,
|
||||
uploadGoogleDrive
|
||||
} from "@/features/channel/components/storages/google-drive";
|
||||
|
||||
type ProviderHandler = {
|
||||
upload: (config: any, input: StorageInput & { action: 'upload' }) => Promise<StorageResult>;
|
||||
get: (config: any, input: StorageInput & { action: 'get' }) => Promise<StorageResult>;
|
||||
delete: (config: any, input: StorageInput & { action: 'delete' }) => Promise<StorageResult>;
|
||||
ping: (config: any, input: { action: 'ping' }) => Promise<StorageResult>;
|
||||
copy: (config: any, input: StorageInput & { action: 'copy' }) => Promise<StorageResult>;
|
||||
};
|
||||
|
||||
const handlers: Record<StorageProviderKind, ProviderHandler> = {
|
||||
local: {
|
||||
upload: uploadLocal,
|
||||
get: getLocal,
|
||||
delete: deleteLocal,
|
||||
ping: pingLocal,
|
||||
copy: copyLocal,
|
||||
},
|
||||
s3: {
|
||||
upload: uploadS3,
|
||||
get: getS3,
|
||||
delete: deleteS3,
|
||||
ping: pingS3,
|
||||
copy: copyS3
|
||||
},
|
||||
"google-drive": {
|
||||
upload: uploadGoogleDrive,
|
||||
get: getGoogleDrive,
|
||||
delete: deleteGoogleDrive,
|
||||
ping: pingGoogleDrive,
|
||||
copy: copyGoogleDrive,
|
||||
},
|
||||
blob: {
|
||||
upload: uploadBlob,
|
||||
get: getBlob,
|
||||
delete: deleteBlob,
|
||||
ping: pingBlob,
|
||||
copy: copyBlob,
|
||||
}
|
||||
};
|
||||
|
||||
export async function dispatchViaProvider(
|
||||
kind: StorageProviderKind,
|
||||
config: any,
|
||||
input: StorageInput,
|
||||
): Promise<StorageResult> {
|
||||
const provider = handlers[kind];
|
||||
|
||||
if (!provider) {
|
||||
return {
|
||||
success: false,
|
||||
provider: kind,
|
||||
error: `Unsupported storage provider: ${kind}`,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
return await provider[input.action](config, input as any);
|
||||
} catch (err: any) {
|
||||
return {
|
||||
success: false,
|
||||
provider: kind,
|
||||
error: err.message || 'Storage provider error',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import {z} from "zod";
|
||||
|
||||
export const LocalChannelConfigSchema = z.object({});
|
||||
@@ -0,0 +1,215 @@
|
||||
"use server";
|
||||
import { mkdir, unlink } from "fs/promises";
|
||||
import path from "path";
|
||||
import {
|
||||
StorageCopyInput,
|
||||
StorageDeleteInput,
|
||||
StorageGetInput,
|
||||
StorageMetaData,
|
||||
StorageResult,
|
||||
StorageUploadInput,
|
||||
} from '@/features/storages/types';
|
||||
import fs from "node:fs";
|
||||
import { generateFileUrl } from "@/features/storages/utils/storages.helpers";
|
||||
import { Readable } from "node:stream";
|
||||
import { env } from "@/env.mjs";
|
||||
|
||||
const BASE_DIR = path.join(env.PRIVATE_PATH!, "/uploads");
|
||||
|
||||
export async function uploadLocal(
|
||||
config: { baseDir?: string },
|
||||
input: { data: StorageUploadInput; metadata?: StorageMetaData },
|
||||
): Promise<StorageResult> {
|
||||
const base = config.baseDir
|
||||
? path.join(process.cwd(), config.baseDir ?? "")
|
||||
: BASE_DIR;
|
||||
|
||||
const fullPath = path.join(base, input.data.path);
|
||||
const dir = path.dirname(fullPath);
|
||||
|
||||
await mkdir(dir, { recursive: true });
|
||||
|
||||
try {
|
||||
const file = input.data.file;
|
||||
if (Buffer.isBuffer(file)) {
|
||||
await fs.promises.writeFile(fullPath, input.data.file);
|
||||
} else if (file instanceof Readable) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const writable = fs.createWriteStream(fullPath);
|
||||
file.pipe(writable);
|
||||
writable.on("finish", resolve);
|
||||
writable.on("error", reject);
|
||||
});
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
provider: "local",
|
||||
error: "Unsupported file type. Must be Buffer or ReadableStream",
|
||||
};
|
||||
}
|
||||
|
||||
if (input.data.url) {
|
||||
const url = await generateFileUrl(input);
|
||||
if (!url) {
|
||||
return {
|
||||
success: false,
|
||||
provider: "local",
|
||||
response: "Unable to get URL",
|
||||
};
|
||||
}
|
||||
return { success: true, provider: "local", url };
|
||||
}
|
||||
|
||||
return { success: true, provider: "local" };
|
||||
} catch (err: any) {
|
||||
try {
|
||||
await unlink(fullPath);
|
||||
} catch {}
|
||||
return {
|
||||
success: false,
|
||||
provider: "local",
|
||||
error: err.message || "Upload failed",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function getLocal(
|
||||
config: { baseDir?: string },
|
||||
input: { data: StorageGetInput; metadata: StorageMetaData },
|
||||
): Promise<StorageResult> {
|
||||
const base = config.baseDir
|
||||
? path.join(process.cwd(), config.baseDir ?? "")
|
||||
: BASE_DIR;
|
||||
const filePath = path.join(base, input.data.path);
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return {
|
||||
success: false,
|
||||
provider: "local",
|
||||
error: "File not found",
|
||||
};
|
||||
}
|
||||
|
||||
let fileStream: fs.ReadStream | undefined;
|
||||
|
||||
try {
|
||||
fileStream = fs.createReadStream(filePath);
|
||||
} catch (err: any) {
|
||||
return {
|
||||
success: false,
|
||||
provider: "local",
|
||||
error: err.message,
|
||||
};
|
||||
}
|
||||
|
||||
if (input.data.signedUrl) {
|
||||
const url = await generateFileUrl(input);
|
||||
if (!url) {
|
||||
return {
|
||||
success: false,
|
||||
provider: "local",
|
||||
error: "Unable to generate signed URL",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
provider: "local",
|
||||
file: fileStream,
|
||||
url,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
provider: "local",
|
||||
file: fileStream,
|
||||
};
|
||||
}
|
||||
|
||||
export async function deleteLocal(
|
||||
config: { baseDir?: string },
|
||||
input: { data: StorageDeleteInput; metadata?: StorageMetaData },
|
||||
): Promise<StorageResult> {
|
||||
const base = config.baseDir
|
||||
? path.join(process.cwd(), config.baseDir ?? "")
|
||||
: BASE_DIR;
|
||||
const fullPath = path.join(base, input.data.path);
|
||||
|
||||
await unlink(fullPath);
|
||||
return {
|
||||
success: true,
|
||||
provider: "local",
|
||||
};
|
||||
}
|
||||
|
||||
export async function pingLocal(config: {
|
||||
baseDir?: string;
|
||||
}): Promise<StorageResult> {
|
||||
const base = path.join(process.cwd(), config.baseDir ?? "") || BASE_DIR;
|
||||
const fullPath = path.join(base, "ping.txt");
|
||||
|
||||
await fs.promises.writeFile(fullPath, "ping");
|
||||
await fs.promises.readFile(fullPath);
|
||||
await fs.promises.unlink(fullPath);
|
||||
return {
|
||||
success: true,
|
||||
provider: "local",
|
||||
response: "Local storage OK",
|
||||
};
|
||||
}
|
||||
|
||||
export async function copyLocal(
|
||||
config: { baseDir?: string },
|
||||
input: {
|
||||
data: StorageCopyInput,
|
||||
metadata?: StorageMetaData;
|
||||
},
|
||||
): Promise<StorageResult> {
|
||||
const base = config.baseDir
|
||||
? path.join(process.cwd(), config.baseDir ?? "")
|
||||
: BASE_DIR;
|
||||
|
||||
const sourcePath = path.join(base, input.data.from);
|
||||
const destinationPath = path.join(base, input.data.to);
|
||||
|
||||
const dir = path.dirname(destinationPath);
|
||||
await mkdir(dir, { recursive: true });
|
||||
|
||||
if (!fs.existsSync(sourcePath)) {
|
||||
return {
|
||||
success: false,
|
||||
provider: "local",
|
||||
error: "Source file not found",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const readStream = fs.createReadStream(sourcePath);
|
||||
const writeStream = fs.createWriteStream(destinationPath);
|
||||
|
||||
readStream.on("error", reject);
|
||||
writeStream.on("error", reject);
|
||||
writeStream.on("finish", resolve);
|
||||
|
||||
readStream.pipe(writeStream);
|
||||
});
|
||||
|
||||
|
||||
return {
|
||||
success: true,
|
||||
provider: "local",
|
||||
};
|
||||
} catch (err: any) {
|
||||
try {
|
||||
await unlink(destinationPath);
|
||||
} catch {}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
provider: "local",
|
||||
error: err.message || "Copy failed",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { UseFormReturn } from "react-hook-form";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
|
||||
type StorageS3FormProps = {
|
||||
form: UseFormReturn<any, any, any>;
|
||||
};
|
||||
|
||||
export const StorageS3Form = ({ form }: StorageS3FormProps) => {
|
||||
return (
|
||||
<>
|
||||
<Separator className="my-1" />
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.endPointUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Endpoint URL *</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="e.g. s3.amazonaws.com" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.region"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Region</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="e.g. us-east-1" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.accessKey"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Access Key *</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="e.g. AKIA..." />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.secretKey"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Secret Key *</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput {...field} placeholder="e.g. s3-secret-key" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.bucketName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Bucket name *</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="e.g. backups-prod" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.port"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Port</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} type="number" placeholder="e.g. 443" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.ssl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Use SSL</FormLabel>
|
||||
<FormControl>
|
||||
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import {z} from "zod";
|
||||
|
||||
export const S3ChannelConfigSchema = z.object({
|
||||
endPointUrl: z.string().min(1, "Endpoint URL is required"),
|
||||
region: z.string().optional(),
|
||||
accessKey: z.string().min(1, "Access Key is required"),
|
||||
secretKey: z.string().min(1, "Secret Key is required"),
|
||||
bucketName: z.string().min(1, "Bucket name is required"),
|
||||
port: z.union([
|
||||
z.literal("").transform(() => ""),
|
||||
z.literal("").transform(() => ""),
|
||||
z.coerce.number()
|
||||
]).optional(),
|
||||
ssl: z.boolean().optional().default(true),
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
import * as Minio from "minio";
|
||||
import {
|
||||
StorageCopyInput,
|
||||
StorageDeleteInput,
|
||||
StorageGetInput,
|
||||
StorageMetaData,
|
||||
StorageResult,
|
||||
StorageUploadInput
|
||||
} from '@/features/storages/types';
|
||||
import {Readable} from "node:stream";
|
||||
|
||||
type S3Config = {
|
||||
endPointUrl: string;
|
||||
region?: string;
|
||||
accessKey: string;
|
||||
secretKey: string;
|
||||
bucketName: string;
|
||||
port?: number;
|
||||
ssl?: boolean;
|
||||
};
|
||||
|
||||
async function getS3Client(config: S3Config) {
|
||||
return new Minio.Client({
|
||||
endPoint: config.endPointUrl,
|
||||
region: config.region ?? "us-east-1",
|
||||
accessKey: config.accessKey,
|
||||
secretKey: config.secretKey,
|
||||
port: config.port ? Number(config.port) : 443,
|
||||
useSSL: config.ssl ?? true,
|
||||
});
|
||||
}
|
||||
|
||||
const BASE_DIR = "";
|
||||
|
||||
|
||||
async function ensureBucket(config: S3Config) {
|
||||
const client = await getS3Client(config);
|
||||
const exists = await client.bucketExists(config.bucketName);
|
||||
if (!exists) await client.makeBucket(config.bucketName);
|
||||
}
|
||||
|
||||
export async function uploadS3(
|
||||
config: S3Config,
|
||||
input: { data: StorageUploadInput, metadata?: StorageMetaData }
|
||||
): Promise<StorageResult> {
|
||||
const client = await getS3Client(config);
|
||||
await ensureBucket(config);
|
||||
|
||||
const key = `${BASE_DIR}${input.data.path}`;
|
||||
const file = input.data.file;
|
||||
|
||||
let uploadStream: Readable;
|
||||
if (Buffer.isBuffer(file) || file instanceof Uint8Array) {
|
||||
uploadStream = Readable.from(file);
|
||||
} else if ((file as any).pipe) {
|
||||
uploadStream = file;
|
||||
} else {
|
||||
return {success: false, provider: "s3", error: "Unsupported file type for streaming upload"};
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await client.putObject(config.bucketName, key, uploadStream, input.data.size);
|
||||
} catch (err: any) {
|
||||
return {success: false, provider: "s3", error: err.message};
|
||||
}
|
||||
|
||||
return {success: true, provider: "s3"};
|
||||
}
|
||||
|
||||
|
||||
export async function getS3(
|
||||
config: S3Config,
|
||||
input: { data: StorageGetInput, metadata: StorageMetaData }
|
||||
): Promise<StorageResult> {
|
||||
const client = await getS3Client(config);
|
||||
const key = `${BASE_DIR}${input.data.path}`;
|
||||
|
||||
try {
|
||||
await client.statObject(config.bucketName, key);
|
||||
} catch {
|
||||
return {success: false, provider: "s3", error: "File not found"};
|
||||
}
|
||||
|
||||
const fileStream = await client.getObject(config.bucketName, key);
|
||||
|
||||
let presignedUrl: string | undefined;
|
||||
if (input.data.signedUrl) {
|
||||
presignedUrl = await client.presignedGetObject(config.bucketName, key, input.data.expiresInSeconds ?? 60);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
provider: "s3",
|
||||
file: fileStream as unknown as Buffer | Readable,
|
||||
url: presignedUrl,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
export async function deleteS3(config: S3Config, input: {
|
||||
data: StorageDeleteInput,
|
||||
metadata?: StorageMetaData
|
||||
}): Promise<StorageResult> {
|
||||
const client = await getS3Client(config);
|
||||
const key = `${BASE_DIR}${input.data.path}`;
|
||||
|
||||
try {
|
||||
await client.removeObject(config.bucketName, key);
|
||||
return {success: true, provider: "s3"};
|
||||
} catch (err: any) {
|
||||
return {success: false, provider: "s3", error: err.message};
|
||||
}
|
||||
}
|
||||
|
||||
export async function pingS3(config: S3Config): Promise<StorageResult> {
|
||||
try {
|
||||
const client = await getS3Client(config);
|
||||
const exists = await client.bucketExists(config.bucketName);
|
||||
if (!exists) return {
|
||||
success: false,
|
||||
provider: "s3",
|
||||
response: "Bucket does not exist"
|
||||
};
|
||||
const key = `${BASE_DIR}ping.txt`;
|
||||
await client.putObject(config.bucketName, key, Buffer.from("ping"));
|
||||
await client.getObject(config.bucketName, key);
|
||||
await client.removeObject(config.bucketName, key);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
provider: "s3",
|
||||
response: "S3 storage OK"
|
||||
};
|
||||
} catch (err: any) {
|
||||
return {
|
||||
success: false,
|
||||
provider: "s3",
|
||||
response: err.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export async function copyS3(
|
||||
config: S3Config,
|
||||
input: {
|
||||
data: StorageCopyInput,
|
||||
},
|
||||
): Promise<StorageResult> {
|
||||
const client = await getS3Client(config);
|
||||
await ensureBucket(config);
|
||||
|
||||
const sourceKey = `${BASE_DIR}${input.data.from}`;
|
||||
const destinationKey = `${BASE_DIR}${input.data.to}`;
|
||||
|
||||
try {
|
||||
await client.copyObject(
|
||||
config.bucketName,
|
||||
destinationKey,
|
||||
`/${config.bucketName}/${sourceKey}`
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
provider: "s3",
|
||||
};
|
||||
} catch (err: any) {
|
||||
return {
|
||||
success: false,
|
||||
provider: "s3",
|
||||
error: err.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user