gigafibre-fsm/apps/ops/src/modules/campaigns/components/SenderField.vue
louispaulb 1b7bad1686 security: harden payments/hub auth + remove leaked ERPNext token from source
Auth hardening:
- payments: per-customer JWT authorization on dual-use /payments routes
  (balance/methods/invoice/checkout/setup/portal/toggle-ppa) via authorizeCustomer
  + PAYMENTS_AUTH=enforce; portal retains+sends magic-link JWT (sessionStorage)
- hub: fail-closed Stripe webhook, /accept/doc-pdf IDOR gate, telephony field-name
  guard (SQLi), modem-bridge private-IP guard (SSRF), ALWAYS_ENFORCE expansion

Leaked-credential cleanup (token already rotated in ERPNext):
- de-hardcode ERPNext API token -> env in bulk_submit.py, import_items.py,
  apps/client/deploy.sh; placeholder in apps/ops/infra/nginx.conf (nginx injects)
- ops prod build no longer bakes VITE_ERP_TOKEN (.env.production empty)
- de-hardcode legacy DB password -> env in import_items.py
- gitignore legacy migration PII exports (tsv/json)

Conversations/UI:
- floating (un-docked) conversation panel; full-width mailbox
- per-message real sender from email headers; unified scroll; header spacing
- campaign pre-send review (subject/from/channel), Gmail send channel, clone-as-draft

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 06:17:17 -04:00

74 lines
2.5 KiB
Vue

<template>
<!-- Editable "From" field: a plain text input (free text always preserved)
with an auto-suggest dropdown of validated Mailjet senders. Type freely,
or pick a suggestion. -->
<q-input
:model-value="modelValue"
@update:model-value="onInput"
@focus="menuOpen = true"
:label="label"
outlined dense
:placeholder="placeholder"
:hint="hint"
spellcheck="false"
autocomplete="off"
>
<template #append>
<q-icon name="expand_more" class="cursor-pointer" @click.stop="menuOpen = !menuOpen" />
</template>
<q-menu
:model-value="menuOpen && filtered.length > 0"
@update:model-value="v => menuOpen = v"
no-focus no-refocus fit anchor="bottom left" self="top left"
>
<q-list dense style="min-width:320px">
<q-item v-for="s in filtered" :key="s" clickable v-close-popup
:active="s === modelValue" @click="pick(s)">
<q-item-section class="text-body2">{{ s }}</q-item-section>
</q-item>
</q-list>
</q-menu>
</q-input>
</template>
<script setup>
import { ref, computed } from 'vue'
// Validated Mailjet senders — single source of truth for the From picker.
// Mailjet only delivers from a validated sender; an unvalidated address
// silently falls back to noreply@targo.ca, so we surface the known-good ones.
// The field stays free-text, so a newly-validated address can just be typed.
const SENDERS = [
'Facturation Targo <facturation@targo.ca>',
'Service TARGO <support@targointernet.com>',
'TARGO <noreply@targo.ca>',
]
const props = defineProps({
modelValue: { type: String, default: '' },
label: { type: String, default: 'Expéditeur (From)' },
placeholder: { type: String, default: 'Facturation Targo <facturation@targo.ca>' },
hint: { type: String, default: 'Expéditeur validé Mailjet — saisie libre, ou choisir une suggestion' },
})
const emit = defineEmits(['update:modelValue'])
const menuOpen = ref(false)
const filtered = computed(() => {
const q = (props.modelValue || '').toLowerCase().trim()
// Empty, or already an exact match (pre-filled / just picked) → show the
// full list so the dropdown stays a useful picker. Otherwise substring-filter.
if (!q || SENDERS.some(s => s.toLowerCase() === q)) return SENDERS
return SENDERS.filter(s => s.toLowerCase().includes(q))
})
function onInput (val) {
emit('update:modelValue', val == null ? '' : val)
menuOpen.value = true
}
function pick (s) {
emit('update:modelValue', s)
menuOpen.value = false
}
</script>