From f34bbd889dcc25a53ae835b303a66a84f0f74153 Mon Sep 17 00:00:00 2001 From: OverDsh Date: Tue, 3 Feb 2026 21:47:50 +0100 Subject: [PATCH 01/40] feat: renamed databases page to connections --- src/app/dashboard/[teamSlug]/components/TeamSidebar.tsx | 8 ++++---- .../{databases => connections}/DatabaseColumn.tsx | 0 .../LinkDialog/DatabaseTypeSelectorDialog.tsx | 0 .../LinkDialog/LinkDatabaseDialog.tsx | 0 .../LinkDialog/S3DatabaseDialog.tsx | 0 .../[teamSlug]/{databases => connections}/page.tsx | 5 ++++- 6 files changed, 8 insertions(+), 5 deletions(-) rename src/app/dashboard/[teamSlug]/{databases => connections}/DatabaseColumn.tsx (100%) rename src/app/dashboard/[teamSlug]/{databases => connections}/LinkDialog/DatabaseTypeSelectorDialog.tsx (100%) rename src/app/dashboard/[teamSlug]/{databases => connections}/LinkDialog/LinkDatabaseDialog.tsx (100%) rename src/app/dashboard/[teamSlug]/{databases => connections}/LinkDialog/S3DatabaseDialog.tsx (100%) rename src/app/dashboard/[teamSlug]/{databases => connections}/page.tsx (87%) diff --git a/src/app/dashboard/[teamSlug]/components/TeamSidebar.tsx b/src/app/dashboard/[teamSlug]/components/TeamSidebar.tsx index 12c70e3..6f3d92f 100644 --- a/src/app/dashboard/[teamSlug]/components/TeamSidebar.tsx +++ b/src/app/dashboard/[teamSlug]/components/TeamSidebar.tsx @@ -4,7 +4,7 @@ import NavigationSidebar, { } from "@/src/components/NavigationSidebar"; import { useTeam } from "@/src/hooks/useTeam"; import { hasPermission } from "@/src/lib/utils/team-utils"; -import { Box, Database, Settings, User } from "lucide-react"; +import { Box, Cable, Settings, User } from "lucide-react"; export default function TeamSidebar() { const { data, isLoading } = useTeam(); @@ -34,9 +34,9 @@ export default function TeamSidebar() { url: "/settings", }, { - title: "Databases", - Icon: Database, - url: "/databases", + title: "Connections", + Icon: Cable, + url: "/connections", }, ].filter((item) => { if (item.title === "Databases") { diff --git a/src/app/dashboard/[teamSlug]/databases/DatabaseColumn.tsx b/src/app/dashboard/[teamSlug]/connections/DatabaseColumn.tsx similarity index 100% rename from src/app/dashboard/[teamSlug]/databases/DatabaseColumn.tsx rename to src/app/dashboard/[teamSlug]/connections/DatabaseColumn.tsx diff --git a/src/app/dashboard/[teamSlug]/databases/LinkDialog/DatabaseTypeSelectorDialog.tsx b/src/app/dashboard/[teamSlug]/connections/LinkDialog/DatabaseTypeSelectorDialog.tsx similarity index 100% rename from src/app/dashboard/[teamSlug]/databases/LinkDialog/DatabaseTypeSelectorDialog.tsx rename to src/app/dashboard/[teamSlug]/connections/LinkDialog/DatabaseTypeSelectorDialog.tsx diff --git a/src/app/dashboard/[teamSlug]/databases/LinkDialog/LinkDatabaseDialog.tsx b/src/app/dashboard/[teamSlug]/connections/LinkDialog/LinkDatabaseDialog.tsx similarity index 100% rename from src/app/dashboard/[teamSlug]/databases/LinkDialog/LinkDatabaseDialog.tsx rename to src/app/dashboard/[teamSlug]/connections/LinkDialog/LinkDatabaseDialog.tsx diff --git a/src/app/dashboard/[teamSlug]/databases/LinkDialog/S3DatabaseDialog.tsx b/src/app/dashboard/[teamSlug]/connections/LinkDialog/S3DatabaseDialog.tsx similarity index 100% rename from src/app/dashboard/[teamSlug]/databases/LinkDialog/S3DatabaseDialog.tsx rename to src/app/dashboard/[teamSlug]/connections/LinkDialog/S3DatabaseDialog.tsx diff --git a/src/app/dashboard/[teamSlug]/databases/page.tsx b/src/app/dashboard/[teamSlug]/connections/page.tsx similarity index 87% rename from src/app/dashboard/[teamSlug]/databases/page.tsx rename to src/app/dashboard/[teamSlug]/connections/page.tsx index 968edfd..5021e15 100644 --- a/src/app/dashboard/[teamSlug]/databases/page.tsx +++ b/src/app/dashboard/[teamSlug]/connections/page.tsx @@ -16,7 +16,10 @@ export default function Page() { return ( <> - Databases + + Connections + + Databases Date: Tue, 3 Feb 2026 21:48:25 +0100 Subject: [PATCH 02/40] feat: create the RobloxCredentialsService --- src/db/schema/roblox_credentials.ts | 23 ++++ src/lib/config.ts | 11 ++ src/lib/types/roblox-credentials-types.ts | 20 +++ src/lib/utils/team-utils.ts | 6 + src/services/RobloxCredentialsService.ts | 155 ++++++++++++++++++++++ 5 files changed, 215 insertions(+) create mode 100644 src/db/schema/roblox_credentials.ts create mode 100644 src/lib/config.ts create mode 100644 src/lib/types/roblox-credentials-types.ts create mode 100644 src/services/RobloxCredentialsService.ts diff --git a/src/db/schema/roblox_credentials.ts b/src/db/schema/roblox_credentials.ts new file mode 100644 index 0000000..73a39f0 --- /dev/null +++ b/src/db/schema/roblox_credentials.ts @@ -0,0 +1,23 @@ +import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; +import { team } from "./team"; +import { sql } from "drizzle-orm"; +import { user } from "./user"; + +export const roblox_credentials = sqliteTable("roblox_credentials", { + id: text("id").primaryKey(), + teamId: text("team_id") + .notNull() + .references(() => team.id, { onDelete: "cascade" }), + name: text("name").notNull(), + + keyCiphertext: text("key_ciphertext").notNull(), + keyIv: text("key_iv").notNull(), + keyTag: text("key_tag").notNull(), + + createdAt: integer("created_at", { mode: "timestamp_ms" }) + .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`) + .notNull(), + createdBy: text("created_by").references(() => user.id, { + onDelete: "set null", + }), +}); diff --git a/src/lib/config.ts b/src/lib/config.ts new file mode 100644 index 0000000..8e1e2d3 --- /dev/null +++ b/src/lib/config.ts @@ -0,0 +1,11 @@ +export const TEAM_LIMITS = { + free: { + maxMembers: 5, + }, +}; + +export const USER_LIMITS = { + free: { + maxOwnedTeams: 3, + }, +}; diff --git a/src/lib/types/roblox-credentials-types.ts b/src/lib/types/roblox-credentials-types.ts new file mode 100644 index 0000000..d674b89 --- /dev/null +++ b/src/lib/types/roblox-credentials-types.ts @@ -0,0 +1,20 @@ +import { roblox_credentials } from "@/src/db/schema/roblox_credentials"; +import { InferDrizzleSelect } from "../utils"; + +export const RobloxCredentialSelect = { + id: roblox_credentials.id, + teamId: roblox_credentials.teamId, + name: roblox_credentials.name, + createdAt: roblox_credentials.createdAt, +}; +export type RobloxCredential = InferDrizzleSelect< + typeof RobloxCredentialSelect +>; + +export const RobloxCredentialInfo = { + name: roblox_credentials.name, + key: roblox_credentials.keyCiphertext, +}; +export type RobloxCredentialInfo = InferDrizzleSelect< + typeof RobloxCredentialInfo +>; diff --git a/src/lib/utils/team-utils.ts b/src/lib/utils/team-utils.ts index 08f7153..4209993 100644 --- a/src/lib/utils/team-utils.ts +++ b/src/lib/utils/team-utils.ts @@ -20,6 +20,12 @@ export const TEAM_PERMISSIONS = { LinkDatabase: ROLES_RANK.admin, DeleteDatabase: ROLES_RANK.admin, RenameDatabase: ROLES_RANK.admin, + + ListRobloxCredentials: ROLES_RANK.viewer, + LinkRobloxCredential: ROLES_RANK.admin, + DeleteRobloxCredential: ROLES_RANK.admin, + RenameRobloxCredential: ROLES_RANK.admin, + RotateRobloxCredential: ROLES_RANK.admin, } as const satisfies Record; export type TeamAction = keyof typeof TEAM_PERMISSIONS; diff --git a/src/services/RobloxCredentialsService.ts b/src/services/RobloxCredentialsService.ts new file mode 100644 index 0000000..71e2d30 --- /dev/null +++ b/src/services/RobloxCredentialsService.ts @@ -0,0 +1,155 @@ +import { randomUUID } from "crypto"; +import { db } from "../db"; +import { roblox_credentials } from "../db/schema/roblox_credentials"; +import { EncryptString256 } from "../lib/crypto/aes"; +import { + RobloxCredential, + RobloxCredentialInfo, + RobloxCredentialSelect, +} from "../lib/types/roblox-credentials-types"; +import { AccessDenied, DatabaseError } from "../lib/utils/errors"; +import { hasPermission } from "../lib/utils/team-utils"; +import { TeamService } from "./TeamService"; +import { and, eq } from "drizzle-orm"; + +export const RobloxCredentialsService = { + async LinkRobloxCredential( + actorId: string, + teamId: string, + creds: RobloxCredentialInfo, + ): Promise { + const actorRole = await TeamService.GetTeamUserRole(actorId, teamId); + + if (!hasPermission(actorRole, "LinkRobloxCredential")) { + throw AccessDenied; + } + + const encodedKey = EncryptString256(creds.key); + try { + const [newRecord] = await db + .insert(roblox_credentials) + .values({ + id: randomUUID(), + teamId, + name: creds.name, + keyCiphertext: encodedKey.encryptedData, + keyIv: encodedKey.initializationVector, + keyTag: encodedKey.authTag, + createdBy: actorId, + }) + .returning(RobloxCredentialSelect); + + return newRecord; + } catch { + throw DatabaseError; + } + }, + + async DeleteRobloxCredential( + actorId: string, + teamId: string, + credId: string, + ): Promise { + const actorRole = await TeamService.GetTeamUserRole(actorId, teamId); + if (!hasPermission(actorRole, "DeleteRobloxCredential")) { + throw AccessDenied; + } + + try { + const [result] = await db + .delete(roblox_credentials) + .where( + and( + eq(roblox_credentials.id, credId), + eq(roblox_credentials.teamId, teamId), + ), + ) + .returning(RobloxCredentialSelect); + if (!result) throw AccessDenied; + return result; + } catch { + throw DatabaseError; + } + }, + + async RenameRobloxCredential( + actorId: string, + teamId: string, + credId: string, + newName: string, + ): Promise { + const actorRole = await TeamService.GetTeamUserRole(actorId, teamId); + if (!hasPermission(actorRole, "RenameRobloxCredential")) { + throw AccessDenied; + } + + try { + const [result] = await db + .update(roblox_credentials) + .set({ name: newName }) + .where( + and( + eq(roblox_credentials.id, credId), + eq(roblox_credentials.teamId, teamId), + ), + ) + .returning(RobloxCredentialSelect); + if (!result) throw AccessDenied; + return result; + } catch { + throw DatabaseError; + } + }, + + async RotateRobloxCredential( + actorId: string, + teamId: string, + credId: string, + newKey: string, + ): Promise { + const actorRole = await TeamService.GetTeamUserRole(actorId, teamId); + if (!hasPermission(actorRole, "RotateRobloxCredential")) { + throw AccessDenied; + } + + const newKeyEncrypted = EncryptString256(newKey); + try { + await db + .update(roblox_credentials) + .set({ + keyCiphertext: newKeyEncrypted.encryptedData, + keyIv: newKeyEncrypted.initializationVector, + keyTag: newKeyEncrypted.authTag, + }) + .where( + and( + eq(roblox_credentials.id, credId), + eq(roblox_credentials.teamId, teamId), + ), + ) + .returning(RobloxCredentialSelect); + } catch { + throw DatabaseError; + } + }, + + async ListTeamRobloxCredentials( + actorId: string, + teamId: string, + ): Promise { + const actorRole = await TeamService.GetTeamUserRole(actorId, teamId); + if (!hasPermission(actorRole, "RenameRobloxCredential")) { + throw AccessDenied; + } + + try { + const results = await db + .select(RobloxCredentialSelect) + .from(roblox_credentials) + .where(eq(roblox_credentials.teamId, teamId)); + return results; + } catch { + throw DatabaseError; + } + }, +}; From c9ffdc679863fd80963a7ed6da47af1f8545391b Mon Sep 17 00:00:00 2001 From: OverDsh Date: Wed, 4 Feb 2026 14:26:34 +0100 Subject: [PATCH 03/40] feat: create api routes for RobloxCredentialService --- .../[credentialId]/route.ts | 146 ++++++++++++++++++ .../[teamId]/roblox-credentials/route.ts | 79 ++++++++++ src/lib/types/roblox-credentials-types.ts | 20 +++ 3 files changed, 245 insertions(+) create mode 100644 src/app/api/teams/[teamId]/roblox-credentials/[credentialId]/route.ts create mode 100644 src/app/api/teams/[teamId]/roblox-credentials/route.ts diff --git a/src/app/api/teams/[teamId]/roblox-credentials/[credentialId]/route.ts b/src/app/api/teams/[teamId]/roblox-credentials/[credentialId]/route.ts new file mode 100644 index 0000000..3e1c160 --- /dev/null +++ b/src/app/api/teams/[teamId]/roblox-credentials/[credentialId]/route.ts @@ -0,0 +1,146 @@ +import { auth } from "@/src/lib/auth"; +import { + RobloxCredentialRenameSchema, + RobloxCredentialRotateSchema, +} from "@/src/lib/types/roblox-credentials-types"; +import { AccessDenied, ErrorToNextResponse } from "@/src/lib/utils/errors"; +import { RobloxCredentialsService } from "@/src/services/RobloxCredentialsService"; +import { headers } from "next/headers"; +import { NextResponse } from "next/server"; + +interface Context { + params: Promise<{ + teamId: string; + credentialId: string; + }>; +} + +export async function DELETE(req: Request, context: Context) { + const { teamId, credentialId } = await context.params; + if (!teamId) { + return NextResponse.json({ error: "Team ID is required" }, { status: 400 }); + } + if (!credentialId) { + return NextResponse.json( + { error: "Credential ID is required" }, + { status: 400 }, + ); + } + + const session = await auth.api.getSession({ + headers: await headers(), + }); + if (!session) { + return ErrorToNextResponse(AccessDenied); + } + + try { + const deletedCredential = + await RobloxCredentialsService.DeleteRobloxCredential( + session.user.id, + teamId, + credentialId, + ); + return NextResponse.json(deletedCredential); + } catch (error) { + return ErrorToNextResponse(error); + } +} + +export async function PATCH(req: Request, context: Context) { + const { teamId, credentialId } = await context.params; + if (!teamId) { + return NextResponse.json({ error: "Team ID is required" }, { status: 400 }); + } + if (!credentialId) { + return NextResponse.json( + { error: "Credential ID is required" }, + { status: 400 }, + ); + } + + const session = await auth.api.getSession({ + headers: await headers(), + }); + if (!session) { + return ErrorToNextResponse(AccessDenied); + } + + let body; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid json body" }, { status: 400 }); + } + + let validatedData; + try { + validatedData = RobloxCredentialRenameSchema.parse(body); + } catch { + return NextResponse.json( + { error: "Invalid body request format" }, + { status: 400 }, + ); + } + + try { + const renamedCredential = + await RobloxCredentialsService.RenameRobloxCredential( + session.user.id, + teamId, + credentialId, + validatedData.name, + ); + return NextResponse.json(renamedCredential); + } catch (error) { + return ErrorToNextResponse(error); + } +} + +export async function POST(req: Request, context: Context) { + const { teamId, credentialId } = await context.params; + if (!teamId) { + return NextResponse.json({ error: "Team ID is required" }, { status: 400 }); + } + if (!credentialId) { + return NextResponse.json( + { error: "Credential ID is required" }, + { status: 400 }, + ); + } + + const session = await auth.api.getSession({ + headers: await headers(), + }); + if (!session) { + return ErrorToNextResponse(AccessDenied); + } + + let body; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid json body" }, { status: 400 }); + } + + let validatedData; + try { + validatedData = RobloxCredentialRotateSchema.parse(body); + } catch { + return NextResponse.json( + { error: "Invalid body request format" }, + { status: 400 }, + ); + } + + try { + await RobloxCredentialsService.RotateRobloxCredential( + session.user.id, + teamId, + credentialId, + validatedData.key, + ); + } catch (error) { + return ErrorToNextResponse(error); + } +} diff --git a/src/app/api/teams/[teamId]/roblox-credentials/route.ts b/src/app/api/teams/[teamId]/roblox-credentials/route.ts new file mode 100644 index 0000000..da959c6 --- /dev/null +++ b/src/app/api/teams/[teamId]/roblox-credentials/route.ts @@ -0,0 +1,79 @@ +import { auth } from "@/src/lib/auth"; +import { RobloxCredentialInfoSchema } from "@/src/lib/types/roblox-credentials-types"; +import { AccessDenied, ErrorToNextResponse } from "@/src/lib/utils/errors"; +import { RobloxCredentialsService } from "@/src/services/RobloxCredentialsService"; +import { headers } from "next/headers"; +import { NextResponse } from "next/server"; + +interface Context { + params: Promise<{ + teamId: string; + }>; +} + +export async function GET(req: Request, context: Context) { + const { teamId } = await context.params; + if (!teamId) { + return NextResponse.json({ error: "Team ID is required" }, { status: 400 }); + } + + const session = await auth.api.getSession({ + headers: await headers(), + }); + if (!session) { + return ErrorToNextResponse(AccessDenied); + } + + try { + const credentials = + await RobloxCredentialsService.ListTeamRobloxCredentials( + session.user.id, + teamId, + ); + return NextResponse.json(credentials); + } catch (error) { + return ErrorToNextResponse(error); + } +} + +export async function POST(req: Request, context: Context) { + const { teamId } = await context.params; + if (!teamId) { + return NextResponse.json({ error: "Team ID is required" }, { status: 400 }); + } + + const session = await auth.api.getSession({ + headers: await headers(), + }); + if (!session) { + return ErrorToNextResponse(AccessDenied); + } + + let body; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid json body" }, { status: 400 }); + } + + let validatedData; + try { + validatedData = RobloxCredentialInfoSchema.parse(body); + } catch { + return NextResponse.json( + { error: "Invalid body request format" }, + { status: 400 }, + ); + } + + try { + const newCredential = await RobloxCredentialsService.LinkRobloxCredential( + session.user.id, + teamId, + validatedData, + ); + return NextResponse.json(newCredential); + } catch (error) { + return ErrorToNextResponse(error); + } +} diff --git a/src/lib/types/roblox-credentials-types.ts b/src/lib/types/roblox-credentials-types.ts index d674b89..40828f1 100644 --- a/src/lib/types/roblox-credentials-types.ts +++ b/src/lib/types/roblox-credentials-types.ts @@ -1,5 +1,6 @@ import { roblox_credentials } from "@/src/db/schema/roblox_credentials"; import { InferDrizzleSelect } from "../utils"; +import z from "zod"; export const RobloxCredentialSelect = { id: roblox_credentials.id, @@ -18,3 +19,22 @@ export const RobloxCredentialInfo = { export type RobloxCredentialInfo = InferDrizzleSelect< typeof RobloxCredentialInfo >; +export const RobloxCredentialInfoSchema: z.ZodType = + z.object({ + name: z + .string() + .min(3, { error: "Name must be at least 3 characters" }) + .max(32, { error: "Name must be at most 32 characters" }), + key: z.string().max(2048, { error: "Key must be at most 2048 characters" }), + }); + +export const RobloxCredentialRenameSchema = z.object({ + name: z + .string() + .min(3, { error: "Name must be at least 3 characters" }) + .max(32, { error: "Name must be at most 32 characters" }), +}); + +export const RobloxCredentialRotateSchema = z.object({ + key: z.string().max(2048, { error: "Key must be at most 2048 characters" }), +}); From b4c6fb09dbfc583c531e7617131586d85d3d87d5 Mon Sep 17 00:00:00 2001 From: OverDsh Date: Wed, 4 Feb 2026 14:30:01 +0100 Subject: [PATCH 04/40] refactor: renamed errors.ts to api-utils.ts --- src/app/api/teams/[teamId]/databases/[databaseId]/route.ts | 2 +- src/app/api/teams/[teamId]/databases/route.ts | 2 +- src/app/api/teams/[teamId]/members/[memberId]/route.ts | 2 +- src/app/api/teams/[teamId]/members/route.ts | 2 +- .../teams/[teamId]/roblox-credentials/[credentialId]/route.ts | 2 +- src/app/api/teams/[teamId]/roblox-credentials/route.ts | 2 +- src/app/api/teams/[teamId]/route.ts | 2 +- src/app/api/teams/[teamId]/transfer-ownership/route.ts | 2 +- src/app/api/teams/resolve-slug/[slug]/route.ts | 2 +- src/app/api/teams/route.ts | 2 +- src/controllers/ExternalDatabaseController.ts | 2 +- src/controllers/TeamController.ts | 2 +- src/lib/utils/{errors.ts => api-utils.ts} | 0 src/services/ExternalDatabaseService.ts | 2 +- src/services/RobloxCredentialsService.ts | 2 +- src/services/TeamService.test.ts | 2 +- src/services/TeamService.ts | 2 +- 17 files changed, 16 insertions(+), 16 deletions(-) rename src/lib/utils/{errors.ts => api-utils.ts} (100%) diff --git a/src/app/api/teams/[teamId]/databases/[databaseId]/route.ts b/src/app/api/teams/[teamId]/databases/[databaseId]/route.ts index c14f667..5203090 100644 --- a/src/app/api/teams/[teamId]/databases/[databaseId]/route.ts +++ b/src/app/api/teams/[teamId]/databases/[databaseId]/route.ts @@ -1,5 +1,5 @@ import { auth } from "@/src/lib/auth"; -import { ErrorToNextResponse } from "@/src/lib/utils/errors"; +import { ErrorToNextResponse } from "@/src/lib/utils/api-utils"; import { ExternalDatabaseService } from "@/src/services/ExternalDatabaseService"; import { headers } from "next/headers"; import { NextResponse } from "next/server"; diff --git a/src/app/api/teams/[teamId]/databases/route.ts b/src/app/api/teams/[teamId]/databases/route.ts index 0a78382..0e7ec42 100644 --- a/src/app/api/teams/[teamId]/databases/route.ts +++ b/src/app/api/teams/[teamId]/databases/route.ts @@ -1,5 +1,5 @@ import { auth } from "@/src/lib/auth"; -import { ErrorToNextResponse } from "@/src/lib/utils/errors"; +import { ErrorToNextResponse } from "@/src/lib/utils/api-utils"; import { ExternalDatabaseService } from "@/src/services/ExternalDatabaseService"; import { headers } from "next/headers"; import { NextResponse } from "next/server"; diff --git a/src/app/api/teams/[teamId]/members/[memberId]/route.ts b/src/app/api/teams/[teamId]/members/[memberId]/route.ts index a553776..ef4b82e 100644 --- a/src/app/api/teams/[teamId]/members/[memberId]/route.ts +++ b/src/app/api/teams/[teamId]/members/[memberId]/route.ts @@ -1,6 +1,6 @@ import { team_member } from "@/src/db/schema"; import { auth } from "@/src/lib/auth"; -import { ErrorToNextResponse } from "@/src/lib/utils/errors"; +import { ErrorToNextResponse } from "@/src/lib/utils/api-utils"; import { TeamService } from "@/src/services/TeamService"; import { headers } from "next/headers"; import { NextResponse } from "next/server"; diff --git a/src/app/api/teams/[teamId]/members/route.ts b/src/app/api/teams/[teamId]/members/route.ts index ccd5942..a6bdb79 100644 --- a/src/app/api/teams/[teamId]/members/route.ts +++ b/src/app/api/teams/[teamId]/members/route.ts @@ -1,5 +1,5 @@ import { auth } from "@/src/lib/auth"; -import { ErrorToNextResponse } from "@/src/lib/utils/errors"; +import { ErrorToNextResponse } from "@/src/lib/utils/api-utils"; import { TeamService } from "@/src/services/TeamService"; import { headers } from "next/headers"; import { NextResponse } from "next/server"; diff --git a/src/app/api/teams/[teamId]/roblox-credentials/[credentialId]/route.ts b/src/app/api/teams/[teamId]/roblox-credentials/[credentialId]/route.ts index 3e1c160..02e0581 100644 --- a/src/app/api/teams/[teamId]/roblox-credentials/[credentialId]/route.ts +++ b/src/app/api/teams/[teamId]/roblox-credentials/[credentialId]/route.ts @@ -3,7 +3,7 @@ import { RobloxCredentialRenameSchema, RobloxCredentialRotateSchema, } from "@/src/lib/types/roblox-credentials-types"; -import { AccessDenied, ErrorToNextResponse } from "@/src/lib/utils/errors"; +import { AccessDenied, ErrorToNextResponse } from "@/src/lib/utils/api-utils"; import { RobloxCredentialsService } from "@/src/services/RobloxCredentialsService"; import { headers } from "next/headers"; import { NextResponse } from "next/server"; diff --git a/src/app/api/teams/[teamId]/roblox-credentials/route.ts b/src/app/api/teams/[teamId]/roblox-credentials/route.ts index da959c6..c40db9d 100644 --- a/src/app/api/teams/[teamId]/roblox-credentials/route.ts +++ b/src/app/api/teams/[teamId]/roblox-credentials/route.ts @@ -1,6 +1,6 @@ import { auth } from "@/src/lib/auth"; import { RobloxCredentialInfoSchema } from "@/src/lib/types/roblox-credentials-types"; -import { AccessDenied, ErrorToNextResponse } from "@/src/lib/utils/errors"; +import { AccessDenied, ErrorToNextResponse } from "@/src/lib/utils/api-utils"; import { RobloxCredentialsService } from "@/src/services/RobloxCredentialsService"; import { headers } from "next/headers"; import { NextResponse } from "next/server"; diff --git a/src/app/api/teams/[teamId]/route.ts b/src/app/api/teams/[teamId]/route.ts index a52c210..9f4f41a 100644 --- a/src/app/api/teams/[teamId]/route.ts +++ b/src/app/api/teams/[teamId]/route.ts @@ -1,5 +1,5 @@ import { auth } from "@/src/lib/auth"; -import { ErrorToNextResponse } from "@/src/lib/utils/errors"; +import { ErrorToNextResponse } from "@/src/lib/utils/api-utils"; import { TeamService } from "@/src/services/TeamService"; import { headers } from "next/headers"; import { NextResponse } from "next/server"; diff --git a/src/app/api/teams/[teamId]/transfer-ownership/route.ts b/src/app/api/teams/[teamId]/transfer-ownership/route.ts index f4d3c66..4d124fc 100644 --- a/src/app/api/teams/[teamId]/transfer-ownership/route.ts +++ b/src/app/api/teams/[teamId]/transfer-ownership/route.ts @@ -1,5 +1,5 @@ import { auth } from "@/src/lib/auth"; -import { ErrorToNextResponse } from "@/src/lib/utils/errors"; +import { ErrorToNextResponse } from "@/src/lib/utils/api-utils"; import { TeamService } from "@/src/services/TeamService"; import { headers } from "next/headers"; import { NextResponse } from "next/server"; diff --git a/src/app/api/teams/resolve-slug/[slug]/route.ts b/src/app/api/teams/resolve-slug/[slug]/route.ts index 47743bc..858aafc 100644 --- a/src/app/api/teams/resolve-slug/[slug]/route.ts +++ b/src/app/api/teams/resolve-slug/[slug]/route.ts @@ -1,5 +1,5 @@ import { auth } from "@/src/lib/auth"; -import { ErrorToNextResponse } from "@/src/lib/utils/errors"; +import { ErrorToNextResponse } from "@/src/lib/utils/api-utils"; import { TeamService } from "@/src/services/TeamService"; import { headers } from "next/headers"; import { NextResponse } from "next/server"; diff --git a/src/app/api/teams/route.ts b/src/app/api/teams/route.ts index 7f0abd2..38f84a5 100644 --- a/src/app/api/teams/route.ts +++ b/src/app/api/teams/route.ts @@ -1,5 +1,5 @@ import { auth } from "@/src/lib/auth"; -import { ErrorToNextResponse } from "@/src/lib/utils/errors"; +import { ErrorToNextResponse } from "@/src/lib/utils/api-utils"; import { TeamService } from "@/src/services/TeamService"; import { headers } from "next/headers"; import { NextResponse } from "next/server"; diff --git a/src/controllers/ExternalDatabaseController.ts b/src/controllers/ExternalDatabaseController.ts index 1e36eed..9e265e9 100755 --- a/src/controllers/ExternalDatabaseController.ts +++ b/src/controllers/ExternalDatabaseController.ts @@ -1,5 +1,5 @@ import { Database, DatabaseInfo } from "../lib/types/database-types"; -import { ResponseToError } from "../lib/utils/errors"; +import { ResponseToError } from "../lib/utils/api-utils"; export async function LinkDatabase( teamId: string, diff --git a/src/controllers/TeamController.ts b/src/controllers/TeamController.ts index 9817370..eb4fc44 100644 --- a/src/controllers/TeamController.ts +++ b/src/controllers/TeamController.ts @@ -1,5 +1,5 @@ import { Team, TeamMember, UserTeam } from "../lib/types/team-types"; -import { ResponseToError } from "../lib/utils/errors"; +import { ResponseToError } from "../lib/utils/api-utils"; export async function CreateTeam(name: string): Promise { const response = await fetch("/api/teams", { diff --git a/src/lib/utils/errors.ts b/src/lib/utils/api-utils.ts similarity index 100% rename from src/lib/utils/errors.ts rename to src/lib/utils/api-utils.ts diff --git a/src/services/ExternalDatabaseService.ts b/src/services/ExternalDatabaseService.ts index 4214a04..0259a92 100755 --- a/src/services/ExternalDatabaseService.ts +++ b/src/services/ExternalDatabaseService.ts @@ -4,7 +4,7 @@ import { database } from "../db/schema"; import { HeadBucketCommand, S3Client } from "@aws-sdk/client-s3"; import { EncryptString256 } from "../lib/crypto/aes"; import { randomUUID } from "crypto"; -import { AccessDenied, ApiError, DatabaseError } from "../lib/utils/errors"; +import { AccessDenied, ApiError, DatabaseError } from "../lib/utils/api-utils"; import { Database, DatabaseSelect } from "../lib/types/database-types"; import { TeamService } from "./TeamService"; import { hasPermission } from "../lib/utils/team-utils"; diff --git a/src/services/RobloxCredentialsService.ts b/src/services/RobloxCredentialsService.ts index 71e2d30..2503b4c 100644 --- a/src/services/RobloxCredentialsService.ts +++ b/src/services/RobloxCredentialsService.ts @@ -7,7 +7,7 @@ import { RobloxCredentialInfo, RobloxCredentialSelect, } from "../lib/types/roblox-credentials-types"; -import { AccessDenied, DatabaseError } from "../lib/utils/errors"; +import { AccessDenied, DatabaseError } from "../lib/utils/api-utils"; import { hasPermission } from "../lib/utils/team-utils"; import { TeamService } from "./TeamService"; import { and, eq } from "drizzle-orm"; diff --git a/src/services/TeamService.test.ts b/src/services/TeamService.test.ts index 7083d8f..5846098 100644 --- a/src/services/TeamService.test.ts +++ b/src/services/TeamService.test.ts @@ -3,7 +3,7 @@ import { db } from "@/src/db"; import { TeamService } from "./TeamService"; import { team_member, user } from "../db/schema"; import { and, eq } from "drizzle-orm"; -import { UserNotFound } from "../lib/utils/errors"; +import { UserNotFound } from "../lib/utils/api-utils"; import { TeamRole } from "../lib/types/team-types"; async function createUser(name: string) { diff --git a/src/services/TeamService.ts b/src/services/TeamService.ts index 5531f89..cfd0812 100644 --- a/src/services/TeamService.ts +++ b/src/services/TeamService.ts @@ -7,7 +7,7 @@ import { ApiError, DatabaseError, UserNotFound, -} from "../lib/utils/errors"; +} from "../lib/utils/api-utils"; import { randomUUID } from "crypto"; import { team_member } from "../db/schema/team_member"; import { CheckUserExist } from "../lib/utils/auth-utils"; From 40381cc11b392a7b07663b7839aaa50f9c7b823f Mon Sep 17 00:00:00 2001 From: OverDsh Date: Wed, 4 Feb 2026 14:51:30 +0100 Subject: [PATCH 05/40] refactor: create fetcher util to use in controllers --- src/app/dashboard/components/TeamColumn.tsx | 10 +- src/controllers/ExternalDatabaseController.ts | 89 ++++-------- src/controllers/TeamController.ts | 129 +++++------------- src/hooks/useDatabase.ts | 15 +- src/hooks/useMember.ts | 4 +- src/hooks/useTeam.ts | 18 +-- src/lib/utils/api-utils.ts | 14 ++ 7 files changed, 91 insertions(+), 188 deletions(-) diff --git a/src/app/dashboard/components/TeamColumn.tsx b/src/app/dashboard/components/TeamColumn.tsx index 650abf1..0b30202 100644 --- a/src/app/dashboard/components/TeamColumn.tsx +++ b/src/app/dashboard/components/TeamColumn.tsx @@ -10,7 +10,7 @@ import { DropdownMenuTrigger, } from "@/src/components/ui/dropdown-menu"; import { Skeleton } from "@/src/components/ui/skeleton"; -import { RemoveTeamMember } from "@/src/controllers/TeamController"; +import { TeamController } from "@/src/controllers/TeamController"; import { authClient } from "@/src/lib/auth-client"; import { Team } from "@/src/lib/types/team-types"; import { useMutation } from "@tanstack/react-query"; @@ -54,7 +54,13 @@ export const teamColumns: ColumnDef[] = [ cell: ({ row }) => { const { data, isPending } = authClient.useSession(); const { mutateAsync: leaveTeam } = useMutation({ - mutationFn: RemoveTeamMember, + mutationFn: ({ + teamId, + memberId, + }: { + teamId: string; + memberId: string; + }) => TeamController.removeMember(teamId, memberId), onSuccess: (_, { teamId }) => { queryClient.setQueryData(["teams"], (prevData) => prevData ? prevData.filter((team) => team.id !== teamId) : [], diff --git a/src/controllers/ExternalDatabaseController.ts b/src/controllers/ExternalDatabaseController.ts index 9e265e9..12335ee 100755 --- a/src/controllers/ExternalDatabaseController.ts +++ b/src/controllers/ExternalDatabaseController.ts @@ -1,67 +1,24 @@ import { Database, DatabaseInfo } from "../lib/types/database-types"; -import { ResponseToError } from "../lib/utils/api-utils"; - -export async function LinkDatabase( - teamId: string, - data: DatabaseInfo, -): Promise { - const response = await fetch(`/api/teams/${teamId}/databases`, { - method: "POST", - body: JSON.stringify(data), - }); - - const responseData = await response.json(); - if (!response.ok) { - console.log(responseData); - throw ResponseToError(responseData); - } - - return responseData as Database; -} - -export async function ListDatabases(teamId: string): Promise { - const response = await fetch(`/api/teams/${teamId}/databases`, { - method: "GET", - }); - - const responseData = await response.json(); - if (!response.ok) { - throw ResponseToError(responseData); - } - - return responseData as Database[]; -} - -export async function DeleteDatabase( - teamId: string, - databaseId: string, -): Promise { - const response = await fetch(`/api/teams/${teamId}/databases/${databaseId}`, { - method: "DELETE", - }); - - const responseData = await response.json(); - if (!response.ok) { - throw ResponseToError(responseData); - } - - return responseData as Database; -} - -export async function RenameDatabase( - teamId: string, - databaseId: string, - newName: string, -): Promise { - const response = await fetch(`/api/teams/${teamId}/databases/${databaseId}`, { - method: "PATCH", - body: JSON.stringify({ name: newName }), - }); - - const responseData = await response.json(); - if (!response.ok) { - throw ResponseToError(responseData); - } - - return responseData as Database; -} +import { fetcher } from "../lib/utils/api-utils"; + +export const ExternalDatabaseController = { + link: (teamId: string, data: DatabaseInfo) => + fetcher(`/api/teams/${teamId}/databases`, { + method: "POST", + body: JSON.stringify(data), + }), + + list: (teamId: string) => + fetcher(`/api/teams/${teamId}/databases`), + + delete: (teamId: string, databaseId: string) => + fetcher(`/api/teams/${teamId}/databases/${databaseId}`, { + method: "DELETE", + }), + + rename: (teamId: string, databaseId: string, newName: string) => + fetcher(`/api/teams/${teamId}/databases/${databaseId}`, { + method: "PATCH", + body: JSON.stringify({ name: newName }), + }), +}; diff --git a/src/controllers/TeamController.ts b/src/controllers/TeamController.ts index eb4fc44..68a2c09 100644 --- a/src/controllers/TeamController.ts +++ b/src/controllers/TeamController.ts @@ -1,97 +1,34 @@ import { Team, TeamMember, UserTeam } from "../lib/types/team-types"; -import { ResponseToError } from "../lib/utils/api-utils"; - -export async function CreateTeam(name: string): Promise { - const response = await fetch("/api/teams", { - method: "POST", - body: JSON.stringify({ name }), - }); - - const responseData = await response.json(); - if (!response.ok) { - throw ResponseToError(responseData); - } - - return responseData as UserTeam; -} - -export async function DeleteTeam(teamId: string): Promise { - const response = await fetch(`/api/teams/${teamId}`, { method: "DELETE" }); - - const responseData = await response.json(); - if (!response.ok) { - const error = ResponseToError(responseData); - console.log(error); - throw error; - } - - return responseData as Team; -} - -export async function ListTeams(): Promise { - const response = await fetch(`/api/teams`, { method: "GET" }); - - const responseData = await response.json(); - if (!response.ok) { - throw ResponseToError(responseData); - } - - return responseData as UserTeam[]; -} - -export async function RemoveTeamMember({ - memberId, - teamId, -}: { - memberId: string; - teamId: string; -}): Promise { - const response = await fetch(`/api/teams/${teamId}/members/${memberId}`, { - method: "DELETE", - }); - - const responseData = await response.json(); - if (!response.ok) { - throw ResponseToError(responseData); - } - - return responseData as TeamMember; -} - -export async function ResolveTeamBySlug(slug: string): Promise { - const response = await fetch(`/api/teams/resolve-slug/${slug}`); - - const responseData = await response.json(); - if (!response.ok) { - throw ResponseToError(responseData); - } - return responseData as UserTeam; -} - -export async function ChangeTeamName( - teamId: string, - newName: { name?: string; displayName?: string }, -): Promise { - const response = await fetch(`/api/teams/${teamId}`, { - method: "PATCH", - body: JSON.stringify(newName), - }); - - const responseData = await response.json(); - if (!response.ok) { - throw ResponseToError(responseData); - } - - return responseData as Team; -} - -export async function ListTeamMembers(teamId: string): Promise { - const response = await fetch(`/api/teams/${teamId}/members`); - - const responseData = await response.json(); - if (!response.ok) { - throw ResponseToError(responseData); - } - - return responseData as TeamMember[]; -} +import { fetcher } from "../lib/utils/api-utils"; + +export const TeamController = { + create: (name: string) => + fetcher("/api/teams", { + method: "POST", + body: JSON.stringify({ name }), + }), + + delete: (teamId: string) => + fetcher(`/api/teams/${teamId}`, { method: "DELETE" }), + + list: () => fetcher("/api/teams"), + + resolve: (slug: string) => + fetcher(`/api/teams/resolve-slug/${slug}`), + + changeName: ( + teamId: string, + newName: { name?: string; displayName?: string }, + ) => + fetcher(`/api/teams/${teamId}`, { + method: "PATCH", + body: JSON.stringify(newName), + }), + + removeMember: (teamId: string, memberId: string) => + fetcher(`/api/teams/${teamId}/members/${memberId}`, { + method: "DELETE", + }), + listMembers: (teamId: string) => + fetcher(`/api/teams/${teamId}/members`), +}; diff --git a/src/hooks/useDatabase.ts b/src/hooks/useDatabase.ts index ad53d53..b82937a 100644 --- a/src/hooks/useDatabase.ts +++ b/src/hooks/useDatabase.ts @@ -1,15 +1,10 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useTeam } from "./useTeam"; -import { - DeleteDatabase, - LinkDatabase, - ListDatabases, - RenameDatabase, -} from "../controllers/ExternalDatabaseController"; import { Database, DatabaseInfo } from "../lib/types/database-types"; import { useEffect } from "react"; import { hasPermission } from "../lib/utils/team-utils"; import { useRouter } from "next/navigation"; +import { ExternalDatabaseController } from "../controllers/ExternalDatabaseController"; export function useDatabases() { const { @@ -21,7 +16,7 @@ export function useDatabases() { const query = useQuery({ queryKey: ["databases", team?.id], - queryFn: () => ListDatabases(team!.id), + queryFn: () => ExternalDatabaseController.list(team!.id), enabled: !!team?.id, }); @@ -46,7 +41,7 @@ export function useDatabaseMutations() { const createDatabase = useMutation({ mutationFn: ({ teamId, data }: { teamId: string; data: DatabaseInfo }) => - LinkDatabase(teamId, data), + ExternalDatabaseController.link(teamId, data), onSuccess: (database, variables) => { queryClient.setQueryData( ["databases", variables.teamId], @@ -65,7 +60,7 @@ export function useDatabaseMutations() { }: { teamId: string; databaseId: string; - }) => DeleteDatabase(teamId, databaseId), + }) => ExternalDatabaseController.delete(teamId, databaseId), onSuccess: (database, variables) => { queryClient.setQueryData( ["databases", variables.teamId], @@ -86,7 +81,7 @@ export function useDatabaseMutations() { teamId: string; databaseId: string; newName: string; - }) => RenameDatabase(teamId, databaseId, newName), + }) => ExternalDatabaseController.rename(teamId, databaseId, newName), onSuccess: (database, variables) => { queryClient.setQueryData( ["databases", variables.teamId], diff --git a/src/hooks/useMember.ts b/src/hooks/useMember.ts index feeab3e..6fbec80 100644 --- a/src/hooks/useMember.ts +++ b/src/hooks/useMember.ts @@ -1,7 +1,7 @@ "use client"; import { useQuery } from "@tanstack/react-query"; import { useTeam } from "./useTeam"; -import { ListTeamMembers } from "../controllers/TeamController"; +import { TeamController } from "../controllers/TeamController"; export function useMembers() { const { @@ -12,7 +12,7 @@ export function useMembers() { const query = useQuery({ queryKey: ["members", team?.id], - queryFn: () => ListTeamMembers(team!.id), + queryFn: () => TeamController.listMembers(team!.id), enabled: !!team?.id, }); diff --git a/src/hooks/useTeam.ts b/src/hooks/useTeam.ts index d400bc8..bdeaa2d 100644 --- a/src/hooks/useTeam.ts +++ b/src/hooks/useTeam.ts @@ -1,15 +1,9 @@ "use client"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useParams, useRouter } from "next/navigation"; -import { - ChangeTeamName, - CreateTeam, - DeleteTeam, - ListTeams, - ResolveTeamBySlug, -} from "../controllers/TeamController"; import { useEffect } from "react"; import { UserTeam } from "../lib/types/team-types"; +import { TeamController } from "../controllers/TeamController"; export function useTeam() { const { teamSlug } = useParams(); @@ -17,7 +11,7 @@ export function useTeam() { const query = useQuery({ queryKey: ["team", teamSlug], - queryFn: () => ResolveTeamBySlug(teamSlug as string), + queryFn: () => TeamController.resolve(teamSlug as string), enabled: !!teamSlug, retry: false, }); @@ -34,7 +28,7 @@ export function useTeam() { export function useTeams() { return useQuery({ queryKey: ["teams"], - queryFn: ListTeams, + queryFn: () => TeamController.list(), }); } @@ -43,7 +37,7 @@ export function useTeamMutations() { const router = useRouter(); const createTeam = useMutation({ - mutationFn: (name: string) => CreateTeam(name), + mutationFn: (name: string) => TeamController.create(name), onSuccess: (team) => { // Add the newly created team to the teams list queryClient.setQueryData(["teams"], (prevData) => { @@ -54,7 +48,7 @@ export function useTeamMutations() { }); const deleteTeam = useMutation({ - mutationFn: (teamId: string) => DeleteTeam(teamId), + mutationFn: (teamId: string) => TeamController.delete(teamId), onSuccess: (oldTeam) => { // Remove old team from the teams list queryClient.setQueryData(["teams"], (prevData) => { @@ -74,7 +68,7 @@ export function useTeamMutations() { }: { teamId: string; payload: { name?: string; displayName?: string }; - }) => ChangeTeamName(teamId, payload), + }) => TeamController.changeName(teamId, payload), onSuccess: (newTeam, variables) => { const cachedTeam = queryClient.getQueryData(["teams"]); const oldTeam = cachedTeam?.find((team) => team.id === variables.teamId); diff --git a/src/lib/utils/api-utils.ts b/src/lib/utils/api-utils.ts index 1f00ca3..a3330ca 100644 --- a/src/lib/utils/api-utils.ts +++ b/src/lib/utils/api-utils.ts @@ -56,3 +56,17 @@ export const AuthenticationRequired = new ApiError( "AuthenticationRequired", "You must reauthenticate to perform this action", ); + +export async function fetcher( + url: string, + options?: RequestInit, +): Promise { + const response = await fetch(url, options); + const data = await response.json(); + + if (!response.ok) { + throw ResponseToError(data); + } + + return data as T; +} From 35fbb2b36c16fde90af03b319e99c6d78be9ed67 Mon Sep 17 00:00:00 2001 From: OverDsh Date: Wed, 4 Feb 2026 15:12:14 +0100 Subject: [PATCH 06/40] feat: create frontend hooks for RobloxCredentialService --- src/controllers/RobloxCredentialController.ts | 34 ++++++ src/hooks/useRobloxCredential.ts | 112 ++++++++++++++++++ 2 files changed, 146 insertions(+) create mode 100644 src/controllers/RobloxCredentialController.ts create mode 100644 src/hooks/useRobloxCredential.ts diff --git a/src/controllers/RobloxCredentialController.ts b/src/controllers/RobloxCredentialController.ts new file mode 100644 index 0000000..a7313bd --- /dev/null +++ b/src/controllers/RobloxCredentialController.ts @@ -0,0 +1,34 @@ +import { + RobloxCredential, + RobloxCredentialInfo, +} from "../lib/types/roblox-credentials-types"; +import { fetcher } from "../lib/utils/api-utils"; + +export const RobloxCredentialController = { + link: (teamId: string, data: RobloxCredentialInfo) => + fetcher(`/api/teams/${teamId}/roblox-credentials`, { + method: "POST", + body: JSON.stringify(data), + }), + + delete: (teamId: string, credId: string) => + fetcher( + `/api/teams/${teamId}/roblox-credentials/${credId}`, + { method: "DELETE" }, + ), + + rename: (teamId: string, credId: string, newName: string) => + fetcher( + `/api/teams/${teamId}/roblox-credentials/${credId}`, + { method: "PATCH", body: JSON.stringify({ name: newName }) }, + ), + + rotate: (teamId: string, credId: string, newKey: string) => + fetcher(`/api/teams/${teamId}/roblox-credentials/${credId}`, { + method: "POST", + body: JSON.stringify({ key: newKey }), + }), + + list: (teamId: string) => + fetcher(`/api/teams/${teamId}/roblox-credentials`), +}; diff --git a/src/hooks/useRobloxCredential.ts b/src/hooks/useRobloxCredential.ts new file mode 100644 index 0000000..c371ded --- /dev/null +++ b/src/hooks/useRobloxCredential.ts @@ -0,0 +1,112 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useTeam } from "./useTeam"; +import { RobloxCredentialController } from "../controllers/RobloxCredentialController"; +import { useEffect } from "react"; +import { hasPermission } from "../lib/utils/team-utils"; +import { useRouter } from "next/navigation"; +import { + RobloxCredential, + RobloxCredentialInfo, +} from "../lib/types/roblox-credentials-types"; + +export function useRobloxCredentials() { + const router = useRouter(); + const { + data: team, + isLoading: isTeamLoading, + isError: isTeamError, + } = useTeam(); + + const query = useQuery({ + queryKey: ["robloxCredentials", team?.id], + queryFn: () => RobloxCredentialController.list(team!.id), + enabled: !!team?.id, + }); + + useEffect(() => { + if (team && !hasPermission(team.role, "ListRobloxCredentials")) { + router.replace(`/dashboard/${team.slug}`); + } + }, [team]); + + const isLoading = isTeamLoading || query.isLoading; + const isError = isTeamError || query.isError; + + return { + ...query, + isLoading, + isError, + }; +} + +export function useRobloxCredentialMutations() { + const queryClient = useQueryClient(); + + const linkRobloxCredential = useMutation({ + mutationFn: ({ + teamId, + data, + }: { + teamId: string; + data: RobloxCredentialInfo; + }) => RobloxCredentialController.link(teamId, data), + onSuccess: (newCred, variables) => { + queryClient.setQueryData( + ["robloxCredentials", variables.teamId], + (prevData) => { + if (!prevData) return [newCred]; + return [...prevData, newCred]; + }, + ); + }, + }); + + const deleteRobloxCredential = useMutation({ + mutationFn: ({ teamId, credId }: { teamId: string; credId: string }) => + RobloxCredentialController.delete(teamId, credId), + onSuccess: (oldCred, variables) => { + queryClient.setQueryData( + ["robloxCredentials", variables.teamId], + (prevData) => { + if (!prevData) return prevData; + return prevData.filter((cred) => cred.id !== oldCred.id); + }, + ); + }, + }); + + const renameRobloxCredential = useMutation({ + mutationFn: ({ + teamId, + credId, + newName, + }: { + teamId: string; + credId: string; + newName: string; + }) => RobloxCredentialController.rename(teamId, credId, newName), + onSuccess: (renamedCred, variables) => { + queryClient.setQueryData( + ["robloxCredentials", variables.teamId], + (prevData) => { + if (!prevData) return [renamedCred]; + return prevData.map((cred) => + cred.id == renamedCred.id ? renamedCred : cred, + ); + }, + ); + }, + }); + + const rotateRobloxCredential = useMutation({ + mutationFn: ({ + teamId, + credId, + newKey, + }: { + teamId: string; + credId: string; + newKey: string; + }) => RobloxCredentialController.rotate(teamId, credId, newKey), + }); +} From d2f7e93d1bed15647b604ee08ca5c2db331fc4e2 Mon Sep 17 00:00:00 2001 From: OverDsh Date: Wed, 4 Feb 2026 15:58:23 +0100 Subject: [PATCH 07/40] fix: include roblox credentials table in drizzle schema --- src/db/schema/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/db/schema/index.ts b/src/db/schema/index.ts index ce40bfa..2b31b5f 100644 --- a/src/db/schema/index.ts +++ b/src/db/schema/index.ts @@ -6,3 +6,4 @@ export * from "./two_factor"; export * from "./database"; export * from "./team"; export * from "./team_member"; +export * from "./roblox_credentials"; From 9247a9739430dea06645018e1642439b71a61c11 Mon Sep 17 00:00:00 2001 From: OverDsh Date: Wed, 4 Feb 2026 15:58:38 +0100 Subject: [PATCH 08/40] fix: return mutations in useRobloxCredentialMutations --- src/hooks/useRobloxCredential.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/hooks/useRobloxCredential.ts b/src/hooks/useRobloxCredential.ts index c371ded..9e0ac9a 100644 --- a/src/hooks/useRobloxCredential.ts +++ b/src/hooks/useRobloxCredential.ts @@ -109,4 +109,11 @@ export function useRobloxCredentialMutations() { newKey: string; }) => RobloxCredentialController.rotate(teamId, credId, newKey), }); + + return { + linkRobloxCredential, + deleteRobloxCredential, + renameRobloxCredential, + rotateRobloxCredential, + }; } From 2a46c18f99cdcc54930aba1e0ae6770d16e76a27 Mon Sep 17 00:00:00 2001 From: OverDsh Date: Wed, 4 Feb 2026 15:59:01 +0100 Subject: [PATCH 09/40] fix: remove type from RobloxCredentialInfoSchema --- src/lib/types/roblox-credentials-types.ts | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/lib/types/roblox-credentials-types.ts b/src/lib/types/roblox-credentials-types.ts index 40828f1..09e0a6b 100644 --- a/src/lib/types/roblox-credentials-types.ts +++ b/src/lib/types/roblox-credentials-types.ts @@ -19,14 +19,13 @@ export const RobloxCredentialInfo = { export type RobloxCredentialInfo = InferDrizzleSelect< typeof RobloxCredentialInfo >; -export const RobloxCredentialInfoSchema: z.ZodType = - z.object({ - name: z - .string() - .min(3, { error: "Name must be at least 3 characters" }) - .max(32, { error: "Name must be at most 32 characters" }), - key: z.string().max(2048, { error: "Key must be at most 2048 characters" }), - }); +export const RobloxCredentialInfoSchema = z.object({ + name: z + .string() + .min(3, { error: "Name must be at least 3 characters" }) + .max(32, { error: "Name must be at most 32 characters" }), + key: z.string().max(2048, { error: "Key must be at most 2048 characters" }), +}); export const RobloxCredentialRenameSchema = z.object({ name: z From 7a7a2e5c5ab92dcc5015ce34422a5d013139bd52 Mon Sep 17 00:00:00 2001 From: OverDsh Date: Wed, 4 Feb 2026 16:01:05 +0100 Subject: [PATCH 10/40] enhancement: remove unused imports --- .../[teamSlug]/connections/LinkDialog/S3DatabaseDialog.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/app/dashboard/[teamSlug]/connections/LinkDialog/S3DatabaseDialog.tsx b/src/app/dashboard/[teamSlug]/connections/LinkDialog/S3DatabaseDialog.tsx index 76bbf68..16dc0a7 100644 --- a/src/app/dashboard/[teamSlug]/connections/LinkDialog/S3DatabaseDialog.tsx +++ b/src/app/dashboard/[teamSlug]/connections/LinkDialog/S3DatabaseDialog.tsx @@ -1,5 +1,4 @@ import FormDialog from "@/src/components/FormDialog"; -import { queryClient } from "@/src/components/QueryClientWrapper"; import { FormControl, FormField, @@ -8,7 +7,6 @@ import { FormMessage, } from "@/src/components/ui/form"; import { Input } from "@/src/components/ui/input"; -import { LinkDatabase } from "@/src/controllers/ExternalDatabaseController"; import { useDatabaseMutations } from "@/src/hooks/useDatabase"; import { useTeam } from "@/src/hooks/useTeam"; import { zodResolver } from "@hookform/resolvers/zod"; From bc10b1bf2ade54de5fd1291963873082ee412d0a Mon Sep 17 00:00:00 2001 From: OverDsh Date: Wed, 4 Feb 2026 16:01:26 +0100 Subject: [PATCH 11/40] feat: implement roblox credentials data table in the connections page --- .../connections/LinkRoCredDialog.tsx | 100 ++++++++++++++++ .../connections/RobloxCredentialColumn.tsx | 109 ++++++++++++++++++ .../dashboard/[teamSlug]/connections/page.tsx | 79 +++++++++---- 3 files changed, 266 insertions(+), 22 deletions(-) create mode 100644 src/app/dashboard/[teamSlug]/connections/LinkRoCredDialog.tsx create mode 100644 src/app/dashboard/[teamSlug]/connections/RobloxCredentialColumn.tsx diff --git a/src/app/dashboard/[teamSlug]/connections/LinkRoCredDialog.tsx b/src/app/dashboard/[teamSlug]/connections/LinkRoCredDialog.tsx new file mode 100644 index 0000000..9d89d5e --- /dev/null +++ b/src/app/dashboard/[teamSlug]/connections/LinkRoCredDialog.tsx @@ -0,0 +1,100 @@ +import FormDialog from "@/src/components/FormDialog"; +import { + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/src/components/ui/form"; +import { Input } from "@/src/components/ui/input"; +import { useRobloxCredentialMutations } from "@/src/hooks/useRobloxCredential"; +import { useTeam } from "@/src/hooks/useTeam"; +import { RobloxCredentialInfoSchema } from "@/src/lib/types/roblox-credentials-types"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; + +interface LinkRobloxCredentialDialogProps { + open: boolean; + setIsOpen: (open: boolean) => void; +} +export default function LinkRobloxCredentialDialog({ + open, + setIsOpen, +}: LinkRobloxCredentialDialogProps) { + const { linkRobloxCredential } = useRobloxCredentialMutations(); + const { data: team } = useTeam(); + + const form = useForm({ + resolver: zodResolver(RobloxCredentialInfoSchema), + defaultValues: { + name: "", + key: "", + }, + }); + + return ( + { + const id = toast.loading("Linking Roblox API key to your team..."); + linkRobloxCredential + .mutateAsync({ + teamId: team!.id, + data: { name, key }, + }) + .then(() => { + toast.success("Successfully linked Roblox API key to your team!", { + id, + }); + setIsOpen(false); + }) + .catch((error) => { + if (error instanceof Error) { + toast.error(error.message, { id }); + } else { + toast.error( + "An unknown error happened while linking Roblox API key to your team. Please try again later.", + { id }, + ); + } + }); + }} + > + ( + + Name + + + + + + )} + /> + ( + + API Key + + + + + + )} + /> + + ); +} diff --git a/src/app/dashboard/[teamSlug]/connections/RobloxCredentialColumn.tsx b/src/app/dashboard/[teamSlug]/connections/RobloxCredentialColumn.tsx new file mode 100644 index 0000000..4c4e159 --- /dev/null +++ b/src/app/dashboard/[teamSlug]/connections/RobloxCredentialColumn.tsx @@ -0,0 +1,109 @@ +import CallbackDialog from "@/src/components/CallbackDialog"; +import LocalTime from "@/src/components/LocalTime"; +import { Button } from "@/src/components/ui/button"; +import { + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/src/components/ui/dropdown-menu"; +import { Skeleton } from "@/src/components/ui/skeleton"; +import { useRobloxCredentialMutations } from "@/src/hooks/useRobloxCredential"; +import { useTeam } from "@/src/hooks/useTeam"; +import { RobloxCredential } from "@/src/lib/types/roblox-credentials-types"; +import { hasPermission } from "@/src/lib/utils/team-utils"; +import { DropdownMenu } from "@radix-ui/react-dropdown-menu"; +import { ColumnDef } from "@tanstack/react-table"; +import { MoreHorizontal } from "lucide-react"; +import { toast } from "sonner"; + +export const robloxCredentialColumn: ColumnDef[] = [ + { + accessorKey: "name", + header: "Name", + }, + { + accessorKey: "createdAt", + header: "Created At", + cell: ({ getValue }) => { + const time = new Date(getValue()); + return ; + }, + }, + { + id: "actions", + cell: ({ row }) => { + const cred = row.original; + const { data: team, isLoading } = useTeam(); + const { deleteRobloxCredential } = useRobloxCredentialMutations(); + + if (!team || isLoading) { + return ( +
+ +
+ ); + } + + return ( +
+ + + + + + navigator.clipboard.writeText(cred.id)} + > + Copy Roblox Credential ID + + + { + const id = toast.loading("Deleting roblox credential..."); + try { + await deleteRobloxCredential.mutateAsync({ + teamId: team.id, + credId: cred.id, + }); + toast.success("Successfully deleted roblox credential!", { + id, + }); + } catch (error) { + if (error instanceof Error) { + toast.error(error.message, { id }); + } else { + toast.error( + "An unknown error occured while deleting your roblox credential. Please try again later.", + { id }, + ); + } + } + }} + confirmationText={cred.name} + trigger={ + { + e.preventDefault(); + }} + > + Delete Roblox Credential + + } + /> + + +
+ ); + }, + }, +]; diff --git a/src/app/dashboard/[teamSlug]/connections/page.tsx b/src/app/dashboard/[teamSlug]/connections/page.tsx index 5021e15..0034bad 100644 --- a/src/app/dashboard/[teamSlug]/connections/page.tsx +++ b/src/app/dashboard/[teamSlug]/connections/page.tsx @@ -8,38 +8,73 @@ import { useState } from "react"; import LinkDatabaseDialog from "./LinkDialog/LinkDatabaseDialog"; import { hasPermission } from "@/src/lib/utils/team-utils"; import { useTeam } from "@/src/hooks/useTeam"; +import { robloxCredentialColumn } from "./RobloxCredentialColumn"; +import LinkRobloxCredentialDialog from "./LinkRoCredDialog"; +import { useRobloxCredentials } from "@/src/hooks/useRobloxCredential"; export default function Page() { - const [open, setIsOpen] = useState(false); + const [isLinkDbOpen, setIsLinkDbOpen] = useState(false); + const [isLinkCredOpen, setIsLinkCredOpen] = useState(false); const { data: team } = useTeam(); - const { data, isLoading } = useDatabases(); + const { data: dbData, isLoading: isDbLoading } = useDatabases(); + const { data: credData, isLoading: isCredLoading } = useRobloxCredentials(); return ( <> Connections - Databases - setIsOpen(true)} - disabled={!hasPermission(team?.role, "LinkDatabase")} - > - - Link Database - - } +
+ Databases + setIsLinkDbOpen(true)} + disabled={!hasPermission(team?.role, "LinkDatabase")} + > + + Link Database + + } + /> +
+ +
+ + Roblox Credentials + + setIsLinkCredOpen(true)} + disabled={!hasPermission(team?.role, "LinkRobloxCredential")} + > + + Link Roblox Credential + + } + /> +
+ - ); } From bda1e0cece0f2777266caa747922ad913ee2e625 Mon Sep 17 00:00:00 2001 From: OverDsh Date: Wed, 4 Feb 2026 16:48:21 +0100 Subject: [PATCH 12/40] enhancement: close form dialog after running callback --- src/components/FormDialog.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/components/FormDialog.tsx b/src/components/FormDialog.tsx index dc33752..5fa7ed4 100644 --- a/src/components/FormDialog.tsx +++ b/src/components/FormDialog.tsx @@ -10,7 +10,7 @@ import { DialogTrigger, } from "@/src/components/ui/dialog"; import { Form } from "@/src/components/ui/form"; -import React from "react"; +import React, { useState } from "react"; import { FieldValues, UseFormReturn } from "react-hook-form"; export interface FormDialogProps { @@ -53,15 +53,20 @@ export default function FormDialog({ trigger, form, callback, - open, - onOpenChange, + open: externalOpen, + onOpenChange: setExternalOpen, children, }: FormDialogProps & React.PropsWithChildren) { const [isLoading, setIsLoading] = React.useState(false); + const [internalOpen, setInternalOpen] = useState(false); + + const open = externalOpen ?? internalOpen; + const onOpenChange = setExternalOpen ?? setInternalOpen; const handleSubmit = async (data: TFormData) => { setIsLoading(true); await callback(data); + onOpenChange(false); form.reset(); setIsLoading(false); }; From 3f06eeb6d93a717a0ecdb46b70dd934f43b454ab Mon Sep 17 00:00:00 2001 From: OverDsh Date: Wed, 4 Feb 2026 16:49:19 +0100 Subject: [PATCH 13/40] enhancement: set submit button text to "Link" in RobloxCredentialDialog --- src/app/dashboard/[teamSlug]/connections/LinkRoCredDialog.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app/dashboard/[teamSlug]/connections/LinkRoCredDialog.tsx b/src/app/dashboard/[teamSlug]/connections/LinkRoCredDialog.tsx index 9d89d5e..a6a0b1c 100644 --- a/src/app/dashboard/[teamSlug]/connections/LinkRoCredDialog.tsx +++ b/src/app/dashboard/[teamSlug]/connections/LinkRoCredDialog.tsx @@ -40,6 +40,7 @@ export default function LinkRobloxCredentialDialog({ title="Link Roblox API Key" description="Provide a Roblox API key to be used in your projects" form={form} + submitButtonText="Link" callback={({ name, key }) => { const id = toast.loading("Linking Roblox API key to your team..."); linkRobloxCredential From ad43bcf37e2feb3c5cb580311242b3543b0b5668 Mon Sep 17 00:00:00 2001 From: OverDsh Date: Wed, 4 Feb 2026 16:49:45 +0100 Subject: [PATCH 14/40] fix: return success: true after rotating roblox credential key --- .../teams/[teamId]/roblox-credentials/[credentialId]/route.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app/api/teams/[teamId]/roblox-credentials/[credentialId]/route.ts b/src/app/api/teams/[teamId]/roblox-credentials/[credentialId]/route.ts index 02e0581..9dd5849 100644 --- a/src/app/api/teams/[teamId]/roblox-credentials/[credentialId]/route.ts +++ b/src/app/api/teams/[teamId]/roblox-credentials/[credentialId]/route.ts @@ -140,6 +140,7 @@ export async function POST(req: Request, context: Context) { credentialId, validatedData.key, ); + return NextResponse.json({ success: true }); } catch (error) { return ErrorToNextResponse(error); } From dd9ed2e19dea3144d1a89bdc07edf5e8b5c0c8df Mon Sep 17 00:00:00 2001 From: OverDsh Date: Wed, 4 Feb 2026 16:50:00 +0100 Subject: [PATCH 15/40] feat: implement rename and roatate roblox credential --- .../connections/RobloxCredentialColumn.tsx | 149 +++++++++++++++++- 1 file changed, 148 insertions(+), 1 deletion(-) diff --git a/src/app/dashboard/[teamSlug]/connections/RobloxCredentialColumn.tsx b/src/app/dashboard/[teamSlug]/connections/RobloxCredentialColumn.tsx index 4c4e159..34bfb04 100644 --- a/src/app/dashboard/[teamSlug]/connections/RobloxCredentialColumn.tsx +++ b/src/app/dashboard/[teamSlug]/connections/RobloxCredentialColumn.tsx @@ -1,4 +1,5 @@ import CallbackDialog from "@/src/components/CallbackDialog"; +import FormDialog from "@/src/components/FormDialog"; import LocalTime from "@/src/components/LocalTime"; import { Button } from "@/src/components/ui/button"; import { @@ -7,16 +8,142 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/src/components/ui/dropdown-menu"; +import { + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/src/components/ui/form"; +import { Input } from "@/src/components/ui/input"; import { Skeleton } from "@/src/components/ui/skeleton"; import { useRobloxCredentialMutations } from "@/src/hooks/useRobloxCredential"; import { useTeam } from "@/src/hooks/useTeam"; -import { RobloxCredential } from "@/src/lib/types/roblox-credentials-types"; +import { + RobloxCredential, + RobloxCredentialRenameSchema, + RobloxCredentialRotateSchema, +} from "@/src/lib/types/roblox-credentials-types"; import { hasPermission } from "@/src/lib/utils/team-utils"; +import { zodResolver } from "@hookform/resolvers/zod"; import { DropdownMenu } from "@radix-ui/react-dropdown-menu"; import { ColumnDef } from "@tanstack/react-table"; import { MoreHorizontal } from "lucide-react"; +import { useForm } from "react-hook-form"; import { toast } from "sonner"; +const RenameRobloxCredentialDialog = ({ + credId, + children, +}: { credId: string } & React.PropsWithChildren) => { + const { data: team } = useTeam(); + const { renameRobloxCredential } = useRobloxCredentialMutations(); + const form = useForm({ + resolver: zodResolver(RobloxCredentialRenameSchema), + defaultValues: { + name: "", + }, + }); + + return ( + { + const id = toast.loading("Renaming Roblox credential..."); + renameRobloxCredential + .mutateAsync({ teamId: team!.id, credId, newName: name }) + .then(() => { + toast.success("Successfully renamed Roblox credential!", { id }); + }) + .catch((error) => { + if (error instanceof Error) { + toast.error(error.message, { id }); + } else { + toast.error( + "An unknown error happened while renaming Roblox credential. Please try again later.", + { id }, + ); + } + }); + }} + submitButtonText="Rename" + trigger={children} + > + ( + + Name + + + + + + )} + /> + + ); +}; + +const RotateRobloxCredentialDialog = ({ + credId, + children, +}: { credId: string } & React.PropsWithChildren) => { + const { data: team } = useTeam(); + const { rotateRobloxCredential } = useRobloxCredentialMutations(); + const form = useForm({ + resolver: zodResolver(RobloxCredentialRotateSchema), + defaultValues: { + key: "", + }, + }); + + return ( + { + const id = toast.loading("Rotating Roblox credential..."); + rotateRobloxCredential + .mutateAsync({ teamId: team!.id, credId, newKey: key }) + .then(() => { + toast.success("Successfully rotated Roblox credential!", { id }); + }) + .catch((error) => { + if (error instanceof Error) { + toast.error(error.message, { id }); + } else { + toast.error( + "An unknown error happened while rotating Roblox credential. Please try again later.", + { id }, + ); + } + }); + }} + submitButtonText="Rotate" + trigger={children} + > + ( + + Key + + + + + + )} + /> + + ); +}; + export const robloxCredentialColumn: ColumnDef[] = [ { accessorKey: "name", @@ -60,7 +187,27 @@ export const robloxCredentialColumn: ColumnDef[] = [ > Copy Roblox Credential ID + + { + e.preventDefault(); + }} + > + Rename Roblox Credential + + + + { + e.preventDefault(); + }} + > + Rotate Roblox Credential + + Date: Wed, 4 Feb 2026 17:19:19 +0100 Subject: [PATCH 16/40] enhancement: require roblox credential key to be at least 1 character --- src/lib/types/roblox-credentials-types.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/lib/types/roblox-credentials-types.ts b/src/lib/types/roblox-credentials-types.ts index 09e0a6b..5751299 100644 --- a/src/lib/types/roblox-credentials-types.ts +++ b/src/lib/types/roblox-credentials-types.ts @@ -24,7 +24,10 @@ export const RobloxCredentialInfoSchema = z.object({ .string() .min(3, { error: "Name must be at least 3 characters" }) .max(32, { error: "Name must be at most 32 characters" }), - key: z.string().max(2048, { error: "Key must be at most 2048 characters" }), + key: z + .string() + .min(1, { error: "Key must contain at least 1 character" }) + .max(2048, { error: "Key must be at most 2048 characters" }), }); export const RobloxCredentialRenameSchema = z.object({ @@ -35,5 +38,8 @@ export const RobloxCredentialRenameSchema = z.object({ }); export const RobloxCredentialRotateSchema = z.object({ - key: z.string().max(2048, { error: "Key must be at most 2048 characters" }), + key: z + .string() + .min(1, { error: "Key must contain at least 1 character" }) + .max(2048, { error: "Key must be at most 2048 characters" }), }); From 7fe2fd687965232098f6c4dbc89a6f1fccc0d1a9 Mon Sep 17 00:00:00 2001 From: OverDsh Date: Wed, 4 Feb 2026 21:16:22 +0100 Subject: [PATCH 17/40] enhancement: add a 5 min stale time to hooks using react query --- src/hooks/useDatabase.ts | 1 + src/hooks/useMember.ts | 1 + src/hooks/useRobloxCredential.ts | 1 + src/hooks/useTeam.ts | 2 ++ 4 files changed, 5 insertions(+) diff --git a/src/hooks/useDatabase.ts b/src/hooks/useDatabase.ts index b82937a..91c8127 100644 --- a/src/hooks/useDatabase.ts +++ b/src/hooks/useDatabase.ts @@ -18,6 +18,7 @@ export function useDatabases() { queryKey: ["databases", team?.id], queryFn: () => ExternalDatabaseController.list(team!.id), enabled: !!team?.id, + staleTime: 5 * 60 * 1000, }); useEffect(() => { diff --git a/src/hooks/useMember.ts b/src/hooks/useMember.ts index 6fbec80..c4d9835 100644 --- a/src/hooks/useMember.ts +++ b/src/hooks/useMember.ts @@ -14,6 +14,7 @@ export function useMembers() { queryKey: ["members", team?.id], queryFn: () => TeamController.listMembers(team!.id), enabled: !!team?.id, + staleTime: 5 * 60 * 1000, }); const isLoading = isLoadingTeam || query.isLoading; diff --git a/src/hooks/useRobloxCredential.ts b/src/hooks/useRobloxCredential.ts index 9e0ac9a..a77dfe0 100644 --- a/src/hooks/useRobloxCredential.ts +++ b/src/hooks/useRobloxCredential.ts @@ -21,6 +21,7 @@ export function useRobloxCredentials() { queryKey: ["robloxCredentials", team?.id], queryFn: () => RobloxCredentialController.list(team!.id), enabled: !!team?.id, + staleTime: 5 * 60 * 1000, }); useEffect(() => { diff --git a/src/hooks/useTeam.ts b/src/hooks/useTeam.ts index bdeaa2d..c6796e7 100644 --- a/src/hooks/useTeam.ts +++ b/src/hooks/useTeam.ts @@ -13,6 +13,7 @@ export function useTeam() { queryKey: ["team", teamSlug], queryFn: () => TeamController.resolve(teamSlug as string), enabled: !!teamSlug, + staleTime: 5 * 60 * 1000, retry: false, }); @@ -29,6 +30,7 @@ export function useTeams() { return useQuery({ queryKey: ["teams"], queryFn: () => TeamController.list(), + staleTime: 5 * 60 * 1000, }); } From c075ee857ee108697f3fd6ca50a50895dbe745bd Mon Sep 17 00:00:00 2001 From: OverDsh Date: Sun, 15 Feb 2026 15:44:32 +0100 Subject: [PATCH 18/40] chore: updated radix ui --- package-lock.json | 2417 ++++++++++++++++++++++++++++++++++++++++----- package.json | 1 + 2 files changed, 2177 insertions(+), 241 deletions(-) diff --git a/package-lock.json b/package-lock.json index e971780..d8ff69f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,6 +37,7 @@ "next-themes": "^0.4.6", "nodemailer": "^7.0.10", "qrcode": "^1.5.4", + "radix-ui": "^1.4.3", "react": "18.3.1", "react-dom": "18.3.1", "react-downloadfile-hook": "^1.0.3", @@ -3682,19 +3683,25 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@radix-ui/number": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", + "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==", + "license": "MIT" + }, "node_modules/@radix-ui/primitive": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", "license": "MIT" }, - "node_modules/@radix-ui/react-arrow": { + "node_modules/@radix-ui/react-accessible-icon": { "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", - "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accessible-icon/-/react-accessible-icon-1.1.7.tgz", + "integrity": "sha512-XM+E4WXl0OqUJFovy6GjmxxFyx9opfCAIUku4dlKRd5YEPqt4kALOkQOp0Of6reHuUkJuiPBEc5k0o4z4lTC8A==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.3" + "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", @@ -3711,13 +3718,21 @@ } } }, - "node_modules/@radix-ui/react-arrow/node_modules/@radix-ui/react-primitive": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", - "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "node_modules/@radix-ui/react-accordion": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.12.tgz", + "integrity": "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==", "license": "MIT", "dependencies": { - "@radix-ui/react-slot": "1.2.3" + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collapsible": "1.1.12", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", @@ -3734,61 +3749,59 @@ } } }, - "node_modules/@radix-ui/react-arrow/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", "license": "MIT", "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" + "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { "optional": true + }, + "@types/react-dom": { + "optional": true } } }, - "node_modules/@radix-ui/react-aspect-ratio": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.1.8.tgz", - "integrity": "sha512-5nZrJTF7gH+e0nZS7/QxFz6tJV4VimhQb1avEgtsJxvvIp5JilL+c58HICsKzPxghdwaDt48hEfPM1au4zGy+w==", + "node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.4" + "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { "optional": true - }, - "@types/react-dom": { - "optional": true } } }, - "node_modules/@radix-ui/react-checkbox": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz", - "integrity": "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==", + "node_modules/@radix-ui/react-alert-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.15.tgz", + "integrity": "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-use-previous": "1.1.1", - "@radix-ui/react-use-size": "1.1.1" + "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", @@ -3805,7 +3818,7 @@ } } }, - "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-primitive": { + "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-primitive": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", @@ -3828,7 +3841,7 @@ } } }, - "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-slot": { + "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-slot": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", @@ -3846,16 +3859,13 @@ } } }, - "node_modules/@radix-ui/react-collection": { + "node_modules/@radix-ui/react-arrow": { "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", - "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", + "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", "license": "MIT", "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-slot": "1.2.3" + "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", @@ -3872,7 +3882,7 @@ } } }, - "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-primitive": { + "node_modules/@radix-ui/react-arrow/node_modules/@radix-ui/react-primitive": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", @@ -3895,7 +3905,7 @@ } } }, - "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": { + "node_modules/@radix-ui/react-arrow/node_modules/@radix-ui/react-slot": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", @@ -3913,56 +3923,40 @@ } } }, - "node_modules/@radix-ui/react-compose-refs": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", - "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "node_modules/@radix-ui/react-aspect-ratio": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.1.8.tgz", + "integrity": "sha512-5nZrJTF7gH+e0nZS7/QxFz6tJV4VimhQb1avEgtsJxvvIp5JilL+c58HICsKzPxghdwaDt48hEfPM1au4zGy+w==", "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "dependencies": { + "@radix-ui/react-primitive": "2.1.4" }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-context": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", - "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", - "license": "MIT", "peerDependencies": { "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { "optional": true + }, + "@types/react-dom": { + "optional": true } } }, - "node_modules/@radix-ui/react-dialog": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", - "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", + "node_modules/@radix-ui/react-avatar": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.10.tgz", + "integrity": "sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.11", - "@radix-ui/react-focus-guards": "1.1.3", - "@radix-ui/react-focus-scope": "1.1.7", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-portal": "1.1.9", - "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-slot": "1.2.3", - "@radix-ui/react-use-controllable-state": "1.2.2", - "aria-hidden": "^1.2.4", - "react-remove-scroll": "^2.6.3" + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-is-hydrated": "0.1.0", + "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", @@ -3979,7 +3973,7 @@ } } }, - "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-primitive": { + "node_modules/@radix-ui/react-avatar/node_modules/@radix-ui/react-primitive": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", @@ -4002,7 +3996,7 @@ } } }, - "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": { + "node_modules/@radix-ui/react-avatar/node_modules/@radix-ui/react-slot": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", @@ -4020,32 +4014,20 @@ } } }, - "node_modules/@radix-ui/react-direction": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", - "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-dismissable-layer": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", - "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "node_modules/@radix-ui/react-checkbox": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz", + "integrity": "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-escape-keydown": "1.1.1" + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", @@ -4062,7 +4044,7 @@ } } }, - "node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-primitive": { + "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-primitive": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", @@ -4085,7 +4067,7 @@ } } }, - "node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-slot": { + "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-slot": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", @@ -4103,19 +4085,20 @@ } } }, - "node_modules/@radix-ui/react-dropdown-menu": { - "version": "2.1.16", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz", - "integrity": "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==", + "node_modules/@radix-ui/react-collapsible": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz", + "integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-controllable-state": "1.2.2" + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", @@ -4132,7 +4115,7 @@ } } }, - "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-primitive": { + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-primitive": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", @@ -4155,7 +4138,7 @@ } } }, - "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-slot": { + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-slot": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", @@ -4173,30 +4156,16 @@ } } }, - "node_modules/@radix-ui/react-focus-guards": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", - "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-focus-scope": { + "node_modules/@radix-ui/react-collection": { "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", - "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", + "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", "license": "MIT", "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1" + "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", @@ -4213,7 +4182,7 @@ } } }, - "node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-primitive": { + "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-primitive": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", @@ -4236,7 +4205,7 @@ } } }, - "node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-slot": { + "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", @@ -4254,14 +4223,11 @@ } } }, - "node_modules/@radix-ui/react-id": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", - "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", "license": "MIT", - "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.1" - }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" @@ -4272,53 +4238,33 @@ } } }, - "node_modules/@radix-ui/react-label": { - "version": "2.1.8", - "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.8.tgz", - "integrity": "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A==", + "node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", "license": "MIT", - "dependencies": { - "@radix-ui/react-primitive": "2.1.4" - }, "peerDependencies": { "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { "optional": true - }, - "@types/react-dom": { - "optional": true } } }, - "node_modules/@radix-ui/react-menu": { - "version": "2.1.16", - "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz", - "integrity": "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==", + "node_modules/@radix-ui/react-context-menu": { + "version": "2.2.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.2.16.tgz", + "integrity": "sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-collection": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-dismissable-layer": "1.1.11", - "@radix-ui/react-focus-guards": "1.1.3", - "@radix-ui/react-focus-scope": "1.1.7", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-popper": "1.2.8", - "@radix-ui/react-portal": "1.1.9", - "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-roving-focus": "1.1.11", - "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", - "aria-hidden": "^1.2.4", - "react-remove-scroll": "^2.6.3" + "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", @@ -4335,7 +4281,7 @@ } } }, - "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-primitive": { + "node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-primitive": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", @@ -4358,7 +4304,978 @@ } } }, - "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": { + "node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", + "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", + "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz", + "integrity": "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", + "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", + "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-form": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-form/-/react-form-0.1.8.tgz", + "integrity": "sha512-QM70k4Zwjttifr5a4sZFts9fn8FzHYvQ5PiB19O2HsYibaHSVt9fH9rzB0XZo/YcM+b7t/p7lYCT/F5eOeF5yQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-label": "2.1.7", + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-form/node_modules/@radix-ui/react-label": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.7.tgz", + "integrity": "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-form/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-form/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-hover-card": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.15.tgz", + "integrity": "sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.8.tgz", + "integrity": "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz", + "integrity": "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menubar": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menubar/-/react-menubar-1.1.16.tgz", + "integrity": "sha512-EB1FktTz5xRRi2Er974AUQZWg2yVBb1yjip38/lgwtCVRd3a+maUoGHN/xs9Yv8SY8QwbSEb+YrxGadVWbEutA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menubar/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menubar/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-navigation-menu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.14.tgz", + "integrity": "sha512-YB9mTFQvCOAQMHU+C/jVl96WmuWeltyUEpRJJky51huhds5W2FQr1J8D/16sQlf0ozxkPK8uF3niQMdUwZPv5w==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-navigation-menu/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-navigation-menu/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-one-time-password-field": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-one-time-password-field/-/react-one-time-password-field-0.1.8.tgz", + "integrity": "sha512-ycS4rbwURavDPVjCb5iS3aG4lURFDILi6sKI/WITUMZ13gMmn/xGjpLoqBAalhJaDk8I3UbCM5GzKHrnzwHbvg==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-is-hydrated": "0.1.0", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-one-time-password-field/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-one-time-password-field/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-password-toggle-field": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-password-toggle-field/-/react-password-toggle-field-0.1.3.tgz", + "integrity": "sha512-/UuCrDBWravcaMix4TdT+qlNdVwOM1Nck9kWx/vafXsdfj1ChfhOdfi3cy9SGBpWgTXwYCuboT/oYpJy3clqfw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-is-hydrated": "0.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-password-toggle-field/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-password-toggle-field/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz", + "integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-slot": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", @@ -4382,16 +5299,825 @@ "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", "license": "MIT", "dependencies": { - "@floating-ui/react-dom": "^2.0.0", - "@radix-ui/react-arrow": "1.1.7", + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-rect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", + "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.7.tgz", + "integrity": "sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-radio-group": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.8.tgz", + "integrity": "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", + "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.10.tgz", + "integrity": "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz", + "integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.8.tgz", + "integrity": "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slider": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.3.6.tgz", + "integrity": "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", + "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz", + "integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", + "integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toast": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.15.tgz", + "integrity": "sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", - "@radix-ui/react-use-rect": "1.1.1", - "@radix-ui/react-use-size": "1.1.1", - "@radix-ui/rect": "1.1.1" + "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", @@ -4408,7 +6134,7 @@ } } }, - "node_modules/@radix-ui/react-popper/node_modules/@radix-ui/react-primitive": { + "node_modules/@radix-ui/react-toast/node_modules/@radix-ui/react-primitive": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", @@ -4431,7 +6157,7 @@ } } }, - "node_modules/@radix-ui/react-popper/node_modules/@radix-ui/react-slot": { + "node_modules/@radix-ui/react-toast/node_modules/@radix-ui/react-slot": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", @@ -4449,14 +6175,15 @@ } } }, - "node_modules/@radix-ui/react-portal": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", - "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "node_modules/@radix-ui/react-toggle": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz", + "integrity": "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==", "license": "MIT", "dependencies": { + "@radix-ui/primitive": "1.1.3", "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-layout-effect": "1.1.1" + "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", @@ -4473,7 +6200,36 @@ } } }, - "node_modules/@radix-ui/react-portal/node_modules/@radix-ui/react-primitive": { + "node_modules/@radix-ui/react-toggle-group": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.11.tgz", + "integrity": "sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-toggle": "1.1.10", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle-group/node_modules/@radix-ui/react-primitive": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", @@ -4496,7 +6252,7 @@ } } }, - "node_modules/@radix-ui/react-portal/node_modules/@radix-ui/react-slot": { + "node_modules/@radix-ui/react-toggle-group/node_modules/@radix-ui/react-slot": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", @@ -4514,14 +6270,13 @@ } } }, - "node_modules/@radix-ui/react-presence": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", - "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "node_modules/@radix-ui/react-toggle/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", "license": "MIT", "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-use-layout-effect": "1.1.1" + "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", @@ -4538,44 +6293,37 @@ } } }, - "node_modules/@radix-ui/react-primitive": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", - "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", + "node_modules/@radix-ui/react-toggle/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", "license": "MIT", "dependencies": { - "@radix-ui/react-slot": "1.2.4" + "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { "optional": true - }, - "@types/react-dom": { - "optional": true } } }, - "node_modules/@radix-ui/react-roving-focus": { + "node_modules/@radix-ui/react-toolbar": { "version": "1.1.11", - "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", - "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toolbar/-/react-toolbar-1.1.11.tgz", + "integrity": "sha512-4ol06/1bLoFu1nwUqzdD4Y5RZ9oDdKeiHIsntug54Hcr1pgaHiPqHFEaXI1IFP/EsOfROQZ8Mig9VTIRza6Tjg==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-collection": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-controllable-state": "1.2.2" + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-separator": "1.1.7", + "@radix-ui/react-toggle-group": "1.1.11" }, "peerDependencies": { "@types/react": "*", @@ -4592,7 +6340,7 @@ } } }, - "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-primitive": { + "node_modules/@radix-ui/react-toolbar/node_modules/@radix-ui/react-primitive": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", @@ -4615,31 +6363,13 @@ } } }, - "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-separator": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.8.tgz", - "integrity": "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==", + "node_modules/@radix-ui/react-toolbar/node_modules/@radix-ui/react-separator": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz", + "integrity": "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.4" + "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", @@ -4656,10 +6386,10 @@ } } }, - "node_modules/@radix-ui/react-slot": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", - "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", + "node_modules/@radix-ui/react-toolbar/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", "license": "MIT", "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" @@ -4819,6 +6549,24 @@ } } }, + "node_modules/@radix-ui/react-use-is-hydrated": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.0.tgz", + "integrity": "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.5.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-layout-effect": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", @@ -14638,6 +16386,193 @@ ], "license": "MIT" }, + "node_modules/radix-ui": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/radix-ui/-/radix-ui-1.4.3.tgz", + "integrity": "sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-accessible-icon": "1.1.7", + "@radix-ui/react-accordion": "1.2.12", + "@radix-ui/react-alert-dialog": "1.1.15", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-aspect-ratio": "1.1.7", + "@radix-ui/react-avatar": "1.1.10", + "@radix-ui/react-checkbox": "1.3.3", + "@radix-ui/react-collapsible": "1.1.12", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-context-menu": "2.2.16", + "@radix-ui/react-dialog": "1.1.15", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-dropdown-menu": "2.1.16", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-form": "0.1.8", + "@radix-ui/react-hover-card": "1.1.15", + "@radix-ui/react-label": "2.1.7", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-menubar": "1.1.16", + "@radix-ui/react-navigation-menu": "1.2.14", + "@radix-ui/react-one-time-password-field": "0.1.8", + "@radix-ui/react-password-toggle-field": "0.1.3", + "@radix-ui/react-popover": "1.1.15", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-progress": "1.1.7", + "@radix-ui/react-radio-group": "1.3.8", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-scroll-area": "1.2.10", + "@radix-ui/react-select": "2.2.6", + "@radix-ui/react-separator": "1.1.7", + "@radix-ui/react-slider": "1.3.6", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-switch": "1.2.6", + "@radix-ui/react-tabs": "1.1.13", + "@radix-ui/react-toast": "1.2.15", + "@radix-ui/react-toggle": "1.1.10", + "@radix-ui/react-toggle-group": "1.1.11", + "@radix-ui/react-toolbar": "1.1.11", + "@radix-ui/react-tooltip": "1.2.8", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-escape-keydown": "1.1.1", + "@radix-ui/react-use-is-hydrated": "0.1.0", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/radix-ui/node_modules/@radix-ui/react-aspect-ratio": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.1.7.tgz", + "integrity": "sha512-Yq6lvO9HQyPwev1onK1daHCHqXVLzPhSVjmsNjCa2Zcxy2f7uJD2itDtxknv6FzAKCwD1qQkeVDmX/cev13n/g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/radix-ui/node_modules/@radix-ui/react-label": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.7.tgz", + "integrity": "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/radix-ui/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/radix-ui/node_modules/@radix-ui/react-separator": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz", + "integrity": "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/radix-ui/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/raw-body": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.4.1.tgz", diff --git a/package.json b/package.json index d8f4c20..618816b 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "next-themes": "^0.4.6", "nodemailer": "^7.0.10", "qrcode": "^1.5.4", + "radix-ui": "^1.4.3", "react": "18.3.1", "react-dom": "18.3.1", "react-downloadfile-hook": "^1.0.3", From 0ff499f5c5115f24c7d41a4cc640cfa539fbe831 Mon Sep 17 00:00:00 2001 From: OverDsh Date: Sun, 15 Feb 2026 15:47:37 +0100 Subject: [PATCH 19/40] feat: install badge and popover components --- src/components/ui/badge.tsx | 48 +++++++++++++++++++ src/components/ui/popover.tsx | 89 +++++++++++++++++++++++++++++++++++ 2 files changed, 137 insertions(+) create mode 100644 src/components/ui/badge.tsx create mode 100644 src/components/ui/popover.tsx diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx new file mode 100644 index 0000000..aae8f8b --- /dev/null +++ b/src/components/ui/badge.tsx @@ -0,0 +1,48 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { Slot } from "radix-ui" + +import { cn } from "@/src/lib/utils" + +const badgeVariants = cva( + "inline-flex items-center justify-center rounded-full border border-transparent px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90", + secondary: + "bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", + destructive: + "bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: + "border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", + ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground", + link: "text-primary underline-offset-4 [a&]:hover:underline", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Badge({ + className, + variant = "default", + asChild = false, + ...props +}: React.ComponentProps<"span"> & + VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot.Root : "span" + + return ( + + ) +} + +export { Badge, badgeVariants } diff --git a/src/components/ui/popover.tsx b/src/components/ui/popover.tsx new file mode 100644 index 0000000..c9cb4a0 --- /dev/null +++ b/src/components/ui/popover.tsx @@ -0,0 +1,89 @@ +"use client" + +import * as React from "react" +import { Popover as PopoverPrimitive } from "radix-ui" + +import { cn } from "@/src/lib/utils" + +function Popover({ + ...props +}: React.ComponentProps) { + return +} + +function PopoverTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function PopoverContent({ + className, + align = "center", + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function PopoverAnchor({ + ...props +}: React.ComponentProps) { + return +} + +function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function PopoverTitle({ className, ...props }: React.ComponentProps<"h2">) { + return ( +
+ ) +} + +function PopoverDescription({ + className, + ...props +}: React.ComponentProps<"p">) { + return ( +

+ ) +} + +export { + Popover, + PopoverTrigger, + PopoverContent, + PopoverAnchor, + PopoverHeader, + PopoverTitle, + PopoverDescription, +} From 95ba04fb53c18c045228ec21e1398849f545eb19 Mon Sep 17 00:00:00 2001 From: OverDsh Date: Sun, 15 Feb 2026 15:50:06 +0100 Subject: [PATCH 20/40] feat: add refresh credential method, and various QOL improvements --- .../[teamId]/databases/[databaseId]/route.ts | 6 +- .../[credentialId]/refresh/route.ts | 43 +++ .../[credentialId]/route.ts | 6 +- src/controllers/RobloxCredentialController.ts | 17 +- src/db/schema/roblox_credentials.ts | 18 ++ src/hooks/useRobloxCredential.ts | 28 ++ src/lib/types/roblox-credentials-types.ts | 9 + src/lib/utils/team-utils.ts | 1 + src/services/RobloxCredentialsService.ts | 271 +++++++++++++++++- 9 files changed, 374 insertions(+), 25 deletions(-) create mode 100644 src/app/api/teams/[teamId]/roblox-credentials/[credentialId]/refresh/route.ts diff --git a/src/app/api/teams/[teamId]/databases/[databaseId]/route.ts b/src/app/api/teams/[teamId]/databases/[databaseId]/route.ts index 5203090..5e305e0 100644 --- a/src/app/api/teams/[teamId]/databases/[databaseId]/route.ts +++ b/src/app/api/teams/[teamId]/databases/[databaseId]/route.ts @@ -12,7 +12,7 @@ interface Context { }>; } -export async function DELETE(req: Request, context: Context) { +export async function DELETE(context: Context) { const params = await context.params; const databaseId = params.databaseId; @@ -38,12 +38,12 @@ export async function DELETE(req: Request, context: Context) { } try { - await ExternalDatabaseService.DeleteDatabase( + const deletedDb = await ExternalDatabaseService.DeleteDatabase( session.user.id, teamId, databaseId, ); - return NextResponse.json({ message: "Database deleted successfully" }); + return NextResponse.json(deletedDb); } catch (error) { return ErrorToNextResponse(error); } diff --git a/src/app/api/teams/[teamId]/roblox-credentials/[credentialId]/refresh/route.ts b/src/app/api/teams/[teamId]/roblox-credentials/[credentialId]/refresh/route.ts new file mode 100644 index 0000000..be245ba --- /dev/null +++ b/src/app/api/teams/[teamId]/roblox-credentials/[credentialId]/refresh/route.ts @@ -0,0 +1,43 @@ +import { auth } from "@/src/lib/auth"; +import { AccessDenied, ErrorToNextResponse } from "@/src/lib/utils/api-utils"; +import { RobloxCredentialsService } from "@/src/services/RobloxCredentialsService"; +import { headers } from "next/headers"; +import { NextResponse } from "next/server"; + +interface Context { + params: Promise<{ + teamId: string; + credentialId: string; + }>; +} + +export async function POST(context: Context) { + const { teamId, credentialId } = await context.params; + if (!teamId) { + return NextResponse.json({ error: "Team ID is required" }, { status: 400 }); + } + if (!credentialId) { + return NextResponse.json( + { error: "Credential ID is required" }, + { status: 400 }, + ); + } + + const session = await auth.api.getSession({ + headers: await headers(), + }); + if (!session) { + return ErrorToNextResponse(AccessDenied); + } + + try { + const newCred = await RobloxCredentialsService.RefreshRobloxCredential( + session.user.id, + teamId, + credentialId, + ); + return NextResponse.json(newCred); + } catch (error) { + return ErrorToNextResponse(error); + } +} diff --git a/src/app/api/teams/[teamId]/roblox-credentials/[credentialId]/route.ts b/src/app/api/teams/[teamId]/roblox-credentials/[credentialId]/route.ts index 9dd5849..e26c8fb 100644 --- a/src/app/api/teams/[teamId]/roblox-credentials/[credentialId]/route.ts +++ b/src/app/api/teams/[teamId]/roblox-credentials/[credentialId]/route.ts @@ -15,7 +15,7 @@ interface Context { }>; } -export async function DELETE(req: Request, context: Context) { +export async function DELETE(context: Context) { const { teamId, credentialId } = await context.params; if (!teamId) { return NextResponse.json({ error: "Team ID is required" }, { status: 400 }); @@ -134,13 +134,13 @@ export async function POST(req: Request, context: Context) { } try { - await RobloxCredentialsService.RotateRobloxCredential( + const rotatedCred = await RobloxCredentialsService.RotateRobloxCredential( session.user.id, teamId, credentialId, validatedData.key, ); - return NextResponse.json({ success: true }); + return NextResponse.json(rotatedCred); } catch (error) { return ErrorToNextResponse(error); } diff --git a/src/controllers/RobloxCredentialController.ts b/src/controllers/RobloxCredentialController.ts index a7313bd..bf012d4 100644 --- a/src/controllers/RobloxCredentialController.ts +++ b/src/controllers/RobloxCredentialController.ts @@ -24,10 +24,19 @@ export const RobloxCredentialController = { ), rotate: (teamId: string, credId: string, newKey: string) => - fetcher(`/api/teams/${teamId}/roblox-credentials/${credId}`, { - method: "POST", - body: JSON.stringify({ key: newKey }), - }), + fetcher( + `/api/teams/${teamId}/roblox-credentials/${credId}`, + { + method: "POST", + body: JSON.stringify({ key: newKey }), + }, + ), + + refresh: (teamId: string, credId: string) => + fetcher( + `/api/teams/${teamId}/roblox-credentials/${credId}/refresh`, + { method: "POST" }, + ), list: (teamId: string) => fetcher(`/api/teams/${teamId}/roblox-credentials`), diff --git a/src/db/schema/roblox_credentials.ts b/src/db/schema/roblox_credentials.ts index 73a39f0..22d9b0c 100644 --- a/src/db/schema/roblox_credentials.ts +++ b/src/db/schema/roblox_credentials.ts @@ -10,10 +10,28 @@ export const roblox_credentials = sqliteTable("roblox_credentials", { .references(() => team.id, { onDelete: "cascade" }), name: text("name").notNull(), + status: text("status", { enum: ["healthy", "warning", "error"] }) + .notNull() + .default("healthy"), + errorMessage: text("error_message"), + keyCiphertext: text("key_ciphertext").notNull(), keyIv: text("key_iv").notNull(), keyTag: text("key_tag").notNull(), + keyOwnerRobloxId: integer("key_owner_roblox_id").notNull(), + + expirationDate: integer("expiration_date", { + mode: "timestamp_ms", + }), + lastUsed: integer("last_used", { mode: "timestamp_ms" }) + .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`) + .notNull(), + lastRefreshedAt: integer("last_refreshed_at", { + mode: "timestamp_ms", + }) + .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`) + .notNull(), createdAt: integer("created_at", { mode: "timestamp_ms" }) .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`) .notNull(), diff --git a/src/hooks/useRobloxCredential.ts b/src/hooks/useRobloxCredential.ts index a77dfe0..9c662ff 100644 --- a/src/hooks/useRobloxCredential.ts +++ b/src/hooks/useRobloxCredential.ts @@ -109,6 +109,33 @@ export function useRobloxCredentialMutations() { credId: string; newKey: string; }) => RobloxCredentialController.rotate(teamId, credId, newKey), + onSuccess: (rotatedCred, variables) => { + queryClient.setQueryData( + ["robloxCredentials", variables.teamId], + (prevData) => { + if (!prevData) return [rotatedCred]; + return prevData.map((cred) => + cred.id == rotatedCred.id ? rotatedCred : cred, + ); + }, + ); + }, + }); + + const refreshRobloxCredential = useMutation({ + mutationFn: ({ teamId, credId }: { teamId: string; credId: string }) => + RobloxCredentialController.refresh(teamId, credId), + onSuccess: (refreshedCred, variables) => { + queryClient.setQueryData( + ["robloxCredentials", variables.teamId], + (prevData) => { + if (!prevData) return [refreshedCred]; + return prevData.map((cred) => + cred.id === refreshedCred.id ? refreshedCred : cred, + ); + }, + ); + }, }); return { @@ -116,5 +143,6 @@ export function useRobloxCredentialMutations() { deleteRobloxCredential, renameRobloxCredential, rotateRobloxCredential, + refreshRobloxCredential, }; } diff --git a/src/lib/types/roblox-credentials-types.ts b/src/lib/types/roblox-credentials-types.ts index 5751299..75ffb4b 100644 --- a/src/lib/types/roblox-credentials-types.ts +++ b/src/lib/types/roblox-credentials-types.ts @@ -2,11 +2,20 @@ import { roblox_credentials } from "@/src/db/schema/roblox_credentials"; import { InferDrizzleSelect } from "../utils"; import z from "zod"; +export type RobloxCredentialStatus = + typeof roblox_credentials.$inferSelect.status; + export const RobloxCredentialSelect = { id: roblox_credentials.id, teamId: roblox_credentials.teamId, + status: roblox_credentials.status, + errorMessage: roblox_credentials.errorMessage, name: roblox_credentials.name, + expirationDate: roblox_credentials.expirationDate, + keyOwnerRobloxId: roblox_credentials.keyOwnerRobloxId, createdAt: roblox_credentials.createdAt, + lastUsed: roblox_credentials.lastUsed, + lastRefreshedAt: roblox_credentials.lastRefreshedAt, }; export type RobloxCredential = InferDrizzleSelect< typeof RobloxCredentialSelect diff --git a/src/lib/utils/team-utils.ts b/src/lib/utils/team-utils.ts index 4209993..90d3480 100644 --- a/src/lib/utils/team-utils.ts +++ b/src/lib/utils/team-utils.ts @@ -26,6 +26,7 @@ export const TEAM_PERMISSIONS = { DeleteRobloxCredential: ROLES_RANK.admin, RenameRobloxCredential: ROLES_RANK.admin, RotateRobloxCredential: ROLES_RANK.admin, + RefreshRobloxCredential: ROLES_RANK.admin, } as const satisfies Record; export type TeamAction = keyof typeof TEAM_PERMISSIONS; diff --git a/src/services/RobloxCredentialsService.ts b/src/services/RobloxCredentialsService.ts index 2503b4c..ea56e56 100644 --- a/src/services/RobloxCredentialsService.ts +++ b/src/services/RobloxCredentialsService.ts @@ -1,18 +1,175 @@ import { randomUUID } from "crypto"; import { db } from "../db"; import { roblox_credentials } from "../db/schema/roblox_credentials"; -import { EncryptString256 } from "../lib/crypto/aes"; +import { DecryptString256, EncryptString256 } from "../lib/crypto/aes"; import { RobloxCredential, RobloxCredentialInfo, RobloxCredentialSelect, + RobloxCredentialStatus, } from "../lib/types/roblox-credentials-types"; -import { AccessDenied, DatabaseError } from "../lib/utils/api-utils"; +import { AccessDenied, ApiError, DatabaseError } from "../lib/utils/api-utils"; import { hasPermission } from "../lib/utils/team-utils"; import { TeamService } from "./TeamService"; import { and, eq } from "drizzle-orm"; export const RobloxCredentialsService = { + // Internal Methods + async _introspectKey(key: string) { + const response = await fetch( + "https://apis.roblox.com/api-keys/v1/introspect", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ apiKey: key }), + }, + ); + + const data = await response.json(); + + if (!response.ok) { + throw new ApiError( + response.status, + "ROBLOX_INTROSPECT_ERROR", + data.message ?? + "There was an error while verifying your API key. Please try again later", + ); + } + + return data; + }, + + async _setKeyStatus( + credId: string, + info: { kind: RobloxCredentialStatus; message?: string }, + tx?: any, + ) { + const client = tx ?? db; + return await client + .update(roblox_credentials) + .set({ + status: info.kind, + errorMessage: info.kind == "healthy" ? null : (info.message ?? null), + }) + .where(eq(roblox_credentials.id, credId)); + }, + + async _useCredential( + credId: string, + callback: ( + key: string, + setStatus: ( + kind: RobloxCredentialStatus, + message?: string, + ) => Promise, + tx: Parameters[0]>[0], + ) => T | Promise, + ): Promise { + return await db.transaction(async (tx) => { + let keyInfo: typeof roblox_credentials.$inferSelect; + try { + [keyInfo] = await tx + .select() + .from(roblox_credentials) + .where(eq(roblox_credentials.id, credId)); + } catch { + throw DatabaseError; + } + + if (!keyInfo) { + throw AccessDenied; + } + + let decryptedKey; + try { + decryptedKey = DecryptString256({ + encryptedData: keyInfo.keyCiphertext, + initializationVector: keyInfo.keyIv, + authTag: keyInfo.keyTag, + }); + } catch { + throw new ApiError( + 500, + "ROBLOX_CRED_DECRYPTION_ERROR", + "There was an error while accessing your Roblox API Key. If the problem persists, please contact support.", + ); + } + + const setStatus = async ( + kind: RobloxCredentialStatus, + message?: string, + ) => { + try { + await this._setKeyStatus(credId, { kind, message: message }, tx); + } catch { + throw DatabaseError; + } + }; + + try { + await tx + .update(roblox_credentials) + .set({ lastUsed: new Date() }) + .where(eq(roblox_credentials.id, credId)); + } catch { + throw DatabaseError; + } + + return await callback(decryptedKey, setStatus, tx); + }); + }, + + async _applyIntrospectResults( + credId: string, + keyInfo: any, + tx?: Parameters[0]>[0], + ) { + let status: RobloxCredentialStatus = "healthy"; + let message: string | undefined = undefined; + + const client = tx ?? db; + + // Check if the credential is expiring soon (in less than a week) + const expDate = new Date(keyInfo.expirationUtcTime); + const now = Date.now(); + const ONE_WEEK_MS = 7 * 24 * 60 * 60 * 1000; + const isExpiringSoon = expDate.getTime() - now < ONE_WEEK_MS; + + if (!keyInfo.enabled) { + status = "error"; + message = "This key is disabled in the Roblox Creator Dashboard"; + } else if (keyInfo.expired) { + status = "error"; + message = "This key has reached its expiration date"; + } else if (isExpiringSoon) { + status = "warning"; + message = "This key expires in less than a week"; + } + + // Update the credential row with the new introspect data + try { + const [updated] = await client + .update(roblox_credentials) + .set({ + expirationDate: keyInfo.expirationUtcTime + ? new Date(keyInfo.expirationUtcTime) + : null, + lastRefreshedAt: new Date(), + status, + errorMessage: message ?? null, + keyOwnerRobloxId: keyInfo.authorizedUserId, + }) + .where(eq(roblox_credentials.id, credId)) + .returning(RobloxCredentialSelect); + + // Return immediately the updated credential + return updated; + } catch { + throw DatabaseError; + } + }, + + // API Methods async LinkRobloxCredential( actorId: string, teamId: string, @@ -24,6 +181,18 @@ export const RobloxCredentialsService = { throw AccessDenied; } + let finalStatus: RobloxCredentialStatus = "healthy"; + let message; + const keyInfo = await this._introspectKey(creds.key); + + if (!keyInfo.enabled) { + finalStatus = "error"; + message = "This key is disabled in the Roblox Creator Dashboard"; + } else if (keyInfo.expired) { + finalStatus = "error"; + message = "This key has reached it's expiration date"; + } + const encodedKey = EncryptString256(creds.key); try { const [newRecord] = await db @@ -32,9 +201,15 @@ export const RobloxCredentialsService = { id: randomUUID(), teamId, name: creds.name, + status: finalStatus, + errorMessage: message, keyCiphertext: encodedKey.encryptedData, keyIv: encodedKey.initializationVector, keyTag: encodedKey.authTag, + expirationDate: keyInfo.expirationUtcTime + ? new Date(keyInfo.expirationUtcTime) + : null, + keyOwnerRobloxId: keyInfo.authorizedUserId, createdBy: actorId, }) .returning(RobloxCredentialSelect); @@ -106,31 +281,40 @@ export const RobloxCredentialsService = { teamId: string, credId: string, newKey: string, - ): Promise { + ): Promise { const actorRole = await TeamService.GetTeamUserRole(actorId, teamId); if (!hasPermission(actorRole, "RotateRobloxCredential")) { throw AccessDenied; } + const keyInfo = await this._introspectKey(newKey); + if (!keyInfo.enabled) { + throw new ApiError( + 400, + "ROTATION_FAILED", + "The new key is disabled. Please enable the key in the Roblox Creator Dashboard before rotating", + ); + } else if (keyInfo.expired) { + throw new ApiError( + 400, + "ROTATION_FAILED", + "The new key is expired. Please refresh it in the Roblox Creator Dashboard before rotating", + ); + } + const newKeyEncrypted = EncryptString256(newKey); - try { - await db + return await db.transaction(async (tx) => { + await tx .update(roblox_credentials) .set({ keyCiphertext: newKeyEncrypted.encryptedData, keyIv: newKeyEncrypted.initializationVector, keyTag: newKeyEncrypted.authTag, }) - .where( - and( - eq(roblox_credentials.id, credId), - eq(roblox_credentials.teamId, teamId), - ), - ) - .returning(RobloxCredentialSelect); - } catch { - throw DatabaseError; - } + .where(eq(roblox_credentials.id, credId)); + + return this._applyIntrospectResults(credId, keyInfo, tx); + }); }, async ListTeamRobloxCredentials( @@ -152,4 +336,61 @@ export const RobloxCredentialsService = { throw DatabaseError; } }, + + async RefreshRobloxCredential( + actorId: string, + teamId: string, + credId: string, + ): Promise { + const actorRole = await TeamService.GetTeamUserRole(actorId, teamId); + if (!hasPermission(actorRole, "RefreshRobloxCredential")) { + throw AccessDenied; + } + + return await this._useCredential(credId, async (key, setStatus, tx) => { + // Try to fetch the credential info using _introspectKey + let keyInfo; + try { + keyInfo = await this._introspectKey(key); + } catch (error) { + if (error instanceof ApiError) { + if (error.status === 429) { + await setStatus("warning", "Roblox is rate-limiting this key"); + } else if (error.status >= 500) { + await setStatus( + "warning", + "Roblox servers are currently unreachable", + ); + } else { + await setStatus("error", error.clientMessage); + } + } else { + throw error; + } + } + + // Update credential row + // Skip if _introspectKey rejected + if (keyInfo) { + try { + const updatedCred = await this._applyIntrospectResults( + credId, + keyInfo, + tx, + ); + if (updatedCred) return updatedCred; + } catch (error) {} + } + + // Return the final credential info (fallback for when _introspectKey rejected) + const result = await tx + .select(RobloxCredentialSelect) + .from(roblox_credentials) + .where(eq(roblox_credentials.id, credId)) + .get(); + + if (!result) throw DatabaseError; + return result; + }); + }, }; From 7ffda684d9502f303a9ee62ca4696e955a16cacc Mon Sep 17 00:00:00 2001 From: OverDsh Date: Sun, 15 Feb 2026 15:50:40 +0100 Subject: [PATCH 21/40] feat: implement credential status on the frontend --- .../connections/RobloxCredentialColumn.tsx | 104 ++++++++++++++---- src/components/LocalTime.tsx | 29 ++++- src/components/StatusBadge.tsx | 95 ++++++++++++++++ 3 files changed, 208 insertions(+), 20 deletions(-) create mode 100644 src/components/StatusBadge.tsx diff --git a/src/app/dashboard/[teamSlug]/connections/RobloxCredentialColumn.tsx b/src/app/dashboard/[teamSlug]/connections/RobloxCredentialColumn.tsx index 34bfb04..c2a3ec5 100644 --- a/src/app/dashboard/[teamSlug]/connections/RobloxCredentialColumn.tsx +++ b/src/app/dashboard/[teamSlug]/connections/RobloxCredentialColumn.tsx @@ -1,6 +1,7 @@ import CallbackDialog from "@/src/components/CallbackDialog"; import FormDialog from "@/src/components/FormDialog"; import LocalTime from "@/src/components/LocalTime"; +import StatusBadge from "@/src/components/StatusBadge"; import { Button } from "@/src/components/ui/button"; import { DropdownMenuContent, @@ -8,13 +9,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/src/components/ui/dropdown-menu"; -import { - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/src/components/ui/form"; +import { FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/src/components/ui/form"; import { Input } from "@/src/components/ui/input"; import { Skeleton } from "@/src/components/ui/skeleton"; import { useRobloxCredentialMutations } from "@/src/hooks/useRobloxCredential"; @@ -28,7 +23,7 @@ import { hasPermission } from "@/src/lib/utils/team-utils"; import { zodResolver } from "@hookform/resolvers/zod"; import { DropdownMenu } from "@radix-ui/react-dropdown-menu"; import { ColumnDef } from "@tanstack/react-table"; -import { MoreHorizontal } from "lucide-react"; +import { Copy, MoreHorizontal, Pencil, RefreshCw, RotateCcwKey, Trash } from "lucide-react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; @@ -149,9 +144,53 @@ export const robloxCredentialColumn: ColumnDef[] = [ accessorKey: "name", header: "Name", }, + { + accessorKey: "status", + header: "Status", + cell: ({ row }) => { + const cred = row.original; + const { data: team, isLoading } = useTeam(); + + if (!team || isLoading) { + return ( +

+ +
+ ); + } + + return ( + + ); + }, + }, + { + accessorKey: "expirationDate", + header: "Expires", + cell: ({ getValue }) => { + const time = getValue(); + if (!!time) { + return ; + } else { + return "Never"; + } + }, + }, { accessorKey: "createdAt", - header: "Created At", + header: "Created", + cell: ({ getValue }) => { + const time = new Date(getValue()); + return ; + }, + }, + { + accessorKey: "lastUsed", + header: "Last Used", cell: ({ getValue }) => { const time = new Date(getValue()); return ; @@ -162,7 +201,7 @@ export const robloxCredentialColumn: ColumnDef[] = [ cell: ({ row }) => { const cred = row.original; const { data: team, isLoading } = useTeam(); - const { deleteRobloxCredential } = useRobloxCredentialMutations(); + const { deleteRobloxCredential, refreshRobloxCredential } = useRobloxCredentialMutations(); if (!team || isLoading) { return ( @@ -172,6 +211,25 @@ export const robloxCredentialColumn: ColumnDef[] = [ ); } + const handleKeyRefresh = () => { + const id = toast.loading("Refreshing Roblox credential info..."); + refreshRobloxCredential + .mutateAsync({ teamId: team.id, credId: cred.id }) + .then(() => { + toast.success("Successfully refreshed Roblox credential info!", { id }); + }) + .catch((error) => { + if (error instanceof Error) { + toast.error(error.message, { id }); + } else { + toast.error( + "An unknown error happened while refreshing Roblox credential. Please try again later.", + { id }, + ); + } + }); + }; + return (
@@ -182,11 +240,18 @@ export const robloxCredentialColumn: ColumnDef[] = [ + navigator.clipboard.writeText(cred.id)}> + + Copy Key ID + navigator.clipboard.writeText(cred.id)} + disabled={!hasPermission(team.role, "RefreshRobloxCredential")} + onClick={() => handleKeyRefresh()} > - Copy Roblox Credential ID + + Refresh Key + [] = [ e.preventDefault(); }} > - Rename Roblox Credential + + Rename Key - [] = [ e.preventDefault(); }} > - Rotate Roblox Credential + + Rotate Key + [] = [ trigger={ { e.preventDefault(); }} > - Delete Roblox Credential + + Delete Key } /> diff --git a/src/components/LocalTime.tsx b/src/components/LocalTime.tsx index 2ff9527..72c13b4 100644 --- a/src/components/LocalTime.tsx +++ b/src/components/LocalTime.tsx @@ -9,7 +9,24 @@ const formatter = Intl.DateTimeFormat(undefined, { minute: "2-digit", }); -export default function LocalTime({ time }: { time: Date }) { +const relativeFormatter = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" }); + +const units: { unit: Intl.RelativeTimeFormatUnit; amount: number }[] = [ + { unit: "year", amount: 365 * 24 * 60 * 60 }, + { unit: "month", amount: 30 * 24 * 60 * 60 }, + { unit: "day", amount: 24 * 60 * 60 }, + { unit: "hour", amount: 60 * 60 }, + { unit: "minute", amount: 60 }, + { unit: "second", amount: 1 }, +]; + +export default function LocalTime({ + time, + mode = "relative", +}: { + time: Date; + mode?: "absolute" | "relative"; +}) { const [isMounted, setIsMounted] = useState(false); useEffect(() => { @@ -20,5 +37,15 @@ export default function LocalTime({ time }: { time: Date }) { return Loading...; } + if (mode === "relative") { + const diffInSec = Math.floor((time.getTime() - Date.now()) / 1000); + for (const { unit, amount } of units) { + if (Math.abs(diffInSec) >= amount) { + const timeString = relativeFormatter.format(Math.floor(diffInSec / amount), unit); + return {timeString.charAt(0).toUpperCase() + timeString.slice(1)}; + } + } + } + return {formatter.format(time)}; } diff --git a/src/components/StatusBadge.tsx b/src/components/StatusBadge.tsx new file mode 100644 index 0000000..f57345a --- /dev/null +++ b/src/components/StatusBadge.tsx @@ -0,0 +1,95 @@ +import React from "react"; +import { RobloxCredentialStatus } from "../lib/types/roblox-credentials-types"; +import { Badge } from "./ui/badge"; +import { Info } from "lucide-react"; +import { + Popover, + PopoverContent, + PopoverDescription, + PopoverHeader, + PopoverTitle, + PopoverTrigger, +} from "./ui/popover"; +import LocalTime from "./LocalTime"; + +type StatusConfigType = { + label: string; + className: string; + Icon?: React.ReactNode; + popoverTitle: string; + popoverDescription?: string; +}; + +interface StatusBadgeProps { + kind: RobloxCredentialStatus; + errorMessage?: string; + lastRefreshed: Date; +} +export default function StatusBadge({ kind, errorMessage, lastRefreshed }: StatusBadgeProps) { + const statusConfig: Record = { + healthy: { + label: "Healthy", + className: `bg-green-50 text-green-700 dark:bg-green-950 dark:text-green-300 cursor-pointer`, + popoverTitle: "System Operational", + }, + warning: { + label: "Warning", + className: `bg-yellow-700 text-orange-200 cursor-pointer`, + Icon: , + popoverTitle: "Attention Required", + popoverDescription: + errorMessage ?? "A non-critical issue was detected. No further details available.", + }, + error: { + label: "Error", + className: `bg-red-50 text-red-700 dark:bg-red-950 dark:text-red-300 cursor-pointer`, + popoverTitle: "Action Required", + popoverDescription: + errorMessage ?? + "A critical connection issue occured. Please check your key in the Roblox Creator Dashboard", + }, + }; + + const config = statusConfig[kind] ?? statusConfig.healthy; + const BadgeElement = React.forwardRef>( + ({ className, onClick, ...props }, ref) => { + return ( +
{ + onClick?.(e); + }} + > + + {config.label} + {config.Icon} + +
+ ); + }, + ); + + return ( + + + + + + + {config.popoverTitle} + {config.popoverDescription && ( + {config.popoverDescription} + )} + {lastRefreshed && ( + + Last checked: + + + )} + + + + ); +} From 1dc58606694962bd3824169da42a57eb2a70803b Mon Sep 17 00:00:00 2001 From: OverDsh Date: Sun, 15 Feb 2026 16:16:57 +0100 Subject: [PATCH 22/40] fix: fix request object being cleared by the linter because treated as unused --- src/app/api/teams/[teamId]/databases/[databaseId]/route.ts | 2 +- src/app/api/teams/[teamId]/databases/route.ts | 2 +- src/app/api/teams/[teamId]/members/[memberId]/route.ts | 2 +- src/app/api/teams/[teamId]/members/route.ts | 2 +- .../[teamId]/roblox-credentials/[credentialId]/refresh/route.ts | 2 +- .../teams/[teamId]/roblox-credentials/[credentialId]/route.ts | 2 +- src/app/api/teams/[teamId]/roblox-credentials/route.ts | 2 +- src/app/api/teams/[teamId]/route.ts | 2 +- src/app/api/teams/resolve-slug/[slug]/route.ts | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/app/api/teams/[teamId]/databases/[databaseId]/route.ts b/src/app/api/teams/[teamId]/databases/[databaseId]/route.ts index 5e305e0..3b2af2c 100644 --- a/src/app/api/teams/[teamId]/databases/[databaseId]/route.ts +++ b/src/app/api/teams/[teamId]/databases/[databaseId]/route.ts @@ -12,7 +12,7 @@ interface Context { }>; } -export async function DELETE(context: Context) { +export async function DELETE(_: Request, context: Context) { const params = await context.params; const databaseId = params.databaseId; diff --git a/src/app/api/teams/[teamId]/databases/route.ts b/src/app/api/teams/[teamId]/databases/route.ts index 0e7ec42..c0db10a 100644 --- a/src/app/api/teams/[teamId]/databases/route.ts +++ b/src/app/api/teams/[teamId]/databases/route.ts @@ -11,7 +11,7 @@ interface Context { }>; } -export async function GET(req: Request, context: Context) { +export async function GET(_: Request, context: Context) { const params = await context.params; const teamId = params.teamId; diff --git a/src/app/api/teams/[teamId]/members/[memberId]/route.ts b/src/app/api/teams/[teamId]/members/[memberId]/route.ts index ef4b82e..b218dec 100644 --- a/src/app/api/teams/[teamId]/members/[memberId]/route.ts +++ b/src/app/api/teams/[teamId]/members/[memberId]/route.ts @@ -13,7 +13,7 @@ interface Context { }>; } -export async function DELETE(req: Request, context: Context) { +export async function DELETE(_: Request, context: Context) { const params = await context.params; const teamId = params.teamId; const memberId = params.memberId; diff --git a/src/app/api/teams/[teamId]/members/route.ts b/src/app/api/teams/[teamId]/members/route.ts index a6bdb79..78188b8 100644 --- a/src/app/api/teams/[teamId]/members/route.ts +++ b/src/app/api/teams/[teamId]/members/route.ts @@ -10,7 +10,7 @@ interface Context { }>; } -export async function GET(req: Request, context: Context) { +export async function GET(_: Request, context: Context) { const params = await context.params; const teamId = params.teamId; diff --git a/src/app/api/teams/[teamId]/roblox-credentials/[credentialId]/refresh/route.ts b/src/app/api/teams/[teamId]/roblox-credentials/[credentialId]/refresh/route.ts index be245ba..24fa55c 100644 --- a/src/app/api/teams/[teamId]/roblox-credentials/[credentialId]/refresh/route.ts +++ b/src/app/api/teams/[teamId]/roblox-credentials/[credentialId]/refresh/route.ts @@ -11,7 +11,7 @@ interface Context { }>; } -export async function POST(context: Context) { +export async function POST(_: Request, context: Context) { const { teamId, credentialId } = await context.params; if (!teamId) { return NextResponse.json({ error: "Team ID is required" }, { status: 400 }); diff --git a/src/app/api/teams/[teamId]/roblox-credentials/[credentialId]/route.ts b/src/app/api/teams/[teamId]/roblox-credentials/[credentialId]/route.ts index e26c8fb..1033273 100644 --- a/src/app/api/teams/[teamId]/roblox-credentials/[credentialId]/route.ts +++ b/src/app/api/teams/[teamId]/roblox-credentials/[credentialId]/route.ts @@ -15,7 +15,7 @@ interface Context { }>; } -export async function DELETE(context: Context) { +export async function DELETE(_: Request, context: Context) { const { teamId, credentialId } = await context.params; if (!teamId) { return NextResponse.json({ error: "Team ID is required" }, { status: 400 }); diff --git a/src/app/api/teams/[teamId]/roblox-credentials/route.ts b/src/app/api/teams/[teamId]/roblox-credentials/route.ts index c40db9d..f19d054 100644 --- a/src/app/api/teams/[teamId]/roblox-credentials/route.ts +++ b/src/app/api/teams/[teamId]/roblox-credentials/route.ts @@ -11,7 +11,7 @@ interface Context { }>; } -export async function GET(req: Request, context: Context) { +export async function GET(_: Request, context: Context) { const { teamId } = await context.params; if (!teamId) { return NextResponse.json({ error: "Team ID is required" }, { status: 400 }); diff --git a/src/app/api/teams/[teamId]/route.ts b/src/app/api/teams/[teamId]/route.ts index 9f4f41a..d13f35e 100644 --- a/src/app/api/teams/[teamId]/route.ts +++ b/src/app/api/teams/[teamId]/route.ts @@ -11,7 +11,7 @@ interface Context { }>; } -export async function DELETE(req: Request, context: Context) { +export async function DELETE(_: Request, context: Context) { const params = await context.params; const teamId = params.teamId; diff --git a/src/app/api/teams/resolve-slug/[slug]/route.ts b/src/app/api/teams/resolve-slug/[slug]/route.ts index 858aafc..c9f38e0 100644 --- a/src/app/api/teams/resolve-slug/[slug]/route.ts +++ b/src/app/api/teams/resolve-slug/[slug]/route.ts @@ -10,7 +10,7 @@ interface Context { }>; } -export async function GET(req: Request, context: Context) { +export async function GET(_: Request, context: Context) { const params = await context.params; const slug = params.slug; From 93cf03dc4b9bba955618a2ede00b2de69f9a8413 Mon Sep 17 00:00:00 2001 From: OverDsh Date: Sun, 15 Feb 2026 16:35:28 +0100 Subject: [PATCH 23/40] enhancement: change time format to be consistent and add View Profile button --- .../connections/RobloxCredentialColumn.tsx | 29 +++++++++++++++---- src/components/LocalTime.tsx | 16 ++-------- 2 files changed, 25 insertions(+), 20 deletions(-) diff --git a/src/app/dashboard/[teamSlug]/connections/RobloxCredentialColumn.tsx b/src/app/dashboard/[teamSlug]/connections/RobloxCredentialColumn.tsx index c2a3ec5..7a2b0db 100644 --- a/src/app/dashboard/[teamSlug]/connections/RobloxCredentialColumn.tsx +++ b/src/app/dashboard/[teamSlug]/connections/RobloxCredentialColumn.tsx @@ -24,6 +24,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { DropdownMenu } from "@radix-ui/react-dropdown-menu"; import { ColumnDef } from "@tanstack/react-table"; import { Copy, MoreHorizontal, Pencil, RefreshCw, RotateCcwKey, Trash } from "lucide-react"; +import Link from "next/link"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; @@ -144,6 +145,22 @@ export const robloxCredentialColumn: ColumnDef[] = [ accessorKey: "name", header: "Name", }, + { + accessorKey: "keyOwnerRobloxId", + header: "Roblox User", + cell: ({ getValue }) => { + const userId = getValue(); + return ( + + View Profile + + ); + }, + }, { accessorKey: "status", header: "Status", @@ -170,7 +187,7 @@ export const robloxCredentialColumn: ColumnDef[] = [ }, { accessorKey: "expirationDate", - header: "Expires", + header: "Expires In", cell: ({ getValue }) => { const time = getValue(); if (!!time) { @@ -181,19 +198,19 @@ export const robloxCredentialColumn: ColumnDef[] = [ }, }, { - accessorKey: "createdAt", - header: "Created", + accessorKey: "lastUsed", + header: "Last Used", cell: ({ getValue }) => { const time = new Date(getValue()); return ; }, }, { - accessorKey: "lastUsed", - header: "Last Used", + accessorKey: "createdAt", + header: "Created", cell: ({ getValue }) => { const time = new Date(getValue()); - return ; + return ; }, }, { diff --git a/src/components/LocalTime.tsx b/src/components/LocalTime.tsx index 72c13b4..c31edf7 100644 --- a/src/components/LocalTime.tsx +++ b/src/components/LocalTime.tsx @@ -1,15 +1,13 @@ "use client"; import { useEffect, useState } from "react"; -const formatter = Intl.DateTimeFormat(undefined, { +const formatter = Intl.DateTimeFormat("en-US", { day: "2-digit", month: "short", year: "numeric", - hour: "2-digit", - minute: "2-digit", }); -const relativeFormatter = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" }); +const relativeFormatter = new Intl.RelativeTimeFormat("en-US", { numeric: "auto" }); const units: { unit: Intl.RelativeTimeFormatUnit; amount: number }[] = [ { unit: "year", amount: 365 * 24 * 60 * 60 }, @@ -27,16 +25,6 @@ export default function LocalTime({ time: Date; mode?: "absolute" | "relative"; }) { - const [isMounted, setIsMounted] = useState(false); - - useEffect(() => { - setIsMounted(true); - }, []); - - if (!isMounted) { - return Loading...; - } - if (mode === "relative") { const diffInSec = Math.floor((time.getTime() - Date.now()) / 1000); for (const { unit, amount } of units) { From 800be1ce915ca4ca87f651815743ddd4310dfd79 Mon Sep 17 00:00:00 2001 From: OverDsh Date: Sun, 15 Feb 2026 21:08:56 +0100 Subject: [PATCH 24/40] fix: add icon to error badge --- src/components/StatusBadge.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/StatusBadge.tsx b/src/components/StatusBadge.tsx index f57345a..a3a20da 100644 --- a/src/components/StatusBadge.tsx +++ b/src/components/StatusBadge.tsx @@ -43,6 +43,7 @@ export default function StatusBadge({ kind, errorMessage, lastRefreshed }: Statu error: { label: "Error", className: `bg-red-50 text-red-700 dark:bg-red-950 dark:text-red-300 cursor-pointer`, + Icon: , popoverTitle: "Action Required", popoverDescription: errorMessage ?? From 6e08cab36d9fedea0c720c6bf0b65cc13493227e Mon Sep 17 00:00:00 2001 From: OverDsh Date: Mon, 16 Feb 2026 21:04:53 +0100 Subject: [PATCH 25/40] enhancement: changed popover style --- src/components/StatusBadge.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/components/StatusBadge.tsx b/src/components/StatusBadge.tsx index a3a20da..487b255 100644 --- a/src/components/StatusBadge.tsx +++ b/src/components/StatusBadge.tsx @@ -79,12 +79,14 @@ export default function StatusBadge({ kind, errorMessage, lastRefreshed }: Statu - {config.popoverTitle} + + {config.popoverTitle} + {config.popoverDescription && ( {config.popoverDescription} )} {lastRefreshed && ( - + Last checked: From e418c33f114350d1bdad8813bfdcd424d2377161 Mon Sep 17 00:00:00 2001 From: OverDsh Date: Mon, 16 Feb 2026 21:07:53 +0100 Subject: [PATCH 26/40] fix: list roblox credential checked permission for rename roblox credential --- src/services/RobloxCredentialsService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/RobloxCredentialsService.ts b/src/services/RobloxCredentialsService.ts index ea56e56..a87d92c 100644 --- a/src/services/RobloxCredentialsService.ts +++ b/src/services/RobloxCredentialsService.ts @@ -322,7 +322,7 @@ export const RobloxCredentialsService = { teamId: string, ): Promise { const actorRole = await TeamService.GetTeamUserRole(actorId, teamId); - if (!hasPermission(actorRole, "RenameRobloxCredential")) { + if (!hasPermission(actorRole, "ListRobloxCredentials")) { throw AccessDenied; } From 9f377ff50276c49b01909bf4bc983e534d2693d6 Mon Sep 17 00:00:00 2001 From: OverDsh Date: Mon, 16 Feb 2026 21:08:46 +0100 Subject: [PATCH 27/40] feat: dynamically show databases/roblox credentials data table based on permissions --- .../[teamSlug]/components/TeamSidebar.tsx | 11 +- .../dashboard/[teamSlug]/connections/page.tsx | 123 ++++++++++-------- src/hooks/useDatabase.ts | 6 - src/hooks/useRobloxCredential.ts | 6 - 4 files changed, 74 insertions(+), 72 deletions(-) diff --git a/src/app/dashboard/[teamSlug]/components/TeamSidebar.tsx b/src/app/dashboard/[teamSlug]/components/TeamSidebar.tsx index 6f3d92f..2f923b2 100644 --- a/src/app/dashboard/[teamSlug]/components/TeamSidebar.tsx +++ b/src/app/dashboard/[teamSlug]/components/TeamSidebar.tsx @@ -1,7 +1,5 @@ "use client"; -import NavigationSidebar, { - ItemGroup, -} from "@/src/components/NavigationSidebar"; +import NavigationSidebar, { ItemGroup } from "@/src/components/NavigationSidebar"; import { useTeam } from "@/src/hooks/useTeam"; import { hasPermission } from "@/src/lib/utils/team-utils"; import { Box, Cable, Settings, User } from "lucide-react"; @@ -39,8 +37,11 @@ export default function TeamSidebar() { url: "/connections", }, ].filter((item) => { - if (item.title === "Databases") { - return hasPermission(data?.role, "ListDatabases"); + if (item.title === "Connections") { + return ( + hasPermission(data?.role, "ListDatabases") || + hasPermission(data?.role, "ListRobloxCredentials") + ); } return true; }), diff --git a/src/app/dashboard/[teamSlug]/connections/page.tsx b/src/app/dashboard/[teamSlug]/connections/page.tsx index 0034bad..39f3722 100644 --- a/src/app/dashboard/[teamSlug]/connections/page.tsx +++ b/src/app/dashboard/[teamSlug]/connections/page.tsx @@ -4,77 +4,90 @@ import { useDatabases } from "@/src/hooks/useDatabase"; import { databaseColumn } from "./DatabaseColumn"; import { Button } from "@/src/components/ui/button"; import { Plus } from "lucide-react"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import LinkDatabaseDialog from "./LinkDialog/LinkDatabaseDialog"; import { hasPermission } from "@/src/lib/utils/team-utils"; import { useTeam } from "@/src/hooks/useTeam"; import { robloxCredentialColumn } from "./RobloxCredentialColumn"; import LinkRobloxCredentialDialog from "./LinkRoCredDialog"; import { useRobloxCredentials } from "@/src/hooks/useRobloxCredential"; +import { useRouter } from "next/navigation"; export default function Page() { const [isLinkDbOpen, setIsLinkDbOpen] = useState(false); const [isLinkCredOpen, setIsLinkCredOpen] = useState(false); + const router = useRouter(); const { data: team } = useTeam(); const { data: dbData, isLoading: isDbLoading } = useDatabases(); const { data: credData, isLoading: isCredLoading } = useRobloxCredentials(); + useEffect(() => { + if (!team) return; + if ( + !hasPermission(team.role, "ListDatabases") && + !hasPermission(team.role, "ListRobloxCredentials") + ) { + router.replace(`/dashboard/${team.slug}`); + } + }, [team]); + return ( <> - - Connections - -
- Databases - setIsLinkDbOpen(true)} - disabled={!hasPermission(team?.role, "LinkDatabase")} - > - - Link Database - - } - /> -
- -
- - Roblox Credentials - - setIsLinkCredOpen(true)} - disabled={!hasPermission(team?.role, "LinkRobloxCredential")} - > - - Link Roblox Credential - - } - /> -
- + Connections + {hasPermission(team?.role, "ListDatabases") && ( + <> +
+ Databases + setIsLinkDbOpen(true)} + disabled={!hasPermission(team?.role, "LinkDatabase")} + > + + Link Database + + } + /> +
+ + + )} + {hasPermission(team?.role, "ListRobloxCredentials") && ( + <> +
+ Roblox Credentials + setIsLinkCredOpen(true)} + disabled={!hasPermission(team?.role, "LinkRobloxCredential")} + > + + Link Roblox Credential + + } + /> +
+ + + )} ); } diff --git a/src/hooks/useDatabase.ts b/src/hooks/useDatabase.ts index 91c8127..48851c5 100644 --- a/src/hooks/useDatabase.ts +++ b/src/hooks/useDatabase.ts @@ -21,12 +21,6 @@ export function useDatabases() { staleTime: 5 * 60 * 1000, }); - useEffect(() => { - if (team && !hasPermission(team.role, "ListDatabases")) { - router.replace(`/dashboard/${team.slug}/`); - } - }, [team]); - const isLoading = isLoadingTeam || query.isLoading; const isError = isErrorTeam || query.isError; diff --git a/src/hooks/useRobloxCredential.ts b/src/hooks/useRobloxCredential.ts index 9c662ff..6897ae9 100644 --- a/src/hooks/useRobloxCredential.ts +++ b/src/hooks/useRobloxCredential.ts @@ -24,12 +24,6 @@ export function useRobloxCredentials() { staleTime: 5 * 60 * 1000, }); - useEffect(() => { - if (team && !hasPermission(team.role, "ListRobloxCredentials")) { - router.replace(`/dashboard/${team.slug}`); - } - }, [team]); - const isLoading = isTeamLoading || query.isLoading; const isError = isTeamError || query.isError; From 7b2f7cb779bccc97203136eb0024eb37901a7468 Mon Sep 17 00:00:00 2001 From: OverDsh Date: Thu, 5 Mar 2026 21:33:54 +0100 Subject: [PATCH 28/40] fix: fix wrong key lookup for expiration date in the introspection table in RobloxCredentialService --- src/services/RobloxCredentialsService.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/services/RobloxCredentialsService.ts b/src/services/RobloxCredentialsService.ts index a87d92c..041efc8 100644 --- a/src/services/RobloxCredentialsService.ts +++ b/src/services/RobloxCredentialsService.ts @@ -130,7 +130,7 @@ export const RobloxCredentialsService = { const client = tx ?? db; // Check if the credential is expiring soon (in less than a week) - const expDate = new Date(keyInfo.expirationUtcTime); + const expDate = new Date(keyInfo.expirationTimeUtc); const now = Date.now(); const ONE_WEEK_MS = 7 * 24 * 60 * 60 * 1000; const isExpiringSoon = expDate.getTime() - now < ONE_WEEK_MS; @@ -151,9 +151,9 @@ export const RobloxCredentialsService = { const [updated] = await client .update(roblox_credentials) .set({ - expirationDate: keyInfo.expirationUtcTime - ? new Date(keyInfo.expirationUtcTime) - : null, + ...(keyInfo.expirationTimeUtc !== undefined + ? { expirationDate: new Date(keyInfo.expirationTimeUtc) } + : {}), lastRefreshedAt: new Date(), status, errorMessage: message ?? null, @@ -206,8 +206,8 @@ export const RobloxCredentialsService = { keyCiphertext: encodedKey.encryptedData, keyIv: encodedKey.initializationVector, keyTag: encodedKey.authTag, - expirationDate: keyInfo.expirationUtcTime - ? new Date(keyInfo.expirationUtcTime) + expirationDate: keyInfo.expirationTimeUtc + ? new Date(keyInfo.expirationTimeUtc) : null, keyOwnerRobloxId: keyInfo.authorizedUserId, createdBy: actorId, From 2e00712874b61dde38a2e89893a0d34067412f86 Mon Sep 17 00:00:00 2001 From: OverDsh Date: Sat, 7 Mar 2026 12:51:02 +0100 Subject: [PATCH 29/40] refactor: move roblox credential status error message to roblox-credentials-types.ts --- src/lib/types/roblox-credentials-types.ts | 8 ++++++++ src/services/RobloxCredentialsService.ts | 18 ++++++++---------- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/src/lib/types/roblox-credentials-types.ts b/src/lib/types/roblox-credentials-types.ts index 75ffb4b..7589547 100644 --- a/src/lib/types/roblox-credentials-types.ts +++ b/src/lib/types/roblox-credentials-types.ts @@ -52,3 +52,11 @@ export const RobloxCredentialRotateSchema = z.object({ .min(1, { error: "Key must contain at least 1 character" }) .max(2048, { error: "Key must be at most 2048 characters" }), }); + +export const roblox_credential_status = { + expires_soon: "This key expires in less than a week", + expired: "This key has reached its expiration date", + disabled: "This key is disabled in the Roblox Creator Dashboard", + rate_limit: "Roblox is rate-limiting this key", + roblox_down: "Roblox servers are currently unreachable", +}; diff --git a/src/services/RobloxCredentialsService.ts b/src/services/RobloxCredentialsService.ts index 041efc8..f4a2274 100644 --- a/src/services/RobloxCredentialsService.ts +++ b/src/services/RobloxCredentialsService.ts @@ -3,6 +3,7 @@ import { db } from "../db"; import { roblox_credentials } from "../db/schema/roblox_credentials"; import { DecryptString256, EncryptString256 } from "../lib/crypto/aes"; import { + roblox_credential_status, RobloxCredential, RobloxCredentialInfo, RobloxCredentialSelect, @@ -137,13 +138,13 @@ export const RobloxCredentialsService = { if (!keyInfo.enabled) { status = "error"; - message = "This key is disabled in the Roblox Creator Dashboard"; + message = roblox_credential_status.disabled; } else if (keyInfo.expired) { status = "error"; - message = "This key has reached its expiration date"; + message = roblox_credential_status.expired; } else if (isExpiringSoon) { status = "warning"; - message = "This key expires in less than a week"; + message = roblox_credential_status.expires_soon; } // Update the credential row with the new introspect data @@ -187,10 +188,10 @@ export const RobloxCredentialsService = { if (!keyInfo.enabled) { finalStatus = "error"; - message = "This key is disabled in the Roblox Creator Dashboard"; + message = roblox_credential_status.disabled; } else if (keyInfo.expired) { finalStatus = "error"; - message = "This key has reached it's expiration date"; + message = roblox_credential_status.expired; } const encodedKey = EncryptString256(creds.key); @@ -355,12 +356,9 @@ export const RobloxCredentialsService = { } catch (error) { if (error instanceof ApiError) { if (error.status === 429) { - await setStatus("warning", "Roblox is rate-limiting this key"); + await setStatus("warning", roblox_credential_status.rate_limit); } else if (error.status >= 500) { - await setStatus( - "warning", - "Roblox servers are currently unreachable", - ); + await setStatus("warning", roblox_credential_status.roblox_down); } else { await setStatus("error", error.clientMessage); } From 26f2ed4704012a64fecc574b41e0d0752f567fd0 Mon Sep 17 00:00:00 2001 From: OverDsh Date: Wed, 18 Mar 2026 18:35:30 +0100 Subject: [PATCH 30/40] feat: add database introspection, refresh, and rotate credentials to ExternalDatabaseService --- src/db/schema/database.ts | 15 ++ src/lib/types/database-types.ts | 26 ++- src/lib/utils/team-utils.ts | 2 + src/services/ExternalDatabaseService.ts | 265 +++++++++++++++++++++++- 4 files changed, 304 insertions(+), 4 deletions(-) diff --git a/src/db/schema/database.ts b/src/db/schema/database.ts index f23ba7f..6d275a2 100755 --- a/src/db/schema/database.ts +++ b/src/db/schema/database.ts @@ -8,6 +8,12 @@ export const database = sqliteTable("database", { teamId: text("team_id") .notNull() .references(() => team.id, { onDelete: "cascade" }), + + status: text("status", { enum: ["healthy", "warning", "error"] }) + .notNull() + .default("healthy"), + errorMessage: text("error_message"), + type: text("type", { enum: ["S3"] }) .notNull() .default("S3"), @@ -24,6 +30,15 @@ export const database = sqliteTable("database", { skIv: text("sk_iv").notNull(), skTag: text("sk_tag").notNull(), + lastUsed: integer("last_used", { mode: "timestamp_ms" }) + .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`) + .notNull(), + lastRefreshedAt: integer("last_refreshed_at", { + mode: "timestamp_ms", + }) + .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`) + .notNull(), + createdAt: integer("createdAt", { mode: "timestamp_ms" }) .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`) .notNull(), diff --git a/src/lib/types/database-types.ts b/src/lib/types/database-types.ts index 1d76943..8e5d890 100644 --- a/src/lib/types/database-types.ts +++ b/src/lib/types/database-types.ts @@ -1,18 +1,42 @@ import { database } from "@/src/db/schema"; import { InferDrizzleSelect } from "../utils"; +import z from "zod"; + +export type DatabaseStatus = typeof database.$inferSelect.status; + +export const database_status = { + connection_failed: "Could not connect to the S3 bucket", + invalid_credentials: "Invalid credentials or insufficient bucket permissions", + bucket_not_found: "The specified bucket does not exist or is not accessible", + endpoint_unreachable: "The endpoint URL is unreachable", + rate_limited: "The S3 endpoint is rate limiting requests", + s3_down: "The S3 service or endpoint is currently unavailable", +}; export const DatabaseSelect = { id: database.id, teamId: database.teamId, - createdBy: database.createdBy, + + status: database.status, + errorMessage: database.errorMessage, + name: database.name, endpoint: database.endpoint, region: database.region, type: database.type, + + createdBy: database.createdBy, createdAt: database.createdAt, + lastUsed: database.lastUsed, + lastRefreshedAt: database.lastRefreshedAt, }; export type Database = InferDrizzleSelect; +export const DatabaseRotateSchema = z.object({ + accessKey: z.string().min(1).max(256), + secretKey: z.string().min(1).max(256), +}); + export const DatabaseInfo = { type: database.type, name: database.name, diff --git a/src/lib/utils/team-utils.ts b/src/lib/utils/team-utils.ts index 90d3480..10ea171 100644 --- a/src/lib/utils/team-utils.ts +++ b/src/lib/utils/team-utils.ts @@ -20,6 +20,8 @@ export const TEAM_PERMISSIONS = { LinkDatabase: ROLES_RANK.admin, DeleteDatabase: ROLES_RANK.admin, RenameDatabase: ROLES_RANK.admin, + RefreshDatabase: ROLES_RANK.admin, + RotateDatabaseCredentials: ROLES_RANK.admin, ListRobloxCredentials: ROLES_RANK.viewer, LinkRobloxCredential: ROLES_RANK.admin, diff --git a/src/services/ExternalDatabaseService.ts b/src/services/ExternalDatabaseService.ts index 0259a92..cc342ff 100755 --- a/src/services/ExternalDatabaseService.ts +++ b/src/services/ExternalDatabaseService.ts @@ -2,10 +2,15 @@ import { and, eq } from "drizzle-orm"; import { db } from "../db"; import { database } from "../db/schema"; import { HeadBucketCommand, S3Client } from "@aws-sdk/client-s3"; -import { EncryptString256 } from "../lib/crypto/aes"; +import { DecryptString256, EncryptString256 } from "../lib/crypto/aes"; import { randomUUID } from "crypto"; import { AccessDenied, ApiError, DatabaseError } from "../lib/utils/api-utils"; -import { Database, DatabaseSelect } from "../lib/types/database-types"; +import { + Database, + DatabaseSelect, + DatabaseStatus, + database_status, +} from "../lib/types/database-types"; import { TeamService } from "./TeamService"; import { hasPermission } from "../lib/utils/team-utils"; @@ -24,6 +29,174 @@ const InvalidS3Credentials = new ApiError( ); export const ExternalDatabaseService = { + // Internal Methods + async _setDbStatus( + databaseId: string, + info: { kind: DatabaseStatus; message?: string }, + tx?: any, + ) { + const client = tx ?? db; + return await client + .update(database) + .set({ + status: info.kind, + errorMessage: info.kind == "healthy" ? null : (info.message ?? null), + }) + .where(eq(database.id, databaseId)); + }, + + async _useDatabase( + databaseId: string, + callback: ( + creds: DatabaseCredentials, + setStatus: (kind: DatabaseStatus, message?: string) => Promise, + tx: Parameters[0]>[0], + ) => T | Promise, + ): Promise { + return await db.transaction(async (tx) => { + let dbInfo: typeof database.$inferSelect; + try { + [dbInfo] = await tx + .select() + .from(database) + .where(eq(database.id, databaseId)); + } catch { + throw DatabaseError; + } + + if (!dbInfo) { + throw AccessDenied; + } + + let decryptedAk: string; + let decryptedSk: string; + try { + decryptedAk = DecryptString256({ + encryptedData: dbInfo.akCiphertext, + initializationVector: dbInfo.akIv, + authTag: dbInfo.akTag, + }); + decryptedSk = DecryptString256({ + encryptedData: dbInfo.skCiphertext, + initializationVector: dbInfo.skIv, + authTag: dbInfo.skTag, + }); + } catch { + throw new ApiError( + 500, + "DATABASE_CRED_DECRYPTION_ERROR", + "There was an error while accessing your database credentials. If the problem persists, please contact support.", + ); + } + + const setStatus = async (kind: DatabaseStatus, message?: string) => { + try { + await this._setDbStatus(databaseId, { kind, message }, tx); + } catch { + throw DatabaseError; + } + }; + + try { + await tx + .update(database) + .set({ lastUsed: new Date() }) + .where(eq(database.id, databaseId)); + } catch { + throw DatabaseError; + } + + return await callback( + { + AccessKeyID: decryptedAk, + SecretAccessKey: decryptedSk, + EndpointURL: dbInfo.endpoint, + Region: dbInfo.region, + BucketName: dbInfo.bucketName, + }, + setStatus, + tx, + ); + }); + }, + + async _introspectDatabase( + creds: DatabaseCredentials, + ): Promise<{ status: DatabaseStatus; message?: string }> { + const s3Client = new S3Client({ + region: creds.Region, + endpoint: creds.EndpointURL, + credentials: { + accessKeyId: creds.AccessKeyID, + secretAccessKey: creds.SecretAccessKey, + }, + forcePathStyle: true, + }); + + try { + await s3Client.send(new HeadBucketCommand({ Bucket: creds.BucketName })); + return { status: "healthy" }; + } catch (error: any) { + const httpStatus = error.$metadata?.httpStatusCode; + const errorName = error.name; + + if ( + errorName === "SlowDown" || + errorName === "ThrottlingException" || + httpStatus === 429 + ) { + return { status: "warning", message: database_status.rate_limited }; + } else if (httpStatus === 503) { + return { status: "warning", message: database_status.s3_down }; + } else if ( + httpStatus === 403 || + errorName === "AccessDenied" || + errorName === "Forbidden" + ) { + return { + status: "error", + message: database_status.invalid_credentials, + }; + } else if (httpStatus === 404 || errorName === "NoSuchBucket") { + return { status: "error", message: database_status.bucket_not_found }; + } else if ( + error.code === "ENOTFOUND" || + error.code === "ECONNREFUSED" || + error.code === "ETIMEDOUT" + ) { + return { + status: "error", + message: database_status.endpoint_unreachable, + }; + } else { + return { status: "error", message: database_status.connection_failed }; + } + } + }, + + async _applyIntrospectResults( + databaseId: string, + result: { status: DatabaseStatus; message?: string }, + tx?: any, + ): Promise { + const client = tx ?? db; + try { + const [updated] = await client + .update(database) + .set({ + status: result.status, + errorMessage: + result.status === "healthy" ? null : (result.message ?? null), + lastRefreshedAt: new Date(), + }) + .where(eq(database.id, databaseId)) + .returning(DatabaseSelect); + return updated; + } catch { + throw DatabaseError; + } + }, + async CreateS3Client(Creds: DatabaseCredentials): Promise { const s3Client = new S3Client({ region: Creds.Region, @@ -56,7 +229,14 @@ export const ExternalDatabaseService = { } // Check if credentials are valid - await this.CreateS3Client(creds); // Will throw an error if credentials are invalid + const introspectResult = await this._introspectDatabase(creds); + if (introspectResult.status === "error") { + throw new ApiError( + 400, + "DATABASE_UNREACHABLE", + introspectResult.message ?? database_status.connection_failed, + ); + } // Encrypt sensitive data const encodedAkData = EncryptString256(creds.AccessKeyID); @@ -76,6 +256,9 @@ export const ExternalDatabaseService = { endpoint: creds.EndpointURL, region: creds.Region, + status: introspectResult.status, + errorMessage: introspectResult.message ?? null, + akCiphertext: encodedAkData.encryptedData, akIv: encodedAkData.initializationVector, akTag: encodedAkData.authTag, @@ -159,4 +342,80 @@ export const ExternalDatabaseService = { throw DatabaseError; } }, + + async RefreshDatabase( + actorId: string, + teamId: string, + databaseId: string, + ): Promise { + const actorRole = await TeamService.GetTeamUserRole(actorId, teamId); + if (!hasPermission(actorRole, "RefreshDatabase")) { + throw AccessDenied; + } + + return await this._useDatabase( + databaseId, + async (creds, _setStatus, tx) => { + const updated = await this._applyIntrospectResults( + databaseId, + await this._introspectDatabase(creds), + tx, + ); + if (!updated) throw DatabaseError; + return updated; + }, + ); + }, + + async RotateDatabaseCredentials( + actorId: string, + teamId: string, + databaseId: string, + newCreds: Pick, + ): Promise { + const actorRole = await TeamService.GetTeamUserRole(actorId, teamId); + if (!hasPermission(actorRole, "RotateDatabaseCredentials")) { + throw AccessDenied; + } + + return await this._useDatabase(databaseId, async (creds, _setStatus, tx) => { + const fullCreds: DatabaseCredentials = { + ...creds, + AccessKeyID: newCreds.AccessKeyID, + SecretAccessKey: newCreds.SecretAccessKey, + }; + + const introspectResult = await this._introspectDatabase(fullCreds); + if (introspectResult.status === "error") { + throw new ApiError( + 400, + "DATABASE_UNREACHABLE", + introspectResult.message ?? database_status.connection_failed, + ); + } + + const encodedAkData = EncryptString256(newCreds.AccessKeyID); + const encodedSkData = EncryptString256(newCreds.SecretAccessKey); + + try { + const [updated] = await tx + .update(database) + .set({ + akCiphertext: encodedAkData.encryptedData, + akIv: encodedAkData.initializationVector, + akTag: encodedAkData.authTag, + skCiphertext: encodedSkData.encryptedData, + skIv: encodedSkData.initializationVector, + skTag: encodedSkData.authTag, + }) + .where(eq(database.id, databaseId)) + .returning(DatabaseSelect); + if (!updated) throw AccessDenied; + return await this._applyIntrospectResults(databaseId, introspectResult, tx); + } catch (error) { + if (error instanceof ApiError) throw error; + throw DatabaseError; + } + }); + }, }; From b8ac145cec8b3f1a34b2e2b506e6a1749d20eec2 Mon Sep 17 00:00:00 2001 From: OverDsh Date: Wed, 18 Mar 2026 18:35:35 +0100 Subject: [PATCH 31/40] feat: add refresh and rotate API routes for databases --- .../databases/[databaseId]/refresh/route.ts | 44 ++++++++++++++ .../[teamId]/databases/[databaseId]/route.ts | 58 ++++++++++++++++++- 2 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 src/app/api/teams/[teamId]/databases/[databaseId]/refresh/route.ts diff --git a/src/app/api/teams/[teamId]/databases/[databaseId]/refresh/route.ts b/src/app/api/teams/[teamId]/databases/[databaseId]/refresh/route.ts new file mode 100644 index 0000000..24ebfa9 --- /dev/null +++ b/src/app/api/teams/[teamId]/databases/[databaseId]/refresh/route.ts @@ -0,0 +1,44 @@ +import { auth } from "@/src/lib/auth"; +import { AccessDenied, ErrorToNextResponse } from "@/src/lib/utils/api-utils"; +import { ExternalDatabaseService } from "@/src/services/ExternalDatabaseService"; +import { headers } from "next/headers"; +import { NextResponse } from "next/server"; + +interface Context { + params: Promise<{ + teamId: string; + databaseId: string; + }>; +} + +export async function POST(_: Request, context: Context) { + const { teamId, databaseId } = await context.params; + + if (!teamId) { + return NextResponse.json({ error: "Team ID is required" }, { status: 400 }); + } + if (!databaseId) { + return NextResponse.json( + { error: "Database ID is required" }, + { status: 400 }, + ); + } + + const session = await auth.api.getSession({ + headers: await headers(), + }); + if (!session) { + return ErrorToNextResponse(AccessDenied); + } + + try { + const refreshedDb = await ExternalDatabaseService.RefreshDatabase( + session.user.id, + teamId, + databaseId, + ); + return NextResponse.json(refreshedDb); + } catch (error) { + return ErrorToNextResponse(error); + } +} diff --git a/src/app/api/teams/[teamId]/databases/[databaseId]/route.ts b/src/app/api/teams/[teamId]/databases/[databaseId]/route.ts index 3b2af2c..61c4a34 100644 --- a/src/app/api/teams/[teamId]/databases/[databaseId]/route.ts +++ b/src/app/api/teams/[teamId]/databases/[databaseId]/route.ts @@ -1,6 +1,7 @@ import { auth } from "@/src/lib/auth"; -import { ErrorToNextResponse } from "@/src/lib/utils/api-utils"; +import { AccessDenied, ErrorToNextResponse } from "@/src/lib/utils/api-utils"; import { ExternalDatabaseService } from "@/src/services/ExternalDatabaseService"; +import { DatabaseRotateSchema } from "@/src/lib/types/database-types"; import { headers } from "next/headers"; import { NextResponse } from "next/server"; import z, { ZodError } from "zod"; @@ -104,3 +105,58 @@ export async function PATCH(req: Request, context: Context) { return ErrorToNextResponse(error); } } + +export async function POST(req: Request, context: Context) { + const params = await context.params; + const teamId = params.teamId; + const databaseId = params.databaseId; + + if (!teamId) { + return NextResponse.json({ error: "Team ID is required" }, { status: 400 }); + } + if (!databaseId) { + return NextResponse.json( + { error: "Database ID is required" }, + { status: 400 }, + ); + } + + const session = await auth.api.getSession({ + headers: await headers(), + }); + if (!session) { + return ErrorToNextResponse(AccessDenied); + } + + let body; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid json body" }, { status: 400 }); + } + + let validatedData; + try { + validatedData = DatabaseRotateSchema.parse(body); + } catch { + return NextResponse.json( + { error: "Invalid body request format" }, + { status: 400 }, + ); + } + + try { + const rotatedDb = await ExternalDatabaseService.RotateDatabaseCredentials( + session.user.id, + teamId, + databaseId, + { + AccessKeyID: validatedData.accessKey, + SecretAccessKey: validatedData.secretKey, + }, + ); + return NextResponse.json(rotatedDb); + } catch (error) { + return ErrorToNextResponse(error); + } +} From c8ad13b34c7d5b5a6d7d6bce4145b71a12453524 Mon Sep 17 00:00:00 2001 From: OverDsh Date: Wed, 18 Mar 2026 18:35:40 +0100 Subject: [PATCH 32/40] feat: add refresh status and rotate credentials actions to database connections page --- .../[teamSlug]/connections/DatabaseColumn.tsx | 195 +++++++++++++++--- src/controllers/ExternalDatabaseController.ts | 17 ++ src/hooks/useDatabase.ts | 44 ++++ 3 files changed, 231 insertions(+), 25 deletions(-) diff --git a/src/app/dashboard/[teamSlug]/connections/DatabaseColumn.tsx b/src/app/dashboard/[teamSlug]/connections/DatabaseColumn.tsx index 248d252..0ea973d 100644 --- a/src/app/dashboard/[teamSlug]/connections/DatabaseColumn.tsx +++ b/src/app/dashboard/[teamSlug]/connections/DatabaseColumn.tsx @@ -1,5 +1,7 @@ import CallbackDialog from "@/src/components/CallbackDialog"; import FormDialog from "@/src/components/FormDialog"; +import LocalTime from "@/src/components/LocalTime"; +import StatusBadge from "@/src/components/StatusBadge"; import { Button } from "@/src/components/ui/button"; import { DropdownMenu, @@ -8,22 +10,16 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/src/components/ui/dropdown-menu"; -import { - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/src/components/ui/form"; +import { FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/src/components/ui/form"; import { Input } from "@/src/components/ui/input"; import { Skeleton } from "@/src/components/ui/skeleton"; import { useDatabaseMutations } from "@/src/hooks/useDatabase"; import { useTeam } from "@/src/hooks/useTeam"; -import { Database } from "@/src/lib/types/database-types"; +import { Database, DatabaseRotateSchema } from "@/src/lib/types/database-types"; import { hasPermission } from "@/src/lib/utils/team-utils"; import { zodResolver } from "@hookform/resolvers/zod"; import { ColumnDef } from "@tanstack/react-table"; -import { MoreHorizontal } from "lucide-react"; +import { MoreHorizontal, Copy, RefreshCw, RotateCcwKey, Pencil, Trash } from "lucide-react"; import { useState } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; @@ -91,11 +87,83 @@ const DatabaseRenameDialog = ({ Name - + + + + + )} + /> + + ); +}; + +const RotateDatabaseDialog = ({ + databaseId, + teamId, + trigger, +}: { + databaseId: string; + teamId: string; + trigger: React.ReactNode; +}) => { + const [open, onOpenChange] = useState(false); + const { rotateDatabase } = useDatabaseMutations(); + const form = useForm({ + resolver: zodResolver(DatabaseRotateSchema), + defaultValues: { + accessKey: "", + secretKey: "", + }, + }); + + return ( + { + const id = toast.loading("Rotating database credentials..."); + try { + await rotateDatabase.mutateAsync({ teamId, databaseId, accessKey, secretKey }); + toast.success("Successfully rotated database credentials!", { id }); + } catch (error) { + if (error instanceof Error) { + toast.error(error.message, { id }); + } else { + toast.error( + "An unknown error occured while rotating your database credentials. Please try again later.", + { id }, + ); + } + } + onOpenChange(false); + }} + trigger={trigger} + submitButtonText="Rotate" + open={open} + onOpenChange={onOpenChange} + > + ( + + Access Key ID + + + + + + )} + /> + ( + + Secret Access Key + + @@ -110,6 +178,30 @@ export const databaseColumn: ColumnDef[] = [ accessorKey: "name", header: "Name", }, + { + accessorKey: "status", + header: "Status", + cell: ({ row }) => { + const cred = row.original; + const { data: team, isLoading } = useTeam(); + + if (!team || isLoading) { + return ( +
+ +
+ ); + } + + return ( + + ); + }, + }, { accessorKey: "endpoint", header: "Endpoint URI", @@ -131,12 +223,28 @@ export const databaseColumn: ColumnDef[] = [ accessorKey: "type", header: "Type", }, + { + accessorKey: "lastUsed", + header: "Last Used", + cell: ({ getValue }) => { + const time = new Date(getValue()); + return ; + }, + }, + { + accessorKey: "createdAt", + header: "Created", + cell: ({ getValue }) => { + const time = new Date(getValue()); + return ; + }, + }, { id: "actions", cell: ({ row }) => { const db = row.original; const { data: team, isLoading } = useTeam(); - const { deleteDatabase } = useDatabaseMutations(); + const { deleteDatabase, refreshDatabase } = useDatabaseMutations(); if (!team || isLoading) { return ( @@ -146,6 +254,25 @@ export const databaseColumn: ColumnDef[] = [ ); } + const handleRefresh = () => { + const id = toast.loading("Refreshing database status..."); + refreshDatabase + .mutateAsync({ teamId: team.id, databaseId: db.id }) + .then(() => { + toast.success("Successfully refreshed database status!", { id }); + }) + .catch((error) => { + if (error instanceof Error) { + toast.error(error.message, { id }); + } else { + toast.error( + "An unknown error occured while refreshing your database. Please try again later.", + { id }, + ); + } + }); + }; + return (
@@ -156,22 +283,41 @@ export const databaseColumn: ColumnDef[] = [ + navigator.clipboard.writeText(db.id)}> + + Copy Database ID + navigator.clipboard.writeText(db.id)} + disabled={!hasPermission(team.role, "RefreshDatabase")} + onClick={handleRefresh} > - Copy Database ID + + Refresh Status + { - e.preventDefault(); - }} + onSelect={(e) => e.preventDefault()} + > + + Rename Database + + } + /> + e.preventDefault()} > - Rename Database + + Rotate Credentials } /> @@ -203,11 +349,10 @@ export const databaseColumn: ColumnDef[] = [ { - e.preventDefault(); - }} + onSelect={(e) => e.preventDefault()} > - Delete Database + + Delete Database } /> diff --git a/src/controllers/ExternalDatabaseController.ts b/src/controllers/ExternalDatabaseController.ts index 12335ee..6aa54ac 100755 --- a/src/controllers/ExternalDatabaseController.ts +++ b/src/controllers/ExternalDatabaseController.ts @@ -21,4 +21,21 @@ export const ExternalDatabaseController = { method: "PATCH", body: JSON.stringify({ name: newName }), }), + + rotate: ( + teamId: string, + databaseId: string, + accessKey: string, + secretKey: string, + ) => + fetcher(`/api/teams/${teamId}/databases/${databaseId}`, { + method: "POST", + body: JSON.stringify({ accessKey, secretKey }), + }), + + refresh: (teamId: string, databaseId: string) => + fetcher( + `/api/teams/${teamId}/databases/${databaseId}/refresh`, + { method: "POST" }, + ), }; diff --git a/src/hooks/useDatabase.ts b/src/hooks/useDatabase.ts index 48851c5..6cf82b2 100644 --- a/src/hooks/useDatabase.ts +++ b/src/hooks/useDatabase.ts @@ -88,9 +88,53 @@ export function useDatabaseMutations() { }, }); + const refreshDatabase = useMutation({ + mutationFn: ({ + teamId, + databaseId, + }: { + teamId: string; + databaseId: string; + }) => ExternalDatabaseController.refresh(teamId, databaseId), + onSuccess: (database, variables) => { + queryClient.setQueryData( + ["databases", variables.teamId], + (prevData) => { + if (!prevData) return [database]; + return prevData.map((db) => (db.id === database.id ? database : db)); + }, + ); + }, + }); + + const rotateDatabase = useMutation({ + mutationFn: ({ + teamId, + databaseId, + accessKey, + secretKey, + }: { + teamId: string; + databaseId: string; + accessKey: string; + secretKey: string; + }) => ExternalDatabaseController.rotate(teamId, databaseId, accessKey, secretKey), + onSuccess: (database, variables) => { + queryClient.setQueryData( + ["databases", variables.teamId], + (prevData) => { + if (!prevData) return [database]; + return prevData.map((db) => (db.id === database.id ? database : db)); + }, + ); + }, + }); + return { createDatabase, deleteDatabase, renameDatabase, + refreshDatabase, + rotateDatabase, }; } From cf61ff9299b93ab5cec1ffb9d04f2ff95814351e Mon Sep 17 00:00:00 2001 From: OverDsh Date: Fri, 20 Mar 2026 17:19:09 +0100 Subject: [PATCH 33/40] enhancement: added icons to teams list's dropdown --- src/app/dashboard/components/TeamColumn.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/app/dashboard/components/TeamColumn.tsx b/src/app/dashboard/components/TeamColumn.tsx index 0b30202..097aabf 100644 --- a/src/app/dashboard/components/TeamColumn.tsx +++ b/src/app/dashboard/components/TeamColumn.tsx @@ -7,6 +7,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger, } from "@/src/components/ui/dropdown-menu"; import { Skeleton } from "@/src/components/ui/skeleton"; @@ -15,7 +16,7 @@ import { authClient } from "@/src/lib/auth-client"; import { Team } from "@/src/lib/types/team-types"; import { useMutation } from "@tanstack/react-query"; import { ColumnDef } from "@tanstack/react-table"; -import { MoreHorizontal } from "lucide-react"; +import { Copy, LogOut, MoreHorizontal } from "lucide-react"; import { toast } from "sonner"; export const teamColumns: ColumnDef[] = [ @@ -87,8 +88,10 @@ export const teamColumns: ColumnDef[] = [ navigator.clipboard.writeText(team.id)} > - Copy Team ID + + Copy Team ID + [] = [ variant="destructive" onSelect={(e) => e.preventDefault()} > - Leave Team + + Leave Team } /> From 14c6fc93b0fd67c88e25b21fc3518347b74befbf Mon Sep 17 00:00:00 2001 From: OverDsh Date: Fri, 20 Mar 2026 22:47:31 +0100 Subject: [PATCH 34/40] feat: add structured logger utility and SSRF URL validator - createLogger(service) factory with info/warn/error/debug methods; structured output with timestamp, extracts error.name/message without leaking stacks or raw objects - isSafeEndpointUrl() blocks non-HTTPS and all private/loopback/link-local IP ranges (RFC-1918, 127.x, 169.254.x, 0.x) Co-Authored-By: Claude Sonnet 4.6 --- src/lib/utils/logger.ts | 54 ++++++++++++++++++++++++++++++++++++++ src/lib/utils/url-utils.ts | 23 ++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 src/lib/utils/logger.ts create mode 100644 src/lib/utils/url-utils.ts diff --git a/src/lib/utils/logger.ts b/src/lib/utils/logger.ts new file mode 100644 index 0000000..78c3b3a --- /dev/null +++ b/src/lib/utils/logger.ts @@ -0,0 +1,54 @@ +type LogLevel = "info" | "warn" | "error" | "debug"; + +interface LogContext { + teamId?: string; + actorId?: string; + resourceId?: string; + operation?: string; + [key: string]: string | undefined; +} + +function formatContext(context?: LogContext): string { + if (!context) return ""; + const parts = Object.entries(context) + .filter(([, v]) => v !== undefined) + .map(([k, v]) => `${k}=${v}`); + return parts.length > 0 ? " | " + parts.join(" ") : ""; +} + +function log( + level: LogLevel, + service: string, + message: string, + error?: unknown, + context?: LogContext, +): void { + const timestamp = new Date().toISOString(); + const tag = `[${level.toUpperCase()}] [${service}]`; + const ctx = formatContext(context); + + if (error !== undefined) { + const name = error instanceof Error ? error.name : "UnknownError"; + const msg = error instanceof Error ? error.message : String(error); + console.log(`${timestamp} ${tag} ${message} | errorName=${name} errorMessage=${msg}${ctx}`); + } else { + console.log(`${timestamp} ${tag} ${message}${ctx}`); + } +} + +export function createLogger(service: string) { + return { + info(message: string, context?: LogContext) { + log("info", service, message, undefined, context); + }, + warn(message: string, context?: LogContext) { + log("warn", service, message, undefined, context); + }, + error(message: string, error?: unknown, context?: LogContext) { + log("error", service, message, error, context); + }, + debug(message: string, context?: LogContext) { + log("debug", service, message, undefined, context); + }, + }; +} diff --git a/src/lib/utils/url-utils.ts b/src/lib/utils/url-utils.ts new file mode 100644 index 0000000..c755ad8 --- /dev/null +++ b/src/lib/utils/url-utils.ts @@ -0,0 +1,23 @@ +const PRIVATE_IP_RE = [ + /^localhost$/i, + /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/, + /^10\.\d{1,3}\.\d{1,3}\.\d{1,3}$/, + /^172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}$/, + /^192\.168\.\d{1,3}\.\d{1,3}$/, + /^169\.254\.\d{1,3}\.\d{1,3}$/, + /^0\.\d{1,3}\.\d{1,3}\.\d{1,3}$/, +]; + +export function isSafeEndpointUrl(raw: string): boolean { + let parsed: URL; + try { + parsed = new URL(raw); + } catch { + return false; + } + + if (parsed.protocol !== "https:") return false; + + const host = parsed.hostname; + return !PRIVATE_IP_RE.some((re) => re.test(host)); +} From 94ed00de9c9c76ef1dd915eefa3a5d0024c7aa44 Mon Sep 17 00:00:00 2001 From: OverDsh Date: Fri, 20 Mar 2026 22:47:36 +0100 Subject: [PATCH 35/40] fix: fix IDOR vulnerabilities in _useCredential and _useDatabase _useCredential and _useDatabase previously fetched by ID only, allowing a credential/database belonging to team A to be accessed via a team B scoped endpoint. WHERE clauses now include an AND on teamId so a cross-team lookup returns nothing and hits the existing AccessDenied guard. Co-Authored-By: Claude Sonnet 4.6 --- src/services/ExternalDatabaseService.ts | 19 +++++++++++++------ src/services/RobloxCredentialsService.ts | 12 +++++++++--- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/src/services/ExternalDatabaseService.ts b/src/services/ExternalDatabaseService.ts index cc342ff..979083d 100755 --- a/src/services/ExternalDatabaseService.ts +++ b/src/services/ExternalDatabaseService.ts @@ -13,6 +13,9 @@ import { } from "../lib/types/database-types"; import { TeamService } from "./TeamService"; import { hasPermission } from "../lib/utils/team-utils"; +import { createLogger } from "../lib/utils/logger"; + +const logger = createLogger("ExternalDatabaseService"); export interface DatabaseCredentials { AccessKeyID: string; @@ -47,6 +50,7 @@ export const ExternalDatabaseService = { async _useDatabase( databaseId: string, + teamId: string, callback: ( creds: DatabaseCredentials, setStatus: (kind: DatabaseStatus, message?: string) => Promise, @@ -59,7 +63,7 @@ export const ExternalDatabaseService = { [dbInfo] = await tx .select() .from(database) - .where(eq(database.id, databaseId)); + .where(and(eq(database.id, databaseId), eq(database.teamId, teamId))); } catch { throw DatabaseError; } @@ -271,7 +275,7 @@ export const ExternalDatabaseService = { return newRecord; } catch (error) { - console.error("Database insertion failed: ", error); + logger.error("Database insertion failed", error, { teamId, operation: "LinkDatabase" }); throw DatabaseError; } }, @@ -290,7 +294,7 @@ export const ExternalDatabaseService = { return results; } catch (error) { - console.error("Error while fetching user's databases: ", error); + logger.error("Failed to fetch databases", error, { teamId, operation: "ListDatabase" }); throw DatabaseError; } }, @@ -313,7 +317,8 @@ export const ExternalDatabaseService = { if (!result) throw AccessDenied; return result; } catch (error) { - console.error(`Failed to delete database ${databaseId}: `, error); + if (error instanceof ApiError) throw error; + logger.error("Failed to delete database", error, { resourceId: databaseId, operation: "DeleteDatabase" }); throw DatabaseError; } }, @@ -338,7 +343,8 @@ export const ExternalDatabaseService = { if (!result) throw AccessDenied; return result; } catch (error) { - console.error(`Failed to rename database ${databaseId}: `, error); + if (error instanceof ApiError) throw error; + logger.error("Failed to rename database", error, { resourceId: databaseId, operation: "RenameDatabase" }); throw DatabaseError; } }, @@ -355,6 +361,7 @@ export const ExternalDatabaseService = { return await this._useDatabase( databaseId, + teamId, async (creds, _setStatus, tx) => { const updated = await this._applyIntrospectResults( databaseId, @@ -378,7 +385,7 @@ export const ExternalDatabaseService = { throw AccessDenied; } - return await this._useDatabase(databaseId, async (creds, _setStatus, tx) => { + return await this._useDatabase(databaseId, teamId, async (creds, _setStatus, tx) => { const fullCreds: DatabaseCredentials = { ...creds, AccessKeyID: newCreds.AccessKeyID, diff --git a/src/services/RobloxCredentialsService.ts b/src/services/RobloxCredentialsService.ts index f4a2274..6854244 100644 --- a/src/services/RobloxCredentialsService.ts +++ b/src/services/RobloxCredentialsService.ts @@ -11,9 +11,12 @@ import { } from "../lib/types/roblox-credentials-types"; import { AccessDenied, ApiError, DatabaseError } from "../lib/utils/api-utils"; import { hasPermission } from "../lib/utils/team-utils"; +import { createLogger } from "../lib/utils/logger"; import { TeamService } from "./TeamService"; import { and, eq } from "drizzle-orm"; +const logger = createLogger("RobloxCredentialsService"); + export const RobloxCredentialsService = { // Internal Methods async _introspectKey(key: string) { @@ -57,6 +60,7 @@ export const RobloxCredentialsService = { async _useCredential( credId: string, + teamId: string, callback: ( key: string, setStatus: ( @@ -72,7 +76,7 @@ export const RobloxCredentialsService = { [keyInfo] = await tx .select() .from(roblox_credentials) - .where(eq(roblox_credentials.id, credId)); + .where(and(eq(roblox_credentials.id, credId), eq(roblox_credentials.teamId, teamId))); } catch { throw DatabaseError; } @@ -348,7 +352,7 @@ export const RobloxCredentialsService = { throw AccessDenied; } - return await this._useCredential(credId, async (key, setStatus, tx) => { + return await this._useCredential(credId, teamId, async (key, setStatus, tx) => { // Try to fetch the credential info using _introspectKey let keyInfo; try { @@ -377,7 +381,9 @@ export const RobloxCredentialsService = { tx, ); if (updatedCred) return updatedCred; - } catch (error) {} + } catch (error) { + logger.error("Failed to apply introspect results", error, { resourceId: credId }); + } } // Return the final credential info (fallback for when _introspectKey rejected) From fd37b7da45768939147164f62ead4c68b8cb67fe Mon Sep 17 00:00:00 2001 From: OverDsh Date: Fri, 20 Mar 2026 22:47:43 +0100 Subject: [PATCH 36/40] fix: add SSRF protection, structured logging, and surface silent catch - POST /databases endpoint now rejects non-HTTPS and private/internal endpoint URLs via isSafeEndpointUrl refine - All console.error calls in ExternalDatabaseService and api-utils replaced with createLogger structured calls (teamId/resourceId/operation context, no sensitive values) - Silent empty catch on _applyIntrospectResults in RefreshRobloxCredential now logs the error instead of swallowing it Co-Authored-By: Claude Sonnet 4.6 --- src/app/api/teams/[teamId]/databases/route.ts | 6 +++++- src/lib/utils/api-utils.ts | 5 ++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/app/api/teams/[teamId]/databases/route.ts b/src/app/api/teams/[teamId]/databases/route.ts index c0db10a..a55b46a 100644 --- a/src/app/api/teams/[teamId]/databases/route.ts +++ b/src/app/api/teams/[teamId]/databases/route.ts @@ -1,5 +1,6 @@ import { auth } from "@/src/lib/auth"; import { ErrorToNextResponse } from "@/src/lib/utils/api-utils"; +import { isSafeEndpointUrl } from "@/src/lib/utils/url-utils"; import { ExternalDatabaseService } from "@/src/services/ExternalDatabaseService"; import { headers } from "next/headers"; import { NextResponse } from "next/server"; @@ -41,7 +42,10 @@ export async function GET(_: Request, context: Context) { const PostSchema = z.object({ type: z.enum(["S3"]).default("S3"), name: z.string().min(1).max(64), - endpoint: z.url("Invalid URL").max(2048, "Max URL length exceeded"), + endpoint: z + .url("Invalid URL") + .max(2048, "Max URL length exceeded") + .refine(isSafeEndpointUrl, "Endpoint URL must be a public HTTPS address"), region: z.string().min(1).max(50), bucketName: z.string().min(1).max(100), accessKey: z.string().min(1).max(256), diff --git a/src/lib/utils/api-utils.ts b/src/lib/utils/api-utils.ts index a3330ca..0c37ad2 100644 --- a/src/lib/utils/api-utils.ts +++ b/src/lib/utils/api-utils.ts @@ -1,4 +1,7 @@ import { NextResponse } from "next/server"; +import { createLogger } from "./logger"; + +const logger = createLogger("api-utils"); export type ErrorResponse = { code: string; @@ -28,7 +31,7 @@ export function ErrorToNextResponse(error: unknown): NextResponse { ); } - console.error("Unhandled server error:", (error as any).message ?? error); + logger.error("Unhandled server error", error); return NextResponse.json( { error: "Unknown server error. Please try again later." }, { status: 500 }, From 208c4e3d7170f638c552d57b0f028c09ee36a1fb Mon Sep 17 00:00:00 2001 From: OverDsh Date: Thu, 26 Mar 2026 20:32:57 +0100 Subject: [PATCH 37/40] fix: scope turso token env to job level and ignore non-code related file changes --- .github/workflows/database_cleanup.yml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/database_cleanup.yml b/.github/workflows/database_cleanup.yml index f7a9e26..f2fe1d1 100644 --- a/.github/workflows/database_cleanup.yml +++ b/.github/workflows/database_cleanup.yml @@ -3,6 +3,10 @@ on: pull_request: types: - closed + paths-ignore: + - .gitignore + - .github/** + - README.md workflow_dispatch: inputs: pr_number: @@ -13,6 +17,8 @@ on: jobs: cleanup_database: runs-on: ubuntu-latest + env: + TURSO_API_TOKEN: ${{ secrets.TURSO_API_TOKEN }} steps: - name: Install Turso CLI run: | @@ -30,8 +36,5 @@ jobs: echo "DB_NAME=$DB_NAME" >> $GITHUB_ENV - name: Destroy Turso Database - env: - TURSO_API_TOKEN: ${{ secrets.TURSO_API_TOKEN }} - DB_NAME: ${{ env.DB_NAME }} run: | turso db destroy "$DB_NAME" --yes || echo "Database already gone or never existed" From a0eb9fab710e373cd4d5a76f03eb3cb15662d4ce Mon Sep 17 00:00:00 2001 From: OverDsh Date: Thu, 26 Mar 2026 20:33:56 +0100 Subject: [PATCH 38/40] fix: switched to migration, prevent job cancel to avoid mid-migration cancellation --- .github/workflows/dev_deploy.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/dev_deploy.yml b/.github/workflows/dev_deploy.yml index c4f0dd5..7b9542e 100644 --- a/.github/workflows/dev_deploy.yml +++ b/.github/workflows/dev_deploy.yml @@ -15,7 +15,7 @@ permissions: concurrency: group: deployment-dev - cancel-in-progress: true + cancel-in-progress: false jobs: deploy: @@ -36,7 +36,7 @@ jobs: TURSO_DATABASE_URL: ${{ secrets.DEV_DB_URL }} TURSO_AUTH_TOKEN: ${{ secrets.DEV_DB_TOKEN }} run: | - npx drizzle-kit push + npx drizzle-kit migrate - name: Make Development Deployment env: From 6e7db66208db4dd8af858d6be9555e8b23439c83 Mon Sep 17 00:00:00 2001 From: OverDsh Date: Thu, 26 Mar 2026 20:45:06 +0100 Subject: [PATCH 39/40] fix: added migration check, disabled job cancelling and fix secrets usage --- .github/workflows/production_deploy.yml | 28 ++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/.github/workflows/production_deploy.yml b/.github/workflows/production_deploy.yml index f369ed6..f6b3d58 100644 --- a/.github/workflows/production_deploy.yml +++ b/.github/workflows/production_deploy.yml @@ -10,8 +10,34 @@ on: - README.md workflow_dispatch: +concurrency: + group: deployment-production + cancel-in-progress: false + jobs: + check-migrations: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: "npm" + + - run: npm ci + + - name: Check Migrations + run: | + npx drizzle-kit generate + if [ -n "$(git status --porcelain drizzle/)" ]; then + echo "⚠️ Missing migration files! Please run npx drizzle-kit generate..." + exit 1 + fi + deploy: + needs: [check-migrations] runs-on: ubuntu-latest steps: - name: Checkout code @@ -37,4 +63,4 @@ jobs: VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} run: | - npx vercel deploy --prod --yes --token ${{ secrets.VERCEL_TOKEN }} + npx vercel deploy --prod --yes --token "$VERCEL_TOKEN" From c660fa39625b3f2a8c45f19d51c6998b0e5ee27c Mon Sep 17 00:00:00 2001 From: OverDsh Date: Thu, 26 Mar 2026 20:46:25 +0100 Subject: [PATCH 40/40] fix: switched to migration mode, added migration check and changed secret handling --- .github/workflows/pr_preview.yml | 40 +++++++++++++++++++++++++------- 1 file changed, 31 insertions(+), 9 deletions(-) diff --git a/.github/workflows/pr_preview.yml b/.github/workflows/pr_preview.yml index 3cb7d00..2b75cf8 100644 --- a/.github/workflows/pr_preview.yml +++ b/.github/workflows/pr_preview.yml @@ -17,7 +17,7 @@ on: type: number concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.event.inputs.pr_number }} cancel-in-progress: true permissions: @@ -25,7 +25,29 @@ permissions: contents: read jobs: + check-migrations: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: "npm" + + - run: npm ci + + - name: Check Migrations + run: | + npx drizzle-kit generate + if [ -n "$(git status --porcelain drizzle/)" ]; then + echo "⚠️ Missing migration files! Please run npx drizzle-kit generate..." + exit 1 + fi + deploy: + needs: [check-migrations] environment: Preview runs-on: ubuntu-latest steps: @@ -59,7 +81,7 @@ jobs: TURSO_API_TOKEN: ${{ secrets.TURSO_API_TOKEN }} run: | # Create a branch from the dev database with the generated safe name - turso db create ${{ env.SAFE_BRANCH_NAME }} --from-db dev || true + turso db show ${{ env.SAFE_BRANCH_NAME }} &> /dev/null || turso db create ${{ env.SAFE_BRANCH_NAME }} --from-db dev - name: Get Database Credentials env: @@ -82,7 +104,7 @@ jobs: TURSO_AUTH_TOKEN: ${{ env.DB_TOKEN }} run: | # Push changes to the created database - npx drizzle-kit push + npx drizzle-kit migrate - name: Make Preview Deployment id: vercel-deployment @@ -92,17 +114,17 @@ jobs: VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} run: | # Make a preview deployment with the created database - DEPLOYMENT_URL=$(npx vercel deploy --target preview --token ${{ secrets.VERCEL_TOKEN }} \ - --build-env TURSO_DATABASE_URL=${{ env.DB_URL }} \ - --build-env TURSO_AUTH_TOKEN=${{ env.DB_TOKEN }} \ - --env TURSO_DATABASE_URL=${{ env.DB_URL }} \ - --env TURSO_AUTH_TOKEN=${{ env.DB_TOKEN }} \ + DEPLOYMENT_URL=$(npx vercel deploy --target preview --token "$VERCEL_TOKEN" \ + --build-env TURSO_DATABASE_URL="$DB_URL" \ + --build-env TURSO_AUTH_TOKEN="$DB_TOKEN" \ + --env TURSO_DATABASE_URL="$DB_URL" \ + --env TURSO_AUTH_TOKEN="$DB_TOKEN" \ --yes --logs) echo "DEPLOYMENT_URL=$DEPLOYMENT_URL" >> $GITHUB_ENV - name: Comment on PR - if: always() + if: always() && env.PR_NUM != '' uses: actions/github-script@v8 env: DEPLOYMENT_URL: ${{ env.DEPLOYMENT_URL }}