Skip to content

Commit 61e2c32

Browse files
committed
v1.2.6j - feat: signature builder overhaul, MFA, Google OAuth redirect, template signature preview
Signature Builder (components/SignatureBuilder.tsx): - Rich-text paste import: replaced textarea with contenteditable div that captures clipboard text/html for direct Gmail/Outlook signature paste - Auto-import on paste with field auto-fill, stays in builder mode for editing - Header/banner image: new headerImageUrl field with S3 upload, full-width cover photo rendering at top of all 12 signature templates via wrapperStart - Banner height slider (60-200px) with live preview - Website link display text: users can set different display label vs actual URL - Fixed Icons8 icon mapping (mail→new-post, globe→globe--v1) for proper contact icon rendering across all templates - Increased client-side upload limit to 2MB with clear error messaging Outreach Template Preview (app/api/outreach/preview/template/route.ts): - Template previews in FirstContactWizard now show the user's actual saved signature from the DB instead of a generic "Jordan Mitchell" placeholder - Fallback chain: client → brand → user's saved signature → default Google OAuth (app/api/google/callback/route.ts): - Redirect after OAuth now goes to /profile?tab=integration&google=connected instead of /en/crm/leads, fixing both the redirect and persistence issues MFA & Auth: - LoginComponent.tsx: MFA sign-in flow updates - MfaSettings.tsx: profile MFA settings updates - lib/mfa-utils.ts: MFA utility changes
1 parent 3d4cd55 commit 61e2c32

File tree

6 files changed

+211
-22
lines changed

6 files changed

+211
-22
lines changed

app/(auth)/sign-in/components/LoginComponent.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,8 @@ export function LoginComponent() {
210210
const options = res.data;
211211

212212
// 2. Trigger browser biometric prompt
213-
const asseResp = await startAuthentication(options);
213+
// v13: startAuthentication expects { optionsJSON }
214+
const asseResp = await startAuthentication({ optionsJSON: options });
214215

215216
// 3. Verify with backend
216217
const verifyRes = await axios.post("/api/auth/mfa/webauthn/auth-verify", {

app/(routes)/profile/components/MfaSettings.tsx

Lines changed: 64 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"use client";
22

33
import { useState, useEffect } from "react";
4+
import { useRouter } from "next/navigation";
45
import { Button } from "@/components/ui/button";
56
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
67
import { Badge } from "@/components/ui/badge";
@@ -16,11 +17,27 @@ interface MfaSettingsProps {
1617

1718
export function MfaSettings({ user }: MfaSettingsProps) {
1819
const { toast } = useToast();
20+
const router = useRouter();
1921
const [loading, setLoading] = useState(false);
2022
const [mfaData, setMfaData] = useState<any>(null);
2123
const [setupStep, setSetupStep] = useState<"idle" | "totp" | "sms" | "webauthn">("idle");
2224
const [totpCode, setTotpCode] = useState("");
23-
const [isMfaEnabled, setIsMfaEnabled] = useState(user.mfaEnabled);
25+
const [isMfaEnabled, setIsMfaEnabled] = useState(user.mfaEnabled ?? false);
26+
const [mfaMethod, setMfaMethod] = useState<string>(user.mfaMethod ?? "NONE");
27+
28+
// Fetch fresh MFA status on mount to handle cases where server prop is stale
29+
useEffect(() => {
30+
const fetchStatus = async () => {
31+
try {
32+
const res = await axios.get("/api/user/mfa/status");
33+
setIsMfaEnabled(res.data.mfaEnabled);
34+
setMfaMethod(res.data.mfaMethod || "NONE");
35+
} catch {
36+
// Fall back to server-provided value
37+
}
38+
};
39+
fetchStatus();
40+
}, []);
2441

2542
const fetchMfaSetup = async (type: string) => {
2643
setLoading(true);
@@ -32,13 +49,15 @@ export function MfaSettings({ user }: MfaSettingsProps) {
3249
} else if (type === "webauthn") {
3350
const res = await axios.get("/api/user/mfa/webauthn/register-options");
3451
const options = res.data;
35-
const attResp = await startRegistration(options);
52+
const attResp = await startRegistration({ optionsJSON: options });
3653
await axios.post("/api/user/mfa/webauthn/register-verify", {
3754
body: attResp,
3855
currentOptions: options
3956
});
4057
toast({ title: "Success", description: "Biometric authentication registered successfully." });
4158
setIsMfaEnabled(true);
59+
setMfaMethod("WEBAUTHN");
60+
router.refresh();
4261
}
4362
} catch (error: any) {
4463
toast({
@@ -61,8 +80,10 @@ export function MfaSettings({ user }: MfaSettingsProps) {
6180
});
6281
toast({ title: "MFA Enabled", description: "TOTP Authentication is now active." });
6382
setIsMfaEnabled(true);
83+
setMfaMethod("TOTP");
6484
setSetupStep("idle");
6585
setTotpCode("");
86+
router.refresh();
6687
} catch (error: any) {
6788
toast({
6889
variant: "destructive",
@@ -80,6 +101,8 @@ export function MfaSettings({ user }: MfaSettingsProps) {
80101
await axios.post("/api/user/mfa/disable");
81102
toast({ title: "MFA Disabled", description: "Your account is now less secure." });
82103
setIsMfaEnabled(false);
104+
setMfaMethod("NONE");
105+
router.refresh();
83106
} catch (error) {
84107
toast({ variant: "destructive", title: "Error", description: "Failed to disable MFA." });
85108
} finally {
@@ -101,8 +124,13 @@ export function MfaSettings({ user }: MfaSettingsProps) {
101124
</div>
102125
</div>
103126
{isMfaEnabled ? (
104-
<div className="bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 px-3 py-1 rounded-full text-xs font-semibold flex items-center">
105-
<CheckCircle2 className="h-3 w-3 mr-1" /> Protected
127+
<div className="bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 px-3 py-1 rounded-full text-xs font-semibold flex items-center gap-1">
128+
<CheckCircle2 className="h-3 w-3" /> Protected
129+
{mfaMethod && mfaMethod !== "NONE" && (
130+
<span className="text-emerald-500/60 ml-1">
131+
({mfaMethod === "TOTP" ? "Authenticator" : mfaMethod === "WEBAUTHN" ? "Passkey" : mfaMethod})
132+
</span>
133+
)}
106134
</div>
107135
) : (
108136
<Badge variant="destructive" className="bg-red-500/10 text-red-400 border-red-500/20 px-3 py-1">
@@ -192,7 +220,38 @@ export function MfaSettings({ user }: MfaSettingsProps) {
192220
) : null}
193221

194222
{isMfaEnabled && setupStep === "idle" && (
195-
<div className="mt-8 pt-6 border-t border-white/5">
223+
<div className="mt-8 pt-6 border-t border-white/5 space-y-4">
224+
{/* Reset MFA */}
225+
<div className="flex items-center justify-between p-4 bg-amber-500/5 rounded-xl border border-amber-500/10">
226+
<div>
227+
<h4 className="text-sm font-bold text-amber-400 uppercase tracking-widest">Reset Method</h4>
228+
<p className="text-xs text-muted-foreground mt-1">Switch to a different authentication method or regenerate your current one.</p>
229+
</div>
230+
<Button
231+
variant="outline"
232+
size="sm"
233+
className="border-amber-500/30 text-amber-400 hover:bg-amber-500/10"
234+
onClick={async () => {
235+
setLoading(true);
236+
try {
237+
await axios.post("/api/user/mfa/disable");
238+
setIsMfaEnabled(false);
239+
setMfaMethod("NONE");
240+
toast({ title: "MFA Reset", description: "Choose a new authentication method below." });
241+
// Keep setupStep as idle — the method cards will now show since isMfaEnabled is false
242+
} catch {
243+
toast({ variant: "destructive", title: "Error", description: "Failed to reset MFA." });
244+
} finally {
245+
setLoading(false);
246+
}
247+
}}
248+
disabled={loading}
249+
>
250+
{loading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
251+
Reset MFA
252+
</Button>
253+
</div>
254+
{/* Disable MFA */}
196255
<div className="flex items-center justify-between p-4 bg-red-500/5 rounded-xl border border-red-500/10">
197256
<div>
198257
<h4 className="text-sm font-bold text-red-400 uppercase tracking-widest">Danger Zone</h4>

app/api/outreach/preview/template/route.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,16 @@ export async function POST(req: Request) {
7272
} catch { }
7373
}
7474

75+
// Fetch the user's saved signature from their profile
76+
let userSignature: string | undefined;
77+
try {
78+
const user = await prismadb.users.findUnique({
79+
where: { id: session.user.id },
80+
select: { signature_html: true },
81+
});
82+
if (user?.signature_html) userSignature = user.signature_html as string;
83+
} catch { }
84+
7585
const baseUrl = process.env.NEXTAUTH_URL || "";
7686

7787
const brandColorHex = (body.props?.brand?.accentColor || brandColor || "#1f2937").replace("#", "");
@@ -93,7 +103,7 @@ export async function POST(req: Request) {
93103
subjectPreview: body.props?.subjectPreview || "Exploring Partnership Opportunities",
94104
bodyText: body.props?.bodyText || DEFAULT_PREVIEW_BODY,
95105
resources: resolvedResources,
96-
signatureHtml: body.props?.signatureHtml || brandSignature || DEFAULT_SIGNATURE,
106+
signatureHtml: body.props?.signatureHtml || brandSignature || userSignature || DEFAULT_SIGNATURE,
97107
brand: {
98108
accentColor: body.props?.brand?.accentColor || brandColor,
99109
secondaryColor: body.props?.brand?.secondaryColor || undefined,

app/api/user/mfa/status/route.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { NextResponse } from "next/server";
2+
import { getServerSession } from "next-auth";
3+
import { authOptions } from "@/lib/auth";
4+
import { prismadb } from "@/lib/prisma";
5+
6+
export async function GET() {
7+
const session = await getServerSession(authOptions);
8+
9+
if (!session?.user?.id) {
10+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
11+
}
12+
13+
const user = await prismadb.users.findUnique({
14+
where: { id: session.user.id },
15+
select: {
16+
mfaEnabled: true,
17+
mfaMethod: true,
18+
},
19+
});
20+
21+
if (!user) {
22+
return NextResponse.json({ error: "User not found" }, { status: 404 });
23+
}
24+
25+
return NextResponse.json({
26+
mfaEnabled: user.mfaEnabled,
27+
mfaMethod: user.mfaMethod,
28+
});
29+
}

components/SignatureBuilder.tsx

Lines changed: 91 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ interface SignatureData {
9191
websiteDisplayText: string;
9292
profileImage: string;
9393
companyLogoUrl: string;
94+
headerImageUrl: string;
9495
companyTagline: string;
9596
accentColor: string;
9697
template: "professional" | "modern" | "minimalist" | "elegant" | "creative" | "banner" | "corporate" | "compact" | "tech" | "classic" | "social" | "dense";
@@ -105,6 +106,7 @@ interface SignatureData {
105106
contactIconSize: number;
106107
contactFieldsOrder: ("phone" | "email" | "website")[];
107108
showSeparator: boolean;
109+
headerImageHeight: number;
108110
}
109111

110112
interface SignatureBuilderProps {
@@ -181,8 +183,14 @@ const hexToRgb = (hex: string) => {
181183

182184
const getIconUrl = (name: string, color: string) => {
183185
const hex = color.replace("#", "");
184-
// Map 'twitter' to 'twitterx' for Icons8 compatibility
185-
const iconName = name === 'twitter' ? 'twitterx' : name;
186+
// Map icon names to valid Icons8 icon identifiers
187+
const iconMap: Record<string, string> = {
188+
phone: 'phone',
189+
mail: 'new-post',
190+
globe: 'globe--v1',
191+
twitter: 'twitterx',
192+
};
193+
const iconName = iconMap[name] || name;
186194
return `https://img.icons8.com/ios-filled/50/${hex}/${iconName}.png`;
187195
};
188196

@@ -230,6 +238,7 @@ const SignatureBuilder: React.FC<SignatureBuilderProps> = ({ hasAccess = true, b
230238
websiteDisplayText: "",
231239
profileImage: "",
232240
companyLogoUrl: "https://storage.googleapis.com/tgl_cdn/images/Medallions/TUC.png",
241+
headerImageUrl: "",
233242
companyTagline: "Simple Choices. Complex Outcomes.",
234243
accentColor: DEFAULT_COLOR,
235244
template: "professional",
@@ -244,6 +253,7 @@ const SignatureBuilder: React.FC<SignatureBuilderProps> = ({ hasAccess = true, b
244253
contactIconSize: 15,
245254
contactFieldsOrder: ["phone", "email", "website"],
246255
showSeparator: true,
256+
headerImageHeight: 120,
247257
});
248258

249259
const [saving, setSaving] = useState(false);
@@ -416,7 +426,7 @@ const SignatureBuilder: React.FC<SignatureBuilderProps> = ({ hasAccess = true, b
416426
};
417427

418428
// Handler: Image Upload
419-
const handleImageUpload = async (file: File, field: "profileImage" | "companyLogoUrl") => {
429+
const handleImageUpload = async (file: File, field: "profileImage" | "companyLogoUrl" | "headerImageUrl") => {
420430
// Client-side size check: 2MB max
421431
const MAX_FILE_SIZE = 2 * 1024 * 1024;
422432
if (file.size > MAX_FILE_SIZE) {
@@ -428,7 +438,7 @@ const SignatureBuilder: React.FC<SignatureBuilderProps> = ({ hasAccess = true, b
428438
// SPECIAL HANDLING FOR COMPANY LOGO:
429439
// Upload RAW to preserve transparency perfectly for all formats (PNG, WebP, GIF, etc).
430440
// This fixes the issue where logos were getting black backgrounds or artifacts.
431-
if (field === "companyLogoUrl") {
441+
if (field === "companyLogoUrl" || field === "headerImageUrl") {
432442
const formData = new FormData();
433443
formData.append("file", file); // Upload raw file
434444

@@ -437,7 +447,7 @@ const SignatureBuilder: React.FC<SignatureBuilderProps> = ({ hasAccess = true, b
437447

438448
if (res.ok && json?.document?.document_file_url) {
439449
startUpdate(field, json.document.document_file_url);
440-
toast.success("Logo uploaded!");
450+
toast.success(field === "headerImageUrl" ? "Header image uploaded!" : "Logo uploaded!");
441451
return json.document.document_file_url;
442452
} else {
443453
throw new Error(json?.error || "Upload failed");
@@ -782,7 +792,7 @@ const SignatureBuilder: React.FC<SignatureBuilderProps> = ({ hasAccess = true, b
782792
}
783793
const {
784794
firstName, lastName, title, department, phone, email, website,
785-
profileImage, companyLogoUrl, companyTagline, accentColor,
795+
profileImage, companyLogoUrl, headerImageUrl, companyTagline, accentColor,
786796
template, socialLinks, textColor, backgroundColor, highlightLastName, transparentBackground, medallions, imageShape, contactIconSize
787797
} = data;
788798

@@ -831,10 +841,22 @@ const SignatureBuilder: React.FC<SignatureBuilderProps> = ({ hasAccess = true, b
831841
const displayTitle = title.replace(/\n/g, '<br/>');
832842
const displayDepartment = department.replace(/\n/g, '<br/>');
833843

844+
// Social-media-style banner header — wide cover image at top, templates render content below
845+
const bannerHeight = data.headerImageHeight || 120;
846+
const headerHtml = headerImageUrl ? `
847+
<table cellpadding="0" cellspacing="0" border="0" style="width: 100%; max-width: 600px; border-collapse: collapse; margin-bottom: 0;">
848+
<tr>
849+
<td style="padding: 0;">
850+
<img src="${headerImageUrl}" style="display: block; width: 100%; max-width: 600px; height: ${bannerHeight}px; object-fit: cover; border-radius: 8px 8px 0 0;" alt="Banner" />
851+
</td>
852+
</tr>
853+
</table>
854+
` : '';
855+
834856
// Wrapper style for background color
835857
const bgColorStyle = transparentBackground ? 'background-color: transparent;' : `background-color: ${backgroundColor};`;
836858
const wrapperStyle = `${bgColorStyle} padding: 20px;`;
837-
const wrapperStart = `<div style="${wrapperStyle}">`;
859+
const wrapperStart = `<div style="${wrapperStyle}">${headerHtml}`;
838860
const wrapperEnd = `</div>`;
839861

840862
// Social Icons HTML
@@ -1631,6 +1653,68 @@ const SignatureBuilder: React.FC<SignatureBuilderProps> = ({ hasAccess = true, b
16311653
</div>
16321654
</div>
16331655
)}
1656+
1657+
{/* Header / Banner Image — social-media-style cover photo */}
1658+
<div className="space-y-3 pt-2">
1659+
<div>
1660+
<Label>Header / Banner Image</Label>
1661+
<p className="text-xs text-muted-foreground">Add a social-media-style cover photo above your signature. Profile photo will overlap it automatically.</p>
1662+
</div>
1663+
<div className="flex items-start gap-6">
1664+
<div className="shrink-0">
1665+
<div className="w-40 h-20 rounded bg-muted border-2 border-dashed border-gray-600 flex items-center justify-center overflow-hidden relative group">
1666+
{data.headerImageUrl ? (
1667+
<img src={data.headerImageUrl} alt="Header" className="w-full h-full object-cover" />
1668+
) : (
1669+
<div className="flex flex-col items-center gap-1">
1670+
<ImageIcon className="w-5 h-5 text-muted-foreground" />
1671+
<span className="text-[10px] text-muted-foreground">Cover Photo</span>
1672+
</div>
1673+
)}
1674+
<label className="absolute inset-0 bg-black/50 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer">
1675+
<Upload className="w-5 h-5 text-white" />
1676+
<input type="file" className="hidden" accept="image/*" onChange={(e) => {
1677+
const file = e.target.files?.[0];
1678+
if (file) handleImageUpload(file, "headerImageUrl");
1679+
}} disabled={uploading} />
1680+
</label>
1681+
</div>
1682+
</div>
1683+
<div className="flex-1 space-y-2">
1684+
<Input value={data.headerImageUrl} onChange={(e) => startUpdate("headerImageUrl", e.target.value)} placeholder="https://... or upload" />
1685+
{data.headerImageUrl && (
1686+
<Button variant="ghost" size="sm" className="text-xs text-destructive" onClick={() => startUpdate("headerImageUrl", "")}>
1687+
<Trash2 className="w-3 h-3 mr-1" /> Remove
1688+
</Button>
1689+
)}
1690+
</div>
1691+
</div>
1692+
1693+
{/* Banner Height Slider — only visible when header image is set */}
1694+
{data.headerImageUrl && (
1695+
<div className="space-y-2 pt-2">
1696+
<div className="flex items-center justify-between">
1697+
<Label className="text-sm">Banner Height</Label>
1698+
<span className="text-sm font-bold text-primary bg-primary/10 px-2 py-0.5 rounded-md border border-primary/20">
1699+
{data.headerImageHeight}px
1700+
</span>
1701+
</div>
1702+
<input
1703+
type="range"
1704+
min="60"
1705+
max="200"
1706+
step="5"
1707+
value={data.headerImageHeight}
1708+
onChange={(e) => startUpdate("headerImageHeight", Number(e.target.value))}
1709+
className="w-full h-2 bg-muted rounded-lg appearance-none cursor-pointer accent-primary"
1710+
/>
1711+
<div className="flex justify-between text-[10px] text-muted-foreground uppercase tracking-widest font-semibold px-1">
1712+
<span>Compact</span>
1713+
<span>Tall</span>
1714+
</div>
1715+
</div>
1716+
)}
1717+
</div>
16341718
</div>
16351719
)}
16361720

0 commit comments

Comments
 (0)