diff --git a/apps/api/src/controllers/Projects.ts b/apps/api/src/controllers/Projects.ts index 1667138..a45d62f 100644 --- a/apps/api/src/controllers/Projects.ts +++ b/apps/api/src/controllers/Projects.ts @@ -1,5 +1,6 @@ -import {Controller, Get, Middleware} from '@overnightjs/core'; +import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/core'; import type {NextFunction, Request, Response} from 'express'; +import {MembershipSchemas} from '@plunk/shared'; import {prisma} from '../database/prisma.js'; import {HttpException} from '../exceptions/index.js'; @@ -129,4 +130,254 @@ export class Projects { })), }); } + + /** + * Add a member to a project by email + * POST /projects/:id/members + * Body: { email: string, role?: 'ADMIN' | 'MEMBER' } + */ + @Post(':id/members') + @Middleware([requireAuth]) + @CatchAsync + private async addMember(req: Request, res: Response, _next: NextFunction) { + const auth = res.locals.auth as AuthResponse; + const {id} = req.params; + + // Validate params + if (!id) { + throw new HttpException(400, 'Project ID is required'); + } + + // Validate and parse request body + const parseResult = MembershipSchemas.addMember.safeParse(req.body); + if (!parseResult.success) { + throw new HttpException(400, parseResult.error.errors[0]?.message || 'Invalid request body'); + } + + const {email, role} = parseResult.data; + + // Verify current user is ADMIN or OWNER + const currentMembership = await prisma.membership.findFirst({ + where: { + userId: auth.userId, + projectId: id, + role: { + in: ['ADMIN', 'OWNER'], + }, + }, + }); + + if (!currentMembership) { + throw new HttpException(403, 'Only project admins and owners can add members'); + } + + // Find user by email + const userToAdd = await prisma.user.findUnique({ + where: {email: email.toLowerCase()}, + select: {id: true, email: true}, + }); + + if (!userToAdd) { + throw new HttpException(404, 'User with this email does not have an account'); + } + + // Check if user is already a member + const existingMembership = await prisma.membership.findUnique({ + where: { + userId_projectId: { + userId: userToAdd.id, + projectId: id, + }, + }, + }); + + if (existingMembership) { + throw new HttpException(409, 'User is already a member of this project'); + } + + // Create membership + const newMembership = await prisma.membership.create({ + data: { + userId: userToAdd.id, + projectId: id, + role, + }, + }); + + return res.json({ + success: true, + data: { + userId: userToAdd.id, + email: userToAdd.email, + role: newMembership.role, + }, + }); + } + + /** + * Update a member's role + * PATCH /projects/:id/members/:userId + * Body: { role: 'ADMIN' | 'MEMBER' } + */ + @Patch(':id/members/:userId') + @Middleware([requireAuth]) + @CatchAsync + private async updateMemberRole(req: Request, res: Response, _next: NextFunction) { + const auth = res.locals.auth as AuthResponse; + const {id, userId} = req.params; + + // Validate params + if (!id) { + throw new HttpException(400, 'Project ID is required'); + } + if (!userId) { + throw new HttpException(400, 'User ID is required'); + } + + // Validate and parse request body + const parseResult = MembershipSchemas.updateRole.safeParse(req.body); + if (!parseResult.success) { + throw new HttpException(400, parseResult.error.errors[0]?.message || 'Invalid request body'); + } + + const {role} = parseResult.data; + + // Verify current user is ADMIN or OWNER + const currentMembership = await prisma.membership.findFirst({ + where: { + userId: auth.userId, + projectId: id, + role: { + in: ['ADMIN', 'OWNER'], + }, + }, + }); + + if (!currentMembership) { + throw new HttpException(403, 'Only project admins and owners can update member roles'); + } + + // Get target membership + const targetMembership = await prisma.membership.findUnique({ + where: { + userId_projectId: { + userId, + projectId: id, + }, + }, + }); + + if (!targetMembership) { + throw new HttpException(404, 'Member not found'); + } + + // Cannot change OWNER role + if (targetMembership.role === 'OWNER') { + throw new HttpException(403, 'Cannot change the role of the project owner'); + } + + // Get user info + const user = await prisma.user.findUnique({ + where: {id: userId}, + select: {id: true, email: true}, + }); + + if (!user) { + throw new HttpException(404, 'User not found'); + } + + // Update role + await prisma.membership.update({ + where: { + userId_projectId: { + userId, + projectId: id, + }, + }, + data: {role}, + }); + + return res.json({ + success: true, + data: { + userId: user.id, + email: user.email, + role, + }, + }); + } + + /** + * Remove a member from a project + * DELETE /projects/:id/members/:userId + */ + @Delete(':id/members/:userId') + @Middleware([requireAuth]) + @CatchAsync + private async removeMember(req: Request, res: Response, _next: NextFunction) { + const auth = res.locals.auth as AuthResponse; + const {id, userId} = req.params; + + // Validate params + if (!id) { + throw new HttpException(400, 'Project ID is required'); + } + if (!userId) { + throw new HttpException(400, 'User ID is required'); + } + + // Verify current user is ADMIN or OWNER + const currentMembership = await prisma.membership.findFirst({ + where: { + userId: auth.userId, + projectId: id, + role: { + in: ['ADMIN', 'OWNER'], + }, + }, + }); + + if (!currentMembership) { + throw new HttpException(403, 'Only project admins and owners can remove members'); + } + + // Get target membership + const targetMembership = await prisma.membership.findUnique({ + where: { + userId_projectId: { + userId, + projectId: id, + }, + }, + }); + + if (!targetMembership) { + throw new HttpException(404, 'Member not found'); + } + + // Cannot remove OWNER + if (targetMembership.role === 'OWNER') { + throw new HttpException(403, 'Cannot remove the project owner'); + } + + // Cannot remove yourself + if (userId === auth.userId) { + throw new HttpException(403, 'You cannot remove yourself from the project'); + } + + // Delete membership + await prisma.membership.delete({ + where: { + userId_projectId: { + userId, + projectId: id, + }, + }, + }); + + return res.json({ + success: true, + data: {message: 'Member removed successfully'}, + }); + } } diff --git a/apps/landing/next-env.d.ts b/apps/landing/next-env.d.ts index 1970904..7996d35 100644 --- a/apps/landing/next-env.d.ts +++ b/apps/landing/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/pages/api-reference/config/typescript for more information. diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts index 1970904..7996d35 100644 --- a/apps/web/next-env.d.ts +++ b/apps/web/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/pages/api-reference/config/typescript for more information. diff --git a/apps/web/src/components/BillingInvoices.tsx b/apps/web/src/components/BillingInvoices.tsx index e09e1c1..d95794f 100644 --- a/apps/web/src/components/BillingInvoices.tsx +++ b/apps/web/src/components/BillingInvoices.tsx @@ -88,20 +88,20 @@ export function BillingInvoices({projectId, hasSubscription, onManageBilling}: B const getStatusBadge = (status: string, paid: boolean) => { // Stripe invoices have status 'paid' when paid, or paid boolean is true if (paid || status === 'paid') { - return Paid; + return Paid; } switch (status) { case 'open': - return Unpaid; + return Unpaid; case 'draft': - return Draft; + return Draft; case 'uncollectible': - return Uncollectible; + return Uncollectible; case 'void': - return Void; + return Void; default: - return {status}; + return {status}; } }; diff --git a/apps/web/src/components/TeamSettings.tsx b/apps/web/src/components/TeamSettings.tsx new file mode 100644 index 0000000..fc56b3a --- /dev/null +++ b/apps/web/src/components/TeamSettings.tsx @@ -0,0 +1,399 @@ +import {useState} from 'react'; +import useSWR from 'swr'; +import { + Alert, + Badge, + Button, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + Input, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@plunk/ui'; +import {MembershipSchemas} from '@plunk/shared'; +import {AlertTriangle, Mail, MoreVertical, Trash2, UserPlus} from 'lucide-react'; +import {useForm} from 'react-hook-form'; +import {zodResolver} from '@hookform/resolvers/zod'; +import type {z} from 'zod'; +import {network} from '../lib/network'; + +interface Member { + userId: string; + email: string; + role: 'OWNER' | 'ADMIN' | 'MEMBER'; +} + +interface TeamSettingsProps { + projectId: string; + currentUserRole: 'OWNER' | 'ADMIN' | 'MEMBER'; + currentUserId: string; +} + +type AddMemberForm = z.infer; + +export function TeamSettings({projectId, currentUserRole, currentUserId}: TeamSettingsProps) { + const [showAddDialog, setShowAddDialog] = useState(false); + const [memberToRemove, setMemberToRemove] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + + const {data, mutate, isLoading} = useSWR<{success: boolean; data: Member[]}>( + projectId ? `/projects/${projectId}/members` : null, + {revalidateOnFocus: false}, + ); + + const members = data?.data || []; + const canManageMembers = currentUserRole === 'OWNER' || currentUserRole === 'ADMIN'; + + const form = useForm({ + resolver: zodResolver(MembershipSchemas.addMember), + defaultValues: { + email: '', + role: 'MEMBER', + }, + }); + + const handleAddMember = async (values: AddMemberForm) => { + setIsSubmitting(true); + setError(null); + setSuccess(null); + + try { + await network.fetch('POST', `/projects/${projectId}/members`, values); + setSuccess('Member added successfully'); + await mutate(); + form.reset(); + setShowAddDialog(false); + + // Clear success message after 3 seconds + setTimeout(() => setSuccess(null), 3000); + } catch (err: unknown) { + if (err instanceof Error) { + setError(err.message); + } else { + setError('Failed to add member'); + } + } finally { + setIsSubmitting(false); + } + }; + + const handleRemoveMember = async () => { + if (!memberToRemove) return; + + setIsSubmitting(true); + setError(null); + setSuccess(null); + + try { + await network.fetch('DELETE', `/projects/${projectId}/members/${memberToRemove.userId}`); + setSuccess('Member removed successfully'); + await mutate(); + setMemberToRemove(null); + + // Clear success message after 3 seconds + setTimeout(() => setSuccess(null), 3000); + } catch (err: unknown) { + if (err instanceof Error) { + setError(err.message); + } else { + setError('Failed to remove member'); + } + } finally { + setIsSubmitting(false); + } + }; + + const handleUpdateRole = async (userId: string, newRole: 'ADMIN' | 'MEMBER') => { + setError(null); + setSuccess(null); + + try { + await network.fetch( + 'PATCH', + `/projects/${projectId}/members/${userId}`, + {role: newRole}, + ); + setSuccess('Member role updated successfully'); + await mutate(); + + // Clear success message after 3 seconds + setTimeout(() => setSuccess(null), 3000); + } catch (err: unknown) { + if (err instanceof Error) { + setError(err.message); + } else { + setError('Failed to update member role'); + } + } + }; + + const getRoleBadgeVariant = (role: Member['role']) => { + switch (role) { + case 'OWNER': + return 'default'; + case 'ADMIN': + return 'secondary'; + case 'MEMBER': + return 'outline'; + default: + return 'outline'; + } + }; + + return ( +
+ {success && ( + + +
+

{success}

+
+
+ )} + + {error && ( + + +
+

{error}

+
+
+ )} + + + +
+
+ Team Members + Manage who has access to this project +
+ {canManageMembers && ( + + )} +
+
+ + {isLoading ? ( +
+
+
+ ) : members.length === 0 ? ( +
No members found
+ ) : ( + + + + Email + Role + + + + + {members.map(member => { + const isCurrentUser = member.userId === currentUserId; + const isOwner = member.role === 'OWNER'; + const canModify = canManageMembers && !isOwner && !isCurrentUser; + + return ( + + +
+ {member.email} + {isCurrentUser && ( + + You + + )} +
+
+ + {canModify ? ( + + ) : ( + + {member.role.toLowerCase()} + + )} + + + {canModify && ( + + + + + + setMemberToRemove(member)} + > + + Remove + + + + )} + +
+ ); + })} +
+
+ )} + + + + {/* Add Member Dialog */} + { + if (!open) { + setError(null); + } + setShowAddDialog(open); + }} + > + + + Add Team Member + + Add a user to this project by their email address. They must have an existing account. + + +
+ + {error && ( + + +
+

{error}

+
+
+ )} + ( + + Email Address + + + + + + )} + /> + ( + + Role + + + + )} + /> + + + + + + +
+
+ + {/* Remove Member Dialog */} + setMemberToRemove(null)}> + + + Remove Team Member + + Are you sure you want to remove {memberToRemove?.email} from this project? They will + immediately lose access. + + + + + + + + +
+ ); +} diff --git a/apps/web/src/pages/settings/index.tsx b/apps/web/src/pages/settings/index.tsx index 4d998c4..35c1dc6 100644 --- a/apps/web/src/pages/settings/index.tsx +++ b/apps/web/src/pages/settings/index.tsx @@ -32,7 +32,7 @@ import { } from '@plunk/ui'; import {AnimatePresence, motion} from 'framer-motion'; import {NextSeo} from 'next-seo'; -import {AlertTriangle, CreditCard, Database, Globe, Mail, Settings as SettingsIcon} from 'lucide-react'; +import {AlertTriangle, CreditCard, Database, Globe, Mail, Settings as SettingsIcon, Users} from 'lucide-react'; import type {z} from 'zod'; import {useRouter} from 'next/router'; import {DashboardLayout} from '../../components/DashboardLayout'; @@ -44,12 +44,15 @@ import {UnpaidInvoiceBanner} from '../../components/UnpaidInvoiceBanner'; import {ApiKeyDisplay} from '../../components/ApiKeyDisplay'; import {SmtpSettings} from '../../components/SmtpSettings'; import {DataManagementSettings} from '../../components/DataManagementSettings'; +import {TeamSettings} from '../../components/TeamSettings'; import {useActiveProject} from '../../lib/contexts/ActiveProjectProvider'; import {network} from '../../lib/network'; import {useProjects} from '../../lib/hooks/useProject'; import {useConfig} from '../../lib/hooks/useConfig'; +import {useUser} from '../../lib/hooks/useUser'; +import useSWR from 'swr'; -type TabId = 'general' | 'billing' | 'domains' | 'smtp' | 'data'; +type TabId = 'general' | 'billing' | 'domains' | 'smtp' | 'data' | 'team'; interface Tab { id: TabId; @@ -62,6 +65,7 @@ const buildTabs = (options: {billingEnabled: boolean; smtpEnabled: boolean}): Ta const {billingEnabled, smtpEnabled} = options; const allTabs: Tab[] = [ {id: 'general', label: 'General', icon: SettingsIcon}, + {id: 'team', label: 'Team', icon: Users}, {id: 'billing', label: 'Billing', icon: CreditCard, condition: billingEnabled}, {id: 'domains', label: 'Domains', icon: Globe}, {id: 'smtp', label: 'SMTP', icon: Mail, condition: smtpEnabled}, @@ -75,6 +79,7 @@ export default function Settings() { const {activeProject, setActiveProject} = useActiveProject(); const {mutate: projectsMutate} = useProjects(); const {data: config} = useConfig(); + const {data: user} = useUser(); const [successMessage, setSuccessMessage] = useState(null); const [errorMessage, setErrorMessage] = useState(null); const [showRegenerateDialog, setShowRegenerateDialog] = useState(false); @@ -84,6 +89,15 @@ export default function Settings() { const [resetConfirmText, setResetConfirmText] = useState(''); const [isLoadingBilling, setIsLoadingBilling] = useState(false); + // Fetch current user's membership for the active project + const {data: membershipData} = useSWR<{ + success: boolean; + data: Array<{userId: string; email: string; role: 'OWNER' | 'ADMIN' | 'MEMBER'}>; + }>(activeProject?.id ? `/projects/${activeProject.id}/members` : null, {revalidateOnFocus: false}); + + const currentUserMembership = membershipData?.data.find(m => m.userId === user?.id); + const currentUserRole = currentUserMembership?.role || 'MEMBER'; + const billingEnabled = config?.features.billing.enabled ?? false; const smtpEnabled = config?.features.smtp.enabled ?? false; const trackingToggleEnabled = config?.features.email.trackingToggleEnabled ?? false; @@ -639,6 +653,11 @@ export default function Settings() {
+ {/* Team Tab */} + + + + {/* Domains Tab */} diff --git a/apps/web/src/pages/templates/index.tsx b/apps/web/src/pages/templates/index.tsx index b47e902..ad970de 100644 --- a/apps/web/src/pages/templates/index.tsx +++ b/apps/web/src/pages/templates/index.tsx @@ -1,4 +1,14 @@ -import {Button, Card, CardContent, CardDescription, CardHeader, CardTitle, ConfirmDialog, Input} from '@plunk/ui'; +import { + Badge, + Button, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, + ConfirmDialog, + Input, +} from '@plunk/ui'; import type {Template} from '@plunk/db'; import {DashboardLayout} from '../../components/DashboardLayout'; import {network} from '../../lib/network'; @@ -65,248 +75,245 @@ export default function TemplatesPage() {
- {/* Header */} -
-
-

Email Templates

-

- Create and manage reusable email templates for your campaigns and workflows.{' '} - {data?.total ? `${data.total} total templates` : ''} -

+ {/* Header */} +
+
+

Email Templates

+

+ Create and manage reusable email templates for your campaigns and workflows.{' '} + {data?.total ? `${data.total} total templates` : ''} +

+
+ + +
- - - -
- {/* Search & Filters */} - - -
-
-
- - setSearchInput(e.target.value)} - className="pl-10" - /> -
- - {search && ( - - )} -
- - {/* Type Filter */} -
- - - -
-
-
-
- - {/* Templates Grid */} -
- {isLoading ? ( - - -
-
- - - - -

Loading templates...

+ {/* Search & Filters */} + + +
+
+
+ + setSearchInput(e.target.value)} + className="pl-10" + />
-
- - - ) : data?.templates.length === 0 ? ( - - -
- -

No templates found

-

- {search ? 'Try adjusting your search terms' : 'Get started by creating your first template'} -

- {!search && ( - - - + + {search && ( + )}
-
-
- ) : ( - <> - {data?.templates.map(template => ( - - -
-
-
- {template.name} - - {template.type} - -
- {template.description && ( - {template.description} - )} -
-
- - - - - -
-
-
- -
-
-

Subject

-

{template.subject}

-
-
-

From

-

{template.from}

-
-
-
Created {new Date(template.createdAt).toLocaleDateString()}
- {template.replyTo && ( -
- Reply to: {template.replyTo} -
- )} -
-
-
-
- ))} - {/* Pagination */} - {data && data.totalPages > 1 && ( -
-

- Showing {(page - 1) * data.pageSize + 1} to {Math.min(page * data.pageSize, data.total)} of{' '} - {data.total} templates -

-
- - - Page {page} of {data.totalPages} - - -
+ {/* Type Filter */} +
+ + +
- )} - - )} -
-
+ + + - - + {/* Templates Grid */} +
+ {isLoading ? ( + + +
+
+ + + + +

Loading templates...

+
+
+
+
+ ) : data?.templates.length === 0 ? ( + + +
+ +

No templates found

+

+ {search ? 'Try adjusting your search terms' : 'Get started by creating your first template'} +

+ {!search && ( + + + + )} +
+
+
+ ) : ( + <> + {data?.templates.map(template => ( + + +
+
+
+ {template.name} + + {template.type.toLowerCase()} + +
+ {template.description && ( + {template.description} + )} +
+
+ + + + + +
+
+
+ +
+
+

Subject

+

{template.subject}

+
+
+

From

+

{template.from}

+
+
+
Created {new Date(template.createdAt).toLocaleDateString()}
+ {template.replyTo && ( +
+ Reply to: {template.replyTo} +
+ )} +
+
+
+
+ ))} + + {/* Pagination */} + {data && data.totalPages > 1 && ( +
+

+ Showing {(page - 1) * data.pageSize + 1} to {Math.min(page * data.pageSize, data.total)} of{' '} + {data.total} templates +

+
+ + + Page {page} of {data.totalPages} + + +
+
+ )} + + )} +
+
+ + + ); } diff --git a/apps/web/src/pages/workflows/index.tsx b/apps/web/src/pages/workflows/index.tsx index 373ae84..62944f0 100644 --- a/apps/web/src/pages/workflows/index.tsx +++ b/apps/web/src/pages/workflows/index.tsx @@ -1,4 +1,5 @@ import { + Badge, Button, Card, CardContent, @@ -88,223 +89,218 @@ export default function WorkflowsPage() {
- {/* Header */} -
-
-

Workflows

-

- Automate your email campaigns with powerful workflows.{' '} - {data?.total ? `${data.total} total workflows` : ''} -

+ {/* Header */} +
+
+

Workflows

+

+ Automate your email campaigns with powerful workflows.{' '} + {data?.total ? `${data.total} total workflows` : ''} +

+
+
- -
- {/* Search & Filters */} - - -
-
- - setSearchInput(e.target.value)} - className="pl-10" - /> -
- - {search && ( - - )} -
-
-
+ {/* Search & Filters */} + + +
+
+ + setSearchInput(e.target.value)} + className="pl-10" + /> +
+ + {search && ( + + )} +
+
+
- {/* Workflows Grid */} -
- {isLoading ? ( - - -
-
- - - - -

Loading workflows...

+ {/* Workflows Grid */} +
+ {isLoading ? ( + + +
+
+ + + + +

Loading workflows...

+
-
- - - ) : data?.workflows.length === 0 ? ( - - -
- -

No workflows found

-

- {search ? 'Try adjusting your search terms' : 'Get started by creating your first workflow'} -

- {!search && ( - - )} -
-
-
- ) : ( - <> - {data?.workflows.map(workflow => ( - - -
-
-
- {workflow.name} - + + ) : data?.workflows.length === 0 ? ( + + +
+ +

No workflows found

+

+ {search ? 'Try adjusting your search terms' : 'Get started by creating your first workflow'} +

+ {!search && ( + + )} +
+
+
+ ) : ( + <> + {data?.workflows.map(workflow => ( + + +
+
+
+ {workflow.name} + + {workflow.enabled ? ( + <> + + Active + + ) : ( + <> + + Disabled + + )} + + + {workflow.triggerConfig && + typeof workflow.triggerConfig === 'object' && + 'eventName' in workflow.triggerConfig && ( + {String(workflow.triggerConfig.eventName)} + )} +
+ {workflow.description && ( + {workflow.description} + )} +
+
+
- {workflow.description && ( - {workflow.description} - )} -
-
- - - - - + + + + +
-
- - -
-
- {workflow._count?.steps ?? 0} steps + + +
+
+ {workflow._count?.steps ?? 0} steps +
+
+ {workflow._count?.executions ?? 0}{' '} + executions +
+
Created {new Date(workflow.createdAt).toLocaleDateString()}
-
- {workflow._count?.executions ?? 0}{' '} - executions -
-
Created {new Date(workflow.createdAt).toLocaleDateString()}
-
- - - ))} + + + ))} - {/* Pagination */} - {data && data.totalPages > 1 && ( -
-

- Showing {(page - 1) * data.pageSize + 1} to {Math.min(page * data.pageSize, data.total)} of{' '} - {data.total} workflows -

-
- - - Page {page} of {data.totalPages} - - + {/* Pagination */} + {data && data.totalPages > 1 && ( +
+

+ Showing {(page - 1) * data.pageSize + 1} to {Math.min(page * data.pageSize, data.total)} of{' '} + {data.total} workflows +

+
+ + + Page {page} of {data.totalPages} + + +
-
- )} - - )} + )} + + )} +
-
- {/* Create Workflow Dialog */} - mutate()} /> + {/* Create Workflow Dialog */} + mutate()} /> - - + + ); } @@ -440,7 +436,13 @@ function CreateWorkflowDialog({open, onOpenChange, onSuccess}: CreateWorkflowDia
-