Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
'use client'

import { AccessEnum } from '@/lib/authz/enums/access-enum'
import React, { useCallback, useMemo } from 'react'
import useFormSchema, { bulkEditFieldSchema } from '../hooks/use-form-schema'
import { getEnumLabel } from '@/components/shared/enum-mapper/common-enum'
Expand Down Expand Up @@ -205,6 +206,7 @@ const AssetPage: React.FC = () => {

const tableConfig: AssetTablePageConfig = {
objectType,
createPermission: AccessEnum.CanCreateAsset,
objectName,
tableKey,
exportType,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { Card } from '@repo/ui/cardpanel'
import { Button } from '@repo/ui/button'

type TCampaignsEmptyStateProps = {
onCreateCampaign: () => void
onCreateCampaign?: () => void
}

const CampaignsEmptyState: React.FC<TCampaignsEmptyStateProps> = ({ onCreateCampaign }) => (
Expand All @@ -16,9 +16,11 @@ const CampaignsEmptyState: React.FC<TCampaignsEmptyStateProps> = ({ onCreateCamp
<h3 className="text-lg font-semibold">You haven&apos;t created any campaigns yet</h3>
<p className="mt-1 text-sm text-muted-foreground">Campaigns help you collect information, deliver training, and automate recurring outreach.</p>
</div>
<Button variant="primary" icon={<SquarePlus size={16} />} iconPosition="left" onClick={onCreateCampaign}>
Create your first campaign
</Button>
{onCreateCampaign && (
<Button variant="primary" icon={<SquarePlus size={16} />} iconPosition="left" onClick={onCreateCampaign}>
Create your first campaign
</Button>
)}
</Card>
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ import { getEnumLabel } from '@/components/shared/enum-mapper/common-enum'
import CampaignRunsTable from './campaign-runs-table'
import RecipientsTable from './recipients-table'
import { TextField } from '@/components/shared/crud-base/form-fields/text-field'
import { useSession } from 'next-auth/react'
import { useAccountRoles } from '@/lib/query-hooks/permissions'
import { canEdit } from '@/lib/authz/utils'
import { ObjectTypes } from '@repo/codegen/src/type-names'
import { SelectField } from '@/components/shared/crud-base/form-fields/select-field'
import { DateField } from '@/components/shared/crud-base/form-fields/date-field'

Expand Down Expand Up @@ -81,6 +85,12 @@ const CampaignDetailPage: React.FC = () => {
const params = useParams<{ id: string }>()
const campaignId = params.id
const { setCrumbs } = React.use(BreadcrumbContext)
const { data: session } = useSession()
const { data: campaignPermission } = useAccountRoles(ObjectTypes.CAMPAIGN, campaignId)
// core's Campaign policy is CheckCreateAccess + CheckEditAccess, and
// CheckEditAccess runs CheckAccessForEdit for delete operations too — so
// deleting a campaign needs can_edit on the object, not can_delete.
const canEditCampaign = canEdit(campaignPermission?.roles, session)
const { data, isLoading } = useCampaign(campaignId)
const { emailTemplateOptions, isLoading: emailTemplatesLoading } = useCampaignEmailTemplateSelect({ ensureId: data?.campaign?.emailTemplateID })
const { mutateAsync: updateCampaign, isPending: isUpdating } = useUpdateCampaign()
Expand Down Expand Up @@ -272,17 +282,17 @@ const CampaignDetailPage: React.FC = () => {
const status = campaign.status
const launched = !!campaign.launchedAt
const isDraft = status === CampaignCampaignStatus.DRAFT
const isEditable = isDraft
const isEditable = isDraft && canEditCampaign
const hasQuestionnaire = !!campaign.templateID
const hasEmailTemplate = !!campaign.emailTemplateID
const hasCampaignContent = hasQuestionnaire || hasEmailTemplate
const showProgress = campaign.campaignType !== CampaignCampaignType.CUSTOM || !!campaign.assessmentID
const campaignTypeLabel = campaign.campaignType ? getEnumLabel(campaign.campaignType) : '—'
const launchBlockedReason = getLaunchBlockedReason({ hasCampaignContent, isFetchingRecipients, hasRecipientsError, recipientCount: stats.total })
const isTerminalStatus = CAMPAIGN_TERMINAL_STATUSES.includes(status)
const canCancel = !isTerminalStatus
const canEditRecurrence = !isTerminalStatus
const canSendReminder = status === CampaignCampaignStatus.ACTIVE
const canCancel = !isTerminalStatus && canEditCampaign
const canEditRecurrence = !isTerminalStatus && canEditCampaign
const canSendReminder = status === CampaignCampaignStatus.ACTIVE && canEditCampaign
const lockBanner =
!isDraft &&
{
Expand Down Expand Up @@ -323,7 +333,7 @@ const CampaignDetailPage: React.FC = () => {
},
}

const launchButton = isDraft && (
const launchButton = isDraft && canEditCampaign && (
<Button variant="primary" icon={<Rocket size={14} />} iconPosition="left" onClick={() => setLaunchDialogOpen(true)} disabled={isLaunching || !!launchBlockedReason} className="h-8">
Launch
</Button>
Expand All @@ -335,17 +345,19 @@ const CampaignDetailPage: React.FC = () => {
closeOnSelect
content={(close) => (
<>
<Button
variant="transparent"
className="flex justify-start space-x-2"
onClick={() => {
setTestDialogOpen(true)
close()
}}
>
<Mail size={16} strokeWidth={2} />
<span>Send test email</span>
</Button>
{canEditCampaign && (
<Button
variant="transparent"
className="flex justify-start space-x-2"
onClick={() => {
setTestDialogOpen(true)
close()
}}
>
<Mail size={16} strokeWidth={2} />
<span>Send test email</span>
</Button>
)}
{canSendReminder && (
<Button
variant="transparent"
Expand Down Expand Up @@ -373,17 +385,19 @@ const CampaignDetailPage: React.FC = () => {
<span>Cancel campaign</span>
</Button>
)}
<Button
variant="transparent"
className="flex justify-start space-x-2"
onClick={() => {
setDeleteDialogOpen(true)
close()
}}
>
<Trash2 size={16} strokeWidth={2} />
<span>Delete campaign</span>
</Button>
{canEditCampaign && (
<Button
variant="transparent"
className="flex justify-start space-x-2"
onClick={() => {
setDeleteDialogOpen(true)
close()
}}
>
<Trash2 size={16} strokeWidth={2} />
<span>Delete campaign</span>
</Button>
)}
</>
)}
/>
Expand Down Expand Up @@ -524,7 +538,7 @@ const CampaignDetailPage: React.FC = () => {
name="name"
label=""
isEditing={false}
isEditAllowed
isEditAllowed={canEditCampaign}
isCreate={false}
data={campaign}
internalEditing={internalEditing}
Expand All @@ -547,7 +561,7 @@ const CampaignDetailPage: React.FC = () => {
name="description"
label=""
isEditing={false}
isEditAllowed
isEditAllowed={canEditCampaign}
isCreate={false}
data={campaign}
internalEditing={internalEditing}
Expand All @@ -558,7 +572,7 @@ const CampaignDetailPage: React.FC = () => {
/>
</div>

{isDraft ? (
{isEditable ? (
<CampaignSetupView
hasEmailTemplate={hasEmailTemplate}
emailTemplateName={emailTemplateName}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ import { Button } from '@repo/ui/button'
import { SquarePlus } from 'lucide-react'

import { BreadcrumbContext } from '@/providers/BreadcrumbContext'
import { canEdit } from '@/lib/authz/utils'
import { canEdit, hasPermission } from '@/lib/authz/utils'
import { AccessEnum } from '@/lib/authz/enums/access-enum'
import { useSession } from 'next-auth/react'
import { useOrganizationRoles } from '@/lib/query-hooks/permissions'
import { whereGenerator } from '@/components/shared/table-filter/where-generator'
import { getInitialVisibility } from '@/components/shared/column-visibility-menu/column-visibility-menu'
Expand All @@ -37,6 +39,8 @@ const CampaignsPage: React.FC = () => {
const [pagination, setPagination, resetPagination] = useOrgTablePagination(DEFAULT_PAGINATION, TableKeyEnum.CAMPAIGN)
const { setCrumbs } = React.use(BreadcrumbContext)
const { data: permission } = useOrganizationRoles()
const { data: session } = useSession()
const canCreateCampaign = hasPermission(permission?.roles, AccessEnum.CanCreateCampaign, session)
const { handleExport } = useFileExport()

const [orderBy, setOrderBy] = useOrgTableSort(TableKeyEnum.CAMPAIGN, CampaignOrderField, [
Expand Down Expand Up @@ -144,14 +148,16 @@ const CampaignsPage: React.FC = () => {
heading="Campaigns"
subheading="Create and manage questionnaires, acknowledgements, and recurring outreach."
actions={
<Button variant="primary" icon={<SquarePlus size={16} />} iconPosition="left" onClick={() => setIsCreateSheetOpen(true)}>
Create Campaign
</Button>
canCreateCampaign ? (
<Button variant="primary" icon={<SquarePlus size={16} />} iconPosition="left" onClick={() => setIsCreateSheetOpen(true)}>
Create Campaign
</Button>
) : undefined
}
/>
{summary &&
(summary.totalCampaignCount === 0 ? (
<CampaignsEmptyState onCreateCampaign={() => setIsCreateSheetOpen(true)} />
<CampaignsEmptyState onCreateCampaign={canCreateCampaign ? () => setIsCreateSheetOpen(true) : undefined} />
) : (
<CampaignsSummary summary={summary} activeScope={summaryScope} onScopeChange={handleScopeChange} />
))}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ import { SaveIcon } from 'lucide-react'
import { BreadcrumbContext } from '@/providers/BreadcrumbContext'
import { useCreateEmailTemplate, useUpdateEmailTemplate, useEmailTemplate, useEmailTemplateCatalog, usePreviewEmailTemplateHtml } from '@/lib/graphql-hooks/email-template'
import { useNotification } from '@/hooks/useNotification'
import { useSession } from 'next-auth/react'
import { useOrganizationRoles } from '@/lib/query-hooks/permissions'
import { canEdit, hasPermission } from '@/lib/authz/utils'
import { AccessEnum } from '@/lib/authz/enums/access-enum'
import { parseErrorMessage } from '@/utils/graphQlErrorMatcher'
import { EmailTemplateNotificationTemplateFormat, EmailTemplateTemplateContext } from '@repo/codegen/src/schema'
import { EmailTemplateBasicFields } from './email-template-basic-fields'
Expand Down Expand Up @@ -48,6 +52,9 @@ export const EmailTemplateEditorPage: React.FC = () => {
const { mutateAsync: createEmailTemplate, isPending: isCreating } = useCreateEmailTemplate()
const { mutateAsync: updateEmailTemplate, isPending: isUpdating } = useUpdateEmailTemplate()
const { successNotification, errorNotification } = useNotification()
const { data: session } = useSession()
const { data: orgPermission } = useOrganizationRoles()
const canSave = isEditMode ? canEdit(orgPermission?.roles, session) : hasPermission(orgPermission?.roles, AccessEnum.CanCreateEmailTemplate, session)
const [, copyToClipboard] = useCopyToClipboard()

const isPending = isCreating || isUpdating
Expand Down Expand Up @@ -150,9 +157,11 @@ export const EmailTemplateEditorPage: React.FC = () => {
<Button variant="secondary" onClick={handleCancel} disabled={isPending}>
Cancel
</Button>
<Button variant="primary" onClick={handleSave} disabled={isPending || !name.trim() || !selectedKey} icon={<SaveIcon size={16} />} iconPosition="left">
{isPending ? 'Saving...' : isEditMode ? 'Save Changes' : 'Save Draft'}
</Button>
{canSave && (
<Button variant="primary" onClick={handleSave} disabled={isPending || !name.trim() || !selectedKey} icon={<SaveIcon size={16} />} iconPosition="left">
{isPending ? 'Saving...' : isEditMode ? 'Save Changes' : 'Save Draft'}
</Button>
)}
</div>
</div>

Expand All @@ -163,7 +172,7 @@ export const EmailTemplateEditorPage: React.FC = () => {
<div className="flex flex-col gap-6 min-w-0">
<div className="flex items-center justify-between rounded-lg border border-border bg-card p-4">
<span className="text-sm font-semibold">Active</span>
<Switch checked={active} onCheckedChange={setActive} />
<Switch aria-label="Active" checked={active} onCheckedChange={setActive} />
</div>

<EditorCard title="Basic">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ import { useDebounce } from '@uidotdev/usehooks'
import { Check, CircleCheckBig, CopyPlus, Eye, FilePen, LoaderCircle, Mail, MoreHorizontal, Pencil, SearchIcon, SquarePlus, Trash2 } from 'lucide-react'
import { Input } from '@repo/ui/input'
import { Button } from '@repo/ui/button'
import { useSession } from 'next-auth/react'
import { useOrganizationRoles } from '@/lib/query-hooks/permissions'
import { canEdit, hasPermission } from '@/lib/authz/utils'
import { AccessEnum } from '@/lib/authz/enums/access-enum'
import { Badge } from '@repo/ui/badge'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@repo/ui/dropdown-menu'
import { Tabs, TabsList, TabsTrigger } from '@repo/ui/tabs'
Expand Down Expand Up @@ -62,6 +66,11 @@ export const EmailTemplatesTab: React.FC = () => {

const previewTemplate = useMemo(() => emailTemplatesNodes.find((t) => t.id === previewId), [emailTemplatesNodes, previewId])

const { data: session } = useSession()
const { data: orgPermission } = useOrganizationRoles()
const canCreateEmailTemplate = hasPermission(orgPermission?.roles, AccessEnum.CanCreateEmailTemplate, session)
const canEditEmailTemplate = canEdit(orgPermission?.roles, session)

const handleCreate = () => router.push(EDITOR_PATH)
const handleEdit = (id: string) => router.push(`${EDITOR_PATH}?id=${id}`)

Expand Down Expand Up @@ -132,9 +141,11 @@ export const EmailTemplatesTab: React.FC = () => {
</TabsTrigger>
</TabsList>
</Tabs>
<Button variant="primary" icon={<SquarePlus size={16} />} iconPosition="left" onClick={handleCreate}>
Create Email Template
</Button>
{canCreateEmailTemplate && (
<Button variant="primary" icon={<SquarePlus size={16} />} iconPosition="left" onClick={handleCreate}>
Create Email Template
</Button>
)}
</div>
</div>

Expand Down Expand Up @@ -189,38 +200,46 @@ export const EmailTemplatesTab: React.FC = () => {
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
<Button variant="secondary" size="sm" className="px-2 py-2" onClick={() => handleEdit(template.id)} aria-label="Edit template">
<Pencil size={14} />
</Button>
{canEditEmailTemplate && (
<Button variant="secondary" size="sm" className="px-2 py-2" onClick={() => handleEdit(template.id)} aria-label="Edit template">
<Pencil size={14} />
</Button>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="secondary" size="sm" className="px-2 py-2" aria-label="More actions">
<MoreHorizontal size={16} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-48 border shadow-md">
<DropdownMenuItem onClick={() => handleToggleActive(template.id, template.active)}>
<CircleCheckBig className="h-4 w-4" />
{template.active ? 'Disable' : 'Mark Active'}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleDuplicate(template)}>
<CopyPlus className="h-4 w-4" />
Duplicate
</DropdownMenuItem>
{canEditEmailTemplate && (
<DropdownMenuItem onClick={() => handleToggleActive(template.id, template.active)}>
<CircleCheckBig className="h-4 w-4" />
{template.active ? 'Disable' : 'Mark Active'}
</DropdownMenuItem>
)}
{canCreateEmailTemplate && (
<DropdownMenuItem onClick={() => handleDuplicate(template)}>
<CopyPlus className="h-4 w-4" />
Duplicate
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={() => setPreviewId(template.id)}>
<Eye className="h-4 w-4" />
Preview
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive"
onClick={() => {
setDeleteTargetId(template.id)
setDeleteDialogOpen(true)
}}
>
<Trash2 className="h-4 w-4" />
Delete
</DropdownMenuItem>
{canEditEmailTemplate && (
<DropdownMenuItem
className="text-destructive"
onClick={() => {
setDeleteTargetId(template.id)
setDeleteDialogOpen(true)
}}
>
<Trash2 className="h-4 w-4" />
Delete
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
Expand Down
Loading