11import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema" ;
2- import { Loader2 , PlusIcon , ShieldCheck , TrashIcon , Users } from "lucide-react" ;
2+ import {
3+ Loader2 ,
4+ PlusIcon ,
5+ ShieldCheck ,
6+ Sparkles ,
7+ TrashIcon ,
8+ Users ,
9+ } from "lucide-react" ;
310import { useEffect , useState } from "react" ;
411import { useForm } from "react-hook-form" ;
512import { toast } from "sonner" ;
613import { z } from "zod" ;
7- import { DialogAction } from "@/components/shared/dialog-action" ;
814import { EnterpriseFeatureGate } from "@/components/proprietary/enterprise-feature-gate" ;
9- import { Button } from "@/components/ui/button" ;
1015import { AlertBlock } from "@/components/shared/alert-block" ;
16+ import { DialogAction } from "@/components/shared/dialog-action" ;
17+ import { Button } from "@/components/ui/button" ;
1118import {
1219 Card ,
1320 CardContent ,
@@ -24,11 +31,6 @@ import {
2431 DialogTitle ,
2532 DialogTrigger ,
2633} from "@/components/ui/dialog" ;
27- import {
28- Popover ,
29- PopoverContent ,
30- PopoverTrigger ,
31- } from "@/components/ui/popover" ;
3234import {
3335 Form ,
3436 FormControl ,
@@ -38,6 +40,11 @@ import {
3840 FormMessage ,
3941} from "@/components/ui/form" ;
4042import { Input } from "@/components/ui/input" ;
43+ import {
44+ Popover ,
45+ PopoverContent ,
46+ PopoverTrigger ,
47+ } from "@/components/ui/popover" ;
4148import { Switch } from "@/components/ui/switch" ;
4249import { api } from "@/utils/api" ;
4350
@@ -407,6 +414,114 @@ const ACTION_META: Record<
407414/** Resources that should be hidden from the custom role editor (better-auth internals) */
408415const HIDDEN_RESOURCES = [ "organization" , "invitation" , "team" , "ac" ] ;
409416
417+ /** Predefined role presets with sensible permission defaults */
418+ const ROLE_PRESETS : {
419+ name : string ;
420+ label : string ;
421+ description : string ;
422+ permissions : Record < string , string [ ] > ;
423+ } [ ] = [
424+ {
425+ name : "viewer" ,
426+ label : "Viewer" ,
427+ description : "Read-only access across all resources" ,
428+ permissions : {
429+ service : [ "read" ] ,
430+ environment : [ "read" ] ,
431+ docker : [ "read" ] ,
432+ sshKeys : [ "read" ] ,
433+ gitProviders : [ "read" ] ,
434+ traefikFiles : [ "read" ] ,
435+ api : [ "read" ] ,
436+ volume : [ "read" ] ,
437+ deployment : [ "read" ] ,
438+ envVars : [ "read" ] ,
439+ projectEnvVars : [ "read" ] ,
440+ environmentEnvVars : [ "read" ] ,
441+ server : [ "read" ] ,
442+ registry : [ "read" ] ,
443+ certificate : [ "read" ] ,
444+ backup : [ "read" ] ,
445+ volumeBackup : [ "read" ] ,
446+ schedule : [ "read" ] ,
447+ domain : [ "read" ] ,
448+ destination : [ "read" ] ,
449+ notification : [ "read" ] ,
450+ member : [ "read" ] ,
451+ logs : [ "read" ] ,
452+ monitoring : [ "read" ] ,
453+ auditLog : [ "read" ] ,
454+ } ,
455+ } ,
456+ {
457+ name : "developer" ,
458+ label : "Developer" ,
459+ description : "Deploy services, manage env vars, domains, and view logs" ,
460+ permissions : {
461+ project : [ "create" ] ,
462+ service : [ "create" , "read" ] ,
463+ environment : [ "create" , "read" ] ,
464+ docker : [ "read" ] ,
465+ gitProviders : [ "read" ] ,
466+ api : [ "read" ] ,
467+ volume : [ "read" , "create" , "delete" ] ,
468+ deployment : [ "read" , "create" , "cancel" ] ,
469+ envVars : [ "read" , "write" ] ,
470+ projectEnvVars : [ "read" ] ,
471+ environmentEnvVars : [ "read" ] ,
472+ domain : [ "read" , "create" , "delete" ] ,
473+ schedule : [ "read" , "create" , "update" , "delete" ] ,
474+ logs : [ "read" ] ,
475+ monitoring : [ "read" ] ,
476+ } ,
477+ } ,
478+ {
479+ name : "deployer" ,
480+ label : "Deployer" ,
481+ description : "Trigger and manage deployments only" ,
482+ permissions : {
483+ service : [ "read" ] ,
484+ environment : [ "read" ] ,
485+ deployment : [ "read" , "create" , "cancel" ] ,
486+ logs : [ "read" ] ,
487+ monitoring : [ "read" ] ,
488+ } ,
489+ } ,
490+ {
491+ name : "devops" ,
492+ label : "DevOps" ,
493+ description :
494+ "Full infrastructure access: servers, registries, certs, backups, and deployments" ,
495+ permissions : {
496+ project : [ "create" , "delete" ] ,
497+ service : [ "create" , "read" , "delete" ] ,
498+ environment : [ "create" , "read" , "delete" ] ,
499+ docker : [ "read" ] ,
500+ sshKeys : [ "read" , "create" , "delete" ] ,
501+ gitProviders : [ "read" , "create" , "delete" ] ,
502+ traefikFiles : [ "read" , "write" ] ,
503+ api : [ "read" ] ,
504+ volume : [ "read" , "create" , "delete" ] ,
505+ deployment : [ "read" , "create" , "cancel" ] ,
506+ envVars : [ "read" , "write" ] ,
507+ projectEnvVars : [ "read" , "write" ] ,
508+ environmentEnvVars : [ "read" , "write" ] ,
509+ server : [ "read" , "create" , "delete" ] ,
510+ registry : [ "read" , "create" , "delete" ] ,
511+ certificate : [ "read" , "create" , "delete" ] ,
512+ backup : [ "read" , "create" , "delete" , "restore" ] ,
513+ volumeBackup : [ "read" , "create" , "update" , "delete" , "restore" ] ,
514+ schedule : [ "read" , "create" , "update" , "delete" ] ,
515+ domain : [ "read" , "create" , "delete" ] ,
516+ destination : [ "read" , "create" , "delete" ] ,
517+ notification : [ "read" , "create" , "delete" ] ,
518+ logs : [ "read" ] ,
519+ monitoring : [ "read" ] ,
520+ auditLog : [ "read" ] ,
521+ } ,
522+ } ,
523+ ] ;
524+
410525const createRoleSchema = z . object ( {
411526 roleName : z
412527 . string ( )
@@ -552,7 +667,7 @@ function HandleCustomRole({
552667 </ Button >
553668 ) }
554669 </ DialogTrigger >
555- < DialogContent className = "max-h-[85vh] sm:max-w-5xl overflow-y-auto" >
670+ < DialogContent className = "max-h-[85vh] sm:max-w-5xl overflow-y-auto space-y-2 " >
556671 < DialogHeader >
557672 < DialogTitle >
558673 { isEdit ? "Edit Role" : "Create Custom Role" }
@@ -587,6 +702,32 @@ function HandleCustomRole({
587702 />
588703 </ form >
589704 </ Form >
705+ { ! isEdit && (
706+ < div className = "space-y-2 mt-4" >
707+ < p className = "text-sm font-medium flex items-center gap-1.5" >
708+ < Sparkles className = "size-3.5 text-muted-foreground" />
709+ Start from a preset
710+ </ p >
711+ < div className = "grid grid-cols-2 sm:grid-cols-4 gap-2" >
712+ { ROLE_PRESETS . map ( ( preset ) => (
713+ < button
714+ key = { preset . name }
715+ type = "button"
716+ className = "rounded-lg border p-3 text-left hover:bg-muted/50 transition-colors cursor-pointer space-y-1"
717+ onClick = { ( ) => {
718+ form . setValue ( "roleName" , preset . name ) ;
719+ setPermissions ( { ...preset . permissions } ) ;
720+ } }
721+ >
722+ < p className = "text-sm font-medium" > { preset . label } </ p >
723+ < p className = "text-xs text-muted-foreground leading-snug" >
724+ { preset . description }
725+ </ p >
726+ </ button >
727+ ) ) }
728+ </ div >
729+ </ div >
730+ ) }
590731 < PermissionEditor
591732 resources = { visibleResources }
592733 permissions = { permissions }
@@ -843,7 +984,7 @@ function PermissionEditor({
843984 onToggle : ( resource : string , action : string ) => void ;
844985} ) {
845986 return (
846- < div className = "space-y-3" >
987+ < div className = "space-y-3 mt-4 " >
847988 < p className = "text-sm font-medium" > Permissions</ p >
848989 < div className = "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3" >
849990 { resources . map ( ( [ resource , actions ] ) => {
0 commit comments