Add soft deletes to providers (#642)

This commit is contained in:
Chris Anderson 2025-03-08 17:06:49 -06:00 committed by GitHub
parent d1c0cf1500
commit 456a97851e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 84 additions and 19 deletions

View file

@ -0,0 +1,11 @@
exports.up = async function(knex) {
await knex.schema.table('providers', function(table) {
table.timestamp('deleted_at').nullable()
})
}
exports.down = async function(knex) {
await knex.schema.table('providers', function(table) {
table.dropColumn('deleted_at')
})
}

View file

@ -65,6 +65,7 @@ export default class Provider extends Model {
rate_limit!: number
rate_interval!: RateInterval
setup!: ProviderSetupMeta[]
deleted_at?: Date
static jsonAttributes = ['data']

View file

@ -6,7 +6,7 @@ import { analyticsProviders } from './analytics'
import { emailProviders } from './email'
import Provider from './Provider'
import { getProvider } from './ProviderRepository'
import { allProviders, loadController, pagedProviders } from './ProviderService'
import { allProviders, archiveProvider, loadController, pagedProviders } from './ProviderService'
import { pushProviders } from './push'
import { textProviders } from './text'
import { webhookProviders } from './webhook'
@ -74,4 +74,8 @@ adminRouter.get('/meta', async ctx => {
}))
})
adminRouter.delete('/:id', async ctx => {
ctx.body = await archiveProvider(parseInt(ctx.params.id), ctx.state.project.id)
})
export { adminRouter, publicRouter, providers }

View file

@ -3,11 +3,11 @@ import { ProjectState } from '../auth/AuthMiddleware'
import { PageParams } from '../core/searchParams'
import { JSONSchemaType, validate } from '../core/validate'
import Provider, { ProviderControllers, ProviderGroup, ProviderMeta, ProviderParams } from './Provider'
import { createProvider, loadProvider, updateProvider } from './ProviderRepository'
import { createProvider, getProvider, loadProvider, updateProvider } from './ProviderRepository'
import App from '../app'
export const allProviders = async (projectId: number) => {
return await Provider.all(qb => qb.where('project_id', projectId))
return await Provider.all(qb => qb.where('project_id', projectId).whereNull('deleted_at'))
}
export const hasProvider = async (projectId: number) => {
@ -17,7 +17,7 @@ export const hasProvider = async (projectId: number) => {
export const pagedProviders = async (params: PageParams, projectId: number) => {
return await Provider.search(
{ ...params, fields: ['name', 'group'] },
b => b.where('project_id', projectId),
b => b.where('project_id', projectId).whereNull('deleted_at'),
App.main.db,
(item) => {
item.setup = item.loadSetup(App.main)
@ -26,6 +26,11 @@ export const pagedProviders = async (params: PageParams, projectId: number) => {
)
}
export const archiveProvider = async (id: number, projectId: number) => {
await Provider.archive(id, qb => qb.where('project_id', projectId))
return getProvider(id, projectId)
}
export const loadController = (routers: ProviderControllers, provider: typeof Provider): ProviderMeta => {
const { admin: adminRouter, public: publicRouter } = provider.controllers()
if (routers.admin && adminRouter) {

View file

@ -5,6 +5,7 @@
"add_admin": "Add Admin",
"add_admin_description": "Add a new admin to this organization. Admins have full access to all projects and settings, members can only access projects they are a part of.",
"add_font": "Add Font",
"add_integration": "Add Integration",
"add_list": "Add List",
"add_locale": "Add Locale",
"add_team_member": "Add Team Member",
@ -52,6 +53,7 @@
"create": "Create",
"create_campaign": "Create Campaign",
"create_journey": "Create Journey",
"create_key": "Create Key",
"create_list": "Create List",
"create_locale": "Create Locale",
"create_project": "Create Project",
@ -77,6 +79,8 @@
"delay_until": "Delay Until",
"delete": "Delete",
"delete_admin_confirmation": "Are you sure you want to delete this admin?",
"delete_integration_confirmation": "Are you sure you want to archive this integration?",
"delete_key_confirmation": "Are you sure you want to archive this key? All clients using the key will immediately be unable to access the API.",
"delete_user": "Delete User",
"delete_user_confirmation": "Are you sure you want to delete this user? All existing data will be removed.\n\nNote: If new data is sent for this user, they will be re-created with whatever data is sent.",
"delivery": "Delivery",
@ -259,6 +263,7 @@
"save_settings": "Save Settings",
"schedule": "Schedule",
"scheduled": "Scheduled",
"scope": "Scope",
"search": "Search",
"search_users": "Search by ID, email or phone",
"second": "Second",
@ -318,6 +323,7 @@
"unsubscribe_all": "Unsubscribe From All",
"until_date": "Until a Date",
"until_time": "Until a Time",
"update_key": "Update Key",
"update_permissions": "Update Permissions",
"update_subscription": "Update Subscription",
"update_tag": "Update Tag",
@ -337,6 +343,7 @@
"users_change_subscription_status": "Are you sure you want to change the status of this subscription?",
"users_count": "Users Count",
"users_unsubscribe_all": "Are you sure you want to unsubscribe from all?",
"value": "Value",
"view_all": "View All",
"visual": "Visual",
"wait": "Wait",

View file

@ -5,6 +5,7 @@
"add_admin": "Añadir Admin",
"add_admin_description": "Añade un nuevo administrador a esta organización. Los administradores tienen acceso completo a todos los proyectos y ajustes, los miembros sólo pueden acceder a los proyectos de los que forman parte.",
"add_font": "Añadir Fuente",
"add_integration": "Añadir Integración",
"add_list": "Añadir lista",
"add_locale": "Añadir localidad",
"add_team_member": "Añadir miembro al equipo",
@ -52,6 +53,7 @@
"create": "Crear",
"create_campaign": "Crear Campaña",
"create_journey": "Crear Camino",
"create_key": "Crear Clave",
"create_list": "Crear Lista",
"create_locale": "Crear localidad",
"create_project": "Crear proyecto",
@ -77,6 +79,8 @@
"delay_until": "Espera Hasta",
"delete": "Borrar",
"delete_admin_confirmation": "¿Estás seguro de que deseas eliminar este administrador?",
"delete_integration_confirmation": "¿Estás seguro de que deseas eliminar esta integración?",
"delete_key_confirmation": "¿Estás seguro de que deseas eliminar esta clave?",
"delete_user": "Borrar usuario",
"delete_user_confirmation": "¿Estás seguro de que deseas eliminar este usuario? Todos los datos existentes serán eliminados. \n\n Nota: Si se envían datos nuevos para este usuario, se volverán a crear con los datos que se envíen.",
"delivery": "Resultados",
@ -259,6 +263,7 @@
"save_settings": "Guardar ajustes",
"schedule": "Planea Horario",
"scheduled": "Programado",
"scope": "Alcance",
"search": "Buscar",
"search_users": "Búsqueda por ID, correo electrónico o teléfono",
"second": "Segundo",
@ -277,6 +282,8 @@
"sign_out": "Cerrar sesión",
"sms_opt_out_message": "Mensaje SMS de exclusión",
"sms_opt_out_message_subtitle": "Instrucciones sobre cómo cancelar la recepción de SMS que se adjuntarán a cada texto.",
"sms_help_message": "Mensaje SMS de ajuda",
"sms_help_message_subtitle": "Instrucciones sobre cómo recibir ayuda que se envían automáticamente a los usuarios si responden con AYUDA.",
"start_journey": "Comienza el camino: ",
"state": "Estado",
"static": "Estático",
@ -316,6 +323,7 @@
"unsubscribe_all": "Darse de baja de todos",
"until_date": "Hasta Una Fecha",
"until_time": "Hasta un Tiempo",
"update_key": "Actualizar Clave",
"update_permissions": "Actualizar permisos",
"update_subscription": "Actualizar suscripción",
"update_tag": "Actualizar Etiqueta",
@ -335,6 +343,7 @@
"users_change_subscription_status": "¿Está seguro de que desea cambiar el estado de esta suscripción?",
"users_count": "Número de usuarios",
"users_unsubscribe_all": "¿Estás seguro de que quieres darte de baja de todos?",
"value": "Valor",
"view_all": "Ver Todo",
"visual": "Visual",
"wait": "Espere",

View file

@ -281,6 +281,9 @@ const api = {
update: async (projectId: number | string, entityId: number | string, { group, type, ...provider }: ProviderUpdateParams) => await client
.patch<Provider>(`${projectUrl(projectId)}/providers/${group}/${type}/${entityId}`, provider)
.then(r => r.data),
delete: async (projectId: number | string, id: number) => await client
.delete<number>(`${projectUrl(projectId)}/providers/${id}`)
.then(r => r.data),
},
images: {

View file

@ -14,16 +14,17 @@ import { SingleSelect } from '../../ui/form/SingleSelect'
import { snakeToTitle } from '../../utils'
import { toast } from 'react-hot-toast/headless'
import Alert from '../../ui/Alert'
import { useTranslation } from 'react-i18next'
export default function ProjectApiKeys() {
const { t } = useTranslation()
const [project] = useContext(ProjectContext)
const state = useSearchTableState(useCallback(async params => await api.apiKeys.search(project.id, params), [project]))
const [editing, setEditing] = useState<null | Partial<ProjectApiKey>>(null)
const handleArchive = async (id: number) => {
if (confirm('Are you sure you want to archive this key? All clients using the key will immediately be unable to access the API.')) {
if (confirm(t('delete_key_confirmation'))) {
await api.apiKeys.delete(project.id, id)
await state.reload()
}
@ -40,19 +41,22 @@ export default function ProjectApiKeys() {
<SearchTable
{...state}
columns={[
{ key: 'name' },
{ key: 'name', title: t('name') },
{
key: 'scope',
title: t('scope'),
cell: ({ item }) => snakeToTitle(item.scope),
},
{
key: 'role',
title: t('role'),
cell: ({ item }) => item.scope === 'public'
? undefined
: snakeToTitle(item.role ?? ''),
},
{
key: 'value',
title: t('value'),
cell: ({ item }) => (
<div className="cell-content">
{item.value}
@ -60,13 +64,17 @@ export default function ProjectApiKeys() {
</div>
),
},
{ key: 'description' },
{
key: 'description',
title: t('description'),
},
{
key: 'options',
title: t('options'),
cell: ({ item: { id } }) => (
<Menu size="small">
<MenuItem onClick={async () => await handleArchive(id)}>
<ArchiveIcon />Archive
<ArchiveIcon />{t('archive')}
</MenuItem>
</Menu>
),
@ -74,19 +82,19 @@ export default function ProjectApiKeys() {
]}
itemKey={({ item }) => item.id}
onSelectRow={setEditing}
title="API Keys"
title={t('api_keys')}
actions={
<Button
icon={<PlusIcon />}
size="small"
onClick={() => setEditing({ scope: 'public', role: 'support' })}
>
Create Key
{t('create_key')}
</Button>
}
/>
<Modal
title={editing ? 'Update API Key' : 'Create API Key'}
title={editing?.id ? t('update_key') : t('create_key')}
open={Boolean(editing)}
onClose={() => setEditing(null)}
>
@ -106,7 +114,7 @@ export default function ProjectApiKeys() {
}
}
defaultValues={editing}
submitLabel={editing?.id ? 'Update Key' : 'Create Key'}
submitLabel={editing?.id ? t('update_key') : t('create_key')}
>
{
form => {
@ -116,18 +124,18 @@ export default function ProjectApiKeys() {
<TextInput.Field
form={form}
name="name"
label="Name"
label={t('name')}
required
/>
<TextInput.Field
form={form}
name="description"
label="Description"
label={t('description')}
/>
<RadioInput.Field
form={form}
name="scope"
label="Scope"
label={t('scope')}
options={[
{ key: 'public', label: 'Public' },
{ key: 'secret', label: 'Secret' },
@ -139,7 +147,7 @@ export default function ProjectApiKeys() {
<SingleSelect.Field
form={form}
name="role"
label="Role"
label={t('role')}
options={projectRoles}
getOptionDisplay={snakeToTitle}
required

View file

@ -4,10 +4,11 @@ import { ProjectContext } from '../../contexts'
import { Provider } from '../../types'
import Button from '../../ui/Button'
import Heading from '../../ui/Heading'
import { PlusIcon } from '../../ui/icons'
import { ArchiveIcon, PlusIcon } from '../../ui/icons'
import { SearchTable, useSearchTableState } from '../../ui/SearchTable'
import IntegrationModal from './IntegrationModal'
import { useTranslation } from 'react-i18next'
import { Menu, MenuItem } from '../../ui'
export default function Integrations() {
const { t } = useTranslation()
@ -15,6 +16,11 @@ export default function Integrations() {
const state = useSearchTableState(useCallback(async params => await api.providers.search(project.id, params), [project]))
const [isModalOpen, setIsModalOpen] = useState(false)
const [provider, setProvider] = useState<Provider>()
const handleArchive = async (id: number) => {
if (!confirm(t('delete_integration_confirmation'))) return
await api.providers.delete(project.id, id)
await state.reload()
}
return (
<>
@ -22,7 +28,7 @@ export default function Integrations() {
<Button icon={<PlusIcon />} size="small" onClick={() => {
setProvider(undefined)
setIsModalOpen(true)
}}>Add Integration</Button>
}}>{t('add_integration')}</Button>
} />
<SearchTable
{...state}
@ -31,6 +37,17 @@ export default function Integrations() {
{ key: 'type', title: t('type') },
{ key: 'group', title: t('group') },
{ key: 'created_at', title: t('created_at') },
{
key: 'options',
title: t('options'),
cell: ({ item: { id } }) => (
<Menu size="small">
<MenuItem onClick={async () => await handleArchive(id)}>
<ArchiveIcon />{t('archive')}
</MenuItem>
</Menu>
),
},
]}
itemKey={({ item }) => item.id}
onSelectRow={(provider: Provider) => {