fix: feature-base-refactoring (#297)

* docs: add AGENTS.md with feature-based architecture conventions

Defines the target src/ structure, feature list, naming conventions
(kebab-case, file type patterns), import direction rules, and
feature boundary guidelines for the planned big-bang refactor.

* docs: add features/channel/ to architecture conventions

Generic channel UI (card, modal, form) goes to features/channel/.
Provider-specific sub-folders: channel/notifications/ and channel/storages/.

* refactor: move event emitter to lib/, project service to db/services/, dedup use-mobile hook

* fix: remove unused auth import in db/services/project.ts

* refactor: migrate wrappers/common to components/common (flat)

* fix: remove dead commented-out code from button-with-loading.tsx

* refactor: merge features/browser into features/theme, flatten upload/updates, migrate statistics

* refactor: migrate wrappers/auth to features/auth

Move all auth components and forms from src/components/wrappers/auth/
into src/features/auth/ with flat kebab-case naming. Handle extra
reset-password-form.tsx and differing reset-password-schema.ts by
copying them as reset-password-page-form.tsx and reset-password-page.schema.ts.

* refactor: remove dead auth files and fix zPassword schema

Delete reset-password-section.tsx and reset-password-page-form.tsx (zero consumers).
Fix reset-password-page.schema.ts to import zPassword from @/lib/zod.

* refactor: migrate wrappers/dashboard/common to features/layout, rename Header.tsx to header.tsx

* fix: decouple layout from theme/updates cross-feature imports

Use slot props (actions, updateNotification) so app/ composes
ModeToggle and UpdateNotification into Header and AppSidebar.
Delete dead side-bar-logo.tsx (zero consumers).

* refactor: merge features/keys into features/agents, flatten sub-dirs, migrate agent wrappers

- Merge src/features/keys/keys.action.ts into src/features/agents/
- Flatten src/features/agents/components/ and hooks/ into feature root
- Migrate all src/components/wrappers/dashboard/agent/ files into src/features/agents/
- Update all import paths across app/ and src/ accordingly
- Fix broken code-snippet import in agent-key-modal.tsx (was pointing to non-existent wrappers path)

* fix: move github-releases to lib/, remove dead code and unused imports

Move GitHub release fetch logic from features/updates/github.ts to
lib/github-releases.ts so both features/updates and features/agents
can import it without cross-feature imports.
Remove commented-out impersonation block from agent-database-card.tsx.
Remove unused imports (CopyButton, Terminal, Key, Info).

* refactor: flatten features/projects/components, migrate project wrappers

Move project.dialog/form out of sub-dir, migrate project-card, project-database-card, and delete-project button/action from wrappers into features/projects/. Update all import paths across src/ and app/. Leave wrappers/dashboard/projects/database/ untouched for Task 10.

* fix: extract DatabaseCard to components/common, remove cross-feature import

DatabaseCard was imported by both features/projects and features/agents,
violating the no-cross-feature-import rule. Extract to components/common/
database-card.tsx so both features import from the shared layer.
Also tighten extendedProps type and remove commented-out maxCount prop.

* refactor: migrate all database/backup/restore/health UI to features/database

Consolidates ~40 files from 5 source locations (features/dashboard/backup,
features/dashboard/restore, wrappers/dashboard/backup, wrappers/dashboard/database,
wrappers/dashboard/health, wrappers/dashboard/projects/database) into the new
flat features/database directory with kebab-case filenames and updated @/ imports.

* fix: remove dead restore-form files and console.log from health-grid

Delete restore-form.tsx (186 lines all commented-out, zero consumers)
and restore-form.schema.ts (only a commented import).
Remove console.log(logs) from getOldestLog helper in health-grid.tsx.

Cross-feature imports (database→storages for dispatchStorage/storeBackupFiles)
are accepted as known architectural coupling — storage dispatch is called from
database server actions at runtime and cannot route through app/ props.

* refactor: migrate wrappers/admin/users to features/users, fix table-colums typo

* fix: fix relative imports, string interpolation, and console.error in users feature

Replace relative imports with @/ aliases in user-actions-cell.tsx and
admin-user-edit-modal.tsx. Fix string interpolation bug in dialog title
(was regular string, now template literal). Remove console.error from
admin-user-change-role-modal.tsx.

* refactor: migrate wrappers/profile to features/profile, fix profile-apperance typo

* fix: remove console.error calls and ts-ignore in profile feature

Remove console.error from profile-account.tsx, 2fa-form.tsx (x2).
Replace @ts-ignore on fileInput.onchange with typed cast in avatar-with-upload.tsx.

* refactor: rename features/organization to features/organizations, migrate all org wrappers

Moves 37 org-related files from features/organization/, features/dashboard/,
hooks/, wrappers/admin/organizations/, wrappers/admin/channels/organization/,
and wrappers/dashboard/organization/ into a flat features/organizations/ feature
directory. Updates all import paths codebase-wide. Fixes @ts-ignore and removes
unused @better-fetch/fetch import in admin-organization-form.

* fix: remove console.error, @ts-ignore, and unused destructures in features/organizations

* refactor: migrate wrappers/organization/migration to features/migration

Move all 5 migration files to src/features/migration/, update internal
imports to @/features/migration/ absolute paths, remove console.log,
and delete src/components/wrappers/dashboard/organization/ entirely.

* refactor: migrate wrappers/admin/settings to features/settings

Move all 14 settings files (email, notification, storage, s3) from
wrappers/dashboard/admin/settings/ into features/settings/, rewriting
imports, replacing @ts-ignore with @ts-expect-error, and renaming error
params to _error where applicable.

* refactor: rename notifications files to kebab convention, migrate notification logs

- Rename dispatch.ts/helpers.ts/types.ts to notifications.*.ts
- Migrate notification logs components from wrappers/dashboard/admin/notifications/logs/ to features/notifications/
- Fix all import paths across codebase and in providers/
- Remove unused useRouter import, replace @ts-ignore with @ts-expect-error
- Delete src/components/wrappers/dashboard/admin/notifications/

* refactor: rename storages dispatch/helpers/types to kebab convention

- Rename dispatch.ts/helpers.ts/types.ts to storages.*.ts
- Fix all import paths across codebase and in providers/
- Fix relative ../types and ../../types imports in all storage providers to absolute @/ paths
- Replace @ts-ignore with @ts-expect-error, remove console.debug/console.error

* refactor: create features/channel with generic UI and notifications/storages provider sub-folders

Consolidates 4 source locations into src/features/channel/:
- components/wrappers/dashboard/admin/channels/ → features/channel/ (11 generic UI files)
- features/notifications/providers/ → features/channel/notifications/ (9 backend + 18 form files)
- features/storages/providers/ → features/channel/storages/ (backend + forms)

The two conflicting google-drive/helpers.ts files (client helpers vs OAuth action)
are kept separate: helpers.ts and google-drive-refresh.action.ts.

Rewrites all imports codebase-wide to point to new locations.
Removes console.log/console.error calls, upgrades @ts-ignore to @ts-expect-error.

* fix: remove console.error from local storage provider

* fix: multi-select.tsx scroll issue

* fix: multi-select.tsx scroll issue
This commit is contained in:
Charles GTE
2026-05-24 17:09:12 +02:00
committed by GitHub
parent 4fadfcfaf2
commit 3fa0f9b76d
330 changed files with 627 additions and 1120 deletions
@@ -1,62 +1,4 @@
// "use client";
//
// import { Button } from "@/components/ui/button";
// import { ButtonHTMLAttributes } from "react";
// import { Loader2 } from "lucide-react";
//
// export type VariantButton = {
// secondary: string;
// default: string;
// outline: string;
// ghost: string;
// link: string;
// destructive: string;
// };
// export type sizeButton = {
// default: string;
// icon: string;
// sm: string;
// lg: string;
// };
//
// export type ButtonWithConfirmProps = {
// icon?: any;
// text: string;
// variant?: keyof VariantButton;
// className?: string;
// onClick: () => void;
// isPending?: boolean;
// size: keyof sizeButton;
// };
//
// export const ButtonWithLoading = ({
// icon,
// text,
// variant,
// className,
// onClick,
// isPending,
// size,
// ...props // catch all remaining props
// }: ButtonWithConfirmProps & ButtonHTMLAttributes<HTMLButtonElement>) => {
// return (
// <Button
// onClick={() => {
// onClick();
// }}
// variant={variant ? variant : "default"}
// className={className}
// {...props} // forward the remaining props to the Button component
// size={size || "default"}
// >
// {isPending && <Loader2 className="animate-spin mr-4" size={16} />}
// {text}
// <>{icon ? icon : null}</>
// </Button>
// );
// };
'use client'
"use client"
import { ButtonHTMLAttributes, ReactNode } from "react";
import { Loader2 } from "lucide-react";
@@ -3,8 +3,8 @@
import React, { ComponentType, useState } from "react";
import { cn } from "@/lib/utils";
import { PaginationNavigation } from "@/components/wrappers/common/pagination/pagination-navigation";
import { PaginationSize } from "@/components/wrappers/common/pagination/pagination-size";
import { PaginationNavigation } from "@/components/common/pagination-navigation";
import { PaginationSize } from "@/components/common/pagination-size";
interface CardsWithPaginationProps<T> {
className?: string;
@@ -3,7 +3,7 @@
import {PropsWithChildren, useState} from "react";
import {Button} from "@/components/ui/button";
import {Check, Copy, Terminal} from "lucide-react";
import {copyToClipboardWithMeta} from "@/components/wrappers/common/button/copy-button";
import {copyToClipboardWithMeta} from "@/components/common/copy-button";
import {cn} from "@/lib/utils";
export type CodeSnippetProps = PropsWithChildren<{
@@ -4,35 +4,17 @@ import Link from "next/link";
import Image from "next/image";
import {useState} from "react";
import {Card} from "@/components/ui/card";
import {ConnectionIndicator} from "@/components/wrappers/common/connection-indicator";
import {ConnectionIndicator} from "@/components/common/connection-indicator";
import {formatDateLastContact} from "@/utils/date-formatting";
import {Database} from "@/db/schema/07_database";
import {ChevronRight, Activity, Fingerprint, Copy, Check} from "lucide-react";
export type projectDatabaseCardProps = {
data: Database;
extendedProps: any;
organizationSlug: string;
};
export const ProjectDatabaseCard = (props: projectDatabaseCardProps) => {
const {data: database, extendedProps: extendedProps} = props;
return (
<Link
className="group block transition-all duration-200 outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-xl"
href={`/dashboard/projects/${extendedProps.id}/database/${database.id}`}>
<DatabaseCard data={database}/>
</Link>
);
};
export type databaseCardProps = {
export type DatabaseCardProps = {
data: Database;
withDetails?: boolean;
};
export const DatabaseCard = (props: databaseCardProps) => {
export const DatabaseCard = (props: DatabaseCardProps) => {
const {data: database, withDetails = true} = props;
const [isCopied, setIsCopied] = useState(false);
@@ -1,5 +1,3 @@
//https://github.com/sersavan/shadcn-multi-select-component
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { CheckIcon, XCircle, ChevronDown, XIcon, WandSparkles } from "lucide-react";
@@ -233,53 +231,87 @@ export const MultiSelect = React.forwardRef<HTMLButtonElement, MultiSelectProps>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start" onEscapeKeyDown={() => setIsPopoverOpen(false)}>
<PopoverContent
className="w-auto p-0"
align="start"
onEscapeKeyDown={() => setIsPopoverOpen(false)}
>
<Command>
<CommandInput placeholder="Search..." onKeyDown={handleInputKeyDown} />
<CommandList>
<CommandList className="max-h-none overflow-visible">
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup>
<CommandItem key="all" onSelect={toggleAll} className="cursor-pointer">
<div
className={cn(
"mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary",
selectedValues.length === options.length ? "bg-primary text-primary-foreground" : "opacity-50 [&_svg]:invisible"
)}
>
<CheckIcon className="h-4 w-4" />
</div>
<span>(Select All)</span>
</CommandItem>
{options.map((option) => {
const isSelected = selectedValues.includes(option.value);
return (
<CommandItem key={option.value} onSelect={() => toggleOption(option.value)} className="cursor-pointer">
<div
className={cn(
"mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary",
isSelected ? "bg-primary text-primary-foreground" : "opacity-50 [&_svg]:invisible"
)}
<div
className="max-h-[18rem] overflow-y-auto overflow-x-hidden overscroll-contain touch-pan-y pr-1"
style={{ WebkitOverflowScrolling: "touch" }}
onWheelCapture={(event) => event.stopPropagation()}
onTouchMoveCapture={(event) => event.stopPropagation()}
>
<CommandGroup>
<CommandItem key="all" onSelect={toggleAll} className="cursor-pointer">
<div
className={cn(
"mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary",
selectedValues.length === options.length
? "bg-primary text-primary-foreground"
: "opacity-50 [&_svg]:invisible"
)}
>
<CheckIcon className="h-4 w-4" />
</div>
<span>(Select All)</span>
</CommandItem>
{options.map((option) => {
const isSelected = selectedValues.includes(option.value);
return (
<CommandItem
key={option.value}
onSelect={() => toggleOption(option.value)}
className="cursor-pointer"
>
<CheckIcon className="h-4 w-4" />
</div>
{option.icon && <option.icon className="mr-2 h-4 w-4 text-muted-foreground" />}
<span>{option.label}</span>
</CommandItem>
);
})}
</CommandGroup>
<div
className={cn(
"mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary",
isSelected
? "bg-primary text-primary-foreground"
: "opacity-50 [&_svg]:invisible"
)}
>
<CheckIcon className="h-4 w-4" />
</div>
{option.icon && (
<option.icon className="mr-2 h-4 w-4 text-muted-foreground" />
)}
<span>{option.label}</span>
</CommandItem>
);
})}
</CommandGroup>
</div>
<CommandSeparator />
<CommandGroup>
<div className="flex items-center justify-between">
{selectedValues.length > 0 && (
<>
<CommandItem onSelect={handleClear} className="flex-1 justify-center cursor-pointer">
<CommandItem
onSelect={handleClear}
className="flex-1 justify-center cursor-pointer"
>
Clear
</CommandItem>
<Separator orientation="vertical" className="flex min-h-6 h-full" />
</>
)}
<CommandItem onSelect={() => setIsPopoverOpen(false)} className="flex-1 justify-center cursor-pointer max-w-full">
<CommandItem
onSelect={() => setIsPopoverOpen(false)}
className="flex-1 justify-center cursor-pointer max-w-full"
>
Close
</CommandItem>
</div>
@@ -1,5 +1,5 @@
import { Pagination, PaginationContent, PaginationItem, PaginationNext, PaginationPrevious } from "@/components/ui/pagination";
import { PaginationIndexes } from "@/components/wrappers/common/pagination/pagination-indexes";
import { PaginationIndexes } from "@/components/common/pagination-indexes";
import { cn } from "@/lib/utils";
export type paginationNavigationProps = {
@@ -1,4 +1,4 @@
import { PaginationNavigation } from "@/components/wrappers/common/pagination/pagination-navigation";
import { PaginationNavigation } from "@/components/common/pagination-navigation";
export type paginationNavigationProps = {
className?: string;
@@ -1,7 +1,7 @@
"use client";
import {TablePaginationNavigation} from "@/components/wrappers/common/table/table-pagination-navigation";
import {TablePaginationSize} from "@/components/wrappers/common/table/table-pagination-size";
import {TablePaginationNavigation} from "@/components/common/table-pagination-navigation";
import {TablePaginationSize} from "@/components/common/table-pagination-size";
import {cn} from "@/lib/utils";
interface tablePaginationProps {
+2 -2
View File
@@ -1,7 +1,7 @@
// import * as React from "react";
// import EmailLayout from "./email-layout";
// import {Text, Section, Button} from "@react-email/components";
// import type {EventPayload} from "@/features/notifications/types";
// import type {EventPayload} from "@/features/notifications/notifications.types";
//
// export interface EmailNotificationProps {
// payload: EventPayload
@@ -48,7 +48,7 @@
import * as React from "react";
import EmailLayout from "./email-layout";
import {Text, Section, Button} from "@react-email/components";
import type {EventPayload} from "@/features/notifications/types";
import type {EventPayload} from "@/features/notifications/notifications.types";
export interface EmailNotificationProps {
payload: EventPayload;
@@ -1,46 +0,0 @@
"use client";
import {env} from "@/env.mjs";
import {useTheme} from "next-themes";
import Image from "next/image";
import {useEffect, useState} from "react";
export const AuthLogoSection = () => {
const {resolvedTheme} = useTheme();
const [mounted, setMounted] = useState(false);
const [loaded, setLoaded] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const imageTheme =
resolvedTheme === "dark"
? "/images/logo-dark.png"
: "/images/logo-light.png";
const handleLoad = () => setLoaded(true);
const style = {
transition: "opacity 0.3s ease-in-out",
opacity: loaded ? 1 : 0,
};
return (
<div className="sm:mx-auto sm:w-full sm:max-w-md relative flex items-center justify-center h-[160px]">
{mounted && (
<Image
src={imageTheme}
alt="Logo"
fill
priority
className="object-contain p-10"
onLoad={handleLoad}
style={style}
/>
)}
</div>
);
};
@@ -1,24 +0,0 @@
"use client";
import { toast } from "sonner";
import { useRouter, useSearchParams } from "next/navigation";
import TwoFactorForm from "@/components/wrappers/dashboard/profile/form/2fa-form";
export const GuardForm = () => {
const router = useRouter();
const searchParams = useSearchParams();
const callbackUrl = searchParams.get("redirect") || "/dashboard";
return (
<TwoFactorForm
onSuccess={(success) => {
if (success) {
toast.success("Successfully logged in!");
//@ts-ignore
router.push(callbackUrl);
router.refresh();
}
}}
/>
);
};
@@ -1,10 +0,0 @@
"use client";
import { z } from "zod";
import { zEmail } from "@/lib/zod";
export const ForgotPasswordSchema = z.object({
email: zEmail(),
});
export type ForgotPasswordType = z.infer<typeof ForgotPasswordSchema>;
@@ -1,81 +0,0 @@
"use client";
import { useMutation } from "@tanstack/react-query";
import { toast } from "sonner";
import { FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Form } from "@/components/ui/form";
import { requestPasswordReset } from "@/lib/auth/auth-client";
import { ButtonWithLoading } from "@/components/wrappers/common/button/button-with-loading";
import Link from "next/link";
import { ForgotPasswordSchema, ForgotPasswordType } from "./forgot-password-form.schema";
import { ArrowLeft } from "lucide-react";
export type ForgotPasswordFormProps = {
defaultValues?: ForgotPasswordType;
};
export const ForgotPasswordForm = (props: ForgotPasswordFormProps) => {
const form = useZodForm({
schema: ForgotPasswordSchema,
});
const mutation = useMutation({
mutationFn: async (values: ForgotPasswordType) => {
await requestPasswordReset(
{
email: values.email,
},
{
onSuccess: () => {
toast.success("If an account with this email address exists, you will receive an email with instructions to reset your password.");
},
onError: (error) => {
toast.error(error.error.message);
},
}
);
},
});
return (
<Form
form={form}
className="flex flex-col gap-4 mb-1"
onSubmit={async (values) => {
await mutation.mutateAsync(values);
}}
>
<FormField
control={form.control}
name="email"
defaultValue=""
render={({ field }) => (
<FormItem>
<FormLabel>Email Address</FormLabel>
<FormControl>
<Input autoComplete="email" autoFocus
placeholder="example@portabase.io"
{...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex flex-col items-center gap-y-6 w-full">
<ButtonWithLoading className="mt-2 w-full h-11" isPending={mutation.isPending}>
Send reset link
</ButtonWithLoading>
<Link href="/login" className="group flex items-center text-sm hover:underline">
<ArrowLeft className="mr-1 size-4 text-muted-foreground transition-transform group-hover:-translate-x-1" />
Back to login
</Link>
</div>
</Form>
);
};
@@ -1,75 +0,0 @@
"use server";
import { ServerActionResult } from "@/types/action-type";
import { auth } from "@/lib/auth/auth";
import { zString } from "@/lib/zod";
import z from "zod";
import { db } from "@/db";
import {action} from "@/lib/safe-actions/actions";
//TODO: to be continued...
export const forgotPasswordAction = action
.schema(
z.object({
schema: z.object({
email: zString(),
}),
redirectTo: zString().optional(),
})
)
.action(async ({ parsedInput }): Promise<ServerActionResult<null>> => {
try {
const user = await (await auth.$context).internalAdapter.findUserByEmail(parsedInput.schema.email);
if (!user) {
return {
success: false,
actionError: {
message: "password_reset",
cause: "user_not_found",
},
};
}
const existingToken = await db.query.verification.findFirst({
where: (verifications, { eq, and, gte }) =>
and(eq(verifications.value, user.user.id), gte(verifications.expiresAt, new Date(Date.now() + 15 * 60 * 1000))),
});
if (existingToken) {
return {
success: false,
actionError: {
message: "password_reset",
cause: "reset_already_requested",
},
};
}
// await (
// await auth.$context
// ).options.emailAndPassword
// .sendResetPassword(
// {
// user: user.user,
// url,
// token: verificationToken,
// },
// ctx.request
// )
// .catch((e) => {
// ctx.context.logger.error("Failed to send reset password email", e);
// });
return {
success: true,
};
} catch (error) {
return {
success: false,
actionError: {
message: "password_reset",
cause: error instanceof Error ? error.message : "Unknown error",
},
};
}
});
@@ -1,11 +0,0 @@
"use client";
import { z } from "zod";
import { zEmail, zString } from "@/lib/zod";
export const LoginSchema = z.object({
email: zEmail(),
password: zString().nonempty(),
});
export type LoginType = z.infer<typeof LoginSchema>;
@@ -1,133 +0,0 @@
"use client";
import { useMutation } from "@tanstack/react-query";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect } from "react";
import { toast } from "sonner";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
useZodForm,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { PasswordInput } from "@/components/ui/password-input";
import {
LoginSchema,
type LoginType,
} from "@/components/wrappers/auth/login/login-form/login-form.schema";
import { ButtonWithLoading } from "@/components/wrappers/common/button/button-with-loading";
import { signIn } from "@/lib/auth/auth-client";
export type loginFormProps = {
defaultValues?: LoginType;
isPasskeyEnabled?: boolean;
};
export const LoginForm = (props: loginFormProps) => {
const { isPasskeyEnabled = false } = props;
const url = useSearchParams();
const form = useZodForm({
schema: LoginSchema,
});
const router = useRouter();
const mutation = useMutation({
mutationFn: async (values: LoginType) => {
await signIn.email(
{
password: values.password,
email: values.email,
callbackURL: url?.get("redirect") ?? "/dashboard",
},
{
onSuccess: (context) => {
if (context.data.twoFactorRedirect) {
router.push(
"/guard?redirect=" +
encodeURIComponent(context.data.callbackURL || "/dashboard"),
);
}
toast.success("Login success");
},
onError: (error) => {
toast.error(error.error.message);
},
},
);
},
});
return (
<Form
form={form}
className="flex flex-col gap-4 mb-1"
onSubmit={async (values) => {
await mutation.mutateAsync(values);
}}
>
<FormField
control={form.control}
name="email"
defaultValue=""
render={({ field }) => (
<FormItem>
<FormLabel>Email Address</FormLabel>
<FormControl>
<Input
autoComplete="email"
autoFocus
placeholder="example@portabase.io"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
defaultValue=""
render={({ field }) => (
<FormItem>
<div className="flex items-center justify-between">
<FormLabel>Password</FormLabel>
<div className="text-center text-sm">
<Link
href={"/forgot-password"}
className="hover:underline ml-1"
>
Forgot your password ?
</Link>
</div>
</div>
<FormControl>
<PasswordInput
autoComplete={
isPasskeyEnabled
? "current-password webauthn"
: "current-password"
}
placeholder={"Enter your password"}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<ButtonWithLoading className="mt-2 h-11" isPending={mutation.isPending}>
Login
</ButtonWithLoading>
</Form>
);
};
@@ -1,62 +0,0 @@
"use server";
import { ServerActionResult } from "@/types/action-type";
import { auth } from "@/lib/auth/auth";
import { zPassword, zString } from "@/lib/zod";
import z from "zod";
import {action} from "@/lib/safe-actions/actions";
export const resetPasswordAction = action
.schema(
z.object({
schema: z.object({
password: zPassword(),
}),
token: zString(),
})
)
.action(async ({ parsedInput }): Promise<ServerActionResult<null>> => {
try {
const verification = await (await auth.$context).internalAdapter.findVerificationValue(`reset-password:${parsedInput.token}`);
if (!verification || verification.expiresAt < new Date()) {
return {
success: false,
actionError: {
message: "password_reset",
cause: "invalid_or_expired_token",
},
};
}
const user = await (await auth.$context).internalAdapter.findUserById(verification.value);
if (!user) {
return {
success: false,
actionError: {
message: "password_reset",
cause: "user_not_found",
},
};
}
await (
await auth.$context
).internalAdapter.updateUser(user.id, {
isDefaultPassword: false,
});
return {
success: true,
actionSuccess: {
message: "password_reset",
},
};
} catch (error) {
return {
success: false,
actionError: {
message: "password_reset",
cause: error instanceof Error ? error.message : "Unknown error",
},
};
}
});
@@ -1,21 +0,0 @@
"use client";
import {z} from "zod";
import {zPassword} from "@/lib/zod";
export const ResetPasswordSchema = z
.object({
password: zPassword(),
confirmPassword: zPassword(),
})
.superRefine(({confirmPassword, password}, ctx) => {
if (confirmPassword !== password) {
ctx.addIssue({
code: "custom",
message: "Confirmation password does not match",
path: ["confirmPassword"],
});
}
});
export type ResetPasswordType = z.infer<typeof ResetPasswordSchema>;
@@ -1,96 +0,0 @@
"use client";
import {useMutation} from "@tanstack/react-query";
import {toast} from "sonner";
import {FormControl, FormField, FormItem, FormLabel, useZodForm} from "@/components/ui/form";
import {Form} from "@/components/ui/form";
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
import Link from "next/link";
import {ResetPasswordSchema, ResetPasswordType} from "./reset-password-form.schema";
import {PasswordStrengthInput} from "@/components/ui/password-input-indicator";
import {useRouter, useSearchParams} from "next/navigation";
import {ArrowLeft} from "lucide-react";
import {authClient} from "@/lib/auth/auth-client";
import {BetterAuthError} from "@/types/auth";
import {PasswordInput} from "@/components/ui/password-input";
export type ResetPasswordFormProps = {
defaultValues?: ResetPasswordType;
};
export const ResetPasswordForm = (props: ResetPasswordFormProps) => {
const searchParams = useSearchParams();
const form = useZodForm({
schema: ResetPasswordSchema,
});
const router = useRouter();
const mutation = useMutation({
mutationFn: async (values: ResetPasswordType) => {
const {data, error} = await authClient.resetPassword({
newPassword: values.password,
token: searchParams.get("token") || "",
});
if (error) throw error;
},
onSuccess: () => {
toast.success("Password successfully reset!");
setTimeout(() => router.push("/"), 1400);
},
onError: (error: BetterAuthError) => {
toast.error("An error occurred while resetting password");
},
});
return (
<Form
form={form}
className="flex flex-col gap-4 mb-1"
onSubmit={async (values) => {
await mutation.mutateAsync(values);
}}
>
<FormField
control={form.control}
name="password"
defaultValue=""
render={({field}) => (
<FormItem>
<PasswordStrengthInput label={"New password"} field={field}/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="confirmPassword"
defaultValue=""
render={({field}) => (
<FormItem>
<FormLabel>Confirmation password</FormLabel>
<FormControl>
<PasswordInput placeholder={"Enter your conformation password"} {...field} />
</FormControl>
</FormItem>
)}
/>
<div className="flex flex-col items-center gap-y-6 w-full">
<ButtonWithLoading className="mt-2 w-full h-11" isPending={mutation.isPending}>
Reset
</ButtonWithLoading>
<Link href="/login" className="group flex items-center text-sm hover:underline">
<ArrowLeft
className="mr-1 size-4 text-muted-foreground transition-transform group-hover:-translate-x-1"/>
Back to login
</Link>
</div>
</Form>
);
};
@@ -1,25 +0,0 @@
import { z } from "zod";
// Minimum 8 characters, at least one uppercase letter, one lowercase letter, one number and one special character
const passwordValidation = new RegExp(/^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9])(?=.*?[#?!@$%^&*-]).{8,}$/);
export const RegisterSchema = z
.object({
name: z.string(),
email: z.string(),
password: z.string().min(8, { message: "Must have at least 8 character" }).regex(passwordValidation, {
message: "Your password must contain at least one uppercase letter, one lowercase letter, one number, and one special character.",
}),
confirmPassword: z.string(),
})
.superRefine(({ confirmPassword, password }, ctx) => {
if (confirmPassword !== password) {
ctx.addIssue({
code: "custom",
message: "The passwords did not match",
path: ["confirmPassword"],
});
}
});
export type RegisterType = z.infer<typeof RegisterSchema>;
@@ -1,169 +0,0 @@
"use client";
import { useRouter } from "next/navigation";
import { useMutation } from "@tanstack/react-query";
import { toast } from "sonner";
import { CardContent, CardHeader } from "@/components/ui/card";
import { Form } from "@/components/ui/form";
import {
TooltipProvider,
TooltipTrigger,
Tooltip,
TooltipContent,
} from "@/components/ui/tooltip";
import {
RegisterSchema,
RegisterType,
} from "@/components/wrappers/auth/register/register-form/register-form.schema";
import { signUp } from "@/lib/auth/auth-client";
import { useZodForm } from "@/components/ui/form";
import { CardAuth } from "@/features/layout/card-auth";
import { FormField } from "@/components/ui/form";
import { FormItem } from "@/components/ui/form";
import { FormLabel } from "@/components/ui/form";
import { FormControl } from "@/components/ui/form";
import { FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { PasswordInput } from "@/components/ui/password-input";
import { Button } from "@/components/ui/button";
import Link from "next/link";
import { Info } from "lucide-react";
export type registerFormProps = {
defaultValues?: RegisterType;
};
export const RegisterForm = (props: registerFormProps) => {
const form = useZodForm({
schema: RegisterSchema,
});
const router = useRouter();
const mutation = useMutation({
mutationFn: async (values: RegisterType) => {
await signUp.email(values, {
onSuccess: () => {
toast.success(`Account successfully created`);
router.refresh();
router.push(`/login`);
},
onError: (error) => {
toast.error(error.error.message);
},
});
},
});
return (
<TooltipProvider>
<CardAuth>
<CardHeader>
<div className="grid gap-2 text-center mb-2">
<h1 className="text-3xl font-bold">Create an account</h1>
<p className="text-balance text-muted-foreground">
Enter your informations below to register
</p>
</div>
</CardHeader>
<CardContent>
<Form
form={form}
className="flex flex-col gap-4"
onSubmit={async (values) => {
await mutation.mutateAsync(values);
}}
>
<FormField
control={form.control}
name="name"
defaultValue=""
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Your name" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
defaultValue=""
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="example@portabase.io" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
defaultValue=""
render={({ field }) => (
<FormItem>
<FormLabel className="flex">
Password
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Info size="15" />
</TooltipTrigger>
<TooltipContent>
<p>
Min. 8 characters, 1 uppercase (A-Z), 1 lowercase
(a-z), 1 number (0-9), 1 special character (!, @,
etc.)
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</FormLabel>
<FormControl>
<PasswordInput placeholder="Your password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="confirmPassword"
defaultValue=""
render={({ field }) => (
<FormItem>
<FormLabel>Password Confirmation</FormLabel>
<FormControl>
<PasswordInput
placeholder="Conform your password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
className="h-11"
disabled={mutation.isPending}
>
Sign up
</Button>
<div className="mt-4 text-center text-sm">
Already have an account ?{" "}
<Link href="/login" className="underline">
Sign in
</Link>
</div>
</Form>
</CardContent>
</CardAuth>
</TooltipProvider>
);
};
@@ -1,94 +0,0 @@
"use client";
import {useRouter} from "next/navigation";
import {useMutation} from "@tanstack/react-query";
import {Form, FormControl, FormField, FormItem, FormLabel, useZodForm} from "@/components/ui/form";
import {PasswordStrengthInput} from "@/components/ui/password-input-indicator";
import {ResetPasswordSchema, ResetPasswordType} from "@/components/wrappers/auth/reset-password/reset-password-schema";
import {PasswordInput} from "@/components/ui/password-input";
import {CardContent, CardHeader} from "@/components/ui/card";
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
import {authClient} from "@/lib/auth/auth-client";
import {toast} from "sonner";
import {CardAuth} from "@/features/layout/card-auth";
type ResetPasswordFormProps = {
token: string;
};
export const ResetPasswordForm = ({token}: ResetPasswordFormProps) => {
const router = useRouter();
const form = useZodForm({
schema: ResetPasswordSchema,
});
const mutationResetPassword = useMutation({
mutationFn: async (data: ResetPasswordType) => {
await authClient.resetPassword({
newPassword: data.password,
token,
}, {
onSuccess: (response) => {
toast.success("Password changed successfully.");
setTimeout(() => router.push("/"), 1000);
},
onError: (error) => {
console.error(error);
toast.error(error.error.message);
},
});
},
});
return (
<CardAuth>
<CardHeader>
<div className="grid gap-2 text-center mb-2">
<h1 className="text-3xl font-bold">Reset Password</h1>
<p className="text-balance text-muted-foreground">Fill information below to change your
password</p>
</div>
</CardHeader>
<CardContent>
<Form
form={form}
className="flex flex-col gap-4"
onSubmit={async (values) => {
await mutationResetPassword.mutateAsync(values);
}}
>
<FormField
control={form.control}
name="password"
defaultValue=""
render={({field}) => (
<FormItem>
<PasswordStrengthInput label={"Enter new password"} field={field}/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="confirmPassword"
defaultValue=""
render={({field}) => (
<FormItem>
<FormLabel>Confirmation Password</FormLabel>
<FormControl>
<PasswordInput placeholder={"Confirm password"} {...field}
value={field.value ?? ""}/>
</FormControl>
</FormItem>
)}
/>
<ButtonWithLoading type="submit" isPending={mutationResetPassword.isPending}>
Validate
</ButtonWithLoading>
</Form>
</CardContent>
</CardAuth>
);
};
@@ -1,22 +0,0 @@
import {z} from "zod";
const passwordRegex = /^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9])(?=.*?[#?!@$%^&*-])/;
const zPassword = () => z.string().min(8, {message: "New Password too short"}).regex(passwordRegex, {message: "New Password too weak"});
export const ResetPasswordSchema = z
.object({
password: zPassword(),
confirmPassword: zPassword(),
})
.superRefine(({confirmPassword, password}, ctx) => {
if (confirmPassword !== password) {
ctx.addIssue({
code: "custom",
message: "Passwords don't match.",
path: ["confirmPassword"],
});
}
});
export type ResetPasswordType = z.infer<typeof ResetPasswordSchema>;
@@ -1,37 +0,0 @@
"use client"
import {useRouter, useSearchParams} from "next/navigation";
import {ResetPasswordForm} from "@/components/wrappers/auth/reset-password/reset-password-form";
import {toast} from "sonner";
import {useEffect} from "react";
import {LoadingSpinner} from "@/components/wrappers/common/loading/loading-spinner";
export const ResetPasswordSection = () => {
const router = useRouter();
const searchParams = useSearchParams();
const token = searchParams.get("token");
const error = searchParams.get("error");
useEffect(() => {
if (error || !token) {
if (error == "INVALID_TOKEN") {
toast.error("Invalid reset token");
} else {
toast.error("An error occurred.");
}
setTimeout(() => router.push("/"), 1000);
}
}, [error, token, router]);
if (!token) return (
<div className="w-full h-full flex flex-col items-center justify-center">
<LoadingSpinner size={50}/>
</div>
);
return (
<ResetPasswordForm token={token}/>
)
}
@@ -1,86 +0,0 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { authClient, passkey } from "@/lib/auth/auth-client";
import { Loader2 } from "lucide-react";
import { toast } from "sonner";
import { Icon } from "@iconify/react";
import { AuthProviderConfig } from "@/lib/auth/config";
import Image from "next/image";
import { useRouter } from "next/navigation";
export function SocialAuthButtons({ providers }: { providers: AuthProviderConfig[] }) {
const socialProviders = providers.filter((p) => p.isActive && p.type !== "credential");
const router = useRouter();
const [isLoading, setIsLoading] = useState<string | null>(null);
const handleSocialSignIn = async (provider: AuthProviderConfig) => {
setIsLoading(provider.id);
try {
let result;
if (provider.id === "passkey") {
result = await authClient.signIn.passkey({
fetchOptions: {
onSuccess() {
router.push("/dashboard");
},
onError() {
toast.error("An error occurred during passkey authentication. Please try again.");
},
},
});
} else if (provider.type === "sso") {
result = await authClient.signIn.sso({
providerId: provider.id,
providerType: "oidc",
callbackURL: "/dashboard",
});
console.log(result);
} else {
result = await authClient.signIn.social({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
provider: provider.id as any,
callbackURL: "/dashboard",
});
}
if (result?.error) {
toast.error("An error occurred while signing in with the provider. Please try again.");
} else if (provider.id !== "passkey") {
toast.success("Redirecting to provider...");
}
} catch (err) {
toast.error("An error occurred while signing in with the provider. Please try again.");
} finally {
setIsLoading(null);
}
};
if (socialProviders.length === 0) return null;
return (
<div className="flex flex-col gap-2 w-full">
{socialProviders.map((provider) => (
<Button key={provider.id} variant="outline" className="w-full gap-2" onClick={() => handleSocialSignIn(provider)} disabled={!!isLoading}>
{isLoading === provider.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : provider.icon.startsWith("/") || provider.icon.startsWith("http") ? (
<Image
src={provider.icon}
alt={provider.id || "icon"}
width={16}
height={16}
className="h-4 w-4"
unoptimized={provider.icon.startsWith("http")}
/>
) : (
<Icon icon={provider.icon} className="h-4 w-4" />
)}
<span>{provider.title || provider.name}</span>
</Button>
))}
</div>
);
}
@@ -1,150 +0,0 @@
"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 "@/components/wrappers/dashboard/admin/channels/channel/channel-form/channel-form";
import {ChannelKind, getChannelTextBasedOnKind} from "@/components/wrappers/dashboard/admin/channels/helpers/common";
import {
ChannelOrganisationForm
} from "@/components/wrappers/dashboard/admin/channels/organization/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>
)
}
@@ -1,73 +0,0 @@
"use client";
import {ButtonWithConfirm} from "@/components/wrappers/common/button/button-with-confirm";
import {useMutation} from "@tanstack/react-query";
import {useRouter} from "next/navigation";
import {Trash2} from "lucide-react";
import {
removeNotificationChannelAction,
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/action";
import {toast} from "sonner";
import {ChannelKind, getChannelTextBasedOnKind} from "@/components/wrappers/dashboard/admin/channels/helpers/common";
import {
removeStorageChannelAction
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/storages/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}
/>
);
};
@@ -1,94 +0,0 @@
"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 "@/components/wrappers/dashboard/admin/channels/helpers/common";
import {
updateNotificationChannelAction
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/action";
import {ChannelAddEditModal} from "@/components/wrappers/dashboard/admin/channels/channel/channel-add-edit-modal";
import {
updateStorageChannelAction
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/storages/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-ignore
result = await updateNotificationChannelAction(payload)
} else if (kind == "storage") {
// @ts-ignore
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}
/>
</>
);
};
@@ -1,88 +0,0 @@
"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 "@/components/wrappers/dashboard/admin/channels/channel/channel-card/button-edit-channel";
import {ChannelKind, getChannelIcon} from "@/components/wrappers/dashboard/admin/channels/helpers/common";
import {
DeleteChannelButton
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-card/button-delete-channel";
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, adminView, 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>
);
};
@@ -1,75 +0,0 @@
import {z} from "zod";
import {SlackChannelConfigSchema} from "./providers/notifications/forms/slack.schema";
import {SmtpChannelConfigSchema} from "./providers/notifications/forms/smtp.schema";
import {DiscordChannelConfigSchema} from "./providers/notifications/forms/discord.schema";
import {TelegramChannelConfigSchema} from "./providers/notifications/forms/telegram.schema";
import {GotifyChannelConfigSchema} from "./providers/notifications/forms/gotify.schema";
import {NtfyChannelConfigSchema} from "./providers/notifications/forms/ntfy.schema";
import {WebhookChannelConfigSchema} from "./providers/notifications/forms/webhook.schema";
import {NextcloudChannelConfigSchema} from "./providers/notifications/forms/nextcloud.schema";
import {S3ChannelConfigSchema} from "./providers/storages/forms/s3.schema";
import {GoogleDriveChannelConfigSchema} from "./providers/storages/forms/google-drive.schema";
import {LocalChannelConfigSchema} from "./providers/storages/forms/local.schema";
const BaseChannelFormSchema = z.object({
name: z
.string()
.min(5, "Name must be at least 5 characters long")
.max(40, "Name must be at most 40 characters long"),
enabled: z.boolean().default(true),
});
export const NotificationChannelFormSchema = z.discriminatedUnion("provider", [
BaseChannelFormSchema.extend({
provider: z.literal("slack"),
config: SlackChannelConfigSchema,
}),
BaseChannelFormSchema.extend({
provider: z.literal("smtp"),
config: SmtpChannelConfigSchema,
}),
BaseChannelFormSchema.extend({
provider: z.literal("discord"),
config: DiscordChannelConfigSchema,
}),
BaseChannelFormSchema.extend({
provider: z.literal("telegram"),
config: TelegramChannelConfigSchema,
}),
BaseChannelFormSchema.extend({
provider: z.literal("gotify"),
config: GotifyChannelConfigSchema,
}),
BaseChannelFormSchema.extend({
provider: z.literal("ntfy"),
config: NtfyChannelConfigSchema,
}),
BaseChannelFormSchema.extend({
provider: z.literal("webhook"),
config: WebhookChannelConfigSchema,
}),
BaseChannelFormSchema.extend({
provider: z.literal("nextcloud"),
config: NextcloudChannelConfigSchema,
}),
]);
export const StorageChannelFormSchema = z.discriminatedUnion("provider", [
BaseChannelFormSchema.extend({
provider: z.literal("s3"),
config: S3ChannelConfigSchema,
}),
BaseChannelFormSchema.extend({
provider: z.literal("google-drive"),
config: GoogleDriveChannelConfigSchema,
}),
BaseChannelFormSchema.extend({
provider: z.literal("local"),
config: LocalChannelConfigSchema
})
]);
export type NotificationChannelFormType = z.infer<typeof NotificationChannelFormSchema>;
export type StorageChannelFormType = z.infer<typeof StorageChannelFormSchema>;
@@ -1,254 +0,0 @@
"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/wrappers/common/button/button-with-loading";
import {Input} from "@/components/ui/input";
import {
addStorageChannelAction, updateStorageChannelAction
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/storages/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 "@/components/wrappers/dashboard/admin/channels/channel/channel-form/channel-form.schema";
import {
ChannelKind,
renderChannelForm
} from "@/components/wrappers/dashboard/admin/channels/helpers/common";
import {storageProviders} from "@/components/wrappers/dashboard/admin/channels/helpers/storage";
import {notificationProviders} from "@/components/wrappers/dashboard/admin/channels/helpers/notification";
import {
ChannelTestButton
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/channel-test-button";
import {
addNotificationChannelAction, updateNotificationChannelAction
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/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-ignore
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-ignore
result = isCreate ? await addNotificationChannelAction(payload) : await updateNotificationChannelAction(payload);
} else if (kind === "storage") {
// @ts-ignore
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>
);
};
@@ -1,93 +0,0 @@
"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/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 "@/components/wrappers/dashboard/admin/channels/helpers/common";
import type {StorageInput} from "@/features/storages/types";
import {dispatchStorage} from "@/features/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 {
console.error(result);
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>
)
}
@@ -1,172 +0,0 @@
"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 "@/components/wrappers/dashboard/admin/channels/channel/channel-form/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) {
console.error("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) {
console.error("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) {
console.error("Error:", error);
return {
success: false,
actionError: {
message: "Failed to update notification channel.",
status: 500,
cause: error instanceof Error ? error.message : "Unknown error",
messageParams: {id: ""},
},
};
}
});
@@ -1,38 +0,0 @@
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>
)}
/>
</>
);
};
@@ -1,5 +0,0 @@
import {z} from "zod";
export const DiscordChannelConfigSchema = z.object({
discordWebhook: z.string().url("Must be a valid URL"),
});
@@ -1,49 +0,0 @@
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>
)}
/>
</>
);
};
@@ -1,6 +0,0 @@
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"),
});
@@ -1,62 +0,0 @@
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>
)}
/>
</>
);
};
@@ -1,7 +0,0 @@
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"),
});
@@ -1,91 +0,0 @@
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>
)}
/>
</>
)
}
@@ -1,9 +0,0 @@
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(),
});
@@ -1,38 +0,0 @@
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>
)}
/>
</>
);
};
@@ -1,5 +0,0 @@
import {z} from "zod";
export const SlackChannelConfigSchema = z.object({
slackWebhook: z.string().url("Must be a valid URL"),
});
@@ -1,105 +0,0 @@
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>
)}
/>
</>
)
}
@@ -1,10 +0,0 @@
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"),
});
@@ -1,84 +0,0 @@
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>
)}
/>
</>
);
};
@@ -1,20 +0,0 @@
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"],
},
);
@@ -1,122 +0,0 @@
"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>
</>
);
};
@@ -1,16 +0,0 @@
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>;
@@ -1,173 +0,0 @@
"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 "@/components/wrappers/dashboard/admin/channels/channel/channel-form/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) {
console.error("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) {
console.error("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) {
console.error("Error:", error);
return {
success: false,
actionError: {
message: "Failed to update storage channel.",
status: 500,
cause: error instanceof Error ? error.message : "Unknown error",
messageParams: {id: ""},
},
};
}
});
@@ -1,151 +0,0 @@
"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 "@/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/storages/forms/google-drive/helpers";
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>
</>
);
};
@@ -1,8 +0,0 @@
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(),
});
@@ -1,52 +0,0 @@
"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},
},
};
}
});
@@ -1,3 +0,0 @@
import {z} from "zod";
export const LocalChannelConfigSchema = z.object({});
@@ -1,116 +0,0 @@
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>
)}
/>
</>
);
};
@@ -1,15 +0,0 @@
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),
});
@@ -1,65 +0,0 @@
"use client";
import { NotificationChannelWith } from "@/db/schema/09_notification-channel";
import { CardsWithPagination } from "@/components/wrappers/common/cards-with-pagination";
import { useState } from "react";
import { EmptyStatePlaceholder } from "@/components/wrappers/common/empty-state-placeholder";
import { OrganizationWithMembers } from "@/db/schema/03_organization";
import { StorageChannelWith } from "@/db/schema/12_storage-channel";
import { ChannelCard } from "@/components/wrappers/dashboard/admin/channels/channel/channel-card/channel-card";
import { ChannelAddEditModal } from "@/components/wrappers/dashboard/admin/channels/channel/channel-add-edit-modal";
import {
ChannelKind,
getChannelTextBasedOnKind,
} from "@/components/wrappers/dashboard/admin/channels/helpers/common";
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>
);
};
@@ -1,104 +0,0 @@
import {UseFormReturn} from "react-hook-form";
import {
NotifierSmtpForm
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/forms/smtp.form";
import {
NotifierSlackForm
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/forms/slack.form";
import {
NotifierDiscordForm
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/forms/discord.form";
import {
NotifierTelegramForm
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/forms/telegram.form";
import {
NotifierGotifyForm
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/forms/gotify.form";
import {
NotifierNtfyForm
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/forms/ntfy.form";
import {
NotifierWebhookForm
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/forms/webhook.form";
import {
NotifierNextcloudForm
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/forms/nextcloud.form";
import {
notificationProviders,
} from "@/components/wrappers/dashboard/admin/channels/helpers/notification";
import {storageProviders} from "@/components/wrappers/dashboard/admin/channels/helpers/storage";
import {ForwardRefExoticComponent, JSX, RefAttributes, SVGProps} from "react";
import {LucideProps} from "lucide-react";
import {
StorageS3Form
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/storages/forms/s3.form";
import {
StorageGoogleDriveForm
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/storages/forms/google-drive.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 "s3":
return <StorageS3Form form={form}/>
case "google-drive":
return <StorageGoogleDriveForm form={form}/>
case "local":
return <></>
default:
return null;
}
};
@@ -1,558 +0,0 @@
import {Mail} from "lucide-react";
import type { SVGProps } from 'react';
import {ProviderIconTypes} from "@/components/wrappers/dashboard/admin/channels/helpers/common";
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: "microsoft-teams", label: "Microsoft Teams", icon: MSTeamsIcon, preview: true}
]
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>
);
}
@@ -1,76 +0,0 @@
import {Server} from "lucide-react";
import type {SVGProps} from "react";
import {ProviderIconTypes} from "@/components/wrappers/dashboard/admin/channels/helpers/common";
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, preview: true},
{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>
);
}
@@ -1,115 +0,0 @@
"use client";
import {useRouter} from "next/navigation";
import {useMutation} from "@tanstack/react-query";
import {Form, FormControl, FormField, FormItem, useZodForm} from "@/components/ui/form";
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
import {OrganizationWithMembers} from "@/db/schema/03_organization";
import {NotificationChannelWith} from "@/db/schema/09_notification-channel";
import {MultiSelect} from "@/components/wrappers/common/multiselect/multi-select";
import {toast} from "sonner";
import {StorageChannelWith} from "@/db/schema/12_storage-channel";
import {
ChannelsOrganizationSchema, ChannelsOrganizationType
} from "@/components/wrappers/dashboard/admin/channels/organization/channels-organization.schema";
import {
updateNotificationChannelsOrganizationAction, updateStorageChannelsOrganizationAction
} from "@/components/wrappers/dashboard/admin/channels/organization/channels-organization.action";
import {ChannelKind} from "@/components/wrappers/dashboard/admin/channels/helpers/common";
type ChannelOrganisationFormProps = {
organizations?: OrganizationWithMembers[];
defaultValues?: NotificationChannelWith | StorageChannelWith
kind: ChannelKind
};
export const ChannelOrganisationForm = ({
organizations,
defaultValues,
kind
}: ChannelOrganisationFormProps) => {
const router = useRouter();
const defaultOrganizationIds = defaultValues?.organizations?.map(organization => organization.organizationId) ?? []
const form = useZodForm({
schema: ChannelsOrganizationSchema,
// @ts-ignore
defaultValues: {
organizations: defaultOrganizationIds
},
});
const formatOrganizationsList = (organizations: OrganizationWithMembers[]) => {
return organizations
.map((organization) => ({
value: organization.id,
label: `${organization.name}`,
}));
};
const mutationUpdateChannelOrganizations = useMutation({
mutationFn: async (values: ChannelsOrganizationType) => {
const payload = {
data: values.organizations,
id: defaultValues?.id ?? ""
};
const result = kind === "notification" ? await updateNotificationChannelsOrganizationAction(payload) : await updateStorageChannelsOrganizationAction(payload)
const inner = result?.data;
if (inner?.success) {
toast.success(inner.actionSuccess?.message);
router.refresh();
} else {
toast.error(inner?.actionError?.message);
}
}
});
return (
<Form
form={form}
className="flex flex-col gap-4"
onSubmit={async (values) => {
await mutationUpdateChannelOrganizations.mutateAsync(values);
}}
>
<FormField
control={form.control}
name={`organizations`}
render={({field}) => (
<FormItem>
<FormControl>
<MultiSelect
options={formatOrganizationsList(organizations ?? [])}
onValueChange={field.onChange}
defaultValue={field.value ?? []}
placeholder="Select organization(s)"
variant="inverted"
animation={0}
/>
</FormControl>
</FormItem>
)}
/>
<div className="flex justify-end">
<div className="flex gap-2 justify-end">
<ButtonWithLoading isPending={mutationUpdateChannelOrganizations.isPending}>
Save
</ButtonWithLoading>
</div>
</div>
</Form>
);
};
@@ -1,154 +0,0 @@
"use server"
import {userAction} from "@/lib/safe-actions/actions";
import {z} from "zod";
import {ServerActionResult} from "@/types/action-type";
import {db} from "@/db";
import {and, eq, inArray} from "drizzle-orm";
import * as drizzleDb from "@/db";
import {NotificationChannelWith} from "@/db/schema/09_notification-channel";
import {StorageChannelWith} from "@/db/schema/12_storage-channel";
export const updateNotificationChannelsOrganizationAction = userAction
.schema(
z.object({
data: z.array(z.string()),
id: z.string(),
})
)
.action(async ({parsedInput , ctx}): Promise<ServerActionResult<null>> => {
try {
const organizationsIds = parsedInput.data;
const notificationChannelId = parsedInput.id;
const notificationChannel = await db.query.notificationChannel.findFirst({
where: eq(drizzleDb.schemas.notificationChannel.id, notificationChannelId),
with: {
organizations: true,
}
}) as NotificationChannelWith;
if (!notificationChannel) {
return {
success: false,
actionError: {
message: "Notification channel not found.",
status: 404,
cause: "not_found",
},
};
}
const existingItemIds = notificationChannel.organizations.map((organization) => organization.organizationId);
const organizationsToAdd = organizationsIds.filter((id) => !existingItemIds.includes(id));
const organizationsToRemove = existingItemIds.filter((id) => !organizationsIds.includes(id));
if (organizationsToAdd.length > 0) {
for (const organizationToAdd of organizationsToAdd) {
await db.insert(drizzleDb.schemas.organizationNotificationChannel).values({
organizationId: organizationToAdd,
notificationChannelId: notificationChannelId
});
}
}
if (organizationsToRemove.length > 0) {
await db.delete(drizzleDb.schemas.organizationNotificationChannel).where(and(inArray(drizzleDb.schemas.organizationNotificationChannel.organizationId, organizationsToRemove), eq(drizzleDb.schemas.organizationNotificationChannel.notificationChannelId,notificationChannelId))).execute();
}
return {
success: true,
value: null,
actionSuccess: {
message: "Notification channel organizations has been successfully updated.",
messageParams: {notificationChannelId: notificationChannelId},
},
};
} catch (error) {
console.error("Error updating notification channel:", error);
return {
success: false,
actionError: {
message: "Failed to update notification channel.",
status: 500,
cause: "server_error",
messageParams: {message: "Error updating the notification channel"},
},
};
}
});
export const updateStorageChannelsOrganizationAction = userAction
.schema(
z.object({
data: z.array(z.string()),
id: z.string(),
})
)
.action(async ({parsedInput, ctx}): Promise<ServerActionResult<null>> => {
try {
const organizationsIds = parsedInput.data;
const storageChannelId = parsedInput.id;
const storageChannel = await db.query.storageChannel.findFirst({
where: eq(drizzleDb.schemas.storageChannel.id, storageChannelId),
with: {
organizations: true,
}
}) as StorageChannelWith;
if (!storageChannel) {
return {
success: false,
actionError: {
message: "Storage channel not found.",
status: 404,
cause: "not_found",
},
};
}
const existingItemIds = storageChannel.organizations.map((organization) => organization.organizationId);
const organizationsToAdd = organizationsIds.filter((id) => !existingItemIds.includes(id));
const organizationsToRemove = existingItemIds.filter((id) => !organizationsIds.includes(id));
if (organizationsToAdd.length > 0) {
for (const organizationToAdd of organizationsToAdd) {
await db.insert(drizzleDb.schemas.organizationStorageChannel).values({
organizationId: organizationToAdd,
storageChannelId: storageChannelId
});
}
}
if (organizationsToRemove.length > 0) {
await db.delete(drizzleDb.schemas.organizationStorageChannel).where(and(inArray(drizzleDb.schemas.organizationStorageChannel.organizationId, organizationsToRemove), eq(drizzleDb.schemas.organizationStorageChannel.storageChannelId, storageChannelId))).execute();
}
return {
success: true,
value: null,
actionSuccess: {
message: "Storage channel organizations has been successfully updated.",
messageParams: {storageChannelId: storageChannelId},
},
};
} catch (error) {
console.error("Error updating storage channel:", error);
return {
success: false,
actionError: {
message: "Failed to update storage channel.",
status: 500,
cause: "server_error",
messageParams: {message: "Error updating the storage channel"},
},
};
}
});
@@ -1,7 +0,0 @@
import {z} from "zod";
export const ChannelsOrganizationSchema = z.object({
organizations: z.array(z.string().uuid())
});
export type ChannelsOrganizationType = z.infer<typeof ChannelsOrganizationSchema>;
@@ -1,112 +0,0 @@
"use client";
import {ColumnDef} from "@tanstack/react-table";
import {NotificationLogWithRelations} from "@/db/services/notification-log";
import {humanReadableDate} from "@/utils/date-formatting";
import {CheckCircle2, XCircle} from "lucide-react";
import {Badge} from "@/components/ui/badge";
import {NotificationLogModal} from "@/components/wrappers/dashboard/admin/notifications/logs/notification-log-modal";
import {getChannelIcon} from "@/components/wrappers/dashboard/admin/channels/helpers/common";
export function notificationLogsColumns(): ColumnDef<NotificationLogWithRelations>[] {
return [
{
accessorKey: "success",
header: "Status",
cell: ({row}) => {
const status = row.original.success ? "delivered" : "failed";
return(
<Badge variant="outline" className={`gap-1.5 ${getStatusColor(status)}`}>
{getStatusIcon(row.original.success)}
<span className="capitalize">{status}</span>
</Badge>
)
}
},
{
accessorKey: "channel",
header: "Channel",
cell: ({row}) => {
const channel = row.original.channel
return (
<div className="flex items-center gap-2">
<div
className="flex h-10 w-10 items-center justify-center rounded-md bg-secondary border border-border">
{getChannelIcon(channel?.provider ?? "")}
</div>
{channel?.name}
</div>
)
}
},
{
accessorKey: "sentAt",
header: "Sent At",
cell: ({row}) => {
const sentAt = humanReadableDate(row.original.sentAt)
return (
<div>
{sentAt}
</div>
)
}
},
{
accessorKey: "policy",
header: "Event Kind",
cell: ({ row }) => {
const eventKind = row.original.policy?.event;
if (!eventKind) {
return (
<div className="text-muted-foreground italic">
No event
</div>
);
}
return (
<div className="flex items-center gap-2">
<Badge>{eventKind}</Badge>
</div>
);
}
},
{
accessorKey: "details",
header: "Details",
cell: ({row}) => {
return (
<div>
<NotificationLogModal notificationLog={row.original} />
</div>
);
}
}
];
}
export const getStatusIcon = (status: boolean) => {
switch (status) {
case true:
return <CheckCircle2 className="h-4 w-4"/>
case false:
return <XCircle className="h-4 w-4"/>
}
}
export const getStatusColor = (status: string) => {
switch (status) {
case "delivered":
return "bg-green-100 dark:bg-green-100/10"
case "success":
return "bg-green-100 dark:bg-green-100/10"
case "failed":
return "bg-red-100 dark:bg-red-100/10"
case "pending":
return "bg-orange-100 dark:bg-orange-100/10"
}
}
@@ -1,241 +0,0 @@
"use client";
import {
AlertCircle,
Braces,
Database,
ExternalLink,
Eye,
Hash,
Server,
Sparkles,
} from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import type { NotificationLogWithRelations } from "@/db/services/notification-log";
type NotificationLogModalProps = {
notificationLog: NotificationLogWithRelations;
};
const getTroubleshootingForError = (errorMsg?: any) => {
if (!errorMsg || String(errorMsg).toLowerCase() === "null") return null;
const msg = String(errorMsg).toLowerCase();
if (msg.includes("database connection") || msg.includes("agent")) {
return {
title: "Agent Connection Issue",
resolution:
"It appears the agent cannot reach the database. Please ensure that your agent is actively running, your PostgreSQL credentials are correct, and port 5432 is open on your firewall.",
docLink:
"https://portabase.io/docs/agent/troubleshooting/agent-connection",
};
}
if (msg.includes("timeout")) {
return {
title: "Timeout",
resolution:
"The agent is taking too long to respond. This could be due to high server load, network issues, or a large database.",
docLink: "https://portabase.io/docs/agent/troubleshooting/timeout",
};
}
return {
title: "Unexpected Execution Error",
resolution:
"An unexpected error occurred during execution. Please review the raw system logs or contact support if the issue persists.",
docLink: "https://discord.gg/Wgv7xZ8fWJ",
};
};
const getIconForKey = (key: string, value: any) => {
const isActualError =
key.toLowerCase().includes("error") &&
value !== null &&
String(value).toLowerCase() !== "null";
if (key.toLowerCase().includes("id"))
return <Hash className="w-3.5 h-3.5 text-muted-foreground" />;
if (key.toLowerCase().includes("host"))
return <Server className="w-3.5 h-3.5 text-muted-foreground" />;
if (isActualError)
return <AlertCircle className="w-3.5 h-3.5 text-destructive" />;
return <Database className="w-3.5 h-3.5 text-muted-foreground" />;
};
export const NotificationLogModal = ({
notificationLog,
}: NotificationLogModalProps) => {
const [open, setOpen] = useState(false);
// TODO : to be fixed
// @ts-ignore
const payloadError = notificationLog.payload?.error;
const troubleshooting = getTroubleshootingForError(payloadError);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button
variant="outline"
onClick={() => setOpen(true)}
className="relative"
>
<Eye className="w-4 h-4" />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-xl bg-background">
<DialogHeader>
<DialogTitle>Notification Details</DialogTitle>
<DialogDescription>Execution logs and payload data</DialogDescription>
</DialogHeader>
<div className="space-y-4 mt-2 max-h-[70vh] overflow-y-auto px-1 pb-2">
<div className="relative border rounded-xl bg-card shadow-sm flex flex-col">
<div className="bg-primary/5 border-b px-4 py-2.5 flex items-center gap-2 rounded-t-xl">
<div className="p-1 bg-primary/10 rounded-md">
<Database className="w-4 h-4 text-primary" />
</div>
<span className="font-semibold text-sm text-primary">
Event Trigger
</span>
</div>
<div className="p-4 flex flex-col gap-4">
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-1 sm:gap-4">
<span className="text-xs font-medium text-muted-foreground min-w-20 pt-0.5">
Title
</span>
<span className="text-sm sm:text-right flex-1 wrap-break-words">
{notificationLog.content.title}
</span>
</div>
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-1 sm:gap-4">
<span className="text-xs font-medium text-muted-foreground min-w-20 pt-0.5">
Message
</span>
<span className="text-sm sm:text-right flex-1 wrap-break-word text-muted-foreground">
{notificationLog.content.message}
</span>
</div>
</div>
{notificationLog.payload && (
<div className="absolute -bottom-1.5 left-1/2 -translate-x-1/2 w-3 h-3 bg-primary rounded-full border-2 border-background z-10" />
)}
</div>
<div className="w-px h-6 bg-border mx-auto -my-4 relative z-0" />
{notificationLog.payload &&
Object.keys(notificationLog.payload).length > 0 && (
<div className="relative border rounded-xl bg-card shadow-sm flex flex-col">
<div className="absolute -top-1.5 left-1/2 -translate-x-1/2 w-3 h-3 bg-primary rounded-full border-2 border-background z-10" />
<div className="bg-primary/5 border-b px-4 py-2.5 flex items-center justify-between rounded-t-xl">
<div className="flex items-center gap-2">
<div className="p-1 bg-primary/10 rounded-md">
<Braces className="w-4 h-4 text-primary" />
</div>
<span className="font-semibold text-sm text-primary">
JSON Payload
</span>
</div>
</div>
<div className="flex flex-col">
{Object.entries(notificationLog.payload).map(
([key, value], index, arr) => {
const isActualError =
key === "error" &&
value !== null &&
String(value).toLowerCase() !== "null";
return (
<div
key={key}
className={`flex flex-col sm:flex-row sm:items-center justify-between gap-2 px-4 py-3 hover:bg-muted/50 transition-colors ${
index !== arr.length - 1
? "border-b border-border/50"
: ""
}`}
>
<div className="flex items-center gap-2 min-w-[150px]">
{getIconForKey(key, value)}
<span className="text-sm font-medium">{key}</span>
</div>
<div className="flex-1 sm:text-right">
{isActualError ? (
<span className="inline-flex items-center px-2 py-1 rounded-md bg-destructive/10 text-destructive text-xs font-mono break-all">
{String(value)}
</span>
) : (
<span className="text-sm text-muted-foreground font-mono bg-muted px-2 py-1 rounded-md break-all">
{String(value)}
</span>
)}
</div>
</div>
);
},
)}
</div>
{troubleshooting && (
<div className="absolute -bottom-1.5 left-1/2 -translate-x-1/2 w-3 h-3 bg-primary rounded-full border-2 border-background z-10" />
)}
</div>
)}
{troubleshooting && (
<>
<div className="w-px h-6 bg-border mx-auto -my-4 relative z-0" />
<div className="relative border border-destructive/20 rounded-xl bg-gradient-to-b from-destructive/5 to-transparent shadow-sm flex flex-col">
<div className="absolute -top-1.5 left-1/2 -translate-x-1/2 w-3 h-3 bg-destructive rounded-full border-2 border-background z-10" />
<div className="bg-destructive/10 border-b border-destructive/10 px-4 py-2.5 flex items-center justify-between rounded-t-xl">
<div className="flex items-center gap-2">
<div className="p-1 bg-destructive/20 rounded-md">
<Sparkles className="w-4 h-4 text-destructive" />
</div>
<span className="font-semibold text-sm text-destructive">
Suggested Resolution
</span>
</div>
</div>
<div className="p-4 flex flex-col gap-2">
<h4 className="text-sm font-semibold text-foreground">
{troubleshooting.title}
</h4>
<p className="text-sm text-muted-foreground leading-relaxed">
{troubleshooting.resolution}
</p>
<div className="mt-2">
<a
href={troubleshooting.docLink}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1.5 text-xs font-semibold text-destructive hover:text-destructive/80 transition-colors"
>
Read the documentation{" "}
<ExternalLink className="w-3.5 h-3.5" />
</a>
</div>
</div>
</div>
</>
)}
</div>
</DialogContent>
</Dialog>
);
};
@@ -1,25 +0,0 @@
"use client"
import {DataTable} from "@/components/wrappers/common/table/data-table";
import {useRouter} from "next/navigation";
import {
notificationLogsColumns,
} from "@/components/wrappers/dashboard/admin/notifications/logs/columns";
import {NotificationLogWithRelations} from "@/db/services/notification-log";
type NotificationsLogsListProps = {
notificationLogs: NotificationLogWithRelations[]
}
export const NotificationLogsList = (props: NotificationsLogsListProps) => {
const router = useRouter();
return (
<DataTable
enableSelect={false}
columns={notificationLogsColumns()}
data={props.notificationLogs}
enablePagination
/>
)
}
@@ -1,26 +0,0 @@
"use client"
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card";
import {OrganizationWithMembers} from "@/db/schema/03_organization";
import {AdminOrganizationList} from "@/components/wrappers/dashboard/admin/organizations/organization/admin-orgnization-list";
export type AdminOrganizationsTableProps = {
organizations: OrganizationWithMembers[];
};
export const AdminOrganizationsTable = (props: AdminOrganizationsTableProps) => {
const {organizations} = props;
return (
<div className="flex flex-col gap-y-4 h-full py-4 ">
<Card className="h-full">
<CardHeader>
<CardTitle>Active organizations</CardTitle>
<CardDescription>Manage all system organizations</CardDescription>
</CardHeader>
<CardContent className="h-full">
<AdminOrganizationList organizations={organizations} />
</CardContent>
</Card>
</div>
);
};
@@ -1,27 +0,0 @@
"use client"
import {ColumnDef} from "@tanstack/react-table";
import {Organization} from "@/db/schema/03_organization";
export const organizationsColumnsAdmin: ColumnDef<Organization>[] = [
{
accessorKey: "name",
header: "Name",
},
// {
// header: "Action",
// id: "actions",
// cell: ({row}) => {
// const router = useRouter();
// const {data: session, isPending} = useSession();
// const isSuperAdmin = session?.user.role == "superadmin";
//
// return (
// <ButtonDeleteUser
// disabled={!isSuperAdmin || !session || session?.user.email === row.original.email}
// userId={row.original.id}/>
// );
// },
// },
];
@@ -1,49 +0,0 @@
"use client"
import {CreateOrganizationModal} from "@/components/wrappers/dashboard/organization/create-organisation-modal";
import {useRouter} from "next/navigation";
import {authClient} from "@/lib/auth/auth-client";
import {useState} from "react";
import {Button} from "@/components/ui/button";
import {Plus} from "lucide-react";
type AdminOrganizationAddModalProps = {}
export const AdminOrganizationAddModal = (props: AdminOrganizationAddModalProps) => {
const router = useRouter();
const {data: organizations, refetch} = authClient.useListOrganizations();
const {data: activeOrganization, refetch: refetchActiveOrga} = authClient.useActiveOrganization();
const [openModal, setOpenModal] = useState(false);
if (!organizations) return null;
const handleReload = () => {
refetch();
refetchActiveOrga();
router.refresh();
};
const handleOpen = () => {
setOpenModal(true);
}
return (
<>
<Button onClick={handleOpen}>
<Plus/> Create a new organization
</Button>
<CreateOrganizationModal
redirect={"/dashboard/admin/organizations"}
open={openModal}
onSuccess={handleReload}
onOpenChange={setOpenModal}
/>
</>
)
}
@@ -1,85 +0,0 @@
"use client";
import { useRouter } from "next/navigation";
import { ErrorContext } from "@better-fetch/fetch";
import { useMutation } from "@tanstack/react-query";
import { toast } from "sonner";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm } from "@/components/ui/form";
import { OrganizationSchema } from "@/components/wrappers/dashboard/admin/organizations/organization/organization.schema";
import { ButtonWithLoading } from "@/components/wrappers/common/button/button-with-loading";
import { Input } from "@/components/ui/input";
import { authClient } from "@/lib/auth/auth-client";
import { slugify } from "@/utils/slugify";
type AdminOrganizationFormProps = {
onSuccess?: () => void;
};
export const AdminOrganizationForm = ({ onSuccess }: AdminOrganizationFormProps) => {
const router = useRouter();
const form = useZodForm({ schema: OrganizationSchema });
const mutationCreateOrganisation = useMutation({
mutationFn: async ({ name }: OrganizationSchema) => {
const slug = slugify(name);
await authClient.organization.checkSlug(
{
slug: slug,
},
{
onSuccess: async () => {
await authClient.organization.create(
{
name: name,
slug: slug,
},
{
onSuccess: () => {
toast.success("Organization created successfully.");
router.refresh();
onSuccess?.();
},
onError: (error) => {
toast.error(error.error.message);
},
}
);
},
onError: (error: ErrorContext) => {
toast.error(error.error.message);
onSuccess?.();
},
}
);
},
});
return (
<Form
form={form}
className="flex flex-col gap-4"
onSubmit={async (values) => {
await mutationCreateOrganisation.mutateAsync(values);
}}
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Name of your organization" {...field} value={field.value ?? ""} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex gap-4 justify-end">
<ButtonWithLoading isPending={mutationCreateOrganisation.isPending}>Validate</ButtonWithLoading>
</div>
</Form>
);
};
@@ -1,27 +0,0 @@
"use client";
import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { AdminOrganizationList } from "@/components/wrappers/dashboard/admin/organizations/organization/admin-orgnization-list";
import { AdminOrganizationAddModal } from "@/components/wrappers/dashboard/admin/organizations/organization/admin-organization-add-modal";
import {OrganizationWithMembers} from "@/db/schema/03_organization";
type AdminOrganizationSectionProps = {
organizations: OrganizationWithMembers[];
};
export const AdminOrganizationSection = ({ organizations }: AdminOrganizationSectionProps) => {
return (
<Card>
<CardHeader>
<CardTitle>Add a new organization</CardTitle>
<CardAction>
<AdminOrganizationAddModal />
</CardAction>
</CardHeader>
<CardContent>
<AdminOrganizationList organizations={organizations} />
</CardContent>
</Card>
);
};
@@ -1,12 +0,0 @@
"use client"
import { DataTable } from "@/components/wrappers/common/table/data-table";
import { organizationsListColumns } from "@/components/wrappers/dashboard/admin/organizations/organization/table-colums";
import {OrganizationWithMembers} from "@/db/schema/03_organization";
type AdminOrganizationListProps = {
organizations: OrganizationWithMembers[];
};
export const AdminOrganizationList = ({ organizations }: AdminOrganizationListProps) => {
return <DataTable columns={organizationsListColumns()} data={organizations} enablePagination={true} enableSelect={false} />;
};
@@ -1,71 +0,0 @@
"use client"
import {useRouter} from "next/navigation";
import {useMutation} from "@tanstack/react-query";
import {Trash2} from "lucide-react";
import {toast} from "sonner";
import {ButtonWithConfirm} from "@/components/wrappers/common/button/button-with-confirm";
import {authClient} from "@/lib/auth/auth-client";
import {deleteOrganizationAction} from "@/features/organization/organization.action";
export type ButtonDeleteFleetProps = {
text?: string;
organisationId: string
};
export const ButtonDeleteOrganization = (props: ButtonDeleteFleetProps) => {
const router = useRouter();
const {data: organizations, refetch} = authClient.useListOrganizations();
const mutationDeleteOrganisation = useMutation({
mutationFn: () => deleteOrganizationAction({id: props.organisationId}),
onSuccess: async (result) => {
if (result?.data?.success) {
await authClient.organization.setActive({
organizationSlug: "default",
});
toast.success("Organization deleted!");
router.refresh()
refetch()
} else {
toast.error("An error occurred.");
}
},
onError: (error: any) => {
console.error("network error:", error);
toast.error(error?.message || "A network error occurred.");
},
});
return (
<ButtonWithConfirm
title={props.text ? props.text : ""}
description={"Are you sure you want to delete this organization?"}
button={{
main: {
variant: "outline",
icon: <Trash2 color="red"/>,
},
confirm: {
className: "w-full",
text: "Delete",
icon: <Trash2/>,
variant: "destructive",
onClick: async () => {
await mutationDeleteOrganisation.mutateAsync()
},
},
cancel: {
className: "w-full",
text: "Cancel",
icon: <Trash2/>,
variant: "outline",
},
}}
isPending={mutationDeleteOrganisation.isPending}
/>
);
};
@@ -1,44 +0,0 @@
"use server";
import { ServerActionResult } from "@/types/action-type";
import { z } from "zod";
import { auth } from "@/lib/auth/auth";
import { MemberRoleType } from "@/types/common";
import { Member } from "better-auth/plugins/organization";
import {userAction} from "@/lib/safe-actions/actions";
export const addMemberOrganizationAction = userAction
.schema(
z.object({
userId: z.string(),
organizationId: z.string(),
role: z.string(),
})
)
.action(async ({ parsedInput }): Promise<ServerActionResult<Member | null>> => {
try {
const data = await auth.api.addMember({
body: {
userId: parsedInput.userId,
role: parsedInput.role as MemberRoleType,
organizationId: parsedInput.organizationId,
},
});
return {
success: true,
value: data,
actionSuccess: {
message: "Member added successfully",
},
};
} catch (error) {
return {
success: false,
actionError: {
message: "An error occurred while addinng member",
cause: error instanceof Error ? error.message : "Unknown error",
},
};
}
});
@@ -1,87 +0,0 @@
"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 {
AddMemberSchema,
AddMemberSchemaType
} from "@/components/wrappers/dashboard/admin/organizations/organization/organization.schema";
import {SearchInput} from "@/components/ui/search-input";
import {
addMemberOrganizationAction
} from "@/components/wrappers/dashboard/admin/organizations/organization/details/add-member.action";
import {toast} from "sonner";
import {OrganizationWithMembers, OrganizationWithMembersAndUsers} from "@/db/schema/03_organization";
import {User} from "@/db/schema/02_user";
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
type OrganizationAddMemberFormProps = {
onSuccessAction?: () => void;
users: User[];
organization: OrganizationWithMembersAndUsers;
};
export const OrganizationAddMemberForm = ({onSuccessAction, users, organization}: OrganizationAddMemberFormProps) => {
const organizationMemberUserIds = organization.members.map((member) => member.user.id);
const filteredUsers = users
.filter((user) => !organizationMemberUserIds.includes(user.id))
.map((user) => ({value: user.id, label: `${user.name} | ${user.email}`}));
const router = useRouter();
const form = useZodForm({schema: AddMemberSchema});
const mutationAddMemberOrganisation = useMutation({
mutationFn: async (data: AddMemberSchemaType) => {
const result = await addMemberOrganizationAction({
userId: data.userId,
organizationId: organization.id,
role: "member",
});
toast.success("Member successfully added!");
router.refresh();
onSuccessAction?.();
},
onError: (error) => {
toast.error(error.message);
onSuccessAction?.();
},
});
return (
<Form
form={form}
className="flex flex-col gap-4"
onSubmit={async (values) => {
await mutationAddMemberOrganisation.mutateAsync(values);
}}
>
<FormField
control={form.control}
name="userId"
render={({field}) => (
<FormItem>
<FormLabel>User</FormLabel>
<FormControl>
<SearchInput
name="userId"
placeholder="Enter a user email"
entries={filteredUsers}
onSelect={(entySelected: any) => {
field.onChange(entySelected.value);
}}
/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<div className="flex gap-4 justify-end">
<ButtonWithLoading
isPending={mutationAddMemberOrganisation.isPending}>Confirm</ButtonWithLoading>
</div>
</Form>
);
};
@@ -1,45 +0,0 @@
"use client";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger
} from "@/components/ui/dialog";
import {
OrganizationAddMemberForm
} from "@/components/wrappers/dashboard/admin/organizations/organization/details/organization-add-member-form";
import {useState} from "react";
import {UserPlus} from "lucide-react";
import {Button} from "@/components/ui/button";
import { OrganizationWithMembersAndUsers} from "@/db/schema/03_organization";
import {User} from "@/db/schema/02_user";
type OrganizationAddMemberModalProps = {
users: User[];
organization: OrganizationWithMembersAndUsers;
};
export const OrganizationAddMemberModal = ({users, organization}: OrganizationAddMemberModalProps) => {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>
<UserPlus className="w-4 h-4 mr-2"/>
Add member
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Add member to your organization</DialogTitle>
<DialogDescription>Select a user to add to your organization</DialogDescription>
</DialogHeader>
<OrganizationAddMemberForm users={users} organization={organization}
onSuccessAction={() => setOpen(!open)}/>
</DialogContent>
</Dialog>
);
};

Some files were not shown because too many files have changed in this diff Show More