@@ -2,12 +2,7 @@ import * as Ariakit from "@ariakit/react";
22import { ArrowPathIcon , ChevronUpDownIcon } from "@heroicons/react/20/solid" ;
33import { DialogClose } from "@radix-ui/react-dialog" ;
44import { type MetaFunction , useFetcher } from "@remix-run/react" ;
5- import {
6- type ActionFunctionArgs ,
7- json ,
8- type LoaderFunctionArgs ,
9- redirect ,
10- } from "@remix-run/server-runtime" ;
5+ import { json , type LoaderFunctionArgs , redirect } from "@remix-run/server-runtime" ;
116
127import { AnimatePresence , motion } from "framer-motion" ;
138import { ClipboardCheckIcon , ClipboardIcon , GitBranchPlusIcon } from "lucide-react" ;
@@ -22,6 +17,7 @@ import { ProvidersFilter } from "~/components/metrics/ProvidersFilter";
2217import { AppliedFilter } from "~/components/primitives/AppliedFilter" ;
2318import { Badge } from "~/components/primitives/Badge" ;
2419import { Button , LinkButton } from "~/components/primitives/Buttons" ;
20+ import { PermissionButton } from "~/components/primitives/PermissionButton" ;
2521import { DateTime } from "~/components/primitives/DateTime" ;
2622import { Dialog , DialogContent , DialogHeader } from "~/components/primitives/Dialog" ;
2723import { Header3 } from "~/components/primitives/Headers" ;
@@ -60,7 +56,7 @@ import { Spinner } from "~/components/primitives/Spinner";
6056import { TabButton , TabContainer } from "~/components/primitives/Tabs" ;
6157import { TextArea } from "~/components/primitives/TextArea" ;
6258import { TimeFilter } from "~/components/runs/v3/SharedFilters" ;
63- import { prisma } from "~/db.server" ;
59+ import { $replica , prisma } from "~/db.server" ;
6460import { useEnvironment } from "~/hooks/useEnvironment" ;
6561import { useInterval } from "~/hooks/useInterval" ;
6662import { useOrganization } from "~/hooks/useOrganizations" ;
@@ -73,6 +69,9 @@ import { SpanView } from "~/routes/resources.orgs.$organizationSlug.projects.$pr
7369import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server" ;
7470import { getResizableSnapshot } from "~/services/resizablePanel.server" ;
7571import { requireUserId } from "~/services/session.server" ;
72+ import { rbac } from "~/services/rbac.server" ;
73+ import { dashboardAction } from "~/services/routeBuilders/dashboardBuilder" ;
74+ import { checkPermissions } from "~/services/routeBuilders/permissions.server" ;
7675import { PromptService } from "~/v3/services/promptService.server" ;
7776
7877import { z } from "zod" ;
@@ -122,85 +121,107 @@ const ActionSchema = z.discriminatedUnion("intent", [
122121 } ) ,
123122] ) ;
124123
125- export async function action ( { request, params } : ActionFunctionArgs ) {
126- const userId = await requireUserId ( request ) ;
127- const { organizationSlug, projectParam, envParam, promptSlug } = ParamSchema . parse ( params ) ;
124+ async function resolveOrgIdFromSlug ( slug : string ) : Promise < string | null > {
125+ const org = await $replica . organization . findFirst ( { where : { slug } , select : { id : true } } ) ;
126+ return org ?. id ?? null ;
127+ }
128128
129- const project = await findProjectBySlug ( organizationSlug , projectParam , userId ) ;
130- if ( ! project ) return json ( { error : "Project not found" } , { status : 404 } ) ;
129+ export const action = dashboardAction (
130+ {
131+ params : ParamSchema ,
132+ context : async ( params ) => {
133+ const organizationId = await resolveOrgIdFromSlug ( params . organizationSlug ) ;
134+ return organizationId ? { organizationId } : { } ;
135+ } ,
136+ } ,
137+ async ( { request, params, user, ability } ) => {
138+ const { organizationSlug, projectParam, envParam, promptSlug } = params ;
139+
140+ const project = await findProjectBySlug ( organizationSlug , projectParam , user . id ) ;
141+ if ( ! project ) return json ( { error : "Project not found" } , { status : 404 } ) ;
142+
143+ const environment = await findEnvironmentBySlug ( project . id , envParam , user . id ) ;
144+ if ( ! environment ) return json ( { error : "Environment not found" } , { status : 404 } ) ;
145+
146+ const formData = Object . fromEntries ( await request . formData ( ) ) ;
147+ const parsed = ActionSchema . safeParse ( formData ) ;
148+ if ( ! parsed . success ) return json ( { error : "Invalid action" } , { status : 400 } ) ;
149+
150+ const prompt = await prisma . prompt . findUnique ( {
151+ where : {
152+ projectId_runtimeEnvironmentId_slug : {
153+ projectId : project . id ,
154+ runtimeEnvironmentId : environment . id ,
155+ slug : promptSlug ,
156+ } ,
157+ } ,
158+ } ) ;
131159
132- const environment = await findEnvironmentBySlug ( project . id , envParam , userId ) ;
133- if ( ! environment ) return json ( { error : "Environment not found" } , { status : 404 } ) ;
160+ if ( ! prompt ) return json ( { error : "Prompt not found" } , { status : 404 } ) ;
134161
135- const formData = Object . fromEntries ( await request . formData ( ) ) ;
136- const parsed = ActionSchema . safeParse ( formData ) ;
137- if ( ! parsed . success ) return json ( { error : "Invalid action" } , { status : 400 } ) ;
162+ const data = parsed . data ;
138163
139- const prompt = await prisma . prompt . findUnique ( {
140- where : {
141- projectId_runtimeEnvironmentId_slug : {
142- projectId : project . id ,
143- runtimeEnvironmentId : environment . id ,
144- slug : promptSlug ,
145- } ,
146- } ,
147- } ) ;
164+ // Promoting a version to production is `update:prompts`; creating or
165+ // editing override versions is `write:prompts`. Check the right one per
166+ // intent — a single authorization block can't express both.
167+ const requiredAction = data . intent === "promote" ? "update" : "write" ;
168+ if ( ! ability . can ( requiredAction , { type : "prompts" } ) ) {
169+ return json ( { error : "Unauthorized" } , { status : 403 } ) ;
170+ }
148171
149- if ( ! prompt ) return json ( { error : "Prompt not found" } , { status : 404 } ) ;
172+ const service = new PromptService ( ) ;
150173
151- const data = parsed . data ;
152- const service = new PromptService ( ) ;
174+ if ( data . intent === "promote" ) {
175+ await service . promoteVersion ( prompt . id , data . versionId ) ;
176+ return json ( { ok : true } ) ;
177+ }
153178
154- if ( data . intent === "promote" ) {
155- await service . promoteVersion ( prompt . id , data . versionId ) ;
156- return json ( { ok : true } ) ;
157- }
179+ const url = new URL ( request . url ) ;
158180
159- const url = new URL ( request . url ) ;
181+ if ( data . intent === "saveVersion" ) {
182+ const result = await service . createOverride ( prompt . id , {
183+ textContent : data . textContent ?? "" ,
184+ model : data . model ,
185+ commitMessage : data . commitMessage ,
186+ source : "dashboard" ,
187+ createdBy : user . id ,
188+ } ) ;
189+ url . searchParams . set ( "version" , String ( result . version ) ) ;
190+ return redirect ( url . pathname + url . search ) ;
191+ }
160192
161- if ( data . intent === "saveVersion" ) {
162- const result = await service . createOverride ( prompt . id , {
163- textContent : data . textContent ?? "" ,
164- model : data . model ,
165- commitMessage : data . commitMessage ,
166- source : "dashboard" ,
167- createdBy : userId ,
168- } ) ;
169- url . searchParams . set ( "version" , String ( result . version ) ) ;
170- return redirect ( url . pathname + url . search ) ;
171- }
193+ if ( data . intent === "updateOverride" ) {
194+ await service . updateOverride ( prompt . id , {
195+ textContent : data . textContent ,
196+ model : data . model ,
197+ commitMessage : data . commitMessage ,
198+ } ) ;
199+ return json ( { ok : true } ) ;
200+ }
172201
173- if ( data . intent === "updateOverride" ) {
174- await service . updateOverride ( prompt . id , {
175- textContent : data . textContent ,
176- model : data . model ,
177- commitMessage : data . commitMessage ,
178- } ) ;
179- return json ( { ok : true } ) ;
180- }
202+ if ( data . intent === "removeOverride" ) {
203+ await service . removeOverride ( prompt . id ) ;
204+ // Navigate back to current version
205+ const currentVersion = await prisma . promptVersion . findFirst ( {
206+ where : { promptId : prompt . id , labels : { has : "current" } } ,
207+ select : { version : true } ,
208+ } ) ;
209+ if ( currentVersion ) {
210+ url . searchParams . set ( "version" , String ( currentVersion . version ) ) ;
211+ } else {
212+ url . searchParams . delete ( "version" ) ;
213+ }
214+ return redirect ( url . pathname + url . search ) ;
215+ }
181216
182- if ( data . intent === "removeOverride" ) {
183- await service . removeOverride ( prompt . id ) ;
184- // Navigate back to current version
185- const currentVersion = await prisma . promptVersion . findFirst ( {
186- where : { promptId : prompt . id , labels : { has : "current" } } ,
187- select : { version : true } ,
188- } ) ;
189- if ( currentVersion ) {
190- url . searchParams . set ( "version" , String ( currentVersion . version ) ) ;
191- } else {
192- url . searchParams . delete ( "version" ) ;
217+ if ( data . intent === "reactivateOverride" ) {
218+ await service . reactivateOverride ( prompt . id , data . versionId ) ;
219+ return json ( { ok : true } ) ;
193220 }
194- return redirect ( url . pathname + url . search ) ;
195- }
196221
197- if ( data . intent === "reactivateOverride" ) {
198- await service . reactivateOverride ( prompt . id , data . versionId ) ;
199- return json ( { ok : true } ) ;
222+ return json ( { error : "Unknown intent" } , { status : 400 } ) ;
200223 }
201-
202- return json ( { error : "Unknown intent" } , { status : 400 } ) ;
203- }
224+ ) ;
204225
205226// ─── Loader ──────────────────────────────────────────────
206227
@@ -242,7 +263,10 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
242263 const startTime = fromTime ? new Date ( fromTime ) : new Date ( Date . now ( ) - periodMs ) ;
243264 const endTime = toTime ? new Date ( toTime ) : new Date ( ) ;
244265
245- const clickhouse = await clickhouseFactory . getClickhouseForOrganization ( project . organizationId , "standard" ) ;
266+ const clickhouse = await clickhouseFactory . getClickhouseForOrganization (
267+ project . organizationId ,
268+ "standard"
269+ ) ;
246270 const presenter = new PromptPresenter ( clickhouse ) ;
247271 let generations : Awaited < ReturnType < typeof presenter . listGenerations > > [ "generations" ] = [ ] ;
248272 let generationsPagination : { next ?: string } = { } ;
@@ -301,6 +325,19 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
301325 const possibleOperations = opsErr ? [ ] : opsRows . map ( ( r ) => r . val ) ;
302326 const possibleProviders = provsErr ? [ ] : provsRows . map ( ( r ) => r . val ) ;
303327
328+ // Display flags for the promote / override controls — the action enforces
329+ // update:prompts and write:prompts independently. Permissive in OSS.
330+ const promptAuth = await rbac . authenticateSession ( request , {
331+ userId,
332+ organizationId : project . organizationId ,
333+ } ) ;
334+ const promptPermissions = promptAuth . ok
335+ ? checkPermissions ( promptAuth . ability , {
336+ canWritePrompts : { action : "write" , resource : { type : "prompts" } } ,
337+ canPromote : { action : "update" , resource : { type : "prompts" } } ,
338+ } )
339+ : { canWritePrompts : true , canPromote : true } ;
340+
304341 return typedjson ( {
305342 resizable : {
306343 outer : resizableOuter ,
@@ -353,6 +390,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
353390 possibleModels,
354391 possibleOperations,
355392 possibleProviders,
393+ ...promptPermissions ,
356394 } ) ;
357395} ;
358396
@@ -437,6 +475,8 @@ export default function PromptDetailPage() {
437475 possibleModels,
438476 possibleOperations,
439477 possibleProviders,
478+ canWritePrompts,
479+ canPromote,
440480 } = useTypedLoaderData < typeof loader > ( ) ;
441481 const organization = useOrganization ( ) ;
442482 const project = useProject ( ) ;
@@ -518,18 +558,22 @@ export default function PromptDetailPage() {
518558 </ div >
519559 ) }
520560 { selectedVersion && ! isCurrent && selectedVersion . source === "code" && (
521- < Button
561+ < PermissionButton
562+ hasPermission = { canPromote }
563+ noPermissionTooltip = "You don't have permission to promote prompt versions"
522564 variant = "secondary/small"
523565 onClick = { ( ) => handlePromote ( selectedVersion . id ) }
524566 disabled = { fetcher . state !== "idle" }
525567 >
526568 Promote to current
527- </ Button >
569+ </ PermissionButton >
528570 ) }
529571 { selectedVersion &&
530572 selectedVersion . source !== "code" &&
531573 ! selectedVersion . labels . includes ( "override" ) && (
532- < Button
574+ < PermissionButton
575+ hasPermission = { canWritePrompts }
576+ noPermissionTooltip = "You don't have permission to edit prompt overrides"
533577 variant = "secondary/small"
534578 onClick = { ( ) =>
535579 fetcher . submit (
@@ -540,12 +584,17 @@ export default function PromptDetailPage() {
540584 disabled = { fetcher . state !== "idle" }
541585 >
542586 Reactivate as override
543- </ Button >
587+ </ PermissionButton >
544588 ) }
545589 { ! overrideVersion && (
546- < Button variant = "secondary/small" onClick = { ( ) => setOverrideDialogOpen ( true ) } >
590+ < PermissionButton
591+ hasPermission = { canWritePrompts }
592+ noPermissionTooltip = "You don't have permission to edit prompt overrides"
593+ variant = "secondary/small"
594+ onClick = { ( ) => setOverrideDialogOpen ( true ) }
595+ >
547596 Create override
548- </ Button >
597+ </ PermissionButton >
549598 ) }
550599 </ div >
551600 </ PageAccessories >
@@ -565,21 +614,25 @@ export default function PromptDetailPage() {
565614 instead of the deployed prompt.
566615 </ span >
567616 < div className = "flex items-center gap-2 py-1.5" >
568- < Button
617+ < PermissionButton
618+ hasPermission = { canWritePrompts }
619+ noPermissionTooltip = "You don't have permission to edit prompt overrides"
569620 variant = "tertiary/small"
570621 className = "border-amber-300/50 bg-amber-400/10 text-amber-300 group-hover/button:border-amber-400/60 group-hover/button:bg-amber-500/25 group-hover/button:text-amber-200"
571622 onClick = { ( ) => setOverrideDialogOpen ( true ) }
572623 >
573624 Edit
574- </ Button >
575- < Button
625+ </ PermissionButton >
626+ < PermissionButton
627+ hasPermission = { canWritePrompts }
628+ noPermissionTooltip = "You don't have permission to edit prompt overrides"
576629 variant = "tertiary/small"
577630 className = "border-amber-300/50 bg-amber-400/10 text-amber-300 group-hover/button:border-amber-400/60 group-hover/button:bg-amber-500/25 group-hover/button:text-amber-200"
578631 onClick = { ( ) => fetcher . submit ( { intent : "removeOverride" } , { method : "POST" } ) }
579632 disabled = { fetcher . state !== "idle" }
580633 >
581634 Remove
582- </ Button >
635+ </ PermissionButton >
583636 </ div >
584637 </ motion . div >
585638 ) }
@@ -1502,7 +1555,10 @@ function GenerationsTab({
15021555 { gen . operation_id || gen . task_identifier }
15031556 </ TableCell >
15041557 < TableCell
1505- className = { cn ( "tabular-nums" , isSelected ? "text-text-bright" : "text-charcoal-400" ) }
1558+ className = { cn (
1559+ "tabular-nums" ,
1560+ isSelected ? "text-text-bright" : "text-charcoal-400"
1561+ ) }
15061562 >
15071563 v{ gen . prompt_version }
15081564 </ TableCell >
0 commit comments