Skip to content

Commit 7dad65f

Browse files
authored
Merge pull request #835 from circleboyslimited/fix/issues-762-765-766-768
Fix #762, #765, #766, #768: CSP header, recurring page, settings page, onboarding checklist
2 parents 9894f24 + 2b69187 commit 7dad65f

7 files changed

Lines changed: 520 additions & 2 deletions

File tree

backend/src/app.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4124,7 +4124,7 @@ All errors return JSON with an \`error\` field and optional \`code\`:
41244124
// Supporter view — return the authenticated user's own drip subscriptions
41254125
const subscriptions = await prisma.recurringSupport.findMany({
41264126
where: { supporterId: user.id, status: { not: "cancelled" } },
4127-
include: { profile: { select: { username: true, displayName: true } } },
4127+
include: { profile: { select: { username: true, displayName: true, avatarUrl: true } } },
41284128
orderBy: { createdAt: "desc" },
41294129
});
41304130

@@ -4133,6 +4133,7 @@ All errors return JSON with an \`error\` field and optional \`code\`:
41334133
profileId: s.profileId,
41344134
profileUsername: s.profile.username,
41354135
profileDisplayName: s.profile.displayName,
4136+
profileAvatarUrl: s.profile.avatarUrl,
41364137
amount: s.amount.toString(),
41374138
assetCode: s.assetCode,
41384139
frequency: s.frequency,

frontend/next.config.mjs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,17 @@ const nextConfig = {
1818
source: "/((?!embed).*)",
1919
headers: [
2020
{ key: "X-Frame-Options", value: "DENY" },
21-
{ key: "Content-Security-Policy", value: "frame-ancestors 'none'" },
21+
{
22+
key: "Content-Security-Policy",
23+
value: [
24+
"default-src 'self'",
25+
"script-src 'self' 'unsafe-inline'", // 'unsafe-inline' needed for Next.js inline chunks
26+
"style-src 'self' 'unsafe-inline'",
27+
`connect-src 'self' ${process.env.NEXT_PUBLIC_API_BASE_URL ?? ""} ${process.env.NEXT_PUBLIC_HORIZON_URL ?? ""} ${process.env.NEXT_PUBLIC_SOROBAN_RPC_URL ?? ""}`,
28+
"img-src 'self' data: blob: https:",
29+
"frame-ancestors 'none'",
30+
].join("; "),
31+
},
2232
],
2333
},
2434
{

frontend/src/app/dashboard/page.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
55
import Link from "next/link";
66
import { AppShell } from "@/components/app-shell";
77
import { Toast } from "@/components/toast";
8+
import { OnboardingChecklist } from "@/components/onboarding-checklist";
89
import {
910
TrendingUp, Users, Wallet, Activity,
1011
ArrowUpRight, ArrowDownRight, Plus, Edit2, Trash2, X, Link2, Eye, EyeOff, Copy, Check, ChevronDown, ChevronRight, Download
@@ -393,6 +394,10 @@ export default function DashboardPage() {
393394
</div>
394395
</header>
395396

397+
{username && (
398+
<OnboardingChecklist username={username} milestoneCount={milestones.length} />
399+
)}
400+
396401
{/* Summary Cards */}
397402
{stats && (
398403
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
"use client";
2+
3+
import { useEffect, useState } from "react";
4+
import { useRouter } from "next/navigation";
5+
import { AppShell } from "@/components/app-shell";
6+
import { Toast } from "@/components/toast";
7+
import { EmptyState } from "@/components/empty-state";
8+
import { apiFetch } from "@/lib/api-client";
9+
10+
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:4000";
11+
12+
interface RecurringSupport {
13+
id: string;
14+
profileId: string;
15+
profileUsername: string;
16+
profileDisplayName: string;
17+
profileAvatarUrl: string | null;
18+
amount: string;
19+
assetCode: string;
20+
frequency: string;
21+
nextRunAt: string;
22+
status: string;
23+
createdAt: string;
24+
}
25+
26+
export default function RecurringPage() {
27+
const router = useRouter();
28+
const [subscriptions, setSubscriptions] = useState<RecurringSupport[]>([]);
29+
const [loading, setLoading] = useState(true);
30+
const [error, setError] = useState<string | null>(null);
31+
const [cancelTarget, setCancelTarget] = useState<string | null>(null);
32+
const [cancelling, setCancelling] = useState<string | null>(null);
33+
const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null);
34+
35+
useEffect(() => {
36+
const username = localStorage.getItem("username");
37+
if (!username) {
38+
router.push("/");
39+
return;
40+
}
41+
42+
apiFetch(`${API_BASE_URL}/v1/recurring-support`)
43+
.then(async (res) => {
44+
if (!res.ok) throw new Error("Failed to load recurring support subscriptions");
45+
const data = await res.json();
46+
setSubscriptions(data);
47+
})
48+
.catch((err: unknown) => {
49+
setError(err instanceof Error ? err.message : "Something went wrong");
50+
})
51+
.finally(() => setLoading(false));
52+
}, [router]);
53+
54+
async function handleCancel(id: string) {
55+
setCancelling(id);
56+
try {
57+
const res = await apiFetch(`${API_BASE_URL}/v1/recurring-support/${id}`, {
58+
method: "DELETE",
59+
});
60+
if (!res.ok) throw new Error("Failed to cancel subscription");
61+
setSubscriptions((prev) => prev.filter((s) => s.id !== id));
62+
setToast({ message: "Subscription cancelled", type: "success" });
63+
} catch (err: unknown) {
64+
setToast({
65+
message: err instanceof Error ? err.message : "Failed to cancel subscription",
66+
type: "error",
67+
});
68+
} finally {
69+
setCancelling(null);
70+
setCancelTarget(null);
71+
}
72+
}
73+
74+
return (
75+
<AppShell>
76+
<div className="mx-auto max-w-3xl px-4 py-10">
77+
<h1 className="text-2xl font-semibold text-white mb-1">Recurring support</h1>
78+
<p className="text-sm text-white/50 mb-8">
79+
Manage the creators you support on a recurring basis.
80+
</p>
81+
82+
{loading && <p className="text-sm text-white/40">Loading…</p>}
83+
{error && <p className="text-sm text-red-400">{error}</p>}
84+
85+
{!loading && !error && subscriptions.length === 0 && (
86+
<EmptyState
87+
title="No recurring support yet"
88+
description="When you set up a recurring drip to a creator, it will show up here."
89+
/>
90+
)}
91+
92+
{!loading && subscriptions.length > 0 && (
93+
<ul className="space-y-3">
94+
{subscriptions.map((sub) => (
95+
<li
96+
key={sub.id}
97+
className="flex items-center justify-between gap-4 rounded-2xl border border-white/10 bg-white/5 p-4"
98+
>
99+
<div className="flex items-center gap-3 min-w-0">
100+
{sub.profileAvatarUrl ? (
101+
// eslint-disable-next-line @next/next/no-img-element -- arbitrary external avatar URLs, matches profile-card.tsx convention
102+
<img
103+
src={sub.profileAvatarUrl}
104+
alt={sub.profileDisplayName}
105+
className="h-10 w-10 rounded-full object-cover flex-shrink-0"
106+
/>
107+
) : (
108+
<div className="h-10 w-10 rounded-full bg-white/10 flex items-center justify-center text-sm text-white/60 flex-shrink-0">
109+
{sub.profileDisplayName?.[0]?.toUpperCase() ?? "?"}
110+
</div>
111+
)}
112+
<div className="min-w-0">
113+
<p className="text-sm font-medium text-white truncate">
114+
{sub.profileDisplayName}
115+
</p>
116+
<p className="text-xs text-white/40">
117+
{sub.amount} {sub.assetCode} · {sub.frequency} · next{" "}
118+
{new Date(sub.nextRunAt).toLocaleDateString()}
119+
</p>
120+
</div>
121+
</div>
122+
123+
{cancelTarget === sub.id ? (
124+
<div className="flex items-center gap-2 flex-shrink-0">
125+
<span className="text-xs text-white/50">Cancel?</span>
126+
<button
127+
onClick={() => handleCancel(sub.id)}
128+
disabled={cancelling === sub.id}
129+
className="rounded-lg bg-red-500/20 px-3 py-1.5 text-xs font-medium text-red-300 hover:bg-red-500/30"
130+
>
131+
{cancelling === sub.id ? "Cancelling…" : "Yes, cancel"}
132+
</button>
133+
<button
134+
onClick={() => setCancelTarget(null)}
135+
className="rounded-lg px-3 py-1.5 text-xs text-white/50 hover:text-white"
136+
>
137+
Keep it
138+
</button>
139+
</div>
140+
) : (
141+
<button
142+
onClick={() => setCancelTarget(sub.id)}
143+
className="flex-shrink-0 rounded-lg border border-white/10 px-3 py-1.5 text-xs font-medium text-white/70 hover:border-red-400/40 hover:text-red-300"
144+
>
145+
Cancel
146+
</button>
147+
)}
148+
</li>
149+
))}
150+
</ul>
151+
)}
152+
</div>
153+
154+
{toast && <Toast message={toast.message} type={toast.type} onDismiss={() => setToast(null)} />}
155+
</AppShell>
156+
);
157+
}

frontend/src/app/settings/page.tsx

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
"use client";
2+
3+
import { useEffect, useState } from "react";
4+
import { useRouter } from "next/navigation";
5+
import { AppShell } from "@/components/app-shell";
6+
import { Toast } from "@/components/toast";
7+
import { NotificationPreferences } from "@/components/notification-preferences";
8+
import { apiFetch } from "@/lib/api-client";
9+
10+
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:4000";
11+
12+
export default function SettingsPage() {
13+
const router = useRouter();
14+
const [username, setUsername] = useState<string | null>(null);
15+
const [emailVerified, setEmailVerified] = useState<boolean | null>(null);
16+
const [loading, setLoading] = useState(true);
17+
const [resending, setResending] = useState(false);
18+
const [deleteStep, setDeleteStep] = useState(0);
19+
const [deleting, setDeleting] = useState(false);
20+
const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null);
21+
22+
useEffect(() => {
23+
const storedUsername = localStorage.getItem("username");
24+
if (!storedUsername) {
25+
router.push("/");
26+
return;
27+
}
28+
setUsername(storedUsername);
29+
30+
apiFetch(`${API_BASE_URL}/profiles/${storedUsername}`)
31+
.then(async (res) => {
32+
if (!res.ok) return;
33+
const data = await res.json();
34+
setEmailVerified(Boolean(data.emailVerified));
35+
})
36+
.finally(() => setLoading(false));
37+
}, [router]);
38+
39+
async function handleResendVerification() {
40+
if (!username) return;
41+
setResending(true);
42+
try {
43+
const res = await apiFetch(`${API_BASE_URL}/profiles/${username}/resend-verification-email`, {
44+
method: "POST",
45+
});
46+
setToast(
47+
res.ok
48+
? { message: "Verification email sent — check your inbox", type: "success" }
49+
: { message: "Failed to resend verification email", type: "error" },
50+
);
51+
} catch {
52+
setToast({ message: "Failed to resend verification email", type: "error" });
53+
} finally {
54+
setResending(false);
55+
}
56+
}
57+
58+
async function handleDeleteProfile() {
59+
if (!username) return;
60+
setDeleting(true);
61+
try {
62+
const res = await apiFetch(`${API_BASE_URL}/profiles/${username}`, { method: "DELETE" });
63+
if (!res.ok) throw new Error("Failed to delete profile");
64+
localStorage.removeItem("username");
65+
router.push("/");
66+
} catch (err: unknown) {
67+
setToast({
68+
message: err instanceof Error ? err.message : "Failed to delete profile",
69+
type: "error",
70+
});
71+
setDeleting(false);
72+
setDeleteStep(0);
73+
}
74+
}
75+
76+
if (loading || !username) {
77+
return (
78+
<AppShell>
79+
<div className="mx-auto max-w-2xl px-4 py-10">
80+
<p className="text-sm text-white/40">Loading…</p>
81+
</div>
82+
</AppShell>
83+
);
84+
}
85+
86+
return (
87+
<AppShell>
88+
<div className="mx-auto max-w-2xl px-4 py-10 space-y-8">
89+
<div>
90+
<h1 className="text-2xl font-semibold text-white mb-1">Settings</h1>
91+
<p className="text-sm text-white/50">Manage your account preferences.</p>
92+
</div>
93+
94+
<section className="rounded-2xl border border-white/10 bg-white/5 p-6 space-y-3">
95+
<h2 className="text-sm font-semibold uppercase tracking-widest text-white/60">
96+
Email verification
97+
</h2>
98+
<div className="flex items-center justify-between">
99+
<span
100+
className={`text-sm ${emailVerified ? "text-mint" : "text-amber-400"}`}
101+
>
102+
{emailVerified ? "Verified" : "Not verified"}
103+
</span>
104+
{!emailVerified && (
105+
<button
106+
onClick={handleResendVerification}
107+
disabled={resending}
108+
className="rounded-lg border border-white/10 px-3 py-1.5 text-xs font-medium text-white/70 hover:border-mint/40 hover:text-mint"
109+
>
110+
{resending ? "Sending…" : "Resend verification email"}
111+
</button>
112+
)}
113+
</div>
114+
</section>
115+
116+
<NotificationPreferences username={username} />
117+
118+
<section className="rounded-2xl border border-red-500/20 bg-red-500/5 p-6 space-y-3">
119+
<h2 className="text-sm font-semibold uppercase tracking-widest text-red-300">
120+
Danger zone
121+
</h2>
122+
<p className="text-xs text-white/50">
123+
Deleting your profile permanently removes it and all related data (transactions,
124+
milestones, webhooks). This cannot be undone.
125+
</p>
126+
127+
{deleteStep === 0 && (
128+
<button
129+
onClick={() => setDeleteStep(1)}
130+
className="rounded-lg border border-red-500/30 px-3 py-1.5 text-xs font-medium text-red-300 hover:bg-red-500/10"
131+
>
132+
Delete profile
133+
</button>
134+
)}
135+
136+
{deleteStep === 1 && (
137+
<div className="flex items-center gap-2">
138+
<span className="text-xs text-white/60">Are you sure?</span>
139+
<button
140+
onClick={() => setDeleteStep(2)}
141+
className="rounded-lg bg-red-500/20 px-3 py-1.5 text-xs font-medium text-red-300 hover:bg-red-500/30"
142+
>
143+
Yes, continue
144+
</button>
145+
<button
146+
onClick={() => setDeleteStep(0)}
147+
className="rounded-lg px-3 py-1.5 text-xs text-white/50 hover:text-white"
148+
>
149+
Cancel
150+
</button>
151+
</div>
152+
)}
153+
154+
{deleteStep === 2 && (
155+
<div className="flex items-center gap-2">
156+
<span className="text-xs text-white/60">
157+
This is permanent. Delete @{username}?
158+
</span>
159+
<button
160+
onClick={handleDeleteProfile}
161+
disabled={deleting}
162+
className="rounded-lg bg-red-500 px-3 py-1.5 text-xs font-semibold text-white hover:bg-red-600"
163+
>
164+
{deleting ? "Deleting…" : "Permanently delete"}
165+
</button>
166+
<button
167+
onClick={() => setDeleteStep(0)}
168+
className="rounded-lg px-3 py-1.5 text-xs text-white/50 hover:text-white"
169+
>
170+
Cancel
171+
</button>
172+
</div>
173+
)}
174+
</section>
175+
</div>
176+
177+
{toast && <Toast message={toast.message} type={toast.type} onDismiss={() => setToast(null)} />}
178+
</AppShell>
179+
);
180+
}

0 commit comments

Comments
 (0)