Allows for tracking settings to be set through org UI (#237)

This commit is contained in:
Chris Anderson 2023-08-11 19:31:10 -07:00 committed by GitHub
parent a053b555da
commit cc54e8e600
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
37 changed files with 9077 additions and 8590 deletions

View file

@ -0,0 +1,33 @@
exports.up = async function(knex) {
await knex.schema.alterTable('projects', function(table) {
table.boolean('link_wrap').defaultTo(0)
})
if (process.env.TRACKING_LINK_WRAP) {
await knex('projects').update({ link_wrap: process.env.TRACKING_LINK_WRAP === 'true' })
}
await knex.schema.alterTable('organizations', function(table) {
table.string('tracking_deeplink_mirror_url', 255)
table.integer('notification_provider_id')
.references('id')
.inTable('providers')
.onDelete('SET NULL')
.nullable()
.unsigned()
})
if (process.env.TRACKING_DEEPLINK_MIRROR_URL) {
await knex('organizations').update({ tracking_deeplink_mirror_url: process.env.TRACKING_DEEPLINK_MIRROR_URL })
}
}
exports.down = async function(knex) {
await knex.schema.alterTable('projects', function(table) {
table.dropColumn('link_wrap')
})
await knex.schema.alterTable('organizations', function(table) {
table.dropColumn('tracking_deeplink_mirror_url')
table.dropColumn('notification_provider_id')
})
}

File diff suppressed because it is too large Load diff

View file

@ -21,6 +21,7 @@ import ProjectApiKeyController from '../projects/ProjectApiKeyController'
import AdminController from '../auth/AdminController'
import OrganizationController from '../organizations/OrganizationController'
import App from '../app'
import { organizationMiddleware } from '../organizations/OrganizationMiddleware'
export const register = (parent: Router, ...routers: Router[]) => {
for (const router of routers) {
@ -61,6 +62,7 @@ export default (app: App) => {
export const adminRouter = () => {
const admin = new Router({ prefix: '/admin' })
admin.use(authMiddleware)
admin.use(organizationMiddleware)
admin.use(scopeMiddleware(['admin', 'secret']))
return register(admin,
ProjectController,
@ -125,6 +127,8 @@ export const clientRouter = () => {
*/
export const publicRouter = () => {
const router = new Router()
router.use(organizationMiddleware)
router.get('/health', async (ctx) => {
ctx.body = {
status: 'ok',

View file

@ -23,10 +23,6 @@ export interface Env {
auth: AuthConfig
error: ErrorConfig
redis: RedisConfig
tracking: {
linkWrap: boolean,
deeplinkMirrorUrl: string | undefined,
}
}
export interface DriverConfig {
@ -150,9 +146,5 @@ export default (type?: EnvType): Env => {
dsn: process.env.ERROR_SENTRY_DSN,
}),
}),
tracking: {
linkWrap: (process.env.TRACKING_LINK_WRAP ?? 'true') === 'true',
deeplinkMirrorUrl: process.env.TRACKING_DEEPLINK_MIRROR_URL,
},
}
}

View file

@ -1,10 +1,19 @@
import { AuthProviderConfig } from '../auth/Auth'
import Model from '../core/Model'
import Model, { ModelParams } from '../core/Model'
export interface TrackingOptions {
linkWrap: boolean,
deeplinkMirrorUrl: string | undefined,
}
export default class Organization extends Model {
username!: string
domain!: string
domain?: string
auth!: AuthProviderConfig
notification_provider_id?: number
tracking_deeplink_mirror_url?: string
static jsonAttributes = ['auth']
}
export type OrganizationParams = Omit<Organization, ModelParams | 'id' | 'auth' | 'notification_provider_id'>

View file

@ -1,12 +1,55 @@
import Router from '@koa/router'
import { JSONSchemaType, validate } from '../core/validate'
import App from '../app'
import { Context } from 'koa'
import { JwtAdmin } from '../auth/AuthMiddleware'
import { getOrganization, organizationIntegrations, updateOrganization } from './OrganizationService'
import Organization, { OrganizationParams } from './Organization'
const router = new Router({
const router = new Router<{
admin: JwtAdmin
organization: Organization
}>({
prefix: '/organizations',
})
router.use(async (ctx: Context, next: () => void) => {
ctx.state.organization = await getOrganization(ctx.state.admin.organization_id)
return next()
})
router.get('/', async ctx => {
ctx.body = ctx.state.organization
})
router.get('/metrics', async ctx => {
ctx.body = await App.main.queue.metrics()
})
router.get('/integrations', async ctx => {
ctx.body = await organizationIntegrations(ctx.state.organization)
})
const organizationUpdateParams: JSONSchemaType<OrganizationParams> = {
$id: 'organizationUpdate',
type: 'object',
required: ['username'],
properties: {
username: { type: 'string' },
domain: {
type: 'string',
nullable: true,
},
tracking_deeplink_mirror_url: {
type: 'string',
nullable: true,
},
},
additionalProperties: false,
}
router.patch('/:id', async ctx => {
const payload = validate(organizationUpdateParams, ctx.request.body)
ctx.body = await updateOrganization(ctx.state.organization, payload)
})
export default router

View file

@ -0,0 +1,10 @@
import { Context } from 'koa'
import { getOrganizationByUsername } from './OrganizationService'
export const organizationMiddleware = async (ctx: Context, next: () => void) => {
if (ctx.subdomains && ctx.subdomains[0]) {
const subdomain = ctx.subdomains[0]
ctx.state.organization = await getOrganizationByUsername(subdomain)
}
return next()
}

View file

@ -1,4 +1,5 @@
import { Admin } from '../auth/Admin'
import Provider from '../providers/Provider'
import { encodeHashid } from '../utilities'
import Organization from './Organization'
@ -41,3 +42,14 @@ export const createOrganization = async (domain: string): Promise<Organization>
}
return org
}
export const updateOrganization = async (organization: Organization, params: Partial<Organization>) => {
return await Organization.updateAndFetch(organization.id, params)
}
export const organizationIntegrations = async (organization: Organization) => {
return await Provider.all(
qb => qb.leftJoin('projects', 'projects.id', 'providers.project_id')
.where('projects.organization_id', organization.id),
)
}

View file

@ -8,6 +8,7 @@ export default class Project extends Model {
locale?: string
timezone!: string
text_opt_out_message?: string
link_wrap?: boolean
}
export type ProjectParams = Omit<Project, ModelParams | 'deleted_at' | 'organization_id'>

View file

@ -57,9 +57,7 @@ const projectCreateParams: JSONSchemaType<ProjectParams> = {
type: 'object',
required: ['name', 'timezone'],
properties: {
name: {
type: 'string',
},
name: { type: 'string' },
description: {
type: 'string',
nullable: true,
@ -73,6 +71,10 @@ const projectCreateParams: JSONSchemaType<ProjectParams> = {
type: 'string',
nullable: true,
},
link_wrap: {
type: 'boolean',
nullable: true,
},
},
additionalProperties: false,
}
@ -118,6 +120,10 @@ const projectUpdateParams: JSONSchemaType<Partial<ProjectParams>> = {
type: 'string',
nullable: true,
},
link_wrap: {
type: 'boolean',
nullable: true,
},
},
additionalProperties: false,
}

View file

@ -67,7 +67,6 @@ export async function loadSendJob<T extends TemplateType>({ campaign_id, user_id
template_id: template.id,
channel: campaign.channel,
subscription_id: campaign.subscription_id,
project,
user_step_id,
}

View file

@ -1,4 +1,3 @@
import { DriverConfig } from '../../config/env'
import { AnalyticsProvider, AnalyticsProviderName, AnalyticsUserEvent } from './AnalyticsProvider'

View file

@ -20,7 +20,7 @@ export default class EmailJob extends Job {
const data = await loadSendJob<EmailTemplate>(trigger)
if (!data) return
const { campaign, template, user, project, event, context } = data
const { campaign, template, user, project, context } = data
// Load email channel so its ready to send
const channel = await loadEmailChannel(campaign.provider_id, project.id)
@ -40,7 +40,7 @@ export default class EmailJob extends Job {
if (!isReady) return
try {
await channel.send(template, { user, event, context })
await channel.send(template, data)
} catch (error: any) {
// On error, mark as failed and notify just in case

View file

@ -21,7 +21,7 @@ export default class PushJob extends Job {
const data = await loadSendJob<PushTemplate>(trigger)
if (!data) return
const { campaign, template, user, project, event, context } = data
const { campaign, template, user, project, context } = data
try {
// Load email channel so its ready to send
@ -41,7 +41,7 @@ export default class PushJob extends Job {
if (!isReady) return
// Send the push and update the send record
await channel.send(template, { user, event, context })
await channel.send(template, data)
await updateSendState({
campaign,
user,

View file

@ -37,8 +37,8 @@ export default class TextChannel {
user_id: variables.user.id,
name: 'text_sent',
}))
if (!hasReceivedOptOut && variables.context.project.text_opt_out_message) {
compiled.text += `\n${variables.context.project.text_opt_out_message}`
if (!hasReceivedOptOut && variables.project.text_opt_out_message) {
compiled.text += `\n${variables.project.text_opt_out_message}`
}
return compiled

View file

@ -20,7 +20,7 @@ export default class TextJob extends Job {
const data = await loadSendJob<TextTemplate>(trigger)
if (!data) return
const { campaign, template, user, project, event, context } = data
const { campaign, template, user, project, context } = data
// Send and render text
const channel = await loadTextChannel(campaign.provider_id, project.id)
@ -35,15 +35,13 @@ export default class TextJob extends Job {
return
}
const variables = { user, event, context }
// Check current send rate and if the send is locked
// Increment rate limitter by number of segments
const segments = await channel.segments(template, variables)
const segments = await channel.segments(template, data)
const isReady = await prepareSend(channel, data, raw, segments)
if (!isReady) return
await channel.send(template, variables)
await channel.send(template, data)
// Update send record
await updateSendState({

View file

@ -1,5 +1,6 @@
import { Admin } from '../../../auth/Admin'
import { createProject } from '../../../projects/ProjectService'
import { Variables } from '../../../render'
import { TextTemplate } from '../../../render/Template'
import { UserEvent } from '../../../users/UserEvent'
import { createUser } from '../../../users/UserRepository'
@ -10,7 +11,7 @@ import TextChannel from '../TextChannel'
describe('TextChannel', () => {
const optOut = 'Reply STOP to opt out.'
const setup = async (text_opt_out_message?: string) => {
const setup = async (text_opt_out_message?: string): Promise<{ variables: Variables, template: TextTemplate }> => {
const text = 'Hello world!'
const admin = await Admin.insertAndFetch({
first_name: uuid(),
@ -36,8 +37,8 @@ describe('TextChannel', () => {
template_id: 1,
campaign_id: 1,
subscription_id: 1,
project,
},
project,
},
template,
}
@ -60,7 +61,7 @@ describe('TextChannel', () => {
await UserEvent.insert({
user_id: variables.user.id,
name: 'text_sent',
project_id: variables.context.project.id,
project_id: variables.project.id,
})
const message = await channel.build(template, variables)

View file

@ -18,7 +18,7 @@ export default class WebhookJob extends Job {
const data = await loadSendJob<WebhookTemplate>(trigger)
if (!data) return
const { campaign, template, user, project, event, context } = data
const { campaign, template, user, project, context } = data
// Send and render webhook
const channel = await loadWebhookChannel(campaign.provider_id, project.id)
@ -36,7 +36,7 @@ export default class WebhookJob extends Job {
const isReady = await prepareSend(channel, data, raw)
if (!isReady) return
await channel.send(template, { user, event, context })
await channel.send(template, data)
// Update send record
await updateSendState({

View file

@ -1,8 +1,12 @@
import Router from '@koa/router'
import App from '../app'
import { encodedLinkToParts, trackMessageEvent } from './LinkService'
import Organization from '../organizations/Organization'
const router = new Router<{app: App}>()
const router = new Router<{
app: App
organization?: Organization
}>()
router.get('/c', async ctx => {
@ -26,7 +30,7 @@ router.get('/o', async ctx => {
})
router.get('/.well-known/:file', async ctx => {
const url = App.main.env.tracking.deeplinkMirrorUrl
const url = ctx.state.organization?.tracking_deeplink_mirror_url
const file = ctx.params.file
if (!url) {
ctx.status = 404

View file

@ -277,6 +277,7 @@ router.post('/:templateId/preview', async ctx => {
user: User.fromJson({ ...payload.user, data: payload.user }),
event: UserEvent.fromJson(payload.event || {}),
context: payload.context || {},
project: ctx.state.project,
})
})

View file

@ -13,6 +13,7 @@ import { loadPushChannel } from '../providers/push'
import { getUserFromEmail, getUserFromPhone } from '../users/UserRepository'
import { loadWebhookChannel } from '../providers/webhook'
import Project from '../projects/Project'
import { getProject } from '../projects/ProjectService'
export const pagedTemplates = async (params: PageParams, projectId: number) => {
return await Template.search(
@ -87,35 +88,35 @@ export const validateTemplates = async (projectId: number, campaignId: number) =
export const sendProof = async (template: TemplateType, variables: Variables, recipient: string) => {
const campaign = await getCampaign(template.campaign_id, template.project_id)
if (!campaign) throw new RequestError(CampaignError.CampaignDoesNotExist)
const project = await getProject(template.project_id)
if (!campaign || !project) throw new RequestError(CampaignError.CampaignDoesNotExist)
const event = UserEvent.fromJson(variables.event || {})
const context = {
...variables.context,
campaign_id: template.campaign_id,
}
const projectId = template.project_id
const user = (await getUserFromEmail(projectId, recipient))
?? (await getUserFromPhone(projectId, recipient))
const user = (await getUserFromEmail(project.id, recipient))
?? (await getUserFromPhone(project.id, recipient))
?? User.fromJson({ ...variables.user, email: recipient, phone: recipient })
user.data = { ...user?.data, ...variables.user }
variables = { user, event, context }
variables = { user, event, context, project }
if (template.type === 'email') {
const channel = await loadEmailChannel(campaign.provider_id, projectId)
const channel = await loadEmailChannel(campaign.provider_id, project.id)
await channel?.send(template, variables)
} else if (template.type === 'text') {
const channel = await loadTextChannel(campaign.provider_id, projectId)
const channel = await loadTextChannel(campaign.provider_id, project.id)
await channel?.send(template, variables)
} else if (template.type === 'push') {
const channel = await loadPushChannel(campaign.provider_id, projectId)
const channel = await loadPushChannel(campaign.provider_id, project.id)
if (!user.id) throw new RequestError('Unable to find a user matching the criteria.')
await channel?.send(template, variables)
} else if (template.type === 'webhook') {
const channel = await loadWebhookChannel(campaign.provider_id, projectId)
const channel = await loadWebhookChannel(campaign.provider_id, project.id)
await channel?.send(template, variables)
} else {
throw new RequestError('Sending template proofs is only supported for email and text message types as this time.')

View file

@ -8,11 +8,9 @@ import * as ArrayHelpers from './Helpers/Array'
import { User } from '../users/User'
import { preferencesLink, unsubscribeEmailLink } from '../subscriptions/SubscriptionService'
import { clickWrapHtml, openWrapHtml, preheaderWrapHtml } from './LinkService'
import App from '../app'
import Project from '../projects/Project'
export type RenderContext = {
project: Project
template_id: number
campaign_id: number
subscription_id: number
@ -23,6 +21,7 @@ export interface Variables {
context: RenderContext
user: User
event?: Record<string, any>
project: Project
}
export interface TrackingParams {
@ -54,7 +53,7 @@ interface WrapParams {
preheader?: string
variables: Variables
}
export const Wrap = ({ html, preheader, variables: { user, context } }: WrapParams) => {
export const Wrap = ({ html, preheader, variables: { user, context, project } }: WrapParams) => {
const trackingParams = {
userId: user.id,
campaignId: context.campaign_id,
@ -62,7 +61,7 @@ export const Wrap = ({ html, preheader, variables: { user, context } }: WrapPara
}
// Check if link wrapping is enabled first
if (App.main.env.tracking.linkWrap) {
if (project.link_wrap) {
html = clickWrapHtml(html, trackingParams)
}

2501
apps/ui/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
import Axios from 'axios'
import { env } from './config/env'
import { Admin, AuthMethod, Campaign, CampaignCreateParams, CampaignLaunchParams, CampaignUpdateParams, CampaignUser, Image, Journey, JourneyStepMap, JourneyStepStats, List, ListCreateParams, ListUpdateParams, Project, ProjectAdmin, ProjectAdminParams, ProjectApiKey, ProjectApiKeyParams, Provider, ProviderCreateParams, ProviderMeta, ProviderUpdateParams, QueueMetric, RuleSuggestions, SearchParams, SearchResult, Subscription, SubscriptionParams, Tag, Template, TemplateCreateParams, TemplatePreviewParams, TemplateProofParams, TemplateUpdateParams, User, UserEvent, UserSubscription } from './types'
import { Admin, AuthMethod, Campaign, CampaignCreateParams, CampaignLaunchParams, CampaignUpdateParams, CampaignUser, Image, Journey, JourneyStepMap, JourneyStepStats, List, ListCreateParams, ListUpdateParams, Organization, OrganizationUpdateParams, Project, ProjectAdmin, ProjectAdminParams, ProjectApiKey, ProjectApiKeyParams, Provider, ProviderCreateParams, ProviderMeta, ProviderUpdateParams, QueueMetric, RuleSuggestions, SearchParams, SearchResult, Subscription, SubscriptionParams, Tag, Template, TemplateCreateParams, TemplatePreviewParams, TemplateProofParams, TemplateUpdateParams, User, UserEvent, UserSubscription } from './types'
function appendValue(params: URLSearchParams, name: string, value: unknown) {
if (typeof value === 'undefined' || value === null || typeof value === 'function') return
@ -277,6 +277,12 @@ const api = {
},
organizations: {
get: async () => await client
.get<Organization>('/admin/organizations')
.then(r => r.data),
update: async (id: number | string, params: OrganizationUpdateParams) => await client
.patch<Organization>(`/admin/organizations/${id}`, params)
.then(r => r.data),
metrics: async () => await client
.get<QueueMetric>('/admin/organizations/metrics')
.then(r => r.data),

View file

@ -42,12 +42,12 @@ h2 {
}
h3 {
font-size: 20px;
font-size: 22px;
line-height: 24px;
}
h4 {
font-size: 16px;
font-size: 18px;
line-height: 22px;
}

View file

@ -119,11 +119,15 @@ export interface Admin {
}
export interface Organization {
id: number
username: string
domain: string
auth: any
tracking_deeplink_mirror_url?: string
}
export type OrganizationUpdateParams = Omit<Organization, 'id' | 'auth' | AuditFields>
export const projectRoles = [
'support',
'editor',
@ -150,6 +154,7 @@ export interface Project {
locale: string
timezone: string
text_opt_out_message: string
link_wrap: boolean
created_at: string
updated_at: string
deleted_at?: string

View file

@ -22,7 +22,6 @@ export default function PreviewImage({
const handleLoad = (event: React.SyntheticEvent<HTMLIFrameElement, Event> | undefined) => {
const state = (event?.target as any).contentWindow?.document.body.innerHTML.length > 0
console.log(url, state)
setLoaded(state)
}

View file

@ -1,12 +1,12 @@
import { ReactNode, useState } from 'react'
import { DeepPartial, DeepRequired, FieldErrorsImpl, FieldValues, useForm, UseFormReturn } from 'react-hook-form'
import { DeepRequired, DefaultValues, FieldErrorsImpl, FieldValues, useForm, UseFormReturn } from 'react-hook-form'
import { NavigateFunction, useNavigate } from 'react-router-dom'
import Alert from '../Alert'
import Button from '../Button'
interface FormWrapperProps<T extends FieldValues> {
children: (form: UseFormReturn<T>) => ReactNode
defaultValues?: DeepPartial<T>
defaultValues?: DefaultValues<T>
submitLabel?: string
onSubmit: (data: T, navigate: NavigateFunction) => Promise<void>
onError?: (error: Error) => void

View file

@ -219,3 +219,9 @@ export const QuestionIcon = () => (
<path d="M5.255 5.786a.237.237 0 0 0 .241.247h.825c.138 0 .248-.113.266-.25.09-.656.54-1.134 1.342-1.134.686 0 1.314.343 1.314 1.168 0 .635-.374.927-.965 1.371-.673.489-1.206 1.06-1.168 1.987l.003.217a.25.25 0 0 0 .25.246h.811a.25.25 0 0 0 .25-.25v-.105c0-.718.273-.927 1.01-1.486.609-.463 1.244-.977 1.244-2.056 0-1.511-1.276-2.241-2.673-2.241-1.267 0-2.655.59-2.75 2.286zm1.557 5.763c0 .533.425.927 1.01.927.609 0 1.028-.394 1.028-.927 0-.552-.42-.94-1.029-.94-.584 0-1.009.388-1.009.94z"/>
</svg>
)
export const IntegrationsIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="icon">
<path strokeLinecap="round" strokeLinejoin="round" d="M6.429 9.75L2.25 12l4.179 2.25m0-4.5l5.571 3 5.571-3m-11.142 0L2.25 7.5 12 2.25l9.75 5.25-4.179 2.25m0 0L21.75 12l-4.179 2.25m0 0l4.179 2.25L12 21.75 2.25 16.5l4.179-2.25m11.142 0l-5.571 3-5.571-3" />
</svg>
)

View file

@ -45,7 +45,7 @@ function GrapesReact({ id, mjml, onChange, setAssetState }: GrapesReactProps) {
setEditor(editor)
editor.on('load', () => {
editor.Panels.getButton('views', 'open-blocks')
.set('active', true)
?.set('active', true)
})
editor.render()
editor.setComponents(mjml ?? '<mjml><mj-body></mj-body></mjml>')

View file

@ -0,0 +1,43 @@
import { useContext } from 'react'
import PageContent from '../../ui/PageContent'
import FormWrapper from '../../ui/form/FormWrapper'
import { OrganizationContext } from '../../contexts'
import TextInput from '../../ui/form/TextInput'
import { Organization } from '../../types'
import Heading from '../../ui/Heading'
import api from '../../api'
import { toast } from 'react-hot-toast'
export default function Settings() {
const [organization] = useContext(OrganizationContext)
return (
<>
<PageContent title="Settings">
<FormWrapper<Organization>
defaultValues={organization}
onSubmit={async ({ username, domain, tracking_deeplink_mirror_url }) => {
await api.organizations.update(organization.id, { username, domain, tracking_deeplink_mirror_url })
toast.success('Saved organization settings')
}}
submitLabel="Save Settings"
>
{form => <>
<Heading size="h3" title="General" />
<TextInput.Field
form={form}
name="username"
subtitle="The organization username. Used for the subdomain that the organization is hosted under." />
<TextInput.Field
form={form}
name="domain"
subtitle="If filled, users who log in with SSO and have this domain will be automatically joined to the organization." />
<Heading size="h3" title="Tracking" />
<TextInput.Field form={form} name="tracking_deeplink_mirror_url" label="Tracking Deeplink Mirror URL"
subtitle="The URL to clone universal link settings from." />
</>}
</FormWrapper>
</PageContent>
</>
)
}

View file

@ -3,6 +3,8 @@ import TextInput from '../../ui/form/TextInput'
import { Project } from '../../types'
import FormWrapper from '../../ui/form/FormWrapper'
import { SingleSelect } from '../../ui/form/SingleSelect'
import SwitchField from '../../ui/form/SwitchField'
import Heading from '../../ui/Heading'
// eslint-disable-next-line @typescript-eslint/no-namespace
export declare namespace Intl {
@ -42,18 +44,23 @@ export default function ProjectForm({ project, onSave }: ProjectFormProps) {
return (
<FormWrapper<Project>
defaultValues={defaults}
onSubmit={async ({ id, name, description, locale, timezone, text_opt_out_message }) => {
onSubmit={async ({ id, name, description, locale, timezone, text_opt_out_message, link_wrap }) => {
const params = { name, description, locale, timezone, text_opt_out_message, link_wrap }
const project = id
? await api.projects.update(id, { name, description, locale, timezone, text_opt_out_message })
: await api.projects.create({ name, description, locale, timezone, text_opt_out_message })
? await api.projects.update(id, params)
: await api.projects.create(params)
onSave?.(project)
}}
submitLabel="Save Settings"
>
{
form => (
<>
<TextInput.Field form={form} name="name" required />
<TextInput.Field form={form} name="description" textarea />
<Heading size="h4" title="Defaults" />
<TextInput.Field
form={form}
name="locale"
@ -67,11 +74,13 @@ export default function ProjectForm({ project, onSave }: ProjectFormProps) {
label="Timezone"
required
/>
<Heading size="h4" title="Message Settings" />
<TextInput.Field
form={form}
name="text_opt_out_message"
label="SMS Opt Out Message"
subtitle="Instructions on how to opt out of SMS that will be appended to every text." />
<SwitchField form={form} name="link_wrap" label="Link Wrapping" subtitle="Enable link wrapping for all links in messages." />
</>
)
}

View file

@ -91,7 +91,7 @@ export default function ProjectSidebar({ children, links }: PropsWithChildren<Si
</div>
</div>
}>
<MenuItem onClick={() => navigate('/settings')}>Settings</MenuItem>
<MenuItem onClick={() => navigate('/organization')}>Settings</MenuItem>
<MenuItem onClick={() => setPreferences({ ...preferences, mode: preferences.mode === 'dark' ? 'light' : 'dark' })}>Use {preferences.mode === 'dark' ? 'Light' : 'Dark'} Theme</MenuItem>
<MenuItem onClick={async () => await api.auth.logout()}>Sign Out</MenuItem>
</Menu>

View file

@ -42,14 +42,6 @@ export function Projects() {
}
}, [projects, navigate])
if (!projects) {
return (
<div>
loading...
</div>
)
}
return (
<PageContent
title="Projects"

View file

@ -4,7 +4,7 @@ import api from '../api'
import ErrorPage from './ErrorPage'
import Sidebar from '../ui/Sidebar'
import { LoaderContextProvider, StatefulLoaderContextProvider } from './LoaderContextProvider'
import { AdminContext, CampaignContext, JourneyContext, ListContext, ProjectContext, UserContext } from '../contexts'
import { AdminContext, CampaignContext, JourneyContext, ListContext, OrganizationContext, ProjectContext, UserContext } from '../contexts'
import ApiKeys from './settings/ApiKeys'
import EmailEditor from './campaign/editor/EmailEditor'
import Lists from './users/Lists'
@ -40,6 +40,7 @@ import Performance from './organization/Performance'
import Settings from './settings/Settings'
import ProjectSidebar from './project/ProjectSidebar'
import Admins from './organization/Admins'
import OrganizationSettings from './organization/Settings'
export const useRoute = (includeProject = true) => {
const { projectId = '' } = useParams()
@ -90,9 +91,12 @@ export const router = createBrowserRouter([
],
},
{
path: 'settings',
path: 'organization',
loader: async () => {
return await api.organizations.get()
},
element: (
<StatefulLoaderContextProvider context={ProjectContext}>
<StatefulLoaderContextProvider context={OrganizationContext}>
<Sidebar
links={[
{
@ -113,6 +117,12 @@ export const router = createBrowserRouter([
children: 'Performance',
icon: <PerformanceIcon />,
},
{
key: 'settings',
to: 'settings',
children: 'Settings',
icon: <SettingsIcon />,
},
]}
>
<Outlet />
@ -138,6 +148,10 @@ export const router = createBrowserRouter([
path: 'performance',
element: <Performance />,
},
{
path: 'settings',
element: <OrganizationSettings />,
},
],
},
{

3025
docs/package-lock.json generated

File diff suppressed because it is too large Load diff

10412
package-lock.json generated

File diff suppressed because it is too large Load diff