mirror of
https://github.com/Portabase/portabase.git
synced 2026-07-14 11:16:13 +02:00
Refactoring.
This commit is contained in:
Binary file not shown.
@@ -0,0 +1,47 @@
|
|||||||
|
import {notFound} from "next/navigation";
|
||||||
|
import {eq} from "drizzle-orm";
|
||||||
|
import {db} from "@/db";
|
||||||
|
import * as drizzleDb from "@/db";
|
||||||
|
import {PageParams} from "@/types/next";
|
||||||
|
import {Page} from "@/features/layout/page";
|
||||||
|
import {OrganizationManagement} from "@/components/wrappers/dashboard/admin/organization/organization-management";
|
||||||
|
import {buildOrganizationWithMembers} from "@/utils/common";
|
||||||
|
import {isUUID} from "@/utils/text";
|
||||||
|
import {user} from "@/db/schema/02_user";
|
||||||
|
import {invitation} from "@/db/schema/05_invitation";
|
||||||
|
import {member} from "@/db/schema/04_member";
|
||||||
|
import {organization} from "@/db/schema/03_organization";
|
||||||
|
import {user as drizzleUser} from "@/db/schema/02_user";
|
||||||
|
|
||||||
|
|
||||||
|
export default async function RoutePage(props: PageParams<{ organizationId: string }>) {
|
||||||
|
const {organizationId} = await props.params;
|
||||||
|
|
||||||
|
if (!organizationId) {
|
||||||
|
return notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isUUID(organizationId)) {
|
||||||
|
return notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const users = await db.select().from(drizzleUser);
|
||||||
|
|
||||||
|
const organizationData = await db
|
||||||
|
.select({organization, member, user, invitation})
|
||||||
|
.from(organization)
|
||||||
|
.leftJoin(member, eq(drizzleDb.schemas.organization.id, member.organizationId))
|
||||||
|
.leftJoin(invitation, eq(drizzleDb.schemas.invitation.id, invitation.organizationId))
|
||||||
|
.leftJoin(user, eq(drizzleDb.schemas.member.userId, user.id))
|
||||||
|
.where(eq(organization.id, organizationId));
|
||||||
|
|
||||||
|
const formattedData = buildOrganizationWithMembers(organizationData);
|
||||||
|
|
||||||
|
if (!formattedData) return notFound();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page>
|
||||||
|
<OrganizationManagement organization={formattedData} users={users}/>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,17 +14,28 @@ export default async function RoutePage(props: PageParams<{}>) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const organizations = await db.query.organization.findMany({
|
||||||
|
where: (fields) => isNull(fields.deletedAt),
|
||||||
|
with: {
|
||||||
|
members: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const settings = await db.query.setting.findFirst({
|
const settings = await db.query.setting.findFirst({
|
||||||
where: (fields, {eq}) => eq(fields.name, "system"),
|
where: (fields, {eq}) => eq(fields.name, "system"),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
<PageHeader>
|
<PageHeader>
|
||||||
<PageTitle>Administration Panel</PageTitle>
|
<PageTitle>Administration Panel</PageTitle>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
<PageContent>
|
<PageContent>
|
||||||
<AdminTabs settings={settings!} users={users}/>
|
<AdminTabs
|
||||||
|
organizations={organizations}
|
||||||
|
settings={settings!}
|
||||||
|
users={users}/>
|
||||||
</PageContent>
|
</PageContent>
|
||||||
</Page>
|
</Page>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -33,8 +33,6 @@ export async function POST(
|
|||||||
const generatedId = formData.get("generatedId") as string | null;
|
const generatedId = formData.get("generatedId") as string | null;
|
||||||
const method = formData.get("method") as string | null;
|
const method = formData.get("method") as string | null;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if (!generatedId || !isUuidv4(generatedId)) {
|
if (!generatedId || !isUuidv4(generatedId)) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{error: "generatedId is not a valid UUID"},
|
{error: "generatedId is not a valid UUID"},
|
||||||
@@ -123,9 +121,7 @@ export async function POST(
|
|||||||
|
|
||||||
const uuid = uuidv4();
|
const uuid = uuidv4();
|
||||||
const fileName = `${uuid}.dump`;
|
const fileName = `${uuid}.dump`;
|
||||||
// const buffer = Buffer.from(await fileDecrypted.arrayBuffer());
|
|
||||||
const buffer = Buffer.from(await decryptedFile.arrayBuffer());
|
const buffer = Buffer.from(await decryptedFile.arrayBuffer());
|
||||||
// const buffer = fileDecrypted
|
|
||||||
|
|
||||||
const [settings] = await db.select().from(drizzleDb.schemas.setting).where(eq(drizzleDb.schemas.setting.name, "system")).limit(1);
|
const [settings] = await db.select().from(drizzleDb.schemas.setting).where(eq(drizzleDb.schemas.setting.name, "system")).limit(1);
|
||||||
if (!settings) {
|
if (!settings) {
|
||||||
|
|||||||
+22
-12
@@ -1,9 +1,19 @@
|
|||||||
import { EventEmitter } from 'events';
|
import {EventEmitter} from 'events';
|
||||||
|
import {auth} from "@/lib/auth/auth";
|
||||||
|
import {headers} from "next/headers";
|
||||||
|
import {NextResponse} from "next/server";
|
||||||
|
|
||||||
export const eventEmitter = new EventEmitter();
|
export const eventEmitter = new EventEmitter();
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
console.log('GET request received');
|
|
||||||
|
const session = await auth.api.getSession({
|
||||||
|
headers: await headers(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({error: "Unauthorized"}, {status: 403});
|
||||||
|
}
|
||||||
|
|
||||||
return new Response(
|
return new Response(
|
||||||
new ReadableStream({
|
new ReadableStream({
|
||||||
@@ -37,13 +47,13 @@ export async function GET(request: Request) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
// export async function POST(request: Request) {
|
||||||
console.log('POST request received');
|
// console.log('POST request received');
|
||||||
const data = await request.json();
|
// const data = await request.json();
|
||||||
console.log('Data received:', data);
|
// console.log('Data received:', data);
|
||||||
|
//
|
||||||
// Emit the event to all connected clients
|
// // Emit the event to all connected clients
|
||||||
eventEmitter.emit('modification', data);
|
// eventEmitter.emit('modification', data);
|
||||||
|
//
|
||||||
return new Response('Event sent', { status: 200 });
|
// return new Response('Event sent', {status: 200});
|
||||||
}
|
// }
|
||||||
@@ -12,21 +12,16 @@ export async function GET(
|
|||||||
const expires = searchParams.get('expires');
|
const expires = searchParams.get('expires');
|
||||||
const fileName = (await params).fileName
|
const fileName = (await params).fileName
|
||||||
|
|
||||||
console.log(token);
|
|
||||||
console.log(fileName);
|
|
||||||
|
|
||||||
const uploadsDir = "private/uploads/files/";
|
const uploadsDir = "private/uploads/files/";
|
||||||
const keysDir = "private/keys/";
|
|
||||||
const uploadPath = path.join(uploadsDir, fileName);
|
const uploadPath = path.join(uploadsDir, fileName);
|
||||||
const keyPath = path.join(keysDir, fileName);
|
|
||||||
|
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
|
|
||||||
let filePath = uploadPath;
|
let filePath = null;
|
||||||
if (!fs.existsSync(uploadPath)) {
|
if (fs.existsSync(uploadPath)) {
|
||||||
if (fs.existsSync(keyPath)) filePath = keyPath;
|
filePath = uploadPath;
|
||||||
else
|
} else {
|
||||||
return NextResponse.json({error: "File not found"}, {status: 404});
|
return NextResponse.json({error: "File not found"}, {status: 404})
|
||||||
}
|
}
|
||||||
|
|
||||||
const expectedToken = crypto.createHash('sha256').update(`${fileName}${expires}`).digest('hex');
|
const expectedToken = crypto.createHash('sha256').update(`${fileName}${expires}`).digest('hex');
|
||||||
@@ -36,8 +31,8 @@ export async function GET(
|
|||||||
{status: 403}
|
{status: 403}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
//@ts-ignore
|
|
||||||
const expiresAt = parseInt(expires, 10);
|
const expiresAt = parseInt(expires!, 10);
|
||||||
if (Date.now() > expiresAt) {
|
if (Date.now() > expiresAt) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{error: 'Signed token expired'},
|
{error: 'Signed token expired'},
|
||||||
|
|||||||
@@ -1,38 +1,103 @@
|
|||||||
|
import {NextResponse} from "next/server";
|
||||||
|
import {auth} from "@/lib/auth/auth";
|
||||||
|
import {headers} from "next/headers";
|
||||||
|
import {checkFileExistsInBucket, getObjectFromClient} from "@/utils/s3-file-management";
|
||||||
|
import {env} from "@/env.mjs";
|
||||||
|
import * as stream from "node:stream";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
import {db} from "@/db";
|
||||||
|
import * as drizzleDb from "@/db";
|
||||||
|
import {eq} from "drizzle-orm";
|
||||||
import fs from "fs/promises";
|
import fs from "fs/promises";
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
|
function nodeStreamToWebStream(nodeStream: stream.Readable) {
|
||||||
|
return new ReadableStream({
|
||||||
|
start(controller) {
|
||||||
|
nodeStream.on("data", chunk => controller.enqueue(chunk));
|
||||||
|
nodeStream.on("end", () => controller.close());
|
||||||
|
nodeStream.on("error", err => controller.error(err));
|
||||||
|
},
|
||||||
|
cancel() {
|
||||||
|
nodeStream.destroy();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const privateS3ImageDir = "images/";
|
||||||
|
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
request: Request,
|
req: Request,
|
||||||
{params}: { params: Promise<{ fileName: string }> }
|
{params}: { params: Promise<{ fileName: string }> }
|
||||||
) {
|
) {
|
||||||
|
const fileName = (await params).fileName;
|
||||||
|
if (!fileName) return NextResponse.json({error: "Missing file parameter"}, {status: 400});
|
||||||
|
|
||||||
|
const session = await auth.api.getSession({headers: await headers()});
|
||||||
|
if (!session) return NextResponse.json({error: "Unauthorized"}, {status: 403});
|
||||||
|
|
||||||
|
const [settings] = await db
|
||||||
|
.select()
|
||||||
|
.from(drizzleDb.schemas.setting)
|
||||||
|
.where(eq(drizzleDb.schemas.setting.name, "system"))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!settings) throw new Error("System settings not found.");
|
||||||
|
|
||||||
|
const storageType = settings.storage; // "local" or "s3"
|
||||||
|
const ext = fileName.split(".").pop()?.toLowerCase();
|
||||||
|
const contentType =
|
||||||
|
ext === "png"
|
||||||
|
? "image/png"
|
||||||
|
: ext === "jpg" || ext === "jpeg"
|
||||||
|
? "image/jpeg"
|
||||||
|
: ext === "gif"
|
||||||
|
? "image/gif"
|
||||||
|
: ext === "webp"
|
||||||
|
? "image/webp"
|
||||||
|
: "application/octet-stream";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const fileName = (await params).fileName;
|
if (storageType === "local") {
|
||||||
|
const filePath = path.join(process.cwd(), "private/uploads/images", fileName);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.access(filePath);
|
||||||
|
const file = await fs.readFile(filePath);
|
||||||
|
|
||||||
console.log("fileName", fileName);
|
return new NextResponse(file, {
|
||||||
|
headers: {
|
||||||
const filePath = path.join(process.cwd(), "private/uploads/images", fileName);
|
"Content-Type": contentType,
|
||||||
|
"Cache-Control": "no-store",
|
||||||
// Check if the file exists
|
"Content-Disposition": `inline; filename="${fileName}"`,
|
||||||
try {
|
},
|
||||||
await fs.access(filePath); // Ensures the file exists
|
});
|
||||||
} catch {
|
} catch {
|
||||||
return NextResponse.json({ error: "File not found" }, { status: 404 });
|
// if not found locally, fallback to S3
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read the file
|
const exists = await checkFileExistsInBucket({
|
||||||
const fileContent = await fs.readFile(filePath); // Returns a Buffer
|
bucketName: env.S3_BUCKET_NAME!,
|
||||||
|
fileName: `${privateS3ImageDir}${fileName}`,
|
||||||
|
});
|
||||||
|
if (!exists) return NextResponse.json({error: "File not found"}, {status: 404});
|
||||||
|
|
||||||
return new NextResponse(fileContent, {
|
const nodeStream = await getObjectFromClient({
|
||||||
|
bucketName: env.S3_BUCKET_NAME!,
|
||||||
|
fileName: `${privateS3ImageDir}${fileName}`,
|
||||||
|
});
|
||||||
|
const webStream = nodeStreamToWebStream(nodeStream);
|
||||||
|
|
||||||
|
return new NextResponse(webStream, {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Disposition": `attachment; filename="${fileName}"`,
|
"Content-Type": contentType,
|
||||||
"Content-Type": "application/octet-stream", // Adjust MIME type as needed
|
"Cache-Control": "no-store",
|
||||||
|
"Content-Disposition": `inline; filename="${fileName}"`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (err) {
|
||||||
console.error("Error reading file:", error);
|
console.error("Error streaming image:", err);
|
||||||
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
|
return NextResponse.json({error: "Error fetching file"}, {status: 500});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
|
|
||||||
export type BodyInit = {
|
|
||||||
initialize: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
|
||||||
try {
|
|
||||||
const body: BodyInit = await request.json();
|
|
||||||
|
|
||||||
console.log(body);
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
message: "Initialization successfully done!",
|
|
||||||
},
|
|
||||||
{ status: 200 }
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error in POST initialization:", error);
|
|
||||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+2
-9
@@ -35,14 +35,12 @@ export async function middleware(request: NextRequest) {
|
|||||||
return NextResponse.next();
|
return NextResponse.next();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exclude `/api/auth` and its subpaths
|
|
||||||
if (url.pathname.startsWith("/api/auth")) {
|
if (url.pathname.startsWith("/api/auth")) {
|
||||||
return NextResponse.next();
|
return NextResponse.next();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (url.pathname.startsWith("/api")) {
|
if (url.pathname.startsWith("/api")) {
|
||||||
const routeExists = checkRouteExists(url.pathname);
|
const routeExists = checkRouteExists(url.pathname);
|
||||||
// If the route does not exist, return a 404 JSON response
|
|
||||||
if (!routeExists) {
|
if (!routeExists) {
|
||||||
return new NextResponse(JSON.stringify({ message: "This API route does not exist.", status: 404 }), {
|
return new NextResponse(JSON.stringify({ message: "This API route does not exist.", status: 404 }), {
|
||||||
status: 404,
|
status: 404,
|
||||||
@@ -56,15 +54,10 @@ export async function middleware(request: NextRequest) {
|
|||||||
errorHandler(err);
|
errorHandler(err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Function to check if the route exists (supports dynamic routes)
|
|
||||||
function checkRouteExists(pathname: string) {
|
function checkRouteExists(pathname: string) {
|
||||||
// Define static and dynamic routes with patterns
|
|
||||||
const routePatterns = [
|
const routePatterns = [
|
||||||
// Do not delete
|
/^\/api\/agent\/[^/]+\/status\/?$/,
|
||||||
// /^\/api\/auth\/\d+$/, // Dynamic route with a number as a parameter (e.g., /api/dynamic/123)
|
|
||||||
// /^\/api\/auth\/\w+$/, // Dynamic route with a number as a parameter (e.g., /api/dynamic/123)
|
|
||||||
// /^\/api\/agent\/healthcheck\/\w+$/, // Dynamic route with an alphanumeric parameter (e.g., /api/user/username)
|
|
||||||
/^\/api\/agent\/[^/]+\/status\/?$/, // Dynamic route for /api/agent/[id]/status
|
|
||||||
/^\/api\/agent\/[^/]+\/backup\/?$/,
|
/^\/api\/agent\/[^/]+\/backup\/?$/,
|
||||||
/^\/api\/agent\/[^/]+\/restore\/?$/,
|
/^\/api\/agent\/[^/]+\/restore\/?$/,
|
||||||
/^\/api\/files\/[^/]+\/?$/,
|
/^\/api\/files\/[^/]+\/?$/,
|
||||||
|
|||||||
+2
-2
@@ -4,7 +4,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack -p 8887",
|
"dev": "next dev --turbopack -p 8887",
|
||||||
"build": "next build --experimental-build-mode compile",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
"email": "email dev",
|
"email": "email dev",
|
||||||
@@ -65,7 +65,7 @@
|
|||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
"lucide-react": "^0.510.0",
|
"lucide-react": "^0.510.0",
|
||||||
"minio": "^8.0.5",
|
"minio": "^8.0.5",
|
||||||
"next": "15.5.2",
|
"next": "15.5.6",
|
||||||
"next-safe-action": "^7.10.8",
|
"next-safe-action": "^7.10.8",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import type React from "react"
|
||||||
|
import { useState, useRef, useEffect, forwardRef } from "react"
|
||||||
|
import { Search, X } from "lucide-react"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
export interface IEntry {
|
||||||
|
value: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchInputProps {
|
||||||
|
value?: IEntry
|
||||||
|
onChange?: (value: IEntry) => void
|
||||||
|
onSelect?: (value: IEntry) => void
|
||||||
|
name?: string
|
||||||
|
placeholder?: string
|
||||||
|
entries?: IEntry[]
|
||||||
|
disabled?: boolean
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SearchInput = forwardRef<HTMLInputElement, SearchInputProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
value: controlledValue,
|
||||||
|
onChange,
|
||||||
|
onSelect,
|
||||||
|
name,
|
||||||
|
placeholder = "Search entries...",
|
||||||
|
entries = [],
|
||||||
|
disabled = false,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) => {
|
||||||
|
const [internalValue, setInternalValue] = useState<IEntry | null>(null)
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
const [filteredEntries, setFilteredEntries] = useState<IEntry[]>([])
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(-1)
|
||||||
|
const internalRef = useRef<HTMLInputElement>(null)
|
||||||
|
const listRef = useRef<HTMLUListElement>(null)
|
||||||
|
|
||||||
|
const query = controlledValue?.label ?? internalValue?.label ?? ""
|
||||||
|
const inputRef = (ref as React.RefObject<HTMLInputElement>) || internalRef
|
||||||
|
|
||||||
|
// Filter entries based on query
|
||||||
|
useEffect(() => {
|
||||||
|
if (query.trim()) {
|
||||||
|
const filtered = entries.filter((entry) =>
|
||||||
|
entry.label.toLowerCase().includes(query.toLowerCase()),
|
||||||
|
)
|
||||||
|
setFilteredEntries(filtered)
|
||||||
|
setSelectedIndex(-1)
|
||||||
|
} else {
|
||||||
|
setFilteredEntries([])
|
||||||
|
}
|
||||||
|
}, [query, entries])
|
||||||
|
|
||||||
|
const handleValueChange = (newValue: IEntry | null) => {
|
||||||
|
if (controlledValue === undefined) {
|
||||||
|
setInternalValue(newValue)
|
||||||
|
}
|
||||||
|
if (newValue) {
|
||||||
|
onChange?.(newValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle keyboard navigation
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (!isOpen || filteredEntries.length === 0) return
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case "ArrowDown":
|
||||||
|
e.preventDefault()
|
||||||
|
setSelectedIndex((prev) =>
|
||||||
|
prev < filteredEntries.length - 1 ? prev + 1 : 0,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
case "ArrowUp":
|
||||||
|
e.preventDefault()
|
||||||
|
setSelectedIndex((prev) =>
|
||||||
|
prev > 0 ? prev - 1 : filteredEntries.length - 1,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
case "Enter":
|
||||||
|
e.preventDefault()
|
||||||
|
if (selectedIndex >= 0) {
|
||||||
|
handleSelect(filteredEntries[selectedIndex])
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case "Escape":
|
||||||
|
setIsOpen(false)
|
||||||
|
setSelectedIndex(-1)
|
||||||
|
inputRef.current?.blur()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSelect = (entry: IEntry) => {
|
||||||
|
handleValueChange(entry)
|
||||||
|
onSelect?.(entry)
|
||||||
|
setIsOpen(false)
|
||||||
|
setSelectedIndex(-1)
|
||||||
|
inputRef.current?.blur()
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearSearch = () => {
|
||||||
|
handleValueChange(null)
|
||||||
|
setIsOpen(false)
|
||||||
|
setSelectedIndex(-1)
|
||||||
|
inputRef.current?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("relative w-full", className)}>
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
{...props}
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
name={name}
|
||||||
|
placeholder={placeholder}
|
||||||
|
value={query}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newLabel = e.target.value
|
||||||
|
handleValueChange({ value: newLabel, label: newLabel })
|
||||||
|
}}
|
||||||
|
onFocus={() => !disabled && setIsOpen(true)}
|
||||||
|
onBlur={() => {
|
||||||
|
// Delay closing to allow clicking on entries
|
||||||
|
setTimeout(() => setIsOpen(false), 150)
|
||||||
|
}}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
className="pl-10 pr-10"
|
||||||
|
/>
|
||||||
|
{query && !disabled && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={clearSearch}
|
||||||
|
className="absolute right-1 top-1/2 h-7 w-7 -translate-y-1/2 p-0 hover:bg-muted"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Results dropdown */}
|
||||||
|
{isOpen && !disabled && filteredEntries.length > 0 && (
|
||||||
|
<div className="absolute top-full z-50 w-full mt-1 bg-popover border rounded-md shadow-md">
|
||||||
|
<ul ref={listRef} className="max-h-60 overflow-auto py-1" role="listbox">
|
||||||
|
{filteredEntries.map((entry, index) => (
|
||||||
|
<li
|
||||||
|
key={entry.value}
|
||||||
|
role="option"
|
||||||
|
aria-selected={index === selectedIndex}
|
||||||
|
className={cn(
|
||||||
|
"px-3 py-2 text-sm cursor-pointer transition-colors",
|
||||||
|
"hover:bg-accent hover:text-accent-foreground",
|
||||||
|
index === selectedIndex && "bg-accent text-accent-foreground",
|
||||||
|
)}
|
||||||
|
onClick={() => handleSelect(entry)}
|
||||||
|
>
|
||||||
|
{entry.label}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* No results message */}
|
||||||
|
{isOpen && !disabled && query && filteredEntries.length === 0 && (
|
||||||
|
<div className="absolute top-full z-50 w-full mt-1 bg-popover border rounded-md shadow-md">
|
||||||
|
<div className="px-3 py-2 text-sm text-muted-foreground">
|
||||||
|
No results found for "{query}"
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
SearchInput.displayName = "SearchInput"
|
||||||
@@ -1,8 +1,66 @@
|
|||||||
"use client";
|
// "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>
|
||||||
|
// );
|
||||||
|
// };
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
'use client'
|
||||||
import { ButtonHTMLAttributes } from "react";
|
|
||||||
|
import { ButtonHTMLAttributes, ReactNode } from "react";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
export type VariantButton = {
|
export type VariantButton = {
|
||||||
secondary: string;
|
secondary: string;
|
||||||
@@ -12,46 +70,46 @@ export type VariantButton = {
|
|||||||
link: string;
|
link: string;
|
||||||
destructive: string;
|
destructive: string;
|
||||||
};
|
};
|
||||||
export type sizeButton = {
|
|
||||||
|
export type SizeButton = {
|
||||||
default: string;
|
default: string;
|
||||||
icon: string;
|
icon: string;
|
||||||
sm: string;
|
sm: string;
|
||||||
lg: string;
|
lg: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ButtonWithConfirmProps = {
|
export type ButtonWithLoadingProps = {
|
||||||
icon?: any;
|
children?: string | ReactNode;
|
||||||
text: string;
|
icon?: ReactNode;
|
||||||
variant?: keyof VariantButton;
|
variant?: keyof VariantButton;
|
||||||
className?: string;
|
className?: string;
|
||||||
onClick: () => void;
|
onClick?: () => void;
|
||||||
isPending?: boolean;
|
isPending?: boolean;
|
||||||
size: keyof sizeButton;
|
size?: keyof SizeButton;
|
||||||
};
|
} & ButtonHTMLAttributes<HTMLButtonElement>;
|
||||||
|
|
||||||
export const ButtonWithLoading = ({
|
export const ButtonWithLoading = ({
|
||||||
icon,
|
icon,
|
||||||
text,
|
children,
|
||||||
variant,
|
variant = "default",
|
||||||
className,
|
className,
|
||||||
onClick,
|
onClick,
|
||||||
isPending,
|
isPending,
|
||||||
size,
|
size = "default",
|
||||||
...props // catch all remaining props
|
...rest
|
||||||
}: ButtonWithConfirmProps & ButtonHTMLAttributes<HTMLButtonElement>) => {
|
}: ButtonWithLoadingProps) => {
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => onClick?.()}
|
||||||
onClick();
|
variant={variant}
|
||||||
}}
|
|
||||||
variant={variant ? variant : "default"}
|
|
||||||
className={className}
|
className={className}
|
||||||
{...props} // forward the remaining props to the Button component
|
size={size}
|
||||||
size={size || "default"}
|
{...rest}
|
||||||
>
|
>
|
||||||
{isPending && <Loader2 className="animate-spin mr-4" size={16} />}
|
{isPending && <Loader2 className="mr-2 animate-spin" size={16} />}
|
||||||
{text}
|
{children && children}
|
||||||
<>{icon ? icon : null}</>
|
<>{icon ? icon : null}</>
|
||||||
|
{/*{icon && <span className="ml-2">{icon}</span>}*/}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,20 +1,25 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs";
|
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs";
|
||||||
import {SettingsEmailTab} from "@/components/wrappers/dashboard/admin/admin-email-tab/settings-email-tab";
|
import {SettingsEmailTab} from "@/components/wrappers/dashboard/admin/tabs/admin-email-tab/settings-email-tab";
|
||||||
import {SettingsStorageTab} from "@/components/wrappers/dashboard/admin/admin-storage-tab/settings-storage-tab";
|
import {SettingsStorageTab} from "@/components/wrappers/dashboard/admin/tabs/admin-storage-tab/settings-storage-tab";
|
||||||
import {User, UserWithAccounts} from "@/db/schema/02_user";
|
import {User, UserWithAccounts} from "@/db/schema/02_user";
|
||||||
import {Setting} from "@/db/schema/01_setting";
|
import {Setting} from "@/db/schema/01_setting";
|
||||||
import {useEffect, useState} from "react";
|
import {useEffect, useState} from "react";
|
||||||
import {useRouter, useSearchParams} from "next/navigation";
|
import {useRouter, useSearchParams} from "next/navigation";
|
||||||
import {AdminUsersTable} from "@/components/wrappers/dashboard/admin/admin-user-tab/admin-user-table";
|
import {AdminUsersTable} from "@/components/wrappers/dashboard/admin/tabs/admin-user-tab/admin-user-table";
|
||||||
|
import {
|
||||||
|
AdminOrganizationsTable
|
||||||
|
} from "@/components/wrappers/dashboard/admin/tabs/admin-organizations-tab/admin-organizations-table";
|
||||||
|
import {OrganizationWithMembers} from "@/db/schema/03_organization";
|
||||||
|
|
||||||
export type AdminTabsProps = {
|
export type AdminTabsProps = {
|
||||||
users: UserWithAccounts[];
|
users: UserWithAccounts[];
|
||||||
settings: Setting;
|
settings: Setting;
|
||||||
|
organizations: OrganizationWithMembers[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AdminTabs = ({users, settings}: AdminTabsProps) => {
|
export const AdminTabs = ({users, settings, organizations}: AdminTabsProps) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
@@ -35,6 +40,9 @@ export const AdminTabs = ({users, settings}: AdminTabsProps) => {
|
|||||||
<TabsTrigger className="w-full" value="users">
|
<TabsTrigger className="w-full" value="users">
|
||||||
Users
|
Users
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger className="w-full" value="organizations">
|
||||||
|
Organizations
|
||||||
|
</TabsTrigger>
|
||||||
<TabsTrigger className="w-full" value="email">
|
<TabsTrigger className="w-full" value="email">
|
||||||
Email
|
Email
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
@@ -45,6 +53,9 @@ export const AdminTabs = ({users, settings}: AdminTabsProps) => {
|
|||||||
<TabsContent value="users">
|
<TabsContent value="users">
|
||||||
<AdminUsersTable users={users}/>
|
<AdminUsersTable users={users}/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
<TabsContent value="organizations">
|
||||||
|
<AdminOrganizationsTable organizations={organizations}/>
|
||||||
|
</TabsContent>
|
||||||
<TabsContent value="email">
|
<TabsContent value="email">
|
||||||
<SettingsEmailTab settings={settings}/>
|
<SettingsEmailTab settings={settings}/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import {useState} from "react";
|
||||||
|
import {Plus} from "lucide-react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {Button} from "@/components/ui/button";
|
||||||
|
import {AdminOrganizationForm} from "@/components/wrappers/dashboard/admin/organization/admin-organization-form";
|
||||||
|
|
||||||
|
type AdminOrganizationAddModalProps = {}
|
||||||
|
|
||||||
|
|
||||||
|
export const AdminOrganizationAddModal = (props: AdminOrganizationAddModalProps) => {
|
||||||
|
|
||||||
|
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button>
|
||||||
|
<Plus/> add
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>add organization</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
your description
|
||||||
|
</DialogDescription>
|
||||||
|
<AdminOrganizationForm onSuccess={() => setOpen(false)}/>
|
||||||
|
</DialogHeader>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"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/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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { AdminOrganizationList } from "@/components/wrappers/dashboard/admin/organization/admin-orgnization-list";
|
||||||
|
import { AdminOrganizationAddModal } from "@/components/wrappers/dashboard/admin/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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
"use client"
|
||||||
|
import { DataTable } from "@/components/wrappers/common/table/data-table";
|
||||||
|
import { organizationsListColumns } from "@/components/wrappers/dashboard/admin/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} />;
|
||||||
|
};
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"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 "@/components/wrappers/dashboard/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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"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",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
+90
@@ -0,0 +1,90 @@
|
|||||||
|
"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/organization/organization.schema";
|
||||||
|
import {SearchInput} from "@/components/ui/search-input";
|
||||||
|
import {
|
||||||
|
addMemberOrganizationAction
|
||||||
|
} from "@/components/wrappers/dashboard/admin/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) => {
|
||||||
|
console.log(data);
|
||||||
|
const result = await addMemberOrganizationAction({
|
||||||
|
userId: data.userId,
|
||||||
|
organizationId: organization.id,
|
||||||
|
role: "member",
|
||||||
|
});
|
||||||
|
console.log(result);
|
||||||
|
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) => {
|
||||||
|
console.log("Form selection:", entySelected);
|
||||||
|
field.onChange(entySelected.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage/>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex gap-4 justify-end">
|
||||||
|
<ButtonWithLoading
|
||||||
|
isPending={mutationAddMemberOrganisation.isPending}>Confirm</ButtonWithLoading>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||||
|
import { OrganizationAddMemberForm } from "@/components/wrappers/dashboard/admin/organization/details/organization-add-member-form";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { UserPlus } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {OrganizationWithMembers, 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+67
@@ -0,0 +1,67 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { ButtonWithLoading } from "@/components/wrappers/common/button/button-with-loading";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { authClient } from "@/lib/auth/auth-client";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {MemberWithUser} from "@/db/schema/03_organization";
|
||||||
|
|
||||||
|
type OrganizationDeleteMemberModalProps = {
|
||||||
|
open: boolean;
|
||||||
|
member: MemberWithUser;
|
||||||
|
onOpenChangeAction: (open: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const OrganizationDeleteMemberModal = ({ member, open, onOpenChangeAction }: OrganizationDeleteMemberModalProps) => {
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
await authClient.organization.removeMember(
|
||||||
|
{
|
||||||
|
memberIdOrEmail: member.id,
|
||||||
|
organizationId: member.organizationId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: async (response) => {
|
||||||
|
console.log(response);
|
||||||
|
toast.success("Member successfully deleted!");
|
||||||
|
onOpenChangeAction(false);
|
||||||
|
router.refresh();
|
||||||
|
},
|
||||||
|
onError: async (error) => {
|
||||||
|
console.log(error);
|
||||||
|
toast.error("An error occurred while deleting member!");
|
||||||
|
onOpenChangeAction(false);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AlertDialog open={open} onOpenChange={onOpenChangeAction}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Are you sure you want to delete {member.user.name } ?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>This action is irreversible: it will permanently delete this member’s data.</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<ButtonWithLoading onClick={async () => await mutation.mutateAsync()}>Validate</ButtonWithLoading>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
+96
@@ -0,0 +1,96 @@
|
|||||||
|
"use client";
|
||||||
|
import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar";
|
||||||
|
import {Badge} from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import {Button} from "@/components/ui/button";
|
||||||
|
import {MoreHorizontal, Settings, Trash2} from "lucide-react";
|
||||||
|
import {
|
||||||
|
OrganizationDeleteMemberModal
|
||||||
|
} from "@/components/wrappers/dashboard/admin/organization/details/organization-delete-member-modal";
|
||||||
|
import {useState} from "react";
|
||||||
|
import {authClient} from "@/lib/auth/auth-client";
|
||||||
|
import {
|
||||||
|
OrganizationMemberChangeRoleModal
|
||||||
|
} from "@/components/wrappers/dashboard/admin/organization/details/organization-member-change-role";
|
||||||
|
import {MemberWithUser, OrganizationWithMembersAndUsers} from "@/db/schema/03_organization";
|
||||||
|
|
||||||
|
type OrganizationMemberCardProps = {
|
||||||
|
member: MemberWithUser;
|
||||||
|
organization: OrganizationWithMembersAndUsers;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const OrganizationMemberCard = ({member, organization}: OrganizationMemberCardProps) => {
|
||||||
|
|
||||||
|
const [isModalDeleteOpen, setIsModalDeleteOpen] = useState(false);
|
||||||
|
const [isModalRoleOpen, setIsModalRoleOpen] = useState(false);
|
||||||
|
const {data: session, isPending, error} = authClient.useSession();
|
||||||
|
|
||||||
|
if (isPending || error) return null;
|
||||||
|
const isCurrentUser = session?.user?.id === member.user.id;
|
||||||
|
const isOwner = member?.role === "owner";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={member.id}
|
||||||
|
className="flex flex-col md:flex-row md:items-center justify-between p-4 border rounded-lg">
|
||||||
|
<OrganizationDeleteMemberModal member={member} open={isModalDeleteOpen}
|
||||||
|
onOpenChangeAction={setIsModalDeleteOpen}/>
|
||||||
|
<OrganizationMemberChangeRoleModal member={member} open={isModalRoleOpen}
|
||||||
|
onOpenChangeAction={setIsModalRoleOpen}/>
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<Avatar>
|
||||||
|
<AvatarImage src={member.user.image || ""} alt={member.user.name}/>
|
||||||
|
<AvatarFallback>
|
||||||
|
{member.user.name
|
||||||
|
.split(" ")
|
||||||
|
.map((n) => n[0])
|
||||||
|
.join("")}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{member.user.name}</div>
|
||||||
|
<div className="text-sm text-muted-foreground">{member.user.email}</div>
|
||||||
|
<div
|
||||||
|
className="text-xs text-muted-foreground">Joined {new Date(member.createdAt).toLocaleDateString()}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2 mt-4 md:mt-0">
|
||||||
|
<Badge variant={getRoleBadgeVariant(member.role)}>{member.role}</Badge>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<MoreHorizontal className="w-4 h-4"/>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onSelect={() => setIsModalRoleOpen(true)}>
|
||||||
|
<Settings className="w-4 h-4 mr-2"/>
|
||||||
|
Change role
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator/>
|
||||||
|
<DropdownMenuItem onSelect={() => setIsModalDeleteOpen(true)} className="text-red-600">
|
||||||
|
<Trash2 className="w-4 h-4 mr-2"/>
|
||||||
|
Remove member
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRoleBadgeVariant = (role: string) => {
|
||||||
|
switch (role.toLowerCase()) {
|
||||||
|
case "owner":
|
||||||
|
return "default";
|
||||||
|
case "admin":
|
||||||
|
return "secondary";
|
||||||
|
default:
|
||||||
|
return "outline";
|
||||||
|
}
|
||||||
|
};
|
||||||
+99
@@ -0,0 +1,99 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {useState} from "react";
|
||||||
|
import {useRouter} from "next/navigation";
|
||||||
|
import {useMutation} from "@tanstack/react-query";
|
||||||
|
import {toast} from "sonner";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {authClient} from "@/lib/auth/auth-client";
|
||||||
|
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select";
|
||||||
|
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
|
||||||
|
import {MemberRoleType} from "@/types/common";
|
||||||
|
import {MemberWithUser} from "@/db/schema/03_organization";
|
||||||
|
import {updateMemberRoleAction} from "@/components/wrappers/dashboard/settings/update-member.action";
|
||||||
|
import {RoleSchemaMember} from "@/components/wrappers/dashboard/settings/member.schema";
|
||||||
|
import {
|
||||||
|
updateMemberRoleAdminAction
|
||||||
|
} from "@/components/wrappers/dashboard/admin/organization/details/role-member.action";
|
||||||
|
|
||||||
|
type OrganizationMemberChangeRoleModalProps = {
|
||||||
|
open: boolean;
|
||||||
|
member: MemberWithUser;
|
||||||
|
onOpenChangeAction: (open: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const OrganizationMemberChangeRoleModal = (props: OrganizationMemberChangeRoleModalProps) => {
|
||||||
|
const {member, open, onOpenChangeAction} = props;
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const [role, setRole] = useState<MemberRoleType>(member.role as MemberRoleType);
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
updateMemberRoleAdminAction({
|
||||||
|
memberId: member.id,
|
||||||
|
organizationId: member.organizationId,
|
||||||
|
role: RoleSchemaMember.parse(role),
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Member successfully updated");
|
||||||
|
onOpenChangeAction(false);
|
||||||
|
router.refresh();
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.log(error);
|
||||||
|
toast.error("An error occurred while updating member");
|
||||||
|
onOpenChangeAction(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChangeAction}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Change the user’s role</DialogTitle>
|
||||||
|
<DialogDescription>Modify the role of this user within your organization.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<Select defaultValue={member.role ?? ""} onValueChange={(role) => setRole(role as MemberRoleType)}>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue placeholder="Sélectionnez un rôle"/>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="member">Member</SelectItem>
|
||||||
|
<SelectItem value="admin">Admin</SelectItem>
|
||||||
|
<SelectItem value="owner">Owner</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<DialogFooter>
|
||||||
|
<div className="flex gap-4 justify-end">
|
||||||
|
<ButtonWithLoading
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
onOpenChangeAction(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</ButtonWithLoading>
|
||||||
|
<ButtonWithLoading
|
||||||
|
isPending={mutation.isPending}
|
||||||
|
onClick={async () => {
|
||||||
|
await mutation.mutateAsync();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Validate
|
||||||
|
</ButtonWithLoading>
|
||||||
|
</div>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"use server";
|
||||||
|
import {userAction} from "@/lib/safe-actions/actions";
|
||||||
|
import {z} from "zod";
|
||||||
|
import {ServerActionResult} from "@/types/action-type";
|
||||||
|
import {Member} from "better-auth/plugins";
|
||||||
|
import {RoleSchemaMember} from "@/components/wrappers/dashboard/settings/member.schema";
|
||||||
|
import {db as dbClient} from "@/db";
|
||||||
|
import * as drizzleDb from "@/db";
|
||||||
|
import {and, eq} from "drizzle-orm";
|
||||||
|
import {withUpdatedAt} from "@/db/utils";
|
||||||
|
|
||||||
|
|
||||||
|
export const updateMemberRoleAdminAction = userAction.schema(
|
||||||
|
z.object({
|
||||||
|
memberId: z.string(),
|
||||||
|
organizationId: z.string(),
|
||||||
|
role: RoleSchemaMember,
|
||||||
|
})
|
||||||
|
).action(async ({parsedInput}): Promise<ServerActionResult<Member>> => {
|
||||||
|
try {
|
||||||
|
|
||||||
|
const [updatedMember] = await dbClient
|
||||||
|
.update(drizzleDb.schemas.member)
|
||||||
|
.set(withUpdatedAt({
|
||||||
|
role: parsedInput.role as string,
|
||||||
|
}))
|
||||||
|
.where(and(eq(drizzleDb.schemas.member.id, parsedInput.memberId), eq(drizzleDb.schemas.member.organizationId, parsedInput.organizationId)))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
value: updatedMember,
|
||||||
|
actionSuccess: {
|
||||||
|
message: "Member has been successfully updated.",
|
||||||
|
messageParams: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
actionError: {
|
||||||
|
message: "Failed to update member role.",
|
||||||
|
status: 500,
|
||||||
|
cause: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
messageParams: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
+91
@@ -0,0 +1,91 @@
|
|||||||
|
"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 {
|
||||||
|
UpdateOrganizationSchema,
|
||||||
|
UpdateOrganizationSchemaType
|
||||||
|
} from "@/components/wrappers/dashboard/admin/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 {toast} from "sonner";
|
||||||
|
import {OrganizationWithMembersAndUsers} from "@/db/schema/03_organization";
|
||||||
|
import {updateOrganizationAction} from "@/components/wrappers/dashboard/organization/organization.action";
|
||||||
|
|
||||||
|
type UpdateOrganizationFormProps = {
|
||||||
|
onSuccessAction?: () => void;
|
||||||
|
defaultValues: OrganizationWithMembersAndUsers;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const UpdateOrganizationForm = ({onSuccessAction, defaultValues}: UpdateOrganizationFormProps) => {
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const {data: organizations, refetch} = authClient.useListOrganizations();
|
||||||
|
|
||||||
|
const isDefaultOrganization = defaultValues.slug == "default";
|
||||||
|
|
||||||
|
const form = useZodForm({
|
||||||
|
schema: UpdateOrganizationSchema,
|
||||||
|
defaultValues: defaultValues,
|
||||||
|
disabled: isDefaultOrganization,
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
const mutationUpdateOrganisation = useMutation({
|
||||||
|
mutationFn: ({name}: UpdateOrganizationSchemaType) => updateOrganizationAction({
|
||||||
|
data: {
|
||||||
|
name: name,
|
||||||
|
users: [],
|
||||||
|
slug: defaultValues.slug
|
||||||
|
},
|
||||||
|
organizationId: defaultValues.id,
|
||||||
|
}),
|
||||||
|
onSuccess: async (result) => {
|
||||||
|
if (result?.data?.success) {
|
||||||
|
toast.success("Organization updated successfully.");
|
||||||
|
router.refresh();
|
||||||
|
refetch()
|
||||||
|
} else {
|
||||||
|
// @ts-ignore
|
||||||
|
const errorMsg = result?.data?.actionError?.message || result?.data?.actionError?.messageParams?.message || "Failed to update the organization.";
|
||||||
|
toast.error(errorMsg);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
console.error("Mutation network error:", error);
|
||||||
|
toast.error(error?.message || "A network error occurred.");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
className="flex flex-col gap-4"
|
||||||
|
onSubmit={async (values) => {
|
||||||
|
await mutationUpdateOrganisation.mutateAsync(values);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({field}) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="" {...field} value={field.value ?? ""}/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage/>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex gap-4 justify-end">
|
||||||
|
<ButtonWithLoading disabled={isDefaultOrganization} isPending={mutationUpdateOrganisation.isPending}>Validate</ButtonWithLoading>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {Building2, Shield, Users} from "lucide-react";
|
||||||
|
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card";
|
||||||
|
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs";
|
||||||
|
import {MemberWithUser, OrganizationWithMembersAndUsers} from "@/db/schema/03_organization";
|
||||||
|
import {
|
||||||
|
UpdateOrganizationForm
|
||||||
|
} from "@/components/wrappers/dashboard/admin/organization/details/update-organization-form";
|
||||||
|
import {
|
||||||
|
OrganizationMemberCard
|
||||||
|
} from "@/components/wrappers/dashboard/admin/organization/details/organization-member-card";
|
||||||
|
import {useRouter, useSearchParams} from "next/navigation";
|
||||||
|
import {useEffect, useState} from "react";
|
||||||
|
import {capitalizeFirstLetter} from "@/utils/text";
|
||||||
|
import {User} from "@/db/schema/02_user";
|
||||||
|
import {
|
||||||
|
OrganizationAddMemberModal
|
||||||
|
} from "@/components/wrappers/dashboard/admin/organization/details/organization-add-member-modal";
|
||||||
|
import {cn} from "@/lib/utils";
|
||||||
|
|
||||||
|
type OrganizationManagementProps = {
|
||||||
|
organization: OrganizationWithMembersAndUsers;
|
||||||
|
users: User[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const OrganizationManagement = ({organization, users}: OrganizationManagementProps) => {
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const [tab, setTab] = useState<string>(() => searchParams.get("tab") ?? "members");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const newTab = searchParams.get("tab") ?? "members";
|
||||||
|
setTab(newTab);
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
|
const handleChangeTab = (value: string) => {
|
||||||
|
router.push(`?tab=${value}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className=" space-y-8">
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<div className="flex items-center justify-center w-12 h-12 dark:bg-gray-700 bg-gray-100 rounded-lg">
|
||||||
|
<Building2 className="w-6 h-6 "/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">{capitalizeFirstLetter(organization.name)}</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2 mt-3 md:mt-0">
|
||||||
|
<OrganizationAddMemberModal organization={organization} users={users}/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Members</CardTitle>
|
||||||
|
<Users className="h-4 w-4 text-muted-foreground"/>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{organization.members.length}</div>
|
||||||
|
<p className="text-xs text-muted-foreground">Number of members</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Administrators</CardTitle>
|
||||||
|
<Shield className="h-4 w-4 text-muted-foreground"/>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div
|
||||||
|
className="text-2xl font-bold">{organization.members.filter((m) => m.role === "admin" || m.role === "owner").length}</div>
|
||||||
|
<p className="text-xs text-muted-foreground">With admin roles</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
<Tabs className="space-y-6" value={tab} onValueChange={handleChangeTab}>
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="members">Members</TabsTrigger>
|
||||||
|
<TabsTrigger value="settings">Settings</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="members" className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Organization members</CardTitle>
|
||||||
|
<CardDescription>Manage who has access to your organization and their
|
||||||
|
roles.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{organization.members.map((member: MemberWithUser) => (
|
||||||
|
<OrganizationMemberCard key={member.id} member={member}
|
||||||
|
organization={organization}/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="settings" className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Settings</CardTitle>
|
||||||
|
<CardDescription>Organization configuration settings.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<UpdateOrganizationForm defaultValues={organization}/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import {z} from "zod";
|
||||||
|
|
||||||
|
export const AddMemberSchema = z.object({
|
||||||
|
userId: z.string().min(1, "Invalid field"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const UpdateOrganizationSchema = z.object({
|
||||||
|
name: z.string().min(5),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const OrganizationSchema = z.object({
|
||||||
|
name: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const OrganizationInvitationSchema = z.object({
|
||||||
|
email: z.string(),
|
||||||
|
invitedByUsername: z.string(),
|
||||||
|
invitedByEmail: z.string(),
|
||||||
|
teamName: z.string(),
|
||||||
|
inviteLink: z.string()
|
||||||
|
});
|
||||||
|
|
||||||
|
export type OrganizationInvitationType = z.infer<typeof OrganizationInvitationSchema>;
|
||||||
|
export type OrganizationSchema = z.infer<typeof OrganizationSchema>;
|
||||||
|
export type UpdateOrganizationSchemaType = z.infer<typeof UpdateOrganizationSchema>;
|
||||||
|
export type AddMemberSchemaType = z.infer<typeof AddMemberSchema>;
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"use client";
|
||||||
|
import {ColumnDef} from "@tanstack/react-table";
|
||||||
|
import {ButtonDeleteOrganization} from "@/components/wrappers/dashboard/admin/organization/button-delete-organization";
|
||||||
|
import Link from "next/link";
|
||||||
|
import {Settings} from "lucide-react";
|
||||||
|
import {buttonVariants} from "@/components/ui/button";
|
||||||
|
import {OrganizationWithMembers} from "@/db/schema/03_organization";
|
||||||
|
|
||||||
|
export function organizationsListColumns(): ColumnDef<OrganizationWithMembers>[] {
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
accessorKey: "name",
|
||||||
|
header: "Name",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "members",
|
||||||
|
header: "Members",
|
||||||
|
cell: ({row}) => {
|
||||||
|
const membersCount = row.original.members?.length;
|
||||||
|
return <div className="flex items-center gap-3">{membersCount}</div>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: "Actions",
|
||||||
|
id: "actions",
|
||||||
|
cell: ({row}) => {
|
||||||
|
const isDefaultOrganization = row.original.slug == "default";
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{!isDefaultOrganization && (
|
||||||
|
<ButtonDeleteOrganization organisationId={row.original.id}/>
|
||||||
|
)}
|
||||||
|
<Link className={buttonVariants({variant: "outline"})}
|
||||||
|
href={`admin/organization/${row.original.id}`}>
|
||||||
|
<Settings/>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
"use server";
|
"use server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { EmailFormSchema } from "@/components/wrappers/dashboard/admin/admin-email-tab/email-form/email-form.schema";
|
import { EmailFormSchema } from "@/components/wrappers/dashboard/admin/tabs/admin-email-tab/email-form/email-form.schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import * as drizzleDb from "@/db";
|
import * as drizzleDb from "@/db";
|
||||||
+2
-2
@@ -19,11 +19,11 @@ import {TooltipProvider} from "@/components/ui/tooltip";
|
|||||||
import {
|
import {
|
||||||
EmailFormSchema,
|
EmailFormSchema,
|
||||||
EmailFormType
|
EmailFormType
|
||||||
} from "@/components/wrappers/dashboard/admin/admin-email-tab/email-form/email-form.schema";
|
} from "@/components/wrappers/dashboard/admin/tabs/admin-email-tab/email-form/email-form.schema";
|
||||||
import {PasswordInput} from "@/components/wrappers/auth/password-input/password-input";
|
import {PasswordInput} from "@/components/wrappers/auth/password-input/password-input";
|
||||||
import {
|
import {
|
||||||
updateEmailSettingsAction
|
updateEmailSettingsAction
|
||||||
} from "@/components/wrappers/dashboard/admin/admin-email-tab/email-form/email-form.action";
|
} from "@/components/wrappers/dashboard/admin/tabs/admin-email-tab/email-form/email-form.action";
|
||||||
import {toast} from "sonner";
|
import {toast} from "sonner";
|
||||||
import {useRouter} from "next/navigation";
|
import {useRouter} from "next/navigation";
|
||||||
|
|
||||||
+12
-13
@@ -1,13 +1,13 @@
|
|||||||
import { EmailForm } from "@/components/wrappers/dashboard/admin/admin-email-tab/email-form/email-form";
|
import {EmailForm} from "@/components/wrappers/dashboard/admin/tabs/admin-email-tab/email-form/email-form";
|
||||||
import { Send } from "lucide-react";
|
import {Send} from "lucide-react";
|
||||||
import { ButtonWithLoading } from "@/components/wrappers/common/button/button-with-loading";
|
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import {useMutation} from "@tanstack/react-query";
|
||||||
import { sendEmail } from "@/utils/email-helper";
|
import {sendEmail} from "@/utils/email-helper";
|
||||||
import TestEmailSettings from "../../../../../../emails/TestEmailSettings";
|
import {render} from "@react-email/render";
|
||||||
import { render } from "@react-email/render";
|
import {toast} from "sonner";
|
||||||
import { toast } from "sonner";
|
|
||||||
import {Setting} from "@/db/schema/01_setting";
|
import {Setting} from "@/db/schema/01_setting";
|
||||||
import {EmailFormType} from "@/components/wrappers/dashboard/admin/admin-email-tab/email-form/email-form.schema";
|
import {EmailFormType} from "@/components/wrappers/dashboard/admin/tabs/admin-email-tab/email-form/email-form.schema";
|
||||||
|
import TestEmailSettings from "../../../../../../../emails/TestEmailSettings";
|
||||||
|
|
||||||
export type SettingsEmailTabProps = {
|
export type SettingsEmailTabProps = {
|
||||||
settings: Setting;
|
settings: Setting;
|
||||||
@@ -47,14 +47,13 @@ export const SettingsEmailTab = (props: SettingsEmailTabProps) => {
|
|||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await handleSendMailTest();
|
await handleSendMailTest();
|
||||||
}}
|
}}
|
||||||
icon={<Send />}
|
icon={<Send/>}
|
||||||
text="Send email test"
|
|
||||||
size="default"
|
size="default"
|
||||||
/>
|
>Send email test</ButtonWithLoading>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-5">
|
<div className="mt-5">
|
||||||
<EmailForm defaultValues={props.settings.smtpFrom ? props.settings as EmailFormType : undefined } />
|
<EmailForm defaultValues={props.settings.smtpFrom ? props.settings as EmailFormType : undefined}/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
"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/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>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Active organizations</CardTitle>
|
||||||
|
<CardDescription>Manage all system organizations</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<AdminOrganizationList organizations={organizations} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
+41
@@ -0,0 +1,41 @@
|
|||||||
|
"use client"
|
||||||
|
import {ColumnDef} from "@tanstack/react-table";
|
||||||
|
import {Badge} from "@/components/ui/badge";
|
||||||
|
import {updateUserAction} from "@/components/wrappers/dashboard/profile/user-form/user-form.action";
|
||||||
|
import {useMutation} from "@tanstack/react-query";
|
||||||
|
import {toast} from "sonner";
|
||||||
|
import {useRouter} from "next/navigation";
|
||||||
|
import {useState} from "react";
|
||||||
|
import {Trash2} from "lucide-react";
|
||||||
|
import {deleteUserAction} from "@/components/wrappers/dashboard/profile/button-delete-account/delete-account.action";
|
||||||
|
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
|
||||||
|
import {UserWithAccounts} from "@/db/schema/02_user";
|
||||||
|
import {authClient, useSession} from "@/lib/auth/auth-client";
|
||||||
|
import {formatFrenchDate} from "@/utils/date-formatting";
|
||||||
|
import {providerSwitch} from "@/components/wrappers/common/provider-switch";
|
||||||
|
import {ButtonDeleteUser} from "@/components/wrappers/dashboard/admin/tabs/admin-user-tab/button-delete-use";
|
||||||
|
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}/>
|
||||||
|
// );
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
];
|
||||||
+4
-6
@@ -2,7 +2,7 @@ import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert";
|
|||||||
import {Info, ShieldCheck} from "lucide-react";
|
import {Info, ShieldCheck} from "lucide-react";
|
||||||
import {Switch} from "@/components/ui/switch";
|
import {Switch} from "@/components/ui/switch";
|
||||||
import {Label} from "@/components/ui/label";
|
import {Label} from "@/components/ui/label";
|
||||||
import {StorageS3Form} from "@/components/wrappers/dashboard/admin/admin-storage-tab/storage-s3/storage-s3-form";
|
import {StorageS3Form} from "@/components/wrappers/dashboard/admin/tabs/admin-storage-tab/storage-s3/storage-s3-form";
|
||||||
import {useState} from "react";
|
import {useState} from "react";
|
||||||
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
|
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
|
||||||
import {useMutation} from "@tanstack/react-query";
|
import {useMutation} from "@tanstack/react-query";
|
||||||
@@ -11,9 +11,9 @@ import {toast} from "sonner";
|
|||||||
import {useRouter} from "next/navigation";
|
import {useRouter} from "next/navigation";
|
||||||
import {
|
import {
|
||||||
updateStorageSettingsAction
|
updateStorageSettingsAction
|
||||||
} from "@/components/wrappers/dashboard/admin/admin-storage-tab/storage-s3/s3-form.action";
|
} from "@/components/wrappers/dashboard/admin/tabs/admin-storage-tab/storage-s3/s3-form.action";
|
||||||
import {Setting} from "@/db/schema/01_setting";
|
import {Setting} from "@/db/schema/01_setting";
|
||||||
import {S3FormType} from "@/components/wrappers/dashboard/admin/admin-storage-tab/storage-s3/s3-form.schema";
|
import {S3FormType} from "@/components/wrappers/dashboard/admin/tabs/admin-storage-tab/storage-s3/s3-form.schema";
|
||||||
|
|
||||||
export type SettingsStorageTabProps = {
|
export type SettingsStorageTabProps = {
|
||||||
settings: Setting;
|
settings: Setting;
|
||||||
@@ -93,9 +93,7 @@ export const SettingsStorageTab = (props: SettingsStorageTabProps) => {
|
|||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await mutation.mutateAsync();
|
await mutation.mutateAsync();
|
||||||
}}
|
}}
|
||||||
icon={<ShieldCheck/>}
|
icon={<ShieldCheck/>}>Test connexion</ButtonWithLoading>
|
||||||
text="Test connexion"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{isSwitched && (
|
{isSwitched && (
|
||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import { S3FormSchema, StorageSwitchSchema } from "@/components/wrappers/dashboard/admin/admin-storage-tab/storage-s3/s3-form.schema";
|
import { S3FormSchema, StorageSwitchSchema } from "@/components/wrappers/dashboard/admin/tabs/admin-storage-tab/storage-s3/s3-form.schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import * as drizzleDb from "@/db";
|
import * as drizzleDb from "@/db";
|
||||||
import {userAction} from "@/lib/safe-actions/actions";
|
import {userAction} from "@/lib/safe-actions/actions";
|
||||||
+2
-2
@@ -8,10 +8,10 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
|
|
||||||
import { S3FormSchema, S3FormType } from "@/components/wrappers/dashboard/admin/admin-storage-tab/storage-s3/s3-form.schema";
|
import { S3FormSchema, S3FormType } from "@/components/wrappers/dashboard/admin/tabs/admin-storage-tab/storage-s3/s3-form.schema";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { updateS3SettingsAction } from "@/components/wrappers/dashboard/admin/admin-storage-tab/storage-s3/s3-form.action";
|
import { updateS3SettingsAction } from "@/components/wrappers/dashboard/admin/tabs/admin-storage-tab/storage-s3/s3-form.action";
|
||||||
import {PasswordInput} from "@/components/wrappers/auth/password-input/password-input";
|
import {PasswordInput} from "@/components/wrappers/auth/password-input/password-input";
|
||||||
|
|
||||||
export type S3FormProps = {
|
export type S3FormProps = {
|
||||||
-1
@@ -66,7 +66,6 @@ export const accountsColumns: ColumnDef<{
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<ButtonWithLoading
|
<ButtonWithLoading
|
||||||
variant="outline"
|
variant="outline"
|
||||||
text=""
|
|
||||||
disabled={row.original.provider === "credential" || table.getRowModel().rows.length <= 1}
|
disabled={row.original.provider === "credential" || table.getRowModel().rows.length <= 1}
|
||||||
icon={<Unlink color="red" size={15}/>}
|
icon={<Unlink color="red" size={15}/>}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
import {UserWithAccounts} from "@/db/schema/02_user";
|
import {UserWithAccounts} from "@/db/schema/02_user";
|
||||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card";
|
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card";
|
||||||
import {DataTable} from "@/components/wrappers/common/table/data-table";
|
import {DataTable} from "@/components/wrappers/common/table/data-table";
|
||||||
import {usersColumnsAdmin} from "@/components/wrappers/dashboard/admin/admin-user-tab/columns-users";
|
import {usersColumnsAdmin} from "@/components/wrappers/dashboard/admin/tabs/admin-user-tab/columns-users";
|
||||||
|
|
||||||
export type AdminUsersTableProps = {
|
export type AdminUsersTableProps = {
|
||||||
users: UserWithAccounts[];
|
users: UserWithAccounts[];
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {Trash2} from "lucide-react";
|
||||||
|
import {ButtonWithConfirm} from "@/components/wrappers/common/button/button-with-confirm";
|
||||||
|
import {useMutation} from "@tanstack/react-query";
|
||||||
|
import {useRouter} from "next/navigation";
|
||||||
|
import {toast} from "sonner";
|
||||||
|
import {deleteUserAction} from "@/components/wrappers/dashboard/profile/button-delete-account/delete-account.action";
|
||||||
|
|
||||||
|
export type ButtonDeleteUserProps = {
|
||||||
|
userId: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ButtonDeleteUser = (props: ButtonDeleteUserProps) => {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: () => deleteUserAction(props.userId),
|
||||||
|
onSuccess: async () => {
|
||||||
|
toast.success("User deleted successfully.");
|
||||||
|
router.refresh();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
|
||||||
|
<ButtonWithConfirm
|
||||||
|
title={""}
|
||||||
|
|
||||||
|
description="Are you sure you want to remove this user? This action cannot be undone."
|
||||||
|
button={{
|
||||||
|
main: {
|
||||||
|
disabled: !!props.disabled,
|
||||||
|
text: "",
|
||||||
|
variant: "outline",
|
||||||
|
size: "sm",
|
||||||
|
icon: <Trash2 color="red" size={15}/>,
|
||||||
|
},
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
+4
-22
@@ -13,6 +13,7 @@ import {UserWithAccounts} from "@/db/schema/02_user";
|
|||||||
import {authClient, useSession} from "@/lib/auth/auth-client";
|
import {authClient, useSession} from "@/lib/auth/auth-client";
|
||||||
import {formatFrenchDate} from "@/utils/date-formatting";
|
import {formatFrenchDate} from "@/utils/date-formatting";
|
||||||
import {providerSwitch} from "@/components/wrappers/common/provider-switch";
|
import {providerSwitch} from "@/components/wrappers/common/provider-switch";
|
||||||
|
import {ButtonDeleteUser} from "@/components/wrappers/dashboard/admin/tabs/admin-user-tab/button-delete-use";
|
||||||
|
|
||||||
export const usersColumnsAdmin: ColumnDef<UserWithAccounts>[] = [
|
export const usersColumnsAdmin: ColumnDef<UserWithAccounts>[] = [
|
||||||
{
|
{
|
||||||
@@ -98,29 +99,10 @@ export const usersColumnsAdmin: ColumnDef<UserWithAccounts>[] = [
|
|||||||
const {data: session, isPending} = useSession();
|
const {data: session, isPending} = useSession();
|
||||||
const isSuperAdmin = session?.user.role == "superadmin";
|
const isSuperAdmin = session?.user.role == "superadmin";
|
||||||
|
|
||||||
const mutation = useMutation({
|
|
||||||
mutationFn: () => deleteUserAction(row.original.id),
|
|
||||||
onSuccess: async () => {
|
|
||||||
toast.success("User deleted successfully.");
|
|
||||||
router.refresh();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<ButtonDeleteUser
|
||||||
<div className="flex items-center gap-2">
|
disabled={!isSuperAdmin || !session || session?.user.email === row.original.email}
|
||||||
<ButtonWithLoading
|
userId={row.original.id}/>
|
||||||
disabled={!isSuperAdmin || !session || session?.user.email === row.original.email}
|
|
||||||
variant="outline"
|
|
||||||
text=""
|
|
||||||
icon={<Trash2 color="red" size={15}/>}
|
|
||||||
onClick={async () => {
|
|
||||||
await mutation.mutateAsync();
|
|
||||||
}}
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
-1
@@ -73,7 +73,6 @@ export const sessionsColumns: ColumnDef<Session>[] = [
|
|||||||
<ButtonWithLoading
|
<ButtonWithLoading
|
||||||
variant="outline"
|
variant="outline"
|
||||||
disabled={session?.session.id === row.original.id}
|
disabled={session?.session.id === row.original.id}
|
||||||
text=""
|
|
||||||
icon={<Unlink color="red" size={15} />}
|
icon={<Unlink color="red" size={15} />}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await mutation.mutateAsync();
|
await mutation.mutateAsync();
|
||||||
@@ -38,12 +38,11 @@ export const BackupButton = (props: BackupButtonProps) => {
|
|||||||
<ButtonWithLoading
|
<ButtonWithLoading
|
||||||
icon={<DatabaseZap/>}
|
icon={<DatabaseZap/>}
|
||||||
disabled={props.disable}
|
disabled={props.disable}
|
||||||
text={isMobile ? "" : "Backup"}
|
|
||||||
isPending={mutation.isPending}
|
isPending={mutation.isPending}
|
||||||
size={"default"}
|
size={"default"}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await HandleAction();
|
await HandleAction();
|
||||||
}}
|
}}
|
||||||
/>
|
>{isMobile ? "" : "Backup"}</ButtonWithLoading>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+1
-2
@@ -2,7 +2,6 @@
|
|||||||
import {ButtonWithConfirm} from "@/components/wrappers/common/button/button-with-confirm";
|
import {ButtonWithConfirm} from "@/components/wrappers/common/button/button-with-confirm";
|
||||||
import {deleteOrganizationAction} from "@/components/wrappers/dashboard/organization/organization.action";
|
import {deleteOrganizationAction} from "@/components/wrappers/dashboard/organization/organization.action";
|
||||||
import {useMutation} from "@tanstack/react-query";
|
import {useMutation} from "@tanstack/react-query";
|
||||||
import {setCurrentOrganizationSlug} from "@/features/dashboard/organization-cookie";
|
|
||||||
import {useRouter} from "next/navigation";
|
import {useRouter} from "next/navigation";
|
||||||
import {toast} from "sonner";
|
import {toast} from "sonner";
|
||||||
import {authClient} from "@/lib/auth/auth-client";
|
import {authClient} from "@/lib/auth/auth-client";
|
||||||
@@ -17,7 +16,7 @@ export const DeleteOrganizationButton = (props: DeleteOrganizationButtonProps) =
|
|||||||
const {data: organizations, refetch} = authClient.useListOrganizations();
|
const {data: organizations, refetch} = authClient.useListOrganizations();
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: () => deleteOrganizationAction(props.organizationSlug),
|
mutationFn: () => deleteOrganizationAction({slug: props.organizationSlug}),
|
||||||
|
|
||||||
onSuccess: async (result) => {
|
onSuccess: async (result) => {
|
||||||
if (result?.data?.success) {
|
if (result?.data?.success) {
|
||||||
|
|||||||
@@ -86,8 +86,6 @@ export const OrganizationForm = (props: organizationFormProps) => {
|
|||||||
console.error("Mutation network error:", error);
|
console.error("Mutation network error:", error);
|
||||||
toast.error(error?.message || "A network error occurred.");
|
toast.error(error?.message || "A network error occurred.");
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -8,12 +8,11 @@ import {
|
|||||||
OrganizationFormSchema
|
OrganizationFormSchema
|
||||||
} from "@/components/wrappers/dashboard/organization/organization-form/organization-form.schema";
|
} from "@/components/wrappers/dashboard/organization/organization-form/organization-form.schema";
|
||||||
import {db} from "@/db";
|
import {db} from "@/db";
|
||||||
import {and, eq, inArray} from "drizzle-orm";
|
import {and, eq, inArray, or} from "drizzle-orm";
|
||||||
import {auth, checkSlugOrganization, createOrganization, deleteOrganization} from "@/lib/auth/auth";
|
import {auth, checkSlugOrganization, createOrganization} from "@/lib/auth/auth";
|
||||||
import {slugify} from "@/utils/slugify";
|
import {slugify} from "@/utils/slugify";
|
||||||
import {Organization} from "@/db/schema/03_organization";
|
import {Organization} from "@/db/schema/03_organization";
|
||||||
import * as drizzleDb from "@/db";
|
import * as drizzleDb from "@/db";
|
||||||
import {headers} from "next/headers";
|
|
||||||
|
|
||||||
export const createOrganizationAction = userAction.schema(OrganizationSchema).action(async ({parsedInput}): Promise<ServerActionResult<Organization>> => {
|
export const createOrganizationAction = userAction.schema(OrganizationSchema).action(async ({parsedInput}): Promise<ServerActionResult<Organization>> => {
|
||||||
try {
|
try {
|
||||||
@@ -84,7 +83,6 @@ export const updateOrganizationAction = userAction
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
if (!organization) {
|
if (!organization) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
@@ -113,61 +111,11 @@ export const updateOrganizationAction = userAction
|
|||||||
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
//
|
|
||||||
// await db
|
|
||||||
// .insert(drizzleDb.schemas.member)
|
|
||||||
// .values(
|
|
||||||
// usersToAdd.map((userId) => ({
|
|
||||||
// userId,
|
|
||||||
// organizationId: organization.id,
|
|
||||||
// role: "member",
|
|
||||||
// }))
|
|
||||||
// )
|
|
||||||
// .execute();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (usersToRemove.length > 0) {
|
if (usersToRemove.length > 0) {
|
||||||
await db.delete(drizzleDb.schemas.member).where(and(inArray(drizzleDb.schemas.member.userId, usersToRemove), eq(drizzleDb.schemas.member.organizationId, organization.id))).execute();
|
await db.delete(drizzleDb.schemas.member).where(and(inArray(drizzleDb.schemas.member.userId, usersToRemove), eq(drizzleDb.schemas.member.organizationId, organization.id))).execute();
|
||||||
// TODO : Do not delete, go permission error with better auth
|
|
||||||
// for (const userToRemove of usersToRemove) {
|
|
||||||
//
|
|
||||||
// const memberToRemove = await db.query.member.findFirst({
|
|
||||||
// where: and(eq(drizzleDb.schemas.member.userId, userToRemove), eq(drizzleDb.schemas.member.organizationId, organization.id)),
|
|
||||||
// with: {
|
|
||||||
// user: true
|
|
||||||
// }
|
|
||||||
// })
|
|
||||||
// console.log(memberToRemove)
|
|
||||||
//
|
|
||||||
// if (memberToRemove) {
|
|
||||||
// console.log("ici")
|
|
||||||
// await auth.api.removeMember({
|
|
||||||
// body: {
|
|
||||||
// memberIdOrEmail: memberToRemove.user.email,
|
|
||||||
// organizationId: organization.id,
|
|
||||||
// },
|
|
||||||
// headers: await headers()
|
|
||||||
// });
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// }
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// const updatedOrganization = await auth.api.updateOrganization({
|
|
||||||
// body: {
|
|
||||||
// data: {
|
|
||||||
// name: parsedInput.data.name,
|
|
||||||
// slug: parsedInput.data.slug,
|
|
||||||
// },
|
|
||||||
// organizationId: organization.id,
|
|
||||||
// },
|
|
||||||
// headers: await headers(),
|
|
||||||
// });
|
|
||||||
|
|
||||||
|
|
||||||
const updatedOrganization = await db
|
const updatedOrganization = await db
|
||||||
.update(drizzleDb.schemas.organization)
|
.update(drizzleDb.schemas.organization)
|
||||||
.set({
|
.set({
|
||||||
@@ -200,11 +148,24 @@ export const updateOrganizationAction = userAction
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export const deleteOrganizationAction = userAction.schema(z.string()).action(
|
export const deleteOrganizationAction = userAction.schema(
|
||||||
|
z.object({
|
||||||
|
id: z.string().optional(),
|
||||||
|
slug: z.string().optional(),
|
||||||
|
})
|
||||||
|
).action(
|
||||||
async ({parsedInput, ctx}): Promise<ServerActionResult<Organization>> => {
|
async ({parsedInput, ctx}): Promise<ServerActionResult<Organization>> => {
|
||||||
try {
|
try {
|
||||||
|
const conditions = [];
|
||||||
|
if (parsedInput.id) {
|
||||||
|
conditions.push(eq(drizzleDb.schemas.organization.id, parsedInput.id));
|
||||||
|
}
|
||||||
|
if (parsedInput.slug) {
|
||||||
|
conditions.push(eq(drizzleDb.schemas.organization.slug, parsedInput.slug));
|
||||||
|
}
|
||||||
|
|
||||||
const org = await db.query.organization.findFirst({
|
const org = await db.query.organization.findFirst({
|
||||||
where: eq(drizzleDb.schemas.organization.slug, parsedInput),
|
where: or(...conditions),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!org) {
|
if (!org) {
|
||||||
@@ -221,8 +182,6 @@ export const deleteOrganizationAction = userAction.schema(z.string()).action(
|
|||||||
let deletedOrganization: Organization;
|
let deletedOrganization: Organization;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// TODO : Improve with better auth, always getting 403 error
|
|
||||||
// deletedOrganization = await deleteOrganization(org.id) as Organization;
|
|
||||||
[deletedOrganization] = await db
|
[deletedOrganization] = await db
|
||||||
.delete(drizzleDb.schemas.organization)
|
.delete(drizzleDb.schemas.organization)
|
||||||
.where(eq(drizzleDb.schemas.organization.id, org.id))
|
.where(eq(drizzleDb.schemas.organization.id, org.id))
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ import { UserSchema, UserType } from "@/components/wrappers/dashboard/profile/us
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { updateUserAction } from "@/components/wrappers/dashboard/profile/user-form/user-form.action";
|
import { updateUserAction } from "@/components/wrappers/dashboard/profile/user-form/user-form.action";
|
||||||
import {DataTable} from "@/components/wrappers/common/table/data-table";
|
import {DataTable} from "@/components/wrappers/common/table/data-table";
|
||||||
import {sessionsColumns} from "@/components/wrappers/dashboard/admin/admin-user-tab/sessions/table-columns";
|
import {sessionsColumns} from "@/components/wrappers/dashboard/admin/tabs/admin-user-tab/sessions/table-columns";
|
||||||
import {accountsColumns} from "@/components/wrappers/dashboard/admin/admin-user-tab/accounts/table-columns";
|
import {accountsColumns} from "@/components/wrappers/dashboard/admin/tabs/admin-user-tab/accounts/table-columns";
|
||||||
import {Session} from "better-auth";
|
import {Session} from "better-auth";
|
||||||
|
|
||||||
export type UserFormProps = {
|
export type UserFormProps = {
|
||||||
|
|||||||
@@ -117,7 +117,6 @@ export const DatabaseBackupList = (props: DatabaseBackupListProps) => {
|
|||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<ButtonWithLoading
|
<ButtonWithLoading
|
||||||
variant="outline"
|
variant="outline"
|
||||||
text="Actions"
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|
||||||
}}
|
}}
|
||||||
@@ -125,7 +124,7 @@ export const DatabaseBackupList = (props: DatabaseBackupListProps) => {
|
|||||||
icon={<MoreHorizontal/>}
|
icon={<MoreHorizontal/>}
|
||||||
isPending={mutationDeleteBackups.isPending}
|
isPending={mutationDeleteBackups.isPending}
|
||||||
size="sm"
|
size="sm"
|
||||||
/>
|
>Actions</ButtonWithLoading>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="start">
|
<DropdownMenuContent align="start">
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
|
|||||||
@@ -65,14 +65,13 @@ export const DatabaseRestoreList = (props: DatabaseRestoreListProps) => {
|
|||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<ButtonWithLoading
|
<ButtonWithLoading
|
||||||
variant="outline"
|
variant="outline"
|
||||||
text="Actions"
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
}}
|
}}
|
||||||
disabled={rows.length === 0 || mutationDeleteRestorations.isPending}
|
disabled={rows.length === 0 || mutationDeleteRestorations.isPending}
|
||||||
icon={<MoreHorizontal/>}
|
icon={<MoreHorizontal/>}
|
||||||
isPending={mutationDeleteRestorations.isPending}
|
isPending={mutationDeleteRestorations.isPending}
|
||||||
size="sm"
|
size="sm"
|
||||||
/>
|
>Actions</ButtonWithLoading>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="start">
|
<DropdownMenuContent align="start">
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
|
|||||||
-1
@@ -10,7 +10,6 @@ export const EditButtonSettings= (props:EditButtonSettings) => {
|
|||||||
|
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
|
||||||
return(
|
return(
|
||||||
<Link className={buttonVariants({variant: "outline"})}
|
<Link className={buttonVariants({variant: "outline"})}
|
||||||
href={`${pathname}/edit/`}
|
href={`${pathname}/edit/`}
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ import {RoleSchemaMember} from "@/components/wrappers/dashboard/settings/member.
|
|||||||
import {headers} from "next/headers";
|
import {headers} from "next/headers";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export const updateMemberRoleAction = userAction.schema(
|
export const updateMemberRoleAction = userAction.schema(
|
||||||
z.object({
|
z.object({
|
||||||
memberId: z.string(),
|
memberId: z.string(),
|
||||||
@@ -18,7 +16,6 @@ export const updateMemberRoleAction = userAction.schema(
|
|||||||
})
|
})
|
||||||
).action(async ({parsedInput}): Promise<ServerActionResult<Member>> => {
|
).action(async ({parsedInput}): Promise<ServerActionResult<Member>> => {
|
||||||
try {
|
try {
|
||||||
console.log(parsedInput);
|
|
||||||
const updatedMember = await auth.api.updateMemberRole({
|
const updatedMember = await auth.api.updateMemberRole({
|
||||||
body: {
|
body: {
|
||||||
role: parsedInput.role,
|
role: parsedInput.role,
|
||||||
@@ -27,7 +24,6 @@ export const updateMemberRoleAction = userAction.schema(
|
|||||||
},
|
},
|
||||||
headers: await headers(),
|
headers: await headers(),
|
||||||
});
|
});
|
||||||
console.log(updatedMember);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -1,54 +1,62 @@
|
|||||||
"use server";
|
"use server";
|
||||||
import {userAction} from "@/lib/safe-actions/actions";
|
import {userAction} from "@/lib/safe-actions/actions";
|
||||||
import { z } from "zod";
|
import {z} from "zod";
|
||||||
import { v4 as uuidv4 } from "uuid";
|
import {v4 as uuidv4} from "uuid";
|
||||||
import { mkdir, writeFile } from "fs/promises";
|
import {mkdir, writeFile} from "fs/promises";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { env } from "@/env.mjs";
|
import {env} from "@/env.mjs";
|
||||||
import { checkMinioAlive, createPublicBucket, saveFileInBucket } from "@/utils/s3-file-management";
|
import {checkMinioAlive, saveFileInBucket} from "@/utils/s3-file-management";
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
import { UploadedObjectInfo } from "minio/src/internal/type";
|
import {UploadedObjectInfo} from "minio/src/internal/type";
|
||||||
import { getServerUrl } from "@/utils/get-server-url";
|
import {getServerUrl} from "@/utils/get-server-url";
|
||||||
import { db } from "@/db";
|
import {db} from "@/db";
|
||||||
import { eq } from "drizzle-orm";
|
import {eq} from "drizzle-orm";
|
||||||
import {Setting} from "@/db/schema/01_setting";
|
import {Setting} from "@/db/schema/01_setting";
|
||||||
import * as drizzleDb from "@/db";
|
import * as drizzleDb from "@/db";
|
||||||
|
|
||||||
export const uploadImageAction = userAction.schema(z.instanceof(FormData)).action(async ({ parsedInput: formData, ctx }) => {
|
|
||||||
const file = formData.get("file") as File;
|
|
||||||
const uuid = uuidv4();
|
|
||||||
const fileFormat = file.name.split(".").slice(-1)[0];
|
|
||||||
const fileName = uuid + "." + fileFormat;
|
|
||||||
const arrayBuffer = await file.arrayBuffer();
|
|
||||||
const buffer = Buffer.from(arrayBuffer);
|
|
||||||
|
|
||||||
const [settings] = await db.select().from(drizzleDb.schemas.setting).where(eq(drizzleDb.schemas.setting.name, "system")).limit(1);
|
const privateS3ImageDir = "images/";
|
||||||
if (!settings) {
|
|
||||||
throw new Error("System settings not found.");
|
|
||||||
}
|
|
||||||
|
|
||||||
let result: void | UploadedObjectInfo;
|
|
||||||
const bucketName = "public-image-bucket";
|
|
||||||
|
|
||||||
// TODO : Do not delete
|
export const uploadImageAction = userAction
|
||||||
// if (settings.storage === "local") {
|
.schema(z.instanceof(FormData))
|
||||||
// result = await uploadLocal(fileName, buffer);
|
.action(async ({parsedInput: formData, ctx}) => {
|
||||||
// } else if (settings.storage === "s3") {
|
const file = formData.get("file") as File;
|
||||||
// result = await uploadS3Compatible(bucketName, fileName, buffer);
|
const uuid = uuidv4();
|
||||||
// }
|
const fileFormat = file.name.split(".").pop();
|
||||||
result = await uploadLocal(fileName, buffer);
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
|
const buffer = Buffer.from(arrayBuffer);
|
||||||
|
|
||||||
|
const [settings] = await db
|
||||||
|
.select()
|
||||||
|
.from(drizzleDb.schemas.setting)
|
||||||
|
.where(eq(drizzleDb.schemas.setting.name, "system"))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!settings) throw new Error("System settings not found.");
|
||||||
|
|
||||||
|
let fileName: string | null = null;
|
||||||
|
let result: void | UploadedObjectInfo;
|
||||||
|
|
||||||
|
if (settings.storage === "local") {
|
||||||
|
fileName = `${uuid}.${fileFormat}`;
|
||||||
|
result = await uploadLocal(fileName, buffer);
|
||||||
|
} else if (settings.storage === "s3") {
|
||||||
|
fileName = `${privateS3ImageDir}${uuid}.${fileFormat}`;
|
||||||
|
result = await uploadS3Compatible(env.S3_BUCKET_NAME ?? "", fileName, buffer);
|
||||||
|
} else {
|
||||||
|
throw new Error(`Unsupported storage type: ${settings.storage}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `${getServerUrl()}/api/${fileName}`
|
||||||
|
return {data: {result, url}};
|
||||||
|
});
|
||||||
|
|
||||||
const url = getUrl(fileName, settings, bucketName);
|
|
||||||
console.log(url);
|
|
||||||
return {
|
|
||||||
data: { result: result, url: url },
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
async function uploadLocal(fileName: string, buffer: any) {
|
async function uploadLocal(fileName: string, buffer: any) {
|
||||||
const localDir = "private/uploads/images/";
|
const localDir = "private/uploads/images/";
|
||||||
try {
|
try {
|
||||||
await mkdir(path.join(process.cwd(), localDir), { recursive: true });
|
await mkdir(path.join(process.cwd(), localDir), {recursive: true});
|
||||||
return await writeFile(path.join(process.cwd(), localDir + fileName), buffer);
|
return await writeFile(path.join(process.cwd(), localDir + fileName), buffer);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log("Error occured ", error);
|
console.log("Error occured ", error);
|
||||||
@@ -57,7 +65,6 @@ async function uploadLocal(fileName: string, buffer: any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function uploadS3Compatible(bucketName: string, fileName: string, buffer: any) {
|
async function uploadS3Compatible(bucketName: string, fileName: string, buffer: any) {
|
||||||
await createPublicBucket({ bucketName });
|
|
||||||
return await saveFileInBucket({
|
return await saveFileInBucket({
|
||||||
bucketName,
|
bucketName,
|
||||||
fileName,
|
fileName,
|
||||||
@@ -66,20 +73,11 @@ async function uploadS3Compatible(bucketName: string, fileName: string, buffer:
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getUrl(fileName: string, settings: Setting, bucketName: string): string {
|
function getUrl(fileName: string, settings: Setting, bucketName: string): string {
|
||||||
if (env.NODE_ENV === "production") {
|
if (settings.storage === "s3") {
|
||||||
if (settings.storage === "s3") {
|
return `https://${env.S3_ENDPOINT}/${bucketName}/${fileName}`;
|
||||||
return `https://${env.S3_ENDPOINT}/${bucketName}/${fileName}`;
|
} else if (settings.storage === "local") {
|
||||||
} else if (settings.storage === "local") {
|
const url = getServerUrl();
|
||||||
const url = getServerUrl();
|
return `${url}/api/images/${fileName}`;
|
||||||
return `${url}/api/images/${fileName}`;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (settings.storage === "s3") {
|
|
||||||
return `http://localhost:${env.S3_PORT}/${bucketName}/${fileName}`;
|
|
||||||
} else if (settings.storage === "local") {
|
|
||||||
const url = getServerUrl();
|
|
||||||
return `${url}/api/images/${fileName}`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
throw new Error("Invalid storage configuration");
|
throw new Error("Invalid storage configuration");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { createAccessControl } from "better-auth/plugins/access";
|
import {createAccessControl} from "better-auth/plugins/access";
|
||||||
import { defaultStatements, adminAc } from "better-auth/plugins/admin/access";
|
import {defaultStatements, adminAc} from "better-auth/plugins/admin/access";
|
||||||
import { defaultStatements as orgDefaultStatements, adminAc as orgAdminAc, ownerAc as orgOwnerAc, memberAc as orgMemberAc } from "better-auth/plugins/organization/access";
|
import {
|
||||||
|
defaultStatements as orgDefaultStatements,
|
||||||
|
adminAc as orgAdminAc,
|
||||||
|
ownerAc as orgOwnerAc,
|
||||||
|
memberAc as orgMemberAc
|
||||||
|
} from "better-auth/plugins/organization/access";
|
||||||
|
|
||||||
const statement = {
|
const statement = {
|
||||||
...defaultStatements,
|
...defaultStatements,
|
||||||
@@ -8,20 +13,20 @@ const statement = {
|
|||||||
project: ["create", "list", "update", "delete"],
|
project: ["create", "list", "update", "delete"],
|
||||||
database: ["create", "list", "update", "delete", "backup"],
|
database: ["create", "list", "update", "delete", "backup"],
|
||||||
agent: ["create", "list", "update", "delete"],
|
agent: ["create", "list", "update", "delete"],
|
||||||
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const ac = createAccessControl(statement);
|
const ac = createAccessControl(statement);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const superadmin = ac.newRole({
|
const superadmin = ac.newRole({
|
||||||
project: ["create", "list", "update", "delete"],
|
project: ["create", "list", "update", "delete"],
|
||||||
database: ["create", "list", "update", "delete"],
|
database: ["create", "list", "update", "delete"],
|
||||||
agent: ["create", "list", "update", "delete"],
|
agent: ["create", "list", "update", "delete"],
|
||||||
...adminAc.statements,
|
...adminAc.statements,
|
||||||
|
...orgMemberAc.statements,
|
||||||
...orgAdminAc.statements,
|
...orgAdminAc.statements,
|
||||||
...orgOwnerAc.statements,
|
...orgOwnerAc.statements,
|
||||||
...orgMemberAc.statements,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const admin = ac.newRole({
|
const admin = ac.newRole({
|
||||||
@@ -29,6 +34,8 @@ const admin = ac.newRole({
|
|||||||
database: ["create", "list", "update", "delete"],
|
database: ["create", "list", "update", "delete"],
|
||||||
agent: ["create", "list", "update", "delete"],
|
agent: ["create", "list", "update", "delete"],
|
||||||
...adminAc.statements,
|
...adminAc.statements,
|
||||||
|
...orgMemberAc.statements,
|
||||||
|
...orgAdminAc.statements,
|
||||||
});
|
});
|
||||||
|
|
||||||
const user = ac.newRole({
|
const user = ac.newRole({
|
||||||
@@ -48,18 +55,22 @@ const orgMember = ac.newRole({
|
|||||||
project: ["list"],
|
project: ["list"],
|
||||||
database: ["list"],
|
database: ["list"],
|
||||||
agent: ["list"],
|
agent: ["list"],
|
||||||
|
...orgMemberAc.statements,
|
||||||
});
|
});
|
||||||
|
|
||||||
const orgAdmin = ac.newRole({
|
const orgAdmin = ac.newRole({
|
||||||
project: ["create", "update"],
|
project: ["create", "update"],
|
||||||
|
...orgMemberAc.statements,
|
||||||
...orgAdminAc.statements,
|
...orgAdminAc.statements,
|
||||||
});
|
});
|
||||||
|
|
||||||
const orgOwner = ac.newRole({
|
const orgOwner = ac.newRole({
|
||||||
project: ["create", "update", "delete"],
|
project: ["create", "update", "delete"],
|
||||||
|
...orgMemberAc.statements,
|
||||||
...orgAdminAc.statements,
|
...orgAdminAc.statements,
|
||||||
...orgOwnerAc.statements,
|
...orgOwnerAc.statements,
|
||||||
...orgMemberAc.statements
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export { ac, admin, superadmin, user, pending, orgAdmin, orgMember, orgOwner };
|
|
||||||
|
export {ac, admin, superadmin, user, pending, orgAdmin, orgMember, orgOwner};
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export type MemberRole = "member" | "admin" | "owner";
|
||||||
|
export type MemberRoleType = MemberRole | MemberRole[];
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import {MemberWithUser, Organization, OrganizationWithMembersAndUsers} from "@/db/schema/03_organization";
|
||||||
|
import {OrganizationMember} from "@/db/schema/04_member";
|
||||||
|
import {OrganizationInvitation} from "@/db/schema/05_invitation";
|
||||||
|
import {User} from "@/db/schema/02_user";
|
||||||
|
|
||||||
|
|
||||||
|
export function buildOrganizationWithMembers(
|
||||||
|
rows: {
|
||||||
|
organization: Organization;
|
||||||
|
member: OrganizationMember | null;
|
||||||
|
invitation: OrganizationInvitation | null;
|
||||||
|
user: User | null;
|
||||||
|
}[]
|
||||||
|
): OrganizationWithMembersAndUsers | null {
|
||||||
|
if (rows.length === 0) return null;
|
||||||
|
|
||||||
|
const org = rows[0].organization;
|
||||||
|
|
||||||
|
|
||||||
|
const invitations : OrganizationInvitation[] = rows
|
||||||
|
.filter(r => r.invitation)
|
||||||
|
.map(r => ({
|
||||||
|
...r.invitation!,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const members: MemberWithUser[] = rows
|
||||||
|
.filter(r => r.member && r.user)
|
||||||
|
.map(r => ({
|
||||||
|
...r.member!,
|
||||||
|
user: r.user!,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
...org,
|
||||||
|
invitations,
|
||||||
|
members,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -4,6 +4,7 @@ import internal from "node:stream";
|
|||||||
import {db} from "@/db";
|
import {db} from "@/db";
|
||||||
import * as drizzleDb from "@/db";
|
import * as drizzleDb from "@/db";
|
||||||
import {eq} from "drizzle-orm";
|
import {eq} from "drizzle-orm";
|
||||||
|
import stream from "node:stream";
|
||||||
|
|
||||||
|
|
||||||
async function getS3Client() {
|
async function getS3Client() {
|
||||||
@@ -158,6 +159,20 @@ export async function saveFileInBucket({bucketName, fileName, file}: {
|
|||||||
// }
|
// }
|
||||||
// return true;
|
// return true;
|
||||||
// }
|
// }
|
||||||
|
|
||||||
|
export async function getObjectFromClient({
|
||||||
|
bucketName,
|
||||||
|
fileName,
|
||||||
|
}: {
|
||||||
|
bucketName: string;
|
||||||
|
fileName: string;
|
||||||
|
}): Promise<stream.Readable> {
|
||||||
|
const s3 = await getS3Client();
|
||||||
|
return await s3.getObject(bucketName, fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export async function checkFileExistsInBucket({
|
export async function checkFileExistsInBucket({
|
||||||
bucketName,
|
bucketName,
|
||||||
fileName,
|
fileName,
|
||||||
|
|||||||
@@ -1099,10 +1099,10 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@next/env@npm:15.5.2":
|
"@next/env@npm:15.5.6":
|
||||||
version: 15.5.2
|
version: 15.5.6
|
||||||
resolution: "@next/env@npm:15.5.2"
|
resolution: "@next/env@npm:15.5.6"
|
||||||
checksum: 10c0/39bb834c36361c12b8f3f8fc9e6ce72b2af0daa1b56eab18f2b79d114403fe025317d5079ed2907334da6b4c53b2462724ffc2afa54471c335e6987b4a7a0cc3
|
checksum: 10c0/d75e12391c9ce4789fe458a4c08f150eb4b31cdb1e3f4b75c41f7e2cb7f0ee879a155f5ea2d677d23b486bf3b5f4545fcdee00c80dca0e080b5e3de79d053bc2
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
@@ -1115,58 +1115,58 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@next/swc-darwin-arm64@npm:15.5.2":
|
"@next/swc-darwin-arm64@npm:15.5.6":
|
||||||
version: 15.5.2
|
version: 15.5.6
|
||||||
resolution: "@next/swc-darwin-arm64@npm:15.5.2"
|
resolution: "@next/swc-darwin-arm64@npm:15.5.6"
|
||||||
conditions: os=darwin & cpu=arm64
|
conditions: os=darwin & cpu=arm64
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@next/swc-darwin-x64@npm:15.5.2":
|
"@next/swc-darwin-x64@npm:15.5.6":
|
||||||
version: 15.5.2
|
version: 15.5.6
|
||||||
resolution: "@next/swc-darwin-x64@npm:15.5.2"
|
resolution: "@next/swc-darwin-x64@npm:15.5.6"
|
||||||
conditions: os=darwin & cpu=x64
|
conditions: os=darwin & cpu=x64
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@next/swc-linux-arm64-gnu@npm:15.5.2":
|
"@next/swc-linux-arm64-gnu@npm:15.5.6":
|
||||||
version: 15.5.2
|
version: 15.5.6
|
||||||
resolution: "@next/swc-linux-arm64-gnu@npm:15.5.2"
|
resolution: "@next/swc-linux-arm64-gnu@npm:15.5.6"
|
||||||
conditions: os=linux & cpu=arm64 & libc=glibc
|
conditions: os=linux & cpu=arm64 & libc=glibc
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@next/swc-linux-arm64-musl@npm:15.5.2":
|
"@next/swc-linux-arm64-musl@npm:15.5.6":
|
||||||
version: 15.5.2
|
version: 15.5.6
|
||||||
resolution: "@next/swc-linux-arm64-musl@npm:15.5.2"
|
resolution: "@next/swc-linux-arm64-musl@npm:15.5.6"
|
||||||
conditions: os=linux & cpu=arm64 & libc=musl
|
conditions: os=linux & cpu=arm64 & libc=musl
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@next/swc-linux-x64-gnu@npm:15.5.2":
|
"@next/swc-linux-x64-gnu@npm:15.5.6":
|
||||||
version: 15.5.2
|
version: 15.5.6
|
||||||
resolution: "@next/swc-linux-x64-gnu@npm:15.5.2"
|
resolution: "@next/swc-linux-x64-gnu@npm:15.5.6"
|
||||||
conditions: os=linux & cpu=x64 & libc=glibc
|
conditions: os=linux & cpu=x64 & libc=glibc
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@next/swc-linux-x64-musl@npm:15.5.2":
|
"@next/swc-linux-x64-musl@npm:15.5.6":
|
||||||
version: 15.5.2
|
version: 15.5.6
|
||||||
resolution: "@next/swc-linux-x64-musl@npm:15.5.2"
|
resolution: "@next/swc-linux-x64-musl@npm:15.5.6"
|
||||||
conditions: os=linux & cpu=x64 & libc=musl
|
conditions: os=linux & cpu=x64 & libc=musl
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@next/swc-win32-arm64-msvc@npm:15.5.2":
|
"@next/swc-win32-arm64-msvc@npm:15.5.6":
|
||||||
version: 15.5.2
|
version: 15.5.6
|
||||||
resolution: "@next/swc-win32-arm64-msvc@npm:15.5.2"
|
resolution: "@next/swc-win32-arm64-msvc@npm:15.5.6"
|
||||||
conditions: os=win32 & cpu=arm64
|
conditions: os=win32 & cpu=arm64
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@next/swc-win32-x64-msvc@npm:15.5.2":
|
"@next/swc-win32-x64-msvc@npm:15.5.6":
|
||||||
version: 15.5.2
|
version: 15.5.6
|
||||||
resolution: "@next/swc-win32-x64-msvc@npm:15.5.2"
|
resolution: "@next/swc-win32-x64-msvc@npm:15.5.6"
|
||||||
conditions: os=win32 & cpu=x64
|
conditions: os=win32 & cpu=x64
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
@@ -8128,19 +8128,19 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"next@npm:15.5.2":
|
"next@npm:15.5.6":
|
||||||
version: 15.5.2
|
version: 15.5.6
|
||||||
resolution: "next@npm:15.5.2"
|
resolution: "next@npm:15.5.6"
|
||||||
dependencies:
|
dependencies:
|
||||||
"@next/env": "npm:15.5.2"
|
"@next/env": "npm:15.5.6"
|
||||||
"@next/swc-darwin-arm64": "npm:15.5.2"
|
"@next/swc-darwin-arm64": "npm:15.5.6"
|
||||||
"@next/swc-darwin-x64": "npm:15.5.2"
|
"@next/swc-darwin-x64": "npm:15.5.6"
|
||||||
"@next/swc-linux-arm64-gnu": "npm:15.5.2"
|
"@next/swc-linux-arm64-gnu": "npm:15.5.6"
|
||||||
"@next/swc-linux-arm64-musl": "npm:15.5.2"
|
"@next/swc-linux-arm64-musl": "npm:15.5.6"
|
||||||
"@next/swc-linux-x64-gnu": "npm:15.5.2"
|
"@next/swc-linux-x64-gnu": "npm:15.5.6"
|
||||||
"@next/swc-linux-x64-musl": "npm:15.5.2"
|
"@next/swc-linux-x64-musl": "npm:15.5.6"
|
||||||
"@next/swc-win32-arm64-msvc": "npm:15.5.2"
|
"@next/swc-win32-arm64-msvc": "npm:15.5.6"
|
||||||
"@next/swc-win32-x64-msvc": "npm:15.5.2"
|
"@next/swc-win32-x64-msvc": "npm:15.5.6"
|
||||||
"@swc/helpers": "npm:0.5.15"
|
"@swc/helpers": "npm:0.5.15"
|
||||||
caniuse-lite: "npm:^1.0.30001579"
|
caniuse-lite: "npm:^1.0.30001579"
|
||||||
postcss: "npm:8.4.31"
|
postcss: "npm:8.4.31"
|
||||||
@@ -8183,7 +8183,7 @@ __metadata:
|
|||||||
optional: true
|
optional: true
|
||||||
bin:
|
bin:
|
||||||
next: dist/bin/next
|
next: dist/bin/next
|
||||||
checksum: 10c0/3bed56bcca1f0fe07908fa075229f7f2662b4870974570f9e5a944758db9960868704ea4253f05c79507381b2e0014e8b621d7934408e26a0df8cbb3621986d8
|
checksum: 10c0/17d08dda8e0503aff9f2de27ea77bde193fd5f9f3faaaefa9dfb0f8957880c49f47cb1ebb6c3a014664890dee2aafa1da31e3093e7fd8c205caf956d25781704
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
@@ -8822,7 +8822,7 @@ __metadata:
|
|||||||
input-otp: "npm:^1.4.2"
|
input-otp: "npm:^1.4.2"
|
||||||
lucide-react: "npm:^0.510.0"
|
lucide-react: "npm:^0.510.0"
|
||||||
minio: "npm:^8.0.5"
|
minio: "npm:^8.0.5"
|
||||||
next: "npm:15.5.2"
|
next: "npm:15.5.6"
|
||||||
next-safe-action: "npm:^7.10.8"
|
next-safe-action: "npm:^7.10.8"
|
||||||
next-themes: "npm:^0.4.6"
|
next-themes: "npm:^0.4.6"
|
||||||
node-cron: "npm:^4.2.1"
|
node-cron: "npm:^4.2.1"
|
||||||
|
|||||||
Reference in New Issue
Block a user