Working on implementation of alert policies. Some refactoring in user profile and patching a bug in status endpoint api.

This commit is contained in:
charlesgauthereau
2025-11-28 23:10:37 +01:00
parent d968aa9c16
commit 934e635cdb
29 changed files with 3364 additions and 1551 deletions
@@ -1,8 +1,9 @@
import { z } from "zod";
export const LoginSchema = z.object({
email: z.string(),
password: z.string(),
});
email: z.string().email({message: "Email is invalid"}),
password: z.string().nonempty({message: "Password could not be empty"}),
})
export type LoginType = z.infer<typeof LoginSchema>;
@@ -1,22 +1,22 @@
import { cn } from "@/lib/utils";
import {cn} from "@/lib/utils";
export type ConnectionCircleProps = {
date?: Date | null;
};
export const ConnectionCircle = ({ date }: ConnectionCircleProps) => {
export const ConnectionCircle = ({date}: ConnectionCircleProps) => {
let style = "bg-gray-300 border-gray-400";
if (date instanceof Date && !isNaN(date.getTime())) {
const now = Date.now();
const timestamp = date.getTime();
const interval = now - timestamp;
const interval_seconds = (now - timestamp) / 1000;
console.log({ now, timestamp, interval });
console.log({now, timestamp, interval_seconds});
if (interval < 10000) {
if (interval_seconds < 55) {
style = "bg-green-400 border-green-600";
} else if (interval <= 20000) {
} else if (interval_seconds <= 60) {
style = "bg-orange-400 border-orange-600";
} else {
style = "bg-red-400 border-red-600";
@@ -27,5 +27,5 @@ export const ConnectionCircle = ({ date }: ConnectionCircleProps) => {
console.log(style);
return <div className={cn("w-5 h-5 rounded-full border-4", style)} />;
return <div className={cn("w-5 h-5 rounded-full border-4", style)}/>;
};
@@ -1,28 +1,36 @@
import {Icon} from "@iconify/react";
import {CircleHelp, KeyRound} from "lucide-react";
import {cn} from "@/lib/utils";
export const providerSwitch = (provider: string) => {
export const providerSwitch = (provider: string, small?: boolean) => {
switch (provider) {
case "google":
return (
<div className="p-4">
<Icon icon={"logos:google"} height="24" />
<div className={cn(small ? "p-0" : "p-4")}>
{small ?
<Icon icon={"flat-color-icons:google"} height="24"/>
:
<Icon icon={"logos:google"} height="24"/>
}
</div>
);
case "credential":
return (
<div className="flex flex-row gap-x-2 items-center p-4">
<KeyRound height="24" />
<span>Email and Password</span>
<div className={cn("flex flex-row gap-x-2 items-center", small ? "p-0" : "p-4")}>
<KeyRound height="24"/>
{!small && (<span>Email and Password</span>)}
</div>
);
default:
return (
<div className="flex flex-row gap-x-2 items-center p-4">
<CircleHelp height="24" />
<span>No credentials</span>
<div className={cn("flex flex-row gap-x-2 items-center", small ? "p-0" : "p-4")}>
<CircleHelp height="24"/>
{!small && (<span>No credentials</span>)}
</div>
);
}
}
}
@@ -6,22 +6,17 @@ import {toast} from "sonner";
import {useRouter} from "next/navigation";
import {unlinkUserProviderAction} from "@/components/wrappers/dashboard/profile/user-form/user-form.action";
import {providerSwitch} from "@/components/wrappers/common/provider-switch";
import {Account} from "better-auth";
export const accountsColumns: ColumnDef<{
id: string;
provider: string;
createdAt: Date;
updatedAt: Date;
accountId: string;
scopes: string[];
}>[] = [
export const accountsColumns: ColumnDef<Account>[] = [
{
id: "provider",
header: "Provider",
cell: ({row}) => {
return (
<div>
{providerSwitch(row.original.provider)}
{providerSwitch(row.original.providerId)}
</div>
)
@@ -36,7 +31,7 @@ export const accountsColumns: ColumnDef<{
const mutation = useMutation({
mutationFn: async () => {
if (row.original.provider === "credential") {
if (row.original.providerId === "credential") {
toast.error(`This provider cannot be unlinked.`);
router.refresh();
return;
@@ -49,7 +44,7 @@ export const accountsColumns: ColumnDef<{
}
const status = await unlinkUserProviderAction({
provider: row.original.provider,
provider: row.original.providerId,
account: row.original.accountId,
});
@@ -66,7 +61,7 @@ export const accountsColumns: ColumnDef<{
<div className="flex items-center gap-2">
<ButtonWithLoading
variant="outline"
disabled={row.original.provider === "credential" || table.getRowModel().rows.length <= 1}
disabled={row.original.providerId === "credential" || table.getRowModel().rows.length <= 1}
icon={<Unlink color="red" size={15}/>}
onClick={async () => {
await mutation.mutateAsync();
@@ -68,13 +68,13 @@ export const usersColumnsAdmin: ColumnDef<UserWithAccounts>[] = [
},
{
accessorKey: "accounts",
header: "Provider ID",
header: "Provider(s)",
cell: ({row}) => {
return (
<div>
<div className="flex flex-row items-center gap-x-2">
{row.original.accounts.map((item) => (
<div key={item.id}>
{providerSwitch(item.providerId)}
{providerSwitch(item.providerId, true)}
</div>
))}
</div>
@@ -93,9 +93,11 @@ export const usersColumnsAdmin: ColumnDef<UserWithAccounts>[] = [
id: "actions",
cell: ({row}) => {
const router = useRouter();
const {data: session, isPending} = useSession();
const {data: session, isPending, error} = useSession();
const isSuperAdmin = session?.user.role == "superadmin";
if (isPending || error) return null;
return (
<ButtonDeleteUser
disabled={!isSuperAdmin || !session || session?.user.email === row.original.email}
@@ -1,33 +1,34 @@
import { ButtonWithLoading } from "@/components/wrappers/common/button/button-with-loading";
import { useMutation } from "@tanstack/react-query";
import { ColumnDef } from "@tanstack/react-table";
import { Session } from "better-auth";
import { Unlink } from "lucide-react";
import { toast } from "sonner";
import { useRouter } from "next/navigation";
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
import {useMutation} from "@tanstack/react-query";
import {ColumnDef} from "@tanstack/react-table";
import {Session} from "better-auth";
import {Unlink} from "lucide-react";
import {toast} from "sonner";
import {useRouter} from "next/navigation";
import detectOSWithUA from "@/utils/os-parser";
import { Icon } from "@iconify/react";
import { authClient } from "@/lib/auth/auth-client";
import { timeAgo } from "@/utils/date-formatting";
import {Icon} from "@iconify/react";
import {authClient} from "@/lib/auth/auth-client";
import {timeAgo} from "@/utils/date-formatting";
import {deleteUserSessionAction} from "@/components/wrappers/dashboard/profile/user-form/user-form.action";
export const sessionsColumns: ColumnDef<Session>[] = [
{
accessorKey: "expiresAt",
header: "Expires At",
cell: ({ row }) => {
cell: ({row}) => {
return timeAgo(row.original.expiresAt);
},
},
{
id: "device",
header: "Device",
cell: ({ row }) => {
cell: ({row}) => {
const os = detectOSWithUA(row.original.userAgent!);
return (
<div className="flex flex-row gap-x-2 items-center pt-4 pb-4">
{os.icon && <Icon icon={`logos:${os.icon.name}`} height={os.icon.size.height} width={os.icon.size.width} />}
{os.icon &&
<Icon icon={`logos:${os.icon.name}`} height={os.icon.size.height} width={os.icon.size.width}/>}
{os.showText && <span>{os.name}</span>}
</div>
);
@@ -40,10 +41,11 @@ export const sessionsColumns: ColumnDef<Session>[] = [
{
header: "Action",
id: "actions",
cell: ({ row }) => {
cell: ({row}) => {
const router = useRouter();
const { data: session } = authClient.useSession();
const {data: session, isPending, error} = authClient.useSession();
const mutation = useMutation({
mutationFn: async () => {
@@ -64,12 +66,17 @@ export const sessionsColumns: ColumnDef<Session>[] = [
},
});
if (isPending || error) return null;
return (
<div className="flex items-center gap-2">
<ButtonWithLoading
isPending={mutation.isPending}
variant="outline"
disabled={session?.session.id === row.original.id}
icon={<Unlink color="red" size={15} />}
icon={<Unlink color="red" size={15}/>}
onClick={async () => {
await mutation.mutateAsync();
}}
@@ -43,7 +43,7 @@ export const NotifierAddEditModal = ({
</Button>
}
</DialogTrigger>
<DialogContent>
<DialogContent onOpenAutoFocus={(e) => e.preventDefault()}>
<DialogHeader>
<DialogTitle> {isCreate ? "Add" : "Edit"} Notification Channel</DialogTitle>
<DialogDescription>
@@ -33,7 +33,7 @@ export const DeleteNotifierButton = ({notificationChannelId, organizationId}: De
return (
<ButtonWithConfirm
title="Delete Notifier"
description="Are you sure you want to remove this notifier? This action cannot be undone."
description="Are you sure you want to remove this notifier ? This action cannot be undone and will delete all alert policies related to this channel !"
button={{
main: {
size: "icon",
@@ -153,7 +153,7 @@ export const updateNotificationChannelAction = userAction.schema(
config: channel.config as JSON
},
actionSuccess: {
message: "Notification channel has been successfully updated.",
message: `Notification channel "${channel.name}" has been successfully updated.`,
messageParams: {notificationChannelId: channel.id},
},
};
@@ -25,6 +25,7 @@ import {NotificationChannel} from "@/db/schema/09_notification-channel";
import {
NotifierTestChannelButton
} from "@/components/wrappers/dashboard/common/notifier/notifier-form/notifier-test-channel-button";
import {useEffect} from "react";
type NotifierFormProps = {
onSuccessAction?: () => void;
@@ -35,16 +36,17 @@ type NotifierFormProps = {
export const NotifierForm = ({onSuccessAction, organization, defaultValues}: NotifierFormProps) => {
const isCreate = !Boolean(defaultValues);
const router = useRouter();
const form = useZodForm({
schema: NotificationChannelFormSchema,
// @ts-ignore
defaultValues: {...defaultValues},
});
useEffect(() => {
form.reset(defaultValues ? {...defaultValues} : {});
}, [defaultValues]);
const mutationCreateOrganisation = useMutation({
mutationFn: async (values: NotificationChannelFormType) => {
@@ -60,11 +62,11 @@ export const NotifierForm = ({onSuccessAction, organization, defaultValues}: Not
if (inner?.success) {
toast.success(inner.actionSuccess?.message);
onSuccessAction?.();
isCreate && onSuccessAction?.();
router.refresh();
} else {
toast.error(inner?.actionError?.message);
onSuccessAction?.();
isCreate && onSuccessAction?.();
}
}
});
@@ -112,8 +114,8 @@ export const NotifierForm = ({onSuccessAction, organization, defaultValues}: Not
<SelectContent>
<SelectItem value="smtp">SMTP (Email)</SelectItem>
<SelectItem value="slack">Slack</SelectItem>
<SelectItem value="curl">Curl</SelectItem>
<SelectItem value="webhook">Webhook</SelectItem>
{/*<SelectItem value="curl">Curl</SelectItem>*/}
{/*<SelectItem value="webhook">Webhook</SelectItem>*/}
</SelectContent>
</Select>
</FormControl>
@@ -137,7 +139,7 @@ export const NotifierForm = ({onSuccessAction, organization, defaultValues}: Not
<NotifierTestChannelButton notificationChannel={defaultValues}/>
)}
</div>
<div className="flex gap-2 ">
<div className="flex gap-2">
<Button
type="button"
variant="outline"
@@ -149,7 +151,7 @@ export const NotifierForm = ({onSuccessAction, organization, defaultValues}: Not
Cancel
</Button>
<ButtonWithLoading isPending={mutationCreateOrganisation.isPending}>
Add Channel
{isCreate ? "Add" : "Save"} Channel
</ButtonWithLoading>
</div>
@@ -2,6 +2,7 @@
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";
type NotifierSmtpFormProps = {
@@ -11,6 +12,7 @@ type NotifierSmtpFormProps = {
export const NotifierSlackForm = ({form}: NotifierSmtpFormProps) => {
return(
<>
<Separator className="my-1"/>
<FormField
control={form.control}
name="config.slackWebhook"
@@ -13,7 +13,7 @@ type NotifierSmtpFormProps = {
export const NotifierSmtpForm = ({form}: NotifierSmtpFormProps) => {
return (
<>
<Separator className="my-4"/>
<Separator className="my-1"/>
<FormField
control={form.control}
name="config.host"
@@ -1,5 +1,5 @@
"use client"
import {Megaphone} from "lucide-react";
import {Filter, Megaphone} from "lucide-react";
import {useState} from "react";
import {
@@ -12,12 +12,13 @@ import {
} from "@/components/ui/dialog";
import {Button} from "@/components/ui/button";
import {AlertPolicyForm} from "@/components/wrappers/dashboard/database/alert-policy/alert-policy-form";
import {Database} from "@/db/schema/07_database";
import {DatabaseWith} from "@/db/schema/07_database";
import {NotificationChannel} from "@/db/schema/09_notification-channel";
import {Separator} from "@/components/ui/separator";
import {Badge} from "@/components/ui/badge";
type AlertPolicyModalProps = {
database: Database;
database: DatabaseWith;
notificationChannels: NotificationChannel[];
organizationId: string;
}
@@ -28,8 +29,15 @@ export const AlertPolicyModal = ({database, notificationChannels, organizationId
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline">
<Button variant="outline" onClick={() => setOpen(true)} className="relative">
<Megaphone/>
{database.alertPolicies && database.alertPolicies.length > 0 && (
<Badge
className="absolute -top-1.5 -right-1.5 h-4 w-4 rounded-full p-0 text-[10px] flex items-center justify-center"
>
{database.alertPolicies.length}
</Badge>
)}
</Button>
</DialogTrigger>
<DialogContent>
@@ -61,7 +61,6 @@ export const OrganizationNotifiersTab = ({
return (
<div className="flex flex-col gap-y-6 h-full py-4">
<div className="h-full flex flex-col gap-y-6">
<div className={cn("hidden flex-row justify-between items-start", hasNotifiers && "flex")}>
<div className="max-w-2xl">
@@ -74,6 +73,7 @@ export const OrganizationNotifiersTab = ({
</p>
</div>
<NotifierAddEditModal
organization={organization}
open={isAddModalOpen}
onOpenChangeAction={setIsAddModalOpen}
@@ -6,6 +6,7 @@ import { db } from "@/db";
import { eq } from "drizzle-orm";
import * as drizzleDb from "@/db";
import {revokeSession, unlinkAccount} from "@/lib/auth/auth";
import {withUpdatedAt} from "@/db/utils";
export const updateUserAction = userAction
.schema(
@@ -15,7 +16,7 @@ export const updateUserAction = userAction
})
)
.action(async ({ parsedInput }) => {
const [updatedUser] = await db.update(drizzleDb.schemas.user).set(parsedInput.data).where(eq(drizzleDb.schemas.user.id, parsedInput.id)).returning();
const [updatedUser] = await db.update(drizzleDb.schemas.user).set(withUpdatedAt(parsedInput.data)).where(eq(drizzleDb.schemas.user.id, parsedInput.id)).returning();
return {
data: updatedUser,
};
@@ -14,20 +14,13 @@ import { updateUserAction } from "@/components/wrappers/dashboard/profile/user-f
import {DataTable} from "@/components/wrappers/common/table/data-table";
import {sessionsColumns} from "@/components/wrappers/dashboard/admin/tabs/admin-user-tab/sessions/table-columns";
import {accountsColumns} from "@/components/wrappers/dashboard/admin/tabs/admin-user-tab/accounts/table-columns";
import {Session} from "better-auth";
import {Account, Session} from "better-auth";
export type UserFormProps = {
defaultValues?: UserType;
userId?: string;
sessions: Session[];
accounts: {
id: string;
provider: string;
createdAt: Date;
updatedAt: Date;
accountId: string;
scopes: string[];
}[];
accounts?: Account[];
};
export const UserForm = (props: UserFormProps) => {
@@ -120,7 +113,7 @@ export const UserForm = (props: UserFormProps) => {
<CardDescription>Manage your active auth providers</CardDescription>
</CardHeader>
<CardContent>
<DataTable columns={accountsColumns} data={props.accounts} enableSelect={false}/>
<DataTable columns={accountsColumns} data={props.accounts ?? []} enableSelect={false}/>
</CardContent>
</Card>
</TooltipProvider>
@@ -110,15 +110,14 @@ export const DatabaseBackupList = (props: DatabaseBackupListProps) => {
selectedActions={(rows) => (
<>
<div className="flex justify-start md:justify-between gap-3 md:gap-0 items-center w-full ml-0">
<div className="flex gap-2">
{!isMember && (
<div className="flex justify-start md:justify-between gap-3 md:gap-0 items-center w-full ml-0">
<div className="flex gap-2">
{!isMember && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<ButtonWithLoading
variant="outline"
onClick={() => {
}}
disabled={rows.length === 0 || mutationDeleteBackups.isPending}
icon={<MoreHorizontal/>}
@@ -139,15 +138,15 @@ export const DatabaseBackupList = (props: DatabaseBackupListProps) => {
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
<FiltersDropdown
items={items}
selectedItems={selectedFilters}
onSelect={handleSelectFilter}
clearFilters={clearFilters}
/>
</div>
)}
<FiltersDropdown
items={items}
selectedItems={selectedFilters}
onSelect={handleSelectFilter}
clearFilters={clearFilters}
/>
</div>
</div>
</>
)}
@@ -2,9 +2,9 @@
import Link from "next/link";
import Image from "next/image";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { ConnectionCircle } from "@/components/wrappers/common/connection-circle";
import { formatDateLastContact } from "@/utils/date-formatting";
import {Card, CardContent, CardHeader} from "@/components/ui/card";
import {ConnectionCircle} from "@/components/wrappers/common/connection-circle";
import {formatDateLastContact} from "@/utils/date-formatting";
import {Database} from "@/db/schema/07_database";
import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar";
@@ -15,11 +15,12 @@ export type projectDatabaseCardProps = {
};
export const ProjectDatabaseCard = (props: projectDatabaseCardProps) => {
const { organizationSlug, data: database, extendedProps: extendedProps } = props;
const {organizationSlug, data: database, extendedProps: extendedProps} = props;
return (
<Link className="block transition-all duration-200 hover:scale-[1.01] hover:shadow-md rounded-xl" href={`/dashboard/projects/${extendedProps.id}/database/${database.id}`}>
<DatabaseCard data={database} />
<Link className="block transition-all duration-200 hover:scale-[1.01] hover:shadow-md rounded-xl"
href={`/dashboard/projects/${extendedProps.id}/database/${database.id}`}>
<DatabaseCard data={database}/>
</Link>
);
};
@@ -29,12 +30,13 @@ export type databaseCardProps = {
};
export const DatabaseCard = (props: databaseCardProps) => {
const { data: database } = props;
const {data: database} = props;
return (
<Card className="flex flex-row justify-between">
<div className="flex items-center space-x-4 px-4">
<Image src={`/images/${database.dbms}.png`} alt="Database type Icon" width={60} height={60} className="object-cover ml-4" />
<Card className="flex flex-row justify-between space-x-2 ">
<div className="flex items-center justify-start w-full space-x-5 ">
<Image src={`/images/${database.dbms}.png`} alt="Database type Icon" width={60} height={60}
className="object-cover ml-4"/>
<div className="justify-between">
<div className="font-medium">Name: {database.name}</div>
<div className="text-sm text-muted-foreground">Generated Id: {database.agentDatabaseId}</div>
@@ -43,9 +45,8 @@ export const DatabaseCard = (props: databaseCardProps) => {
</div>
</div>
</div>
<div className="flex items-center px-4">
<ConnectionCircle date={database.lastContact} />
<div className="flex items-center px-5 justify-center">
<ConnectionCircle date={database.lastContact}/>
</div>
</Card>
);
@@ -0,0 +1,3 @@
ALTER TABLE "alert_policy" DROP CONSTRAINT "alert_policy_notification_channel_id_notification_channel_id_fk";
--> statement-breakpoint
ALTER TABLE "alert_policy" ADD CONSTRAINT "alert_policy_notification_channel_id_notification_channel_id_fk" FOREIGN KEY ("notification_channel_id") REFERENCES "public"."notification_channel"("id") ON DELETE cascade ON UPDATE no action;
File diff suppressed because it is too large Load Diff
+7
View File
@@ -64,6 +64,13 @@
"when": 1763914207236,
"tag": "0008_aberrant_scorpion",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1764364481615,
"tag": "0009_lucky_edwin_jarvis",
"breakpoints": true
}
]
}
+1 -1
View File
@@ -12,7 +12,7 @@ export const alertPolicy = pgTable('alert_policy', {
id: uuid('id').defaultRandom().primaryKey(),
notificationChannelId: uuid('notification_channel_id')
.notNull()
.references(() => notificationChannel.id, {onDelete: 'restrict'}),
.references(() => notificationChannel.id, {onDelete: 'cascade'}),
eventKinds: eventKindEnum("event_kind").array().notNull(),
enabled: boolean('enabled').default(true).notNull(),
databaseId: uuid('database_id')