Skip to content

Commit 4d9b28e

Browse files
committed
feat: Implement custom domain verification and PAC configuration in settings
1 parent 446312f commit 4d9b28e

6 files changed

Lines changed: 354 additions & 20 deletions

File tree

src/app/actions/companies.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import { profileFormSchema, type ProfileFormValues } from "@/lib/schemas";
1010
import { getRateLimiter } from "@/lib/rate-limiter";
1111
import { verifyUserRequest } from "@/lib/auth-server";
1212
import { checkUserPermission } from "@/lib/permissions";
13+
import { hashData } from "@/lib/encryption";
14+
import dns from "dns/promises";
1315

1416
export const getCompanyProfile = async (userId: string, idToken: string) => {
1517
if (!db) {
@@ -36,6 +38,83 @@ export const getCompanyProfile = async (userId: string, idToken: string) => {
3638
}
3739
};
3840

41+
function normalizeDomain(domain: string): string | null {
42+
try {
43+
const trimmed = domain.trim();
44+
if (!trimmed) return null;
45+
const urlString = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
46+
const url = new URL(urlString);
47+
return url.origin;
48+
} catch {
49+
return null;
50+
}
51+
}
52+
53+
export const getDomainVerificationInfo = async (userId: string, idToken: string, domainOverride?: string) => {
54+
if (!db) {
55+
return { success: false, message: "Error de configuración: La conexión con la base de datos no está disponible." };
56+
}
57+
try {
58+
if (!userId) {
59+
return { success: false, message: "Usuario no autenticado." };
60+
}
61+
const verifiedUserId = await verifyUserRequest(userId, idToken);
62+
const [company] = await db.select().from(companies).where(eq(companies.userId, verifiedUserId));
63+
const domainToUse = domainOverride || company?.customDomain;
64+
const normalized = domainToUse ? normalizeDomain(domainToUse) : null;
65+
if (!normalized) {
66+
return { success: false, message: "Ingresa y guarda un dominio válido antes de verificar." };
67+
}
68+
69+
const url = new URL(normalized);
70+
const host = url.hostname;
71+
const token = hashData(`${verifiedUserId}:${host}`).slice(0, 32);
72+
const recordName = `_origon-verify.${host}`;
73+
74+
return { success: true, data: { host, token, recordName } };
75+
} catch (error) {
76+
console.error("Error obteniendo info de dominio:", error);
77+
return { success: false, message: "No se pudo generar la instrucción de verificación." };
78+
}
79+
};
80+
81+
export const verifyCustomDomain = async (userId: string, idToken: string) => {
82+
if (!db) {
83+
return { success: false, message: "Error de configuración: La conexión con la base de datos no está disponible." };
84+
}
85+
try {
86+
if (!userId) {
87+
return { success: false, message: "Usuario no autenticado." };
88+
}
89+
const verifiedUserId = await verifyUserRequest(userId, idToken);
90+
const [company] = await db.select().from(companies).where(eq(companies.userId, verifiedUserId));
91+
if (!company || !company.customDomain) {
92+
return { success: false, message: "No hay dominio personalizado configurado." };
93+
}
94+
95+
const url = new URL(company.customDomain);
96+
const host = url.hostname;
97+
const token = hashData(`${verifiedUserId}:${host}`).slice(0, 32);
98+
const recordName = `_origon-verify.${host}`;
99+
100+
try {
101+
const txtRecords = await dns.resolveTxt(recordName);
102+
const flat = txtRecords.flat().map(v => v.trim());
103+
if (flat.includes(token)) {
104+
return { success: true, message: "Dominio verificado correctamente." };
105+
}
106+
return { success: false, message: "No se encontró el token en el TXT. Verifica que el registro esté propagado." };
107+
} catch (error) {
108+
console.error("DNS lookup error:", error);
109+
return { success: false, message: "No se pudo resolver el TXT. Verifica el registro DNS y espera la propagación." };
110+
}
111+
} catch (error) {
112+
console.error("Error verificando dominio:", error);
113+
const message = error instanceof Error ? error.message : "Error desconocido.";
114+
return { success: false, message };
115+
}
116+
};
117+
39118
export const saveCompanyProfile = async (formData: ProfileFormValues, userId: string, idToken: string) => {
40119
const ratelimit = getRateLimiter();
41120
const { success: rateLimitSuccess } = await ratelimit.limit(userId);

src/app/actions/invoices.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -288,7 +288,7 @@ export const stampInvoice = async (invoiceId: number, userId: string, idToken: s
288288
unsignedXmlString = await _generateXmlString(invoiceData);
289289
}
290290

291-
const pacResult = await stampWithFacturaLoPlus(unsignedXmlString);
291+
const pacResult = await stampWithFacturaLoPlus(unsignedXmlString, invoiceData.company);
292292

293293
if (!pacResult.success) {
294294
return { success: false, message: pacResult.message };

src/app/actions/payments.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -272,7 +272,7 @@ export const stampPayment = async (paymentId: number, userId: string, idToken: s
272272

273273
// Timbrar con el PAC
274274
const { stampWithFacturaLoPlus } = await import('@/lib/pac');
275-
const pacResult = await stampWithFacturaLoPlus(paymentXml);
275+
const pacResult = await stampWithFacturaLoPlus(paymentXml, company);
276276

277277
if (!pacResult.success) {
278278
return { success: false, message: pacResult.message };

src/app/dashboard/settings/page.tsx

Lines changed: 223 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { Badge } from "@/components/ui/badge";
3030
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
3131
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
3232
import { SkeletonImage } from "@/components/ui/skeleton-image";
33+
import { Switch } from "@/components/ui/switch";
3334
import {
3435
AlertDialog,
3536
AlertDialogAction,
@@ -69,6 +70,8 @@ export default function SettingsPage() {
6970
defaultValues: {
7071
companyName: "", rfc: "", street: "", exteriorNumber: "", interiorNumber: "", state: "", municipality: "", neighborhood: "", zip: "", phone: "", phone2: "", fax: "", city: "", web: "", contadorEmail: "",
7172
taxRegime: "", commercialMessage: "", logoUrl: "", defaultEmailMessage: "", templateCfdi33: "costine-33", templateCfdi40: "costine-40", templateRep: "costine-rep",
73+
customDomain: "",
74+
pacProvider: "", pacEnvironment: "test", pacUsername: "", pacPassword: "", pacApiKey: "", pacApiUrl: "", pacWebhookUrl: "", pacIsActive: false,
7275
},
7376
});
7477

@@ -131,9 +134,81 @@ export default function SettingsPage() {
131134
setLoading(false);
132135
}
133136
});
134-
return () => unsubscribe();
137+
return () => unsubscribe();
135138
}, [fetchProfile]);
136139

140+
const [verifyingDomain, setVerifyingDomain] = useState(false);
141+
const [domainRecord, setDomainRecord] = useState<{ host: string; token: string; recordName: string } | null>(null);
142+
const [domainError, setDomainError] = useState<string | null>(null);
143+
const [domainInfoLoaded, setDomainInfoLoaded] = useState(false);
144+
const domainValue = profileForm.watch('customDomain');
145+
146+
const handleShowDomainDns = async (domainOverride?: string) => {
147+
if (!user) {
148+
toast({ title: "Error", description: "Debes iniciar sesión.", variant: "destructive" });
149+
return;
150+
}
151+
const rawDomain = domainOverride ?? profileForm.getValues('customDomain');
152+
if (!rawDomain) {
153+
setDomainError("Primero ingresa y guarda un dominio.");
154+
return;
155+
}
156+
try {
157+
setVerifyingDomain(true);
158+
const { getUserAuth } = await import('@/lib/auth-client');
159+
const { uid, token } = await getUserAuth(user);
160+
const { getDomainVerificationInfo } = await import('@/app/actions/companies');
161+
const res = await getDomainVerificationInfo(uid, token, rawDomain);
162+
if (res.success && res.data) {
163+
setDomainRecord(res.data as any);
164+
setDomainError(null);
165+
} else {
166+
toast({ title: "No disponible", description: res.message || "Configura un dominio primero.", variant: "destructive" });
167+
setDomainError(res.message || "Configura un dominio primero.");
168+
}
169+
} catch (error) {
170+
toast({ title: "Error", description: "No se pudo obtener la instrucción DNS.", variant: "destructive" });
171+
setDomainError("No se pudo obtener la instrucción DNS.");
172+
} finally {
173+
setVerifyingDomain(false);
174+
}
175+
};
176+
177+
const handleVerifyDomain = async () => {
178+
if (!user) {
179+
toast({ title: "Error", description: "Debes iniciar sesión.", variant: "destructive" });
180+
return;
181+
}
182+
try {
183+
setVerifyingDomain(true);
184+
const { getUserAuth } = await import('@/lib/auth-client');
185+
const { uid, token } = await getUserAuth(user);
186+
const { verifyCustomDomain, getDomainVerificationInfo } = await import('@/app/actions/companies');
187+
const verify = await verifyCustomDomain(uid, token);
188+
if (verify.success) {
189+
toast({ title: "Dominio verificado", description: "El registro TXT fue encontrado." });
190+
setDomainError(null);
191+
} else {
192+
toast({ title: "No verificado", description: verify.message || "No se encontró el TXT. Espera la propagación y reintenta.", variant: "destructive" });
193+
setDomainError(verify.message || "No se encontró el TXT. Espera la propagación y reintenta.");
194+
}
195+
const info = await getDomainVerificationInfo(uid, token, domainValue);
196+
if (info.success && info.data) setDomainRecord(info.data as any);
197+
} catch (error) {
198+
toast({ title: "Error", description: "No se pudo verificar el dominio.", variant: "destructive" });
199+
setDomainError("No se pudo verificar el dominio.");
200+
} finally {
201+
setVerifyingDomain(false);
202+
}
203+
};
204+
205+
useEffect(() => {
206+
if (domainValue && !domainInfoLoaded && !loading) {
207+
setDomainInfoLoaded(true);
208+
handleShowDomainDns(domainValue);
209+
}
210+
}, [domainValue, domainInfoLoaded, loading]);
211+
137212
async function onProfileSubmit(data: ProfileFormValues) {
138213
if (!user) {
139214
toast({ title: "Error", description: "Debes iniciar sesión para guardar los cambios.", variant: "destructive" });
@@ -338,16 +413,6 @@ export default function SettingsPage() {
338413
</FormItem>
339414
)} />
340415
<FormField control={profileForm.control} name="defaultEmailMessage" render={({ field }) => ( <FormItem><FormLabel>Mensaje predefinido para envio</FormLabel><FormControl><Textarea rows={4} {...field} value={field.value ?? ''}/></FormControl><FormMessage /></FormItem> )} />
341-
<FormField control={profileForm.control} name="customDomain" render={({ field }) => (
342-
<FormItem className="md:col-span-2">
343-
<FormLabel>Dominio Personalizado para Links Públicos</FormLabel>
344-
<FormControl><Input placeholder="https://tuempresa.com" {...field} value={field.value ?? ''} /></FormControl>
345-
<p className="text-xs text-muted-foreground">
346-
Configura tu dominio personalizado para que los links de solicitudes de clientes usen tu marca (ej: https://tuempresa.com/solicitud/abc123)
347-
</p>
348-
<FormMessage />
349-
</FormItem>
350-
)} />
351416
</div>
352417
<Separator/>
353418
<div className="space-y-8">
@@ -394,6 +459,153 @@ export default function SettingsPage() {
394459
</AccordionContent>
395460
</AccordionItem>
396461

462+
<AccordionItem value="pac">
463+
<AccordionTrigger className="text-lg font-semibold bg-muted px-4 rounded-t-lg data-[state=closed]:rounded-b-lg">
464+
PAC / Timbrado
465+
</AccordionTrigger>
466+
<AccordionContent className="p-4 border border-t-0 rounded-b-lg space-y-6">
467+
<div className="text-sm text-muted-foreground">
468+
Define las credenciales de tu PAC para timbrar con tu propia cuenta. Se guardan por empresa y se usan al momento de timbrar facturas y pagos.
469+
</div>
470+
<div className="grid md:grid-cols-2 gap-4">
471+
<FormField control={profileForm.control} name="pacProvider" render={({ field }) => (
472+
<FormItem>
473+
<FormLabel>Proveedor</FormLabel>
474+
<FormControl><Input placeholder="Ej. FacturaLoPlus, Finkok, SW Sapien" {...field} value={field.value ?? ''} /></FormControl>
475+
<FormMessage />
476+
</FormItem>
477+
)} />
478+
<FormField control={profileForm.control} name="pacEnvironment" render={({ field }) => (
479+
<FormItem>
480+
<FormLabel>Ambiente</FormLabel>
481+
<Select onValueChange={field.onChange} value={field.value ?? 'test'}>
482+
<FormControl><SelectTrigger><SelectValue placeholder="Selecciona ambiente" /></SelectTrigger></FormControl>
483+
<SelectContent>
484+
<SelectItem value="test">Pruebas</SelectItem>
485+
<SelectItem value="production">Producción</SelectItem>
486+
</SelectContent>
487+
</Select>
488+
<FormMessage />
489+
</FormItem>
490+
)} />
491+
<FormField control={profileForm.control} name="pacUsername" render={({ field }) => (
492+
<FormItem>
493+
<FormLabel>Usuario</FormLabel>
494+
<FormControl><Input placeholder="Usuario del PAC" {...field} value={field.value ?? ''} /></FormControl>
495+
<FormMessage />
496+
</FormItem>
497+
)} />
498+
<FormField control={profileForm.control} name="pacPassword" render={({ field }) => (
499+
<FormItem>
500+
<FormLabel>Contraseña</FormLabel>
501+
<FormControl><Input type="password" placeholder="Contraseña/Token secreto" {...field} value={field.value ?? ''} /></FormControl>
502+
<FormMessage />
503+
</FormItem>
504+
)} />
505+
<FormField control={profileForm.control} name="pacApiKey" render={({ field }) => (
506+
<FormItem>
507+
<FormLabel>API Key</FormLabel>
508+
<FormControl><Input placeholder="API Key si aplica" {...field} value={field.value ?? ''} /></FormControl>
509+
<FormMessage />
510+
</FormItem>
511+
)} />
512+
<FormField control={profileForm.control} name="pacApiUrl" render={({ field }) => (
513+
<FormItem>
514+
<FormLabel>URL del API</FormLabel>
515+
<FormControl><Input placeholder="https://api.tu-pac.com/timbrar" {...field} value={field.value ?? ''} /></FormControl>
516+
<FormMessage />
517+
</FormItem>
518+
)} />
519+
<FormField control={profileForm.control} name="pacWebhookUrl" render={({ field }) => (
520+
<FormItem>
521+
<FormLabel>Webhook (opcional)</FormLabel>
522+
<FormControl><Input placeholder="URL para notificaciones del PAC" {...field} value={field.value ?? ''} /></FormControl>
523+
<FormMessage />
524+
</FormItem>
525+
)} />
526+
<FormField control={profileForm.control} name="pacIsActive" render={({ field }) => (
527+
<FormItem className="flex items-center justify-between rounded-lg border p-3">
528+
<div className="space-y-0.5">
529+
<FormLabel>Usar este PAC</FormLabel>
530+
<p className="text-xs text-muted-foreground">Activa para timbrar con estas credenciales.</p>
531+
</div>
532+
<FormControl>
533+
<Switch checked={field.value ?? false} onCheckedChange={field.onChange} />
534+
</FormControl>
535+
</FormItem>
536+
)} />
537+
</div>
538+
</AccordionContent>
539+
</AccordionItem>
540+
541+
<AccordionItem value="custom-domain">
542+
<AccordionTrigger className="text-lg font-semibold bg-muted px-4 rounded-t-lg data-[state=closed]:rounded-b-lg">
543+
Dominio Personalizado (requiere verificación DNS)
544+
</AccordionTrigger>
545+
<AccordionContent className="p-4 border border-t-0 rounded-b-lg space-y-4">
546+
<p className="text-sm text-muted-foreground">
547+
Usa un dominio tuempresa.com para los links públicos. Debes apuntar un CNAME/A y verificarlo antes de usarlo; no aceptamos dominios no verificados (ej: google.com).
548+
</p>
549+
<div className="rounded-lg border bg-muted/40 p-3 text-sm">
550+
<div className="text-muted-foreground">Dominio actual:</div>
551+
<div className="font-mono">{profileForm.watch('customDomain') || 'No configurado'}</div>
552+
</div>
553+
{domainRecord && (
554+
<div className="rounded-lg border bg-muted/40 p-3 text-sm space-y-1">
555+
<div className="font-semibold">Registro TXT a crear</div>
556+
<div><span className="text-muted-foreground">Nombre:</span> <span className="font-mono">{domainRecord.recordName}</span></div>
557+
<div><span className="text-muted-foreground">Valor:</span> <span className="font-mono break-all">{domainRecord.token}</span></div>
558+
<div className="text-xs text-muted-foreground">Coloca este TXT en tu DNS y espera propagación.</div>
559+
</div>
560+
)}
561+
<FormField control={profileForm.control} name="customDomain" render={({ field }) => (
562+
<FormItem>
563+
<FormLabel>Dominio</FormLabel>
564+
<FormControl>
565+
<Input
566+
placeholder="facturas.tuempresa.com"
567+
{...field}
568+
value={field.value ?? ''}
569+
className="max-w-xl"
570+
/>
571+
</FormControl>
572+
<p className="text-xs text-muted-foreground">
573+
Puedes escribir solo el dominio; agregamos https:// automáticamente. Configura primero el registro DNS y luego guarda para iniciar verificación.
574+
</p>
575+
{domainError && (
576+
<p className="text-xs text-destructive">{domainError}</p>
577+
)}
578+
<FormMessage />
579+
</FormItem>
580+
)} />
581+
<div className="flex flex-wrap gap-3">
582+
<Button
583+
type="button"
584+
variant="outline"
585+
size="sm"
586+
onClick={() => handleShowDomainDns()}
587+
disabled={verifyingDomain || !domainValue}
588+
>
589+
Obtener registro TXT
590+
</Button>
591+
<Button
592+
type="button"
593+
size="sm"
594+
onClick={handleVerifyDomain}
595+
disabled={verifyingDomain || !domainValue}
596+
>
597+
Verificar dominio
598+
</Button>
599+
</div>
600+
<Alert variant="default" className="text-sm">
601+
<AlertTitle>Verificación pendiente</AlertTitle>
602+
<AlertDescription>
603+
Crea el TXT mostrado, espera propagación y luego presiona "Verificar dominio".
604+
</AlertDescription>
605+
</Alert>
606+
</AccordionContent>
607+
</AccordionItem>
608+
397609
<AccordionItem value="sso">
398610
<AccordionTrigger className="text-lg font-semibold bg-muted px-4 rounded-t-lg data-[state=closed]:rounded-b-lg">Inicio de Sesión Único (SSO)</AccordionTrigger>
399611
<AccordionContent className="p-4 border border-t-0 rounded-b-lg">

0 commit comments

Comments
 (0)