This commit is contained in:
Théo LAGACHE
2026-06-24 16:24:35 +02:00
parent 7a9784244b
commit 8cf164d229
4 changed files with 246 additions and 232 deletions
@@ -1,89 +1,86 @@
"use client";
import {Card} from "@/components/ui/card";
import {NotificationChannelWith} from "@/db/schema/09_notification-channel";
import {Badge} from "@/components/ui/badge";
import {OrganizationWithMembers} from "@/db/schema/03_organization";
import {truncateWords} from "@/utils/text";
import {useIsMobile} from "@/hooks/use-mobile";
import {StorageChannelWith} from "@/db/schema/12_storage-channel";
import { Card } from "@/components/ui/card";
import { NotificationChannelWith } from "@/db/schema/09_notification-channel";
import { Badge } from "@/components/ui/badge";
import { OrganizationWithMembers } from "@/db/schema/03_organization";
import { truncateWords } from "@/utils/text";
import { useIsMobile } from "@/hooks/use-mobile";
import { StorageChannelWith } from "@/db/schema/12_storage-channel";
import { EditChannelButton } from "@/features/channel/components/channel-edit-button";
import {
EditChannelButton
} from "@/features/channel/components/channel-edit-button";
import {ChannelKind, getChannelIcon} from "@/features/channel/components/channels-helpers";
import {
DeleteChannelButton
} from "@/features/channel/components/channel-delete-button";
ChannelKind,
getChannelIcon,
} from "@/features/channel/components/channels-helpers";
import { DeleteChannelButton } from "@/features/channel/components/channel-delete-button";
export type ChannelCardProps = {
data: NotificationChannelWith | StorageChannelWith;
organization?: OrganizationWithMembers;
organizations?: OrganizationWithMembers[];
adminView?: boolean;
kind?: ChannelKind;
defaultStorageChannelId?: string | null | undefined
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 { data, organization, kind, defaultStorageChannelId } = props;
const isMobile = useIsMobile();
const isDefaultSystemStorage = defaultStorageChannelId === data.id;
const isDefaultSystemStorage = defaultStorageChannelId === data.id;
const isOwned = data.organizationId ? true : !organization;
const isLocalSystem = data.provider == "local";
const isSystemChannel = data.organizationId === null;
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 && !isSystemChannel && (
<DeleteChannelButton
kind={kind}
organizationId={organization?.id}
channelId={data.id}
/>
)}
</>
)}
</div>
)}
</Card>
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,190 +1,200 @@
"use server";
import {z} from "zod";
import {ServerActionResult} from "@/types/action-type";
import { z } from "zod";
import { ServerActionResult } from "@/types/action-type";
import * as drizzleDb from "@/db";
import {userAction} from "@/lib/safe-actions/actions";
import {NotificationChannel} from "@/db/schema/09_notification-channel";
import {db} from "@/db";
import {and, eq} from "drizzle-orm";
import {withUpdatedAt} from "@/db/utils";
import {
NotificationChannelFormSchema
} from "@/features/channel/schemas/channel-form.schema";
import { userAction } from "@/lib/safe-actions/actions";
import { NotificationChannel } from "@/db/schema/09_notification-channel";
import { db } from "@/db";
import { and, eq } from "drizzle-orm";
import { withUpdatedAt } from "@/db/utils";
import { NotificationChannelFormSchema } from "@/features/channel/channel-form.schema";
export const addNotificationChannelAction = userAction.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 {
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();
.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,
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},
},
success: true,
value: {
...channel,
config: channel.config as JSON,
},
actionSuccess: {
message: "Notification channel has been successfully created.",
messageParams: { notificationChannelId: channel.id },
},
};
} catch (_error) {
} catch (_error) {
const error = _error;
return {
success: false,
actionError: {
message: "Failed to create notification channel.",
status: 500,
cause: error instanceof Error ? error.message : "Unknown error",
messageParams: {notificationChannelId: ""},
},
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(
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 {
const existing = await db.query.notificationChannel.findFirst({
where: eq(drizzleDb.schemas.notificationChannel.id, notificationChannelId),
});
if (!existing) {
return {
success: false,
actionError: { message: "Notification channel not found.", status: 404, messageParams: { notificationChannelId } },
};
}
if (existing.organizationId === null) {
return {
success: false,
actionError: { message: "System notification channels cannot be deleted.", status: 403, messageParams: { notificationChannelId } },
};
}
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)
)
);
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();
.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: 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},
},
success: true,
value: {
...deletedChannel,
config: deletedChannel.config as JSON,
},
actionSuccess: {
message: "Notification channel has been successfully removed.",
messageParams: { notificationChannelId: notificationChannelId },
},
};
} catch (_error) {
} catch (_error) {
const error = _error;
return {
success: false,
actionError: {
message: "Failed to remove notification channel.",
status: 500,
cause: error instanceof Error ? error.message : "Unknown error",
messageParams: {notificationChannelId: notificationChannelId},
},
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(
export const updateNotificationChannelAction = userAction
.schema(
z.object({
id: z.string(),
data: NotificationChannelFormSchema
})
).action(async ({parsedInput}): Promise<ServerActionResult<NotificationChannel>> => {
const {id, data} = parsedInput;
id: z.string(),
data: NotificationChannelFormSchema,
}),
)
.action(
async ({
parsedInput,
}): Promise<ServerActionResult<NotificationChannel>> => {
const { id, data } = parsedInput;
try {
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();
.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},
},
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) {
} catch (_error) {
const error = _error;
return {
success: false,
actionError: {
message: "Failed to update notification channel.",
status: 500,
cause: error instanceof Error ? error.message : "Unknown error",
messageParams: {id: ""},
},
success: false,
actionError: {
message: "Failed to update notification channel.",
status: 500,
cause: error instanceof Error ? error.message : "Unknown error",
messageParams: { id: "" },
},
};
}
});
}
},
);
@@ -31,9 +31,6 @@ export const OnboardingChecklist = () => {
return (
<div className="flex flex-col gap-0 p-6 h-full">
<p className="text-xs font-medium text-zinc-500 uppercase tracking-wider mb-4">
Progress
</p>
<div className="flex flex-col relative">
{CHECKLIST_STEPS.map((step, i) => {
const isCompleted = i < currentIndex;
+15 -5
View File
@@ -160,7 +160,7 @@ export const auth = betterAuth({
rateLimit: {
enabled: true,
maxRequests: 100,
timeWindow: 1000 * 60 * 60, // 100 requests / hour
timeWindow: 1000 * 60 * 60,
},
},
{
@@ -170,7 +170,7 @@ export const auth = betterAuth({
rateLimit: {
enabled: true,
maxRequests: 1000,
timeWindow: 1000 * 60 * 60, // 1000 requests / hour
timeWindow: 1000 * 60 * 60,
},
},
{
@@ -180,7 +180,7 @@ export const auth = betterAuth({
rateLimit: {
enabled: true,
maxRequests: 10_000,
timeWindow: 1000 * 60 * 60, // 10k requests / hour
timeWindow: 1000 * 60 * 60,
},
},
]),
@@ -436,7 +436,7 @@ export const auth = betterAuth({
},
user: {
update: {
async before(user, context) {
async before(user) {
if (env.AUTH_EMAIL_PASSWORD_ENABLED !== "true") {
if (user.password || user.lastChangedPasswordAt) {
throw new APIError("FORBIDDEN", {
@@ -450,7 +450,7 @@ export const auth = betterAuth({
},
},
create: {
async before(user, context) {
async before(user) {
const userCount = (
await db.select({ count: count() }).from(drizzleDb.schemas.user)
)[0].count;
@@ -461,6 +461,16 @@ export const auth = betterAuth({
});
}
if (env.SKIP_ONBOARDING === "false" && userCount > 0) {
const settings = await db.query.setting.findFirst();
if (!settings?.onboarding) {
throw new APIError("FORBIDDEN", {
message:
"Registration is disabled: onboarding not yet completed.",
});
}
}
const role = userCount === 0 ? "superadmin" : "pending";
return {