CognxSafeTrack commited on
Commit
170eb5d
Β·
1 Parent(s): b8629ec

feat(super-admin): WhatsApp OTP registration and template creation

Browse files

- WhatsApp service: add registerPhoneNumber() and verifyPhoneNumber() methods
- Super-admin routes: POST /whatsapp/numbers/register and /verify for Meta OTP flow
- Super-admin routes: POST /whatsapp/templates to submit templates to Meta for approval
- WhatsAppNumbers page: 2-step modal (orgId + phoneNumberId + PIN β†’ OTP code)
- WhatsAppTemplates page: full creation modal with category cards, char counters, live preview bubble

apps/admin/src/pages/super-admin/WhatsAppNumbers.tsx CHANGED
@@ -2,7 +2,7 @@ import { useState, useEffect } from 'react';
2
  import { useAuth } from '@/lib/auth';
3
  import { api } from '@/lib/api';
4
  import { useToast } from '@/hooks/useToast';
5
- import { Phone, RefreshCw } from 'lucide-react';
6
 
7
  export default function WhatsAppNumbers() {
8
  const { token } = useAuth();
@@ -10,6 +10,16 @@ export default function WhatsAppNumbers() {
10
  const [numbers, setNumbers] = useState<any[]>([]);
11
  const [loading, setLoading] = useState(true);
12
 
 
 
 
 
 
 
 
 
 
 
13
  async function load() {
14
  if (!token) return;
15
  setLoading(true);
@@ -22,6 +32,94 @@ export default function WhatsAppNumbers() {
22
 
23
  useEffect(() => { load(); }, [token]);
24
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  return (
26
  <div className="space-y-4">
27
  <div className="flex items-center justify-between">
@@ -29,10 +127,16 @@ export default function WhatsAppNumbers() {
29
  <h1 className="text-xl font-bold text-white">NumΓ©ros WhatsApp</h1>
30
  <p className="text-sm text-slate-400 mt-0.5">{numbers.length} numΓ©ros enregistrΓ©s</p>
31
  </div>
32
- <button onClick={load} className="flex items-center gap-2 px-3 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 text-sm rounded-lg transition-colors">
33
- <RefreshCw className="w-3.5 h-3.5" />
34
- Actualiser
35
- </button>
 
 
 
 
 
 
36
  </div>
37
 
38
  <div className="bg-slate-900 rounded-xl border border-slate-800 overflow-hidden">
@@ -68,6 +172,136 @@ export default function WhatsAppNumbers() {
68
  </div>
69
  )}
70
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
  </div>
72
  );
73
  }
 
2
  import { useAuth } from '@/lib/auth';
3
  import { api } from '@/lib/api';
4
  import { useToast } from '@/hooks/useToast';
5
+ import { Phone, RefreshCw, Plus, X, ArrowLeft, Info } from 'lucide-react';
6
 
7
  export default function WhatsAppNumbers() {
8
  const { token } = useAuth();
 
10
  const [numbers, setNumbers] = useState<any[]>([]);
11
  const [loading, setLoading] = useState(true);
12
 
13
+ const [showRegister, setShowRegister] = useState(false);
14
+ const [regStep, setRegStep] = useState<1 | 2>(1);
15
+ const [regOrgId, setRegOrgId] = useState('');
16
+ const [regPhoneNumberId, setRegPhoneNumberId] = useState('');
17
+ const [regPin, setRegPin] = useState('');
18
+ const [regCode, setRegCode] = useState('');
19
+ const [regLoading, setRegLoading] = useState(false);
20
+ const [regError, setRegError] = useState('');
21
+ const [orgOptions, setOrgOptions] = useState<{ id: string; name: string }[]>([]);
22
+
23
  async function load() {
24
  if (!token) return;
25
  setLoading(true);
 
32
 
33
  useEffect(() => { load(); }, [token]);
34
 
35
+ async function loadOrgs() {
36
+ if (!token) return;
37
+ try {
38
+ const data = await api.get('/v1/super-admin/organizations?limit=100', token);
39
+ setOrgOptions(data.data ?? []);
40
+ } catch { /* ignore */ }
41
+ }
42
+
43
+ function openModal() {
44
+ setRegStep(1);
45
+ setRegOrgId('');
46
+ setRegPhoneNumberId('');
47
+ setRegPin('');
48
+ setRegCode('');
49
+ setRegError('');
50
+ setRegLoading(false);
51
+ setShowRegister(true);
52
+ loadOrgs();
53
+ }
54
+
55
+ function closeModal() {
56
+ setShowRegister(false);
57
+ setRegStep(1);
58
+ setRegOrgId('');
59
+ setRegPhoneNumberId('');
60
+ setRegPin('');
61
+ setRegCode('');
62
+ setRegError('');
63
+ setRegLoading(false);
64
+ }
65
+
66
+ async function handleRegister() {
67
+ setRegError('');
68
+ if (!regOrgId) { setRegError("Veuillez sΓ©lectionner une organisation."); return; }
69
+ if (!regPhoneNumberId || !/^\d{12,18}$/.test(regPhoneNumberId)) {
70
+ setRegError("Le Phone Number ID doit contenir entre 12 et 18 chiffres.");
71
+ return;
72
+ }
73
+ const pin = regPin.trim() === '' ? '000000' : regPin.trim();
74
+ if (!/^\d{6}$/.test(pin)) { setRegError("Le PIN doit contenir exactement 6 chiffres."); return; }
75
+ setRegLoading(true);
76
+ try {
77
+ const res = await api.post('/v1/super-admin/whatsapp/numbers/register', {
78
+ orgId: regOrgId,
79
+ phoneNumberId: regPhoneNumberId,
80
+ pin,
81
+ }, token);
82
+ if (res.ok) {
83
+ setRegStep(2);
84
+ } else {
85
+ setRegError(res.metaResponse?.detail || res.metaResponse?.message || "Erreur lors de l'enregistrement.");
86
+ }
87
+ } catch (e: any) {
88
+ setRegError(e?.message || "Erreur rΓ©seau.");
89
+ } finally {
90
+ setRegLoading(false);
91
+ }
92
+ }
93
+
94
+ async function handleVerify() {
95
+ setRegError('');
96
+ if (!regCode || !/^\d{4,8}$/.test(regCode)) {
97
+ setRegError("Le code OTP doit contenir entre 4 et 8 chiffres.");
98
+ return;
99
+ }
100
+ setRegLoading(true);
101
+ try {
102
+ const res = await api.post('/v1/super-admin/whatsapp/numbers/verify', {
103
+ orgId: regOrgId,
104
+ phoneNumberId: regPhoneNumberId,
105
+ code: regCode,
106
+ }, token);
107
+ if (res.ok) {
108
+ toast.success('Numéro enregistré avec succès !');
109
+ closeModal();
110
+ load();
111
+ } else {
112
+ setRegError(res.metaResponse?.detail || res.metaResponse?.message || "Code OTP invalide ou expirΓ©.");
113
+ }
114
+ } catch (e: any) {
115
+ setRegError(e?.message || "Erreur rΓ©seau.");
116
+ } finally {
117
+ setRegLoading(false);
118
+ }
119
+ }
120
+
121
+ const selectedOrg = orgOptions.find(o => o.id === regOrgId);
122
+
123
  return (
124
  <div className="space-y-4">
125
  <div className="flex items-center justify-between">
 
127
  <h1 className="text-xl font-bold text-white">NumΓ©ros WhatsApp</h1>
128
  <p className="text-sm text-slate-400 mt-0.5">{numbers.length} numΓ©ros enregistrΓ©s</p>
129
  </div>
130
+ <div className="flex items-center gap-2">
131
+ <button onClick={load} className="flex items-center gap-2 px-3 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 text-sm rounded-lg transition-colors">
132
+ <RefreshCw className="w-3.5 h-3.5" />
133
+ Actualiser
134
+ </button>
135
+ <button onClick={openModal} className="flex items-center gap-2 px-3 py-2 bg-violet-600 hover:bg-violet-500 text-white text-sm rounded-lg transition-colors font-medium">
136
+ <Plus className="w-4 h-4" />
137
+ Enregistrer un numΓ©ro
138
+ </button>
139
+ </div>
140
  </div>
141
 
142
  <div className="bg-slate-900 rounded-xl border border-slate-800 overflow-hidden">
 
172
  </div>
173
  )}
174
  </div>
175
+
176
+ {showRegister && (
177
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 px-4">
178
+ <div className="bg-slate-900 border border-slate-700 rounded-2xl max-w-md w-full p-6 shadow-2xl">
179
+ <div className="flex items-start justify-between mb-5">
180
+ <div>
181
+ <h2 className="text-base font-semibold text-white">Enregistrer un numΓ©ro WhatsApp</h2>
182
+ <p className="text-xs text-slate-400 mt-0.5">Γ‰tape {regStep}/2</p>
183
+ </div>
184
+ <button onClick={closeModal} className="text-slate-400 hover:text-white transition-colors ml-4 shrink-0">
185
+ <X className="w-5 h-5" />
186
+ </button>
187
+ </div>
188
+
189
+ {regStep === 1 && (
190
+ <div className="space-y-4">
191
+ <div>
192
+ <label className="block text-xs font-medium text-slate-300 mb-1.5">Organisation</label>
193
+ <select
194
+ value={regOrgId}
195
+ onChange={e => setRegOrgId(e.target.value)}
196
+ className="w-full bg-slate-800 border border-slate-700 text-white text-sm rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-violet-500 focus:border-transparent"
197
+ >
198
+ <option value="">SΓ©lectionner une organisation...</option>
199
+ {orgOptions.map(o => (
200
+ <option key={o.id} value={o.id}>{o.name}</option>
201
+ ))}
202
+ </select>
203
+ </div>
204
+
205
+ <div>
206
+ <label className="block text-xs font-medium text-slate-300 mb-1.5">Phone Number ID</label>
207
+ <input
208
+ type="text"
209
+ inputMode="numeric"
210
+ value={regPhoneNumberId}
211
+ onChange={e => setRegPhoneNumberId(e.target.value.replace(/\D/g, ''))}
212
+ placeholder="123456789012345"
213
+ maxLength={18}
214
+ className="w-full bg-slate-800 border border-slate-700 text-white text-sm rounded-lg px-3 py-2 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-violet-500 focus:border-transparent font-mono"
215
+ />
216
+ <p className="text-xs text-slate-500 mt-1">Trouvez cet ID dans Meta Business Manager &gt; WhatsApp Accounts</p>
217
+ </div>
218
+
219
+ <div>
220
+ <label className="block text-xs font-medium text-slate-300 mb-1.5">PIN de sΓ©curitΓ©</label>
221
+ <input
222
+ type="password"
223
+ inputMode="numeric"
224
+ value={regPin}
225
+ onChange={e => setRegPin(e.target.value.replace(/\D/g, ''))}
226
+ placeholder="000000"
227
+ maxLength={6}
228
+ className="w-full bg-slate-800 border border-slate-700 text-white text-sm rounded-lg px-3 py-2 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-violet-500 focus:border-transparent font-mono tracking-widest"
229
+ />
230
+ <p className="text-xs text-slate-500 mt-1">PIN Γ  6 chiffres pour sΓ©curiser le numΓ©ro (laisser vide = 000000)</p>
231
+ </div>
232
+
233
+ {regError && (
234
+ <p className="text-xs text-red-400 bg-red-900/20 border border-red-800/40 rounded-lg px-3 py-2">{regError}</p>
235
+ )}
236
+
237
+ <button
238
+ onClick={handleRegister}
239
+ disabled={regLoading}
240
+ className="w-full mt-1 flex items-center justify-center gap-2 px-4 py-2.5 bg-violet-600 hover:bg-violet-500 disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg transition-colors"
241
+ >
242
+ {regLoading ? (
243
+ <RefreshCw className="w-4 h-4 animate-spin" />
244
+ ) : null}
245
+ {regLoading ? 'Envoi en cours...' : 'Envoyer le code OTP'}
246
+ </button>
247
+ </div>
248
+ )}
249
+
250
+ {regStep === 2 && (
251
+ <div className="space-y-4">
252
+ <div className="bg-slate-800 rounded-lg px-4 py-3 space-y-1">
253
+ <p className="text-xs text-slate-400">Organisation : <span className="text-slate-200 font-medium">{selectedOrg?.name || regOrgId}</span></p>
254
+ <p className="text-xs text-slate-400">Phone Number ID : <span className="text-slate-200 font-mono">{regPhoneNumberId}</span></p>
255
+ </div>
256
+
257
+ <div className="flex items-start gap-2.5 bg-blue-900/20 border border-blue-800/40 rounded-lg px-3 py-3">
258
+ <Info className="w-4 h-4 text-blue-400 shrink-0 mt-0.5" />
259
+ <p className="text-xs text-blue-300">Meta vous a envoyΓ© un code OTP par SMS ou appel vocal sur le numΓ©ro. Saisissez-le ci-dessous.</p>
260
+ </div>
261
+
262
+ <div>
263
+ <label className="block text-xs font-medium text-slate-300 mb-1.5">Code OTP</label>
264
+ <input
265
+ type="text"
266
+ inputMode="numeric"
267
+ value={regCode}
268
+ onChange={e => setRegCode(e.target.value.replace(/\D/g, ''))}
269
+ placeholder="123456"
270
+ maxLength={8}
271
+ className="w-full bg-slate-800 border border-slate-700 text-white text-sm rounded-lg px-3 py-2 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-violet-500 focus:border-transparent font-mono tracking-widest text-center text-lg"
272
+ autoFocus
273
+ />
274
+ </div>
275
+
276
+ {regError && (
277
+ <p className="text-xs text-red-400 bg-red-900/20 border border-red-800/40 rounded-lg px-3 py-2">{regError}</p>
278
+ )}
279
+
280
+ <div className="flex gap-2 mt-1">
281
+ <button
282
+ onClick={() => { setRegStep(1); setRegError(''); setRegCode(''); }}
283
+ disabled={regLoading}
284
+ className="flex items-center gap-1.5 px-4 py-2.5 bg-slate-800 hover:bg-slate-700 disabled:opacity-50 disabled:cursor-not-allowed text-slate-300 text-sm rounded-lg transition-colors"
285
+ >
286
+ <ArrowLeft className="w-3.5 h-3.5" />
287
+ Retour
288
+ </button>
289
+ <button
290
+ onClick={handleVerify}
291
+ disabled={regLoading}
292
+ className="flex-1 flex items-center justify-center gap-2 px-4 py-2.5 bg-violet-600 hover:bg-violet-500 disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg transition-colors"
293
+ >
294
+ {regLoading ? (
295
+ <RefreshCw className="w-4 h-4 animate-spin" />
296
+ ) : null}
297
+ {regLoading ? 'VΓ©rification...' : 'VΓ©rifier'}
298
+ </button>
299
+ </div>
300
+ </div>
301
+ )}
302
+ </div>
303
+ </div>
304
+ )}
305
  </div>
306
  );
307
  }
apps/admin/src/pages/super-admin/WhatsAppTemplates.tsx CHANGED
@@ -1,7 +1,8 @@
1
  import { useState, useEffect } from 'react';
2
  import { useAuth } from '@/lib/auth';
3
  import { api } from '@/lib/api';
4
- import { MessageSquare, ChevronDown, ChevronRight, Search } from 'lucide-react';
 
5
 
6
  const PLAN_COLORS: Record<string, string> = {
7
  STARTER: 'bg-slate-700 text-slate-300',
@@ -22,6 +23,29 @@ const STATUS_COLORS: Record<string, string> = {
22
  REJECTED: 'bg-red-900/60 text-red-300',
23
  };
24
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  interface Org {
26
  id: string;
27
  name: string;
@@ -36,8 +60,29 @@ interface Template {
36
  status: string;
37
  }
38
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
  export default function WhatsAppTemplates() {
40
  const { token } = useAuth();
 
41
  const [orgs, setOrgs] = useState<Org[]>([]);
42
  const [loading, setLoading] = useState(true);
43
  const [search, setSearch] = useState('');
@@ -45,6 +90,12 @@ export default function WhatsAppTemplates() {
45
  const [templates, setTemplates] = useState<Record<string, Template[]>>({});
46
  const [loadingTemplates, setLoadingTemplates] = useState<Record<string, boolean>>({});
47
 
 
 
 
 
 
 
48
  useEffect(() => {
49
  if (!token) return;
50
  setLoading(true);
@@ -69,6 +120,75 @@ export default function WhatsAppTemplates() {
69
  }
70
  }
71
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
  const filtered = orgs.filter(o =>
73
  o.name.toLowerCase().includes(search.toLowerCase()) ||
74
  o.wabaId?.toLowerCase().includes(search.toLowerCase())
@@ -78,10 +198,17 @@ export default function WhatsAppTemplates() {
78
  <div className="space-y-4">
79
  <div className="flex items-center gap-3">
80
  <MessageSquare className="w-5 h-5 text-violet-400" />
81
- <div>
82
  <h1 className="text-xl font-bold text-white">WhatsApp Templates</h1>
83
  <p className="text-sm text-slate-400 mt-0.5">{orgs.length} organisation{orgs.length !== 1 ? 's' : ''} avec WhatsApp configurΓ©</p>
84
  </div>
 
 
 
 
 
 
 
85
  </div>
86
 
87
  <div className="bg-slate-900/50 border border-slate-800 rounded-xl px-4 py-3 text-sm text-slate-400">
@@ -208,6 +335,176 @@ export default function WhatsAppTemplates() {
208
  </div>
209
  )}
210
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
211
  </div>
212
  );
213
  }
 
1
  import { useState, useEffect } from 'react';
2
  import { useAuth } from '@/lib/auth';
3
  import { api } from '@/lib/api';
4
+ import { useToast } from '@/hooks/useToast';
5
+ import { MessageSquare, ChevronDown, ChevronRight, Search, Plus, X } from 'lucide-react';
6
 
7
  const PLAN_COLORS: Record<string, string> = {
8
  STARTER: 'bg-slate-700 text-slate-300',
 
23
  REJECTED: 'bg-red-900/60 text-red-300',
24
  };
25
 
26
+ const CATEGORIES = [
27
+ { value: 'MARKETING', label: 'MARKETING', description: 'Promotions, offres, newsletters', color: 'border-violet-500 bg-violet-900/20 text-violet-300' },
28
+ { value: 'UTILITY', label: 'UTILITY', description: 'Confirmations, mises Γ  jour transactionnelles', color: 'border-blue-500 bg-blue-900/20 text-blue-300' },
29
+ { value: 'AUTHENTICATION', label: 'AUTHENTICATION', description: 'Codes OTP, vΓ©rification', color: 'border-emerald-500 bg-emerald-900/20 text-emerald-300' },
30
+ ];
31
+
32
+ const CATEGORY_IDLE: Record<string, string> = {
33
+ MARKETING: 'border-slate-700 hover:border-violet-500/50',
34
+ UTILITY: 'border-slate-700 hover:border-blue-500/50',
35
+ AUTHENTICATION: 'border-slate-700 hover:border-emerald-500/50',
36
+ };
37
+
38
+ const LANGUAGES = [
39
+ { value: 'fr', label: 'FranΓ§ais' },
40
+ { value: 'en', label: 'English' },
41
+ { value: 'es', label: 'EspaΓ±ol' },
42
+ { value: 'pt', label: 'PortuguΓͺs' },
43
+ { value: 'ar', label: 'Ψ§Ω„ΨΉΨ±Ψ¨ΩŠΨ©' },
44
+ { value: 'wo', label: 'Wolof' },
45
+ ];
46
+
47
+ const NAME_REGEX = /^[a-z0-9_]*$/;
48
+
49
  interface Org {
50
  id: string;
51
  name: string;
 
60
  status: string;
61
  }
62
 
63
+ interface FormState {
64
+ orgId: string;
65
+ name: string;
66
+ category: string;
67
+ language: string;
68
+ header: string;
69
+ body: string;
70
+ footer: string;
71
+ }
72
+
73
+ const EMPTY_FORM: FormState = {
74
+ orgId: '',
75
+ name: '',
76
+ category: 'MARKETING',
77
+ language: 'fr',
78
+ header: '',
79
+ body: '',
80
+ footer: '',
81
+ };
82
+
83
  export default function WhatsAppTemplates() {
84
  const { token } = useAuth();
85
+ const toast = useToast();
86
  const [orgs, setOrgs] = useState<Org[]>([]);
87
  const [loading, setLoading] = useState(true);
88
  const [search, setSearch] = useState('');
 
90
  const [templates, setTemplates] = useState<Record<string, Template[]>>({});
91
  const [loadingTemplates, setLoadingTemplates] = useState<Record<string, boolean>>({});
92
 
93
+ const [showCreate, setShowCreate] = useState(false);
94
+ const [form, setForm] = useState<FormState>(EMPTY_FORM);
95
+ const [creating, setCreating] = useState(false);
96
+ const [createError, setCreateError] = useState('');
97
+ const [nameError, setNameError] = useState('');
98
+
99
  useEffect(() => {
100
  if (!token) return;
101
  setLoading(true);
 
120
  }
121
  }
122
 
123
+ async function refreshOrgTemplates(orgId: string) {
124
+ setLoadingTemplates(prev => ({ ...prev, [orgId]: true }));
125
+ try {
126
+ const data = await api.get('/v1/whatsapp/templates', token, orgId);
127
+ setTemplates(prev => ({ ...prev, [orgId]: data.templates ?? [] }));
128
+ } catch {
129
+ setTemplates(prev => ({ ...prev, [orgId]: [] }));
130
+ } finally {
131
+ setLoadingTemplates(prev => ({ ...prev, [orgId]: false }));
132
+ }
133
+ }
134
+
135
+ function handleNameChange(value: string) {
136
+ if (!NAME_REGEX.test(value)) {
137
+ setNameError('Minuscules, chiffres, underscores uniquement');
138
+ } else {
139
+ setNameError('');
140
+ }
141
+ setForm(prev => ({ ...prev, name: value }));
142
+ }
143
+
144
+ function openCreate() {
145
+ setForm(orgs.length > 0 ? { ...EMPTY_FORM, orgId: orgs[0].id } : EMPTY_FORM);
146
+ setCreateError('');
147
+ setNameError('');
148
+ setShowCreate(true);
149
+ }
150
+
151
+ function closeCreate() {
152
+ setShowCreate(false);
153
+ setForm(EMPTY_FORM);
154
+ setCreateError('');
155
+ setNameError('');
156
+ }
157
+
158
+ async function handleCreate() {
159
+ if (!form.orgId || !form.name || !form.body) {
160
+ setCreateError('Organisation, nom et corps du message sont obligatoires.');
161
+ return;
162
+ }
163
+ if (!NAME_REGEX.test(form.name)) {
164
+ setCreateError('Le nom du template est invalide.');
165
+ return;
166
+ }
167
+ setCreating(true);
168
+ setCreateError('');
169
+ try {
170
+ await api.post('/v1/super-admin/whatsapp/templates', {
171
+ orgId: form.orgId,
172
+ name: form.name,
173
+ category: form.category,
174
+ language: form.language,
175
+ body: form.body,
176
+ ...(form.header ? { header: form.header } : {}),
177
+ ...(form.footer ? { footer: form.footer } : {}),
178
+ }, token);
179
+ toast.success('Template soumis Γ  Meta pour approbation');
180
+ if (expanded[form.orgId]) {
181
+ refreshOrgTemplates(form.orgId);
182
+ }
183
+ closeCreate();
184
+ } catch (err: unknown) {
185
+ const msg = err instanceof Error ? err.message : 'Erreur lors de la crΓ©ation du template.';
186
+ setCreateError(msg);
187
+ } finally {
188
+ setCreating(false);
189
+ }
190
+ }
191
+
192
  const filtered = orgs.filter(o =>
193
  o.name.toLowerCase().includes(search.toLowerCase()) ||
194
  o.wabaId?.toLowerCase().includes(search.toLowerCase())
 
198
  <div className="space-y-4">
199
  <div className="flex items-center gap-3">
200
  <MessageSquare className="w-5 h-5 text-violet-400" />
201
+ <div className="flex-1">
202
  <h1 className="text-xl font-bold text-white">WhatsApp Templates</h1>
203
  <p className="text-sm text-slate-400 mt-0.5">{orgs.length} organisation{orgs.length !== 1 ? 's' : ''} avec WhatsApp configurΓ©</p>
204
  </div>
205
+ <button
206
+ onClick={openCreate}
207
+ className="inline-flex items-center gap-2 px-4 py-2 bg-violet-600 hover:bg-violet-500 text-white text-sm font-medium rounded-lg transition-colors"
208
+ >
209
+ <Plus className="w-4 h-4" />
210
+ CrΓ©er un template
211
+ </button>
212
  </div>
213
 
214
  <div className="bg-slate-900/50 border border-slate-800 rounded-xl px-4 py-3 text-sm text-slate-400">
 
335
  </div>
336
  )}
337
  </div>
338
+
339
+ {showCreate && (
340
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
341
+ <div className="bg-slate-900 border border-slate-700 rounded-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto p-6 shadow-2xl">
342
+ <div className="flex items-center justify-between mb-6">
343
+ <h2 className="text-lg font-bold text-white">Nouveau template WhatsApp</h2>
344
+ <button
345
+ onClick={closeCreate}
346
+ className="p-1.5 text-slate-400 hover:text-white hover:bg-slate-800 rounded-lg transition-colors"
347
+ >
348
+ <X className="w-5 h-5" />
349
+ </button>
350
+ </div>
351
+
352
+ <div className="space-y-5">
353
+ <div>
354
+ <label className="block text-sm font-medium text-slate-300 mb-1.5">Organisation</label>
355
+ <select
356
+ value={form.orgId}
357
+ onChange={e => setForm(prev => ({ ...prev, orgId: e.target.value }))}
358
+ className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-sm text-white focus:outline-none focus:border-violet-500"
359
+ >
360
+ <option value="" disabled>SΓ©lectionner une organisation…</option>
361
+ {orgs.map(org => (
362
+ <option key={org.id} value={org.id}>{org.name}</option>
363
+ ))}
364
+ </select>
365
+ </div>
366
+
367
+ <div>
368
+ <label className="block text-sm font-medium text-slate-300 mb-1.5">Nom du template</label>
369
+ <input
370
+ type="text"
371
+ value={form.name}
372
+ onChange={e => handleNameChange(e.target.value)}
373
+ placeholder="welcome_message"
374
+ className={`w-full px-3 py-2 bg-slate-800 border rounded-lg text-sm text-white placeholder-slate-500 focus:outline-none transition-colors ${nameError ? 'border-red-500 focus:border-red-500' : 'border-slate-700 focus:border-violet-500'}`}
375
+ />
376
+ {nameError ? (
377
+ <p className="text-xs text-red-400 mt-1">{nameError}</p>
378
+ ) : (
379
+ <p className="text-xs text-slate-500 mt-1">Minuscules, chiffres, underscores uniquement</p>
380
+ )}
381
+ </div>
382
+
383
+ <div>
384
+ <label className="block text-sm font-medium text-slate-300 mb-2">CatΓ©gorie</label>
385
+ <div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
386
+ {CATEGORIES.map(cat => {
387
+ const isSelected = form.category === cat.value;
388
+ return (
389
+ <button
390
+ key={cat.value}
391
+ type="button"
392
+ onClick={() => setForm(prev => ({ ...prev, category: cat.value }))}
393
+ className={`text-left p-3 rounded-xl border-2 transition-all ${isSelected ? cat.color : `border-slate-700 hover:${CATEGORY_IDLE[cat.value]}`} ${isSelected ? '' : 'bg-slate-800/40'}`}
394
+ >
395
+ <div className={`text-xs font-bold tracking-wider mb-1 ${isSelected ? '' : 'text-slate-300'}`}>{cat.label}</div>
396
+ <div className={`text-xs leading-snug ${isSelected ? 'opacity-80' : 'text-slate-500'}`}>{cat.description}</div>
397
+ </button>
398
+ );
399
+ })}
400
+ </div>
401
+ </div>
402
+
403
+ <div>
404
+ <label className="block text-sm font-medium text-slate-300 mb-1.5">Langue</label>
405
+ <select
406
+ value={form.language}
407
+ onChange={e => setForm(prev => ({ ...prev, language: e.target.value }))}
408
+ className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-sm text-white focus:outline-none focus:border-violet-500"
409
+ >
410
+ {LANGUAGES.map(lang => (
411
+ <option key={lang.value} value={lang.value}>{lang.label}</option>
412
+ ))}
413
+ </select>
414
+ </div>
415
+
416
+ <div>
417
+ <div className="flex items-center justify-between mb-1.5">
418
+ <label className="text-sm font-medium text-slate-300">En-tΓͺte <span className="text-slate-500 font-normal">(optionnel)</span></label>
419
+ <span className="text-xs text-slate-500">{form.header.length}/60</span>
420
+ </div>
421
+ <input
422
+ type="text"
423
+ value={form.header}
424
+ onChange={e => setForm(prev => ({ ...prev, header: e.target.value.slice(0, 60) }))}
425
+ placeholder="Bonjour {{1}} !"
426
+ maxLength={60}
427
+ className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-sm text-white placeholder-slate-500 focus:outline-none focus:border-violet-500"
428
+ />
429
+ </div>
430
+
431
+ <div>
432
+ <div className="flex items-center justify-between mb-1.5">
433
+ <label className="text-sm font-medium text-slate-300">Corps du message</label>
434
+ <span className="text-xs text-slate-500">{form.body.length}/1024</span>
435
+ </div>
436
+ <textarea
437
+ rows={4}
438
+ value={form.body}
439
+ onChange={e => setForm(prev => ({ ...prev, body: e.target.value.slice(0, 1024) }))}
440
+ placeholder="Votre message ici. Utilisez {{1}}, {{2}} pour les variables."
441
+ maxLength={1024}
442
+ className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-sm text-white placeholder-slate-500 focus:outline-none focus:border-violet-500 resize-none"
443
+ />
444
+ </div>
445
+
446
+ <div>
447
+ <div className="flex items-center justify-between mb-1.5">
448
+ <label className="text-sm font-medium text-slate-300">Pied de page <span className="text-slate-500 font-normal">(optionnel)</span></label>
449
+ <span className="text-xs text-slate-500">{form.footer.length}/60</span>
450
+ </div>
451
+ <input
452
+ type="text"
453
+ value={form.footer}
454
+ onChange={e => setForm(prev => ({ ...prev, footer: e.target.value.slice(0, 60) }))}
455
+ maxLength={60}
456
+ placeholder="Ne pas rΓ©pondre Γ  ce message"
457
+ className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-sm text-white placeholder-slate-500 focus:outline-none focus:border-violet-500"
458
+ />
459
+ </div>
460
+
461
+ <div>
462
+ <p className="text-xs font-medium text-slate-400 uppercase tracking-wider mb-2">PrΓ©visualisation</p>
463
+ <div className="bg-[#0a7a4b] rounded-xl p-4">
464
+ <div className="bg-white rounded-lg p-3 max-w-xs shadow text-sm">
465
+ {form.header && <p className="font-bold text-slate-900 mb-1">{form.header}</p>}
466
+ <p className="text-slate-800 whitespace-pre-wrap">{form.body || 'Corps du message...'}</p>
467
+ {form.footer && <p className="text-xs text-slate-400 mt-1">{form.footer}</p>}
468
+ </div>
469
+ </div>
470
+ </div>
471
+
472
+ {createError && (
473
+ <div className="px-4 py-3 bg-red-900/30 border border-red-700/50 rounded-lg text-sm text-red-300">
474
+ {createError}
475
+ </div>
476
+ )}
477
+
478
+ <div className="flex items-center justify-end gap-3 pt-2">
479
+ <button
480
+ type="button"
481
+ onClick={closeCreate}
482
+ disabled={creating}
483
+ className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 hover:text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50"
484
+ >
485
+ Annuler
486
+ </button>
487
+ <button
488
+ type="button"
489
+ onClick={handleCreate}
490
+ disabled={creating || !!nameError || !form.orgId || !form.name || !form.body}
491
+ className="inline-flex items-center gap-2 px-4 py-2 bg-violet-600 hover:bg-violet-500 disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg transition-colors"
492
+ >
493
+ {creating ? (
494
+ <>
495
+ <svg className="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
496
+ <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
497
+ <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
498
+ </svg>
499
+ CrΓ©ation…
500
+ </>
501
+ ) : 'CrΓ©er le template'}
502
+ </button>
503
+ </div>
504
+ </div>
505
+ </div>
506
+ </div>
507
+ )}
508
  </div>
509
  );
510
  }
apps/api/src/routes/super-admin.ts CHANGED
@@ -291,6 +291,126 @@ export async function superAdminRoutes(fastify: FastifyInstance) {
291
  }
292
  });
293
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
294
  // ── Billing ───────────────────────────────────────────────────────────────
295
  fastify.get('/billing/transactions', async (req, reply) => {
296
  const QuerySchema = z.object({
 
291
  }
292
  });
293
 
294
+ // ── WhatsApp Number Registration (OTP flow) ───────────────────────────────
295
+
296
+ // Step 1 β€” Trigger Meta to send OTP to the phone number
297
+ fastify.post('/whatsapp/numbers/register', async (req, reply) => {
298
+ const Schema = z.object({
299
+ orgId: z.string(),
300
+ phoneNumberId: z.string(),
301
+ pin: z.string().length(6).regex(/^\d{6}$/).default('000000'),
302
+ });
303
+ const body = Schema.safeParse(req.body);
304
+ if (!body.success) return reply.code(400).send({ error: body.error.flatten() });
305
+
306
+ const { orgId, phoneNumberId, pin } = body.data;
307
+ try {
308
+ const { decryptSecrets } = await import('../services/organization');
309
+ const { whatsappService } = await import('../services/whatsapp');
310
+ const org = await prisma.organization.findUnique({
311
+ where: { id: orgId },
312
+ select: { id: true, name: true, systemUserToken: true, wabaId: true, systemUserTokenIssuedAt: true, webhookSecret: true },
313
+ });
314
+ if (!org) return reply.code(404).send({ error: 'Organization not found' });
315
+ if (!org.systemUserToken) return reply.code(400).send({ error: 'Organization has no systemUserToken configured' });
316
+
317
+ const decrypted = decryptSecrets(org as any);
318
+ const result = await whatsappService.registerPhoneNumber({ accessToken: decrypted.systemUserToken! }, phoneNumberId, pin);
319
+
320
+ logger.info({ orgId, phoneNumberId, actor: (req as any).user?.id }, '[SUPER_ADMIN] Phone number registration initiated');
321
+ return { ok: true, metaResponse: result };
322
+ } catch (err: any) {
323
+ const detail = err.response?.data?.error?.message ?? err.message;
324
+ return reply.code(502).send({ error: 'Meta API error', detail });
325
+ }
326
+ });
327
+
328
+ // Step 2 β€” Verify OTP sent by Meta
329
+ fastify.post('/whatsapp/numbers/verify', async (req, reply) => {
330
+ const Schema = z.object({
331
+ orgId: z.string(),
332
+ phoneNumberId: z.string(),
333
+ code: z.string().min(4).max(8),
334
+ });
335
+ const body = Schema.safeParse(req.body);
336
+ if (!body.success) return reply.code(400).send({ error: body.error.flatten() });
337
+
338
+ const { orgId, phoneNumberId, code } = body.data;
339
+ try {
340
+ const { decryptSecrets } = await import('../services/organization');
341
+ const { whatsappService } = await import('../services/whatsapp');
342
+ const org = await prisma.organization.findUnique({
343
+ where: { id: orgId },
344
+ select: { id: true, name: true, systemUserToken: true, webhookSecret: true, systemUserTokenIssuedAt: true },
345
+ });
346
+ if (!org) return reply.code(404).send({ error: 'Organization not found' });
347
+ if (!org.systemUserToken) return reply.code(400).send({ error: 'Organization has no systemUserToken configured' });
348
+
349
+ const decrypted = decryptSecrets(org as any);
350
+ const result = await whatsappService.verifyPhoneNumber({ accessToken: decrypted.systemUserToken! }, phoneNumberId, code);
351
+
352
+ logger.info({ orgId, phoneNumberId, actor: (req as any).user?.id }, '[SUPER_ADMIN] Phone number verified');
353
+ return { ok: true, metaResponse: result };
354
+ } catch (err: any) {
355
+ const detail = err.response?.data?.error?.message ?? err.message;
356
+ return reply.code(502).send({ error: 'Meta verification failed', detail });
357
+ }
358
+ });
359
+
360
+ // ── WhatsApp Template Creation (cross-org) ────────────────────────────────
361
+ fastify.post('/whatsapp/templates', async (req, reply) => {
362
+ const Schema = z.object({
363
+ orgId: z.string(),
364
+ name: z.string().min(1).regex(/^[a-z0-9_]+$/, 'Template name must be lowercase snake_case'),
365
+ category: z.enum(['MARKETING', 'UTILITY', 'AUTHENTICATION']),
366
+ language: z.string().min(2),
367
+ body: z.string().min(1).max(1024),
368
+ header: z.string().max(60).optional(),
369
+ footer: z.string().max(60).optional(),
370
+ });
371
+ const body = Schema.safeParse(req.body);
372
+ if (!body.success) return reply.code(400).send({ error: body.error.flatten() });
373
+
374
+ const { orgId, name, category, language, body: bodyText, header, footer } = body.data;
375
+ try {
376
+ const { decryptSecrets } = await import('../services/organization');
377
+ const { whatsappService } = await import('../services/whatsapp');
378
+ const org = await prisma.organization.findUnique({
379
+ where: { id: orgId },
380
+ select: { id: true, name: true, wabaId: true, systemUserToken: true, webhookSecret: true, systemUserTokenIssuedAt: true },
381
+ });
382
+ if (!org) return reply.code(404).send({ error: 'Organization not found' });
383
+ if (!org.wabaId || !org.systemUserToken) return reply.code(400).send({ error: 'Organization WhatsApp not configured' });
384
+
385
+ const decrypted = decryptSecrets(org as any);
386
+
387
+ const components: any[] = [];
388
+ if (header) components.push({ type: 'HEADER', format: 'TEXT', text: header });
389
+ components.push({ type: 'BODY', text: bodyText });
390
+ if (footer) components.push({ type: 'FOOTER', text: footer });
391
+
392
+ const result = await whatsappService.createMetaTemplate(
393
+ { accessToken: decrypted.systemUserToken!, wabaId: org.wabaId },
394
+ { name, category, language, components }
395
+ );
396
+
397
+ await prisma.auditLog.create({
398
+ data: {
399
+ action: 'SUPER_ADMIN_CREATE_TEMPLATE',
400
+ actorId: (req as any).user?.id,
401
+ resourceId: orgId,
402
+ details: { templateName: name, category, language },
403
+ },
404
+ });
405
+
406
+ logger.info({ orgId, templateName: name, actor: (req as any).user?.id }, '[SUPER_ADMIN] Template created');
407
+ return { ok: true, template: result };
408
+ } catch (err: any) {
409
+ const detail = err.response?.data?.error?.message ?? err.message;
410
+ return reply.code(502).send({ error: 'Meta API error', detail });
411
+ }
412
+ });
413
+
414
  // ── Billing ───────────────────────────────────────────────────────────────
415
  fastify.get('/billing/transactions', async (req, reply) => {
416
  const QuerySchema = z.object({
apps/api/src/services/whatsapp.ts CHANGED
@@ -125,6 +125,48 @@ export class WhatsAppService {
125
  }
126
  }
127
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
  /**
129
  * Resolves a Meta Media ID into a permanent or temporary URL.
130
  * This abstracts the two-step download process.
 
125
  }
126
  }
127
 
128
+ /**
129
+ * Initiates WhatsApp Business phone number registration.
130
+ * Meta sends an OTP to the phone number to verify ownership.
131
+ */
132
+ async registerPhoneNumber(config: { accessToken: string }, phoneNumberId: string, pin: string = '000000') {
133
+ try {
134
+ const url = `${this.baseUrl}/${phoneNumberId}/register`;
135
+ const response = await axios.post(url, {
136
+ messaging_product: 'whatsapp',
137
+ pin,
138
+ }, {
139
+ headers: {
140
+ 'Authorization': `Bearer ${config.accessToken}`,
141
+ 'Content-Type': 'application/json',
142
+ }
143
+ });
144
+ return response.data;
145
+ } catch (err: any) {
146
+ logger.error({ error: err.response?.data || err.message, phoneNumberId }, '[WHATSAPP_SERVICE] registerPhoneNumber failed');
147
+ throw err;
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Verifies the OTP code Meta sent during phone number registration.
153
+ */
154
+ async verifyPhoneNumber(config: { accessToken: string }, phoneNumberId: string, code: string) {
155
+ try {
156
+ const url = `${this.baseUrl}/${phoneNumberId}/verify_code`;
157
+ const response = await axios.post(url, { code }, {
158
+ headers: {
159
+ 'Authorization': `Bearer ${config.accessToken}`,
160
+ 'Content-Type': 'application/json',
161
+ }
162
+ });
163
+ return response.data;
164
+ } catch (err: any) {
165
+ logger.error({ error: err.response?.data || err.message, phoneNumberId }, '[WHATSAPP_SERVICE] verifyPhoneNumber failed');
166
+ throw err;
167
+ }
168
+ }
169
+
170
  /**
171
  * Resolves a Meta Media ID into a permanent or temporary URL.
172
  * This abstracts the two-step download process.