Conversation
OverDsh
commented
Mar 27, 2026
- Renamed Database tab in Teams page to Connections to be used to manage different off-site connections
- Implemented Roblox Credentials linking (under the connections page)
- Added introspection on both database and roblox credentials to have visibility on connections status
- Ran security audit using Claude, fixed 2x IDOR (when performing an action like delete or rotate, didn't check if the team owned the target ressource)
- Various QOL improvements (icons in dropdowns, consistent time formatting)
🚀 Preview Deployment:
|
in RobloxCredentialService
roblox-credentials-types.ts
…ExternalDatabaseService
- 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 <noreply@anthropic.com>
_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 <noreply@anthropic.com>
- 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 <noreply@anthropic.com>
fd37b7d to
d88d0b3
Compare
There was a problem hiding this comment.
Pull request overview
Adds a new Connections area to manage external integrations (S3-compatible databases + Roblox API credentials), including connection “health” introspection/status and some UI/QOL improvements, while also refactoring client controllers around a shared fetcher.
Changes:
- Introduces Roblox credentials linking/listing/rename/rotate/refresh (DB schema, service layer, API routes, hooks, UI tables).
- Adds database introspection/status tracking plus refresh/credential rotation flows (schema + service/API/UI updates).
- Refactors client-side controllers to a
fetcherhelper and renames the Teams “Databases” navigation to “Connections”.
Reviewed changes
Copilot reviewed 50 out of 53 changed files in this pull request and generated 14 comments.
Show a summary per file
| File | Description |
|---|---|
| src/services/TeamService.ts | Switches error imports to api-utils. |
| src/services/TeamService.test.ts | Updates error import path for tests. |
| src/services/RobloxCredentialsService.ts | New service for Roblox credential CRUD + introspection. |
| src/services/ExternalDatabaseService.ts | Adds DB status/introspection + refresh/rotate support and logging. |
| src/lib/utils/url-utils.ts | Adds endpoint URL safety validator for DB endpoint inputs. |
| src/lib/utils/team-utils.ts | Adds new permissions for refresh/rotate + Roblox credentials actions. |
| src/lib/utils/logger.ts | Adds lightweight structured logger used across services/utils. |
| src/lib/utils/api-utils.ts | Adds fetcher helper and switches unhandled error logging to new logger. |
| src/lib/types/roblox-credentials-types.ts | Adds shared types + zod schemas for Roblox credential flows. |
| src/lib/types/database-types.ts | Adds DB status fields/messages + rotate schema. |
| src/lib/config.ts | Adds TEAM/USER limits constants. |
| src/hooks/useTeam.ts | Refactors to TeamController object + adds query stale times. |
| src/hooks/useMember.ts | Refactors member listing to TeamController + stale time. |
| src/hooks/useDatabase.ts | Refactors to ExternalDatabaseController + adds refresh/rotate mutations. |
| src/hooks/useRobloxCredential.ts | New hooks for Roblox credentials queries/mutations. |
| src/db/schema/roblox_credentials.ts | New table schema for storing encrypted Roblox API keys + metadata. |
| src/db/schema/index.ts | Exports new roblox_credentials schema. |
| src/db/schema/database.ts | Adds status/error + timestamps (lastUsed/lastRefreshedAt). |
| src/controllers/TeamController.ts | Refactors to a single TeamController object using fetcher. |
| src/controllers/RobloxCredentialController.ts | New controller for Roblox credentials API calls. |
| src/controllers/ExternalDatabaseController.ts | Refactors to controller object + adds refresh/rotate calls. |
| src/components/ui/popover.tsx | Adds popover wrapper component. |
| src/components/ui/badge.tsx | Adds badge component for status display. |
| src/components/StatusBadge.tsx | Adds status badge UI with popover details. |
| src/components/LocalTime.tsx | Updates time formatting (relative/absolute modes). |
| src/components/FormDialog.tsx | Adds internal open-state support and auto-close/reset after submit. |
| src/app/dashboard/components/TeamColumn.tsx | Adds dropdown icons/UX tweaks and uses TeamController.removeMember. |
| src/app/dashboard/[teamSlug]/databases/page.tsx | Removes old Databases page in favor of Connections. |
| src/app/dashboard/[teamSlug]/connections/page.tsx | Adds Connections page combining DB + Roblox credentials tables. |
| src/app/dashboard/[teamSlug]/connections/* | Adds DB/Roblox column components + link dialogs. |
| src/app/dashboard/[teamSlug]/components/TeamSidebar.tsx | Renames nav item to Connections + updates permission gating. |
| src/app/api/teams/** | Updates routes to use api-utils errors; adds new DB refresh/rotate + Roblox credentials routes. |
| package.json | Adds radix-ui dependency. |
| package-lock.json | Locks new radix-ui dependency tree. |
Comments suppressed due to low confidence (1)
src/lib/utils/api-utils.ts:75
fetcherassumes every response has a JSON body and that error bodies match{code,message}. If an endpoint returns 204/empty body orErrorToNextResponsereturns{error: ...}for unknown errors,response.json()/ResponseToErrorwill throw or produce an unhelpful Error. Consider guarding JSON parsing (e.g., handle empty bodies) and normalizing server error responses to a single shape before callingResponseToError.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| "next-themes": "^0.4.6", | ||
| "nodemailer": "^7.0.10", | ||
| "qrcode": "^1.5.4", | ||
| "radix-ui": "^1.4.3", |
There was a problem hiding this comment.
radix-ui is added as a dependency, but the codebase already depends on individual @radix-ui/react-* packages. Pulling in the umbrella package can duplicate dependencies and increase bundle size; consider removing radix-ui and adding only the specific missing primitive packages (e.g. @radix-ui/react-popover) to match existing usage.
| "radix-ui": "^1.4.3", |
| callback={({ name }) => { | ||
| 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 }, | ||
| ); | ||
| } | ||
| }); |
There was a problem hiding this comment.
The callback passed to FormDialog here starts an async mutation via .mutateAsync(...).then().catch() but does not return/await that promise. With the updated FormDialog implementation awaiting the callback, this means the dialog may close/reset immediately while the mutation is still in flight. Make the callback async and await/return the mutation promise so FormDialog can reliably manage loading/close behavior.
| callback={({ name }) => { | |
| 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 }, | |
| ); | |
| } | |
| }); | |
| callback={async ({ name }) => { | |
| const id = toast.loading("Renaming Roblox credential..."); | |
| try { | |
| await renameRobloxCredential.mutateAsync({ teamId: team!.id, credId, newName: name }); | |
| 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 }, | |
| ); | |
| } | |
| } |
| import { | ||
| DropdownMenuContent, | ||
| DropdownMenuItem, | ||
| 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, | ||
| 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"; |
There was a problem hiding this comment.
This file mixes the project’s UI dropdown menu wrappers with a direct import from @radix-ui/react-dropdown-menu (DropdownMenu). For consistency (and to ensure styling/props match), import DropdownMenu from @/src/components/ui/dropdown-menu alongside the other dropdown menu components instead of using the Radix primitive directly.
| callback={({ name, key }) => { | ||
| 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 }, | ||
| ); | ||
| } | ||
| }); |
There was a problem hiding this comment.
Similarly, this FormDialog callback triggers mutateAsync(...).then().catch() without returning the promise. With FormDialog now awaiting callback, the dialog can close/reset immediately and isLoading won’t reflect the actual request lifecycle. Consider making the callback async and await/return the mutation promise (and remove the redundant setIsOpen(false) since FormDialog can handle closing on success).
| callback={({ name, key }) => { | |
| 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 }, | |
| ); | |
| } | |
| }); | |
| callback={async ({ name, key }) => { | |
| const id = toast.loading("Linking Roblox API key to your team..."); | |
| try { | |
| await linkRobloxCredential.mutateAsync({ | |
| teamId: team!.id, | |
| data: { name, key }, | |
| }); | |
| toast.success("Successfully linked Roblox API key to your team!", { | |
| id, | |
| }); | |
| } 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 }, | |
| ); | |
| } | |
| throw error; | |
| } |
| return await db.transaction(async (tx) => { | ||
| await tx | ||
| .update(roblox_credentials) | ||
| .set({ | ||
| keyCiphertext: newKeyEncrypted.encryptedData, | ||
| keyIv: newKeyEncrypted.initializationVector, | ||
| keyTag: newKeyEncrypted.authTag, | ||
| }) | ||
| .where(eq(roblox_credentials.id, credId)); | ||
|
|
||
| return this._applyIntrospectResults(credId, keyInfo, tx); | ||
| }); |
There was a problem hiding this comment.
RotateRobloxCredential updates the credential row using only id, without verifying that the credential belongs to the provided teamId. This reintroduces an IDOR risk (a user with admin on one team could rotate another team’s credential if they know the credId). Use the existing _useCredential helper or add teamId to the update WHERE clause and ensure you throw AccessDenied when no row is updated/returned.
| await callback(data); | ||
| onOpenChange(false); | ||
| form.reset(); | ||
| setIsLoading(false); |
There was a problem hiding this comment.
handleSubmit sets isLoading to true, awaits callback, and then unconditionally closes/resets the dialog. If callback throws/rejects, isLoading will never be reset (no try/finally) and the dialog state can get stuck. Consider wrapping in try/finally and only closing/resetting on success (or allow callers to control close behavior).
| await callback(data); | |
| onOpenChange(false); | |
| form.reset(); | |
| setIsLoading(false); | |
| try { | |
| await callback(data); | |
| onOpenChange(false); | |
| form.reset(); | |
| } finally { | |
| setIsLoading(false); | |
| } |