fix(auth): sign in against the v0.3 backend

v0.3 wallets register with the v0.3 backend, so a site built against the
legacy backend answered every v0.3 sign-in with "User not found". The
v0.3 backend resolves website sign-in by each coin's own key and adopts a
legacy identity on first proof, so it serves both generations.

The v0.3 backend has no social linking. Settings now loads linked accounts
separately and hides that section when they are unavailable, instead of
failing the whole page and taking username editing with it. The public user
count and stats may come from a separate backend via
NEXT_PUBLIC_STATS_API_URL; unset keeps today's behavior.
This commit is contained in:
jwinterm
2026-10-05 19:33:04 -04:00
parent afd1c231db
commit eaf4c90cab
3 changed files with 152 additions and 126 deletions
+4
View File
@@ -2,3 +2,7 @@
# For local development: http://localhost:8080 # For local development: http://localhost:8080
# For production: https://api.smirk.cash (or your backend URL) # For production: https://api.smirk.cash (or your backend URL)
NEXT_PUBLIC_API_URL=http://localhost:8080 NEXT_PUBLIC_API_URL=http://localhost:8080
# Optional: backend serving the public user count and stats, when it differs
# from the sign-in backend above. Unset means the same backend.
# NEXT_PUBLIC_STATS_API_URL=https://backend.smirk.cash
+143 -124
View File
@@ -21,6 +21,7 @@ type LinkStep = 'idle' | 'linking' | 'waiting';
export default function SettingsPage() { export default function SettingsPage() {
const [user, setUser] = useState<MeResponse | null>(null); const [user, setUser] = useState<MeResponse | null>(null);
const [socials, setSocials] = useState<LinkedSocial[]>([]); const [socials, setSocials] = useState<LinkedSocial[]>([]);
const [socialsAvailable, setSocialsAvailable] = useState(true);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -81,10 +82,16 @@ export default function SettingsPage() {
return; return;
} }
Promise.all([getMe(token), getLinkedSocials(token), getMyUsername(token)]) // Linked accounts are a legacy-backend feature; the v0.3 backend has no
.then(([userData, socialsData, username]) => { // social linking. Load them separately so their absence hides that section
// instead of failing the whole page.
getLinkedSocials(token)
.then((socialsData) => setSocials(socialsData.socials))
.catch(() => setSocialsAvailable(false));
Promise.all([getMe(token), getMyUsername(token)])
.then(([userData, username]) => {
setUser(userData); setUser(userData);
setSocials(socialsData.socials);
setSmirkUsername(username || ''); setSmirkUsername(username || '');
setUsernameInput(username || ''); setUsernameInput(username || '');
}) })
@@ -320,139 +327,151 @@ export default function SettingsPage() {
<section> <section>
<h2 className="text-lg font-semibold mb-4 text-zinc-400">Linked Accounts</h2> <h2 className="text-lg font-semibold mb-4 text-zinc-400">Linked Accounts</h2>
{/* Telegram */} {socialsAvailable ? (
<div className="bg-zinc-900 rounded-xl p-4 mb-4"> <>
<div className="flex items-center justify-between"> {/* Telegram */}
<div className="flex items-center gap-3"> <div className="bg-zinc-900 rounded-xl p-4 mb-4">
<div className="w-10 h-10 bg-[#0088cc] rounded-full flex items-center justify-center"> <div className="flex items-center justify-between">
<svg className="w-6 h-6 text-white" viewBox="0 0 24 24" fill="currentColor"> <div className="flex items-center gap-3">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm4.64 6.8c-.15 1.58-.8 5.42-1.13 7.19-.14.75-.42 1-.68 1.03-.58.05-1.02-.38-1.58-.75-.88-.58-1.38-.94-2.23-1.5-.99-.65-.35-1.01.22-1.59.15-.15 2.71-2.48 2.76-2.69a.2.2 0 00-.05-.18c-.06-.05-.14-.03-.21-.02-.09.02-1.49.95-4.22 2.79-.4.27-.76.41-1.08.4-.36-.01-1.04-.2-1.55-.37-.63-.2-1.12-.31-1.08-.66.02-.18.27-.36.74-.55 2.92-1.27 4.86-2.11 5.83-2.51 2.78-1.16 3.35-1.36 3.73-1.36.08 0 .27.02.39.12.1.08.13.19.14.27-.01.06.01.24 0 .38z" /> <div className="w-10 h-10 bg-[#0088cc] rounded-full flex items-center justify-center">
</svg> <svg className="w-6 h-6 text-white" viewBox="0 0 24 24" fill="currentColor">
</div> <path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm4.64 6.8c-.15 1.58-.8 5.42-1.13 7.19-.14.75-.42 1-.68 1.03-.58.05-1.02-.38-1.58-.75-.88-.58-1.38-.94-2.23-1.5-.99-.65-.35-1.01.22-1.59.15-.15 2.71-2.48 2.76-2.69a.2.2 0 00-.05-.18c-.06-.05-.14-.03-.21-.02-.09.02-1.49.95-4.22 2.79-.4.27-.76.41-1.08.4-.36-.01-1.04-.2-1.55-.37-.63-.2-1.12-.31-1.08-.66.02-.18.27-.36.74-.55 2.92-1.27 4.86-2.11 5.83-2.51 2.78-1.16 3.35-1.36 3.73-1.36.08 0 .27.02.39.12.1.08.13.19.14.27-.01.06.01.24 0 .38z" />
<div> </svg>
<p className="font-medium">Telegram</p> </div>
<div>
<p className="font-medium">Telegram</p>
{telegramSocial?.verified ? (
<p className="text-sm text-green-400">
{telegramSocial.username ? `@${telegramSocial.username}` : telegramSocial.display_name || 'Linked'}
</p>
) : telegramSocial?.pending_verification ? (
<p className="text-sm text-yellow-400">Verification pending</p>
) : (
<p className="text-sm text-zinc-500">Not linked</p>
)}
</div>
</div>
{telegramSocial?.verified ? ( {telegramSocial?.verified ? (
<p className="text-sm text-green-400"> <button
{telegramSocial.username ? `@${telegramSocial.username}` : telegramSocial.display_name || 'Linked'} onClick={() => handleUnlink('telegram')}
className="px-3 py-1 text-sm text-red-400 border border-red-400/50 rounded hover:bg-red-400/10"
>
Unlink
</button>
) : linkStep === 'idle' ? (
<button
onClick={handleStartLink}
className="px-4 py-2 bg-[#fbeb0a] text-black font-medium rounded-lg hover:bg-[#d4c708]"
>
Link
</button>
) : null}
</div>
{/* Link flow UI */}
{linkStep === 'linking' && (
<div className="mt-4 pt-4 border-t border-zinc-800">
<div className="flex items-center gap-2">
<div className="animate-spin w-5 h-5 border-2 border-[#fbeb0a] border-t-transparent rounded-full" />
<span className="text-zinc-400">Setting up link...</span>
</div>
</div>
)}
{linkStep === 'waiting' && deepLink && (
<div className="mt-4 pt-4 border-t border-zinc-800">
<p className="text-sm text-zinc-400 mb-4">
Click the button below to open Telegram and link your account automatically:
</p> </p>
) : telegramSocial?.pending_verification ? (
<p className="text-sm text-yellow-400">Verification pending</p> <div className="flex gap-2 mb-4">
) : ( <a
<p className="text-sm text-zinc-500">Not linked</p> href={deepLink}
)} target="_blank"
</div> rel="noopener noreferrer"
className="flex items-center gap-2 px-4 py-2 bg-[#0088cc] text-white font-medium rounded-lg hover:bg-[#0077b3]"
>
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm4.64 6.8c-.15 1.58-.8 5.42-1.13 7.19-.14.75-.42 1-.68 1.03-.58.05-1.02-.38-1.58-.75-.88-.58-1.38-.94-2.23-1.5-.99-.65-.35-1.01.22-1.59.15-.15 2.71-2.48 2.76-2.69a.2.2 0 00-.05-.18c-.06-.05-.14-.03-.21-.02-.09.02-1.49.95-4.22 2.79-.4.27-.76.41-1.08.4-.36-.01-1.04-.2-1.55-.37-.63-.2-1.12-.31-1.08-.66.02-.18.27-.36.74-.55 2.92-1.27 4.86-2.11 5.83-2.51 2.78-1.16 3.35-1.36 3.73-1.36.08 0 .27.02.39.12.1.08.13.19.14.27-.01.06.01.24 0 .38z" />
</svg>
Open Telegram
</a>
<button
onClick={handleCancelLink}
className="px-4 py-2 border border-zinc-700 rounded-lg hover:border-zinc-500"
>
Cancel
</button>
</div>
<div className="flex items-center gap-2 text-sm text-zinc-500">
<div className="animate-spin w-4 h-4 border-2 border-zinc-500 border-t-transparent rounded-full" />
<span>Waiting for verification...</span>
</div>
{linkError && <p className="mt-2 text-sm text-red-400">{linkError}</p>}
</div>
)}
</div> </div>
{telegramSocial?.verified ? ( {/* Discord */}
<button <div className="bg-zinc-900 rounded-xl p-4 mb-4">
onClick={() => handleUnlink('telegram')} <div className="flex items-center justify-between">
className="px-3 py-1 text-sm text-red-400 border border-red-400/50 rounded hover:bg-red-400/10" <div className="flex items-center gap-3">
> <div className="w-10 h-10 bg-[#5865F2] rounded-full flex items-center justify-center">
Unlink <svg className="w-6 h-6 text-white" viewBox="0 0 24 24" fill="currentColor">
</button> <path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189z" />
) : linkStep === 'idle' ? ( </svg>
<button </div>
onClick={handleStartLink} <div>
className="px-4 py-2 bg-[#fbeb0a] text-black font-medium rounded-lg hover:bg-[#d4c708]" <p className="font-medium">Discord</p>
> {discordSocial?.verified ? (
Link <p className="text-sm text-green-400">
</button> {discordSocial.display_name || discordSocial.username || 'Linked'}
) : null} </p>
</div> ) : discordLinking ? (
<p className="text-sm text-yellow-400">Linking...</p>
) : (
<p className="text-sm text-zinc-500">Not linked</p>
)}
</div>
</div>
{/* Link flow UI */}
{linkStep === 'linking' && (
<div className="mt-4 pt-4 border-t border-zinc-800">
<div className="flex items-center gap-2">
<div className="animate-spin w-5 h-5 border-2 border-[#fbeb0a] border-t-transparent rounded-full" />
<span className="text-zinc-400">Setting up link...</span>
</div>
</div>
)}
{linkStep === 'waiting' && deepLink && (
<div className="mt-4 pt-4 border-t border-zinc-800">
<p className="text-sm text-zinc-400 mb-4">
Click the button below to open Telegram and link your account automatically:
</p>
<div className="flex gap-2 mb-4">
<a
href={deepLink}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 px-4 py-2 bg-[#0088cc] text-white font-medium rounded-lg hover:bg-[#0077b3]"
>
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm4.64 6.8c-.15 1.58-.8 5.42-1.13 7.19-.14.75-.42 1-.68 1.03-.58.05-1.02-.38-1.58-.75-.88-.58-1.38-.94-2.23-1.5-.99-.65-.35-1.01.22-1.59.15-.15 2.71-2.48 2.76-2.69a.2.2 0 00-.05-.18c-.06-.05-.14-.03-.21-.02-.09.02-1.49.95-4.22 2.79-.4.27-.76.41-1.08.4-.36-.01-1.04-.2-1.55-.37-.63-.2-1.12-.31-1.08-.66.02-.18.27-.36.74-.55 2.92-1.27 4.86-2.11 5.83-2.51 2.78-1.16 3.35-1.36 3.73-1.36.08 0 .27.02.39.12.1.08.13.19.14.27-.01.06.01.24 0 .38z" />
</svg>
Open Telegram
</a>
<button
onClick={handleCancelLink}
className="px-4 py-2 border border-zinc-700 rounded-lg hover:border-zinc-500"
>
Cancel
</button>
</div>
<div className="flex items-center gap-2 text-sm text-zinc-500">
<div className="animate-spin w-4 h-4 border-2 border-zinc-500 border-t-transparent rounded-full" />
<span>Waiting for verification...</span>
</div>
{linkError && <p className="mt-2 text-sm text-red-400">{linkError}</p>}
</div>
)}
</div>
{/* Discord */}
<div className="bg-zinc-900 rounded-xl p-4 mb-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-[#5865F2] rounded-full flex items-center justify-center">
<svg className="w-6 h-6 text-white" viewBox="0 0 24 24" fill="currentColor">
<path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189z" />
</svg>
</div>
<div>
<p className="font-medium">Discord</p>
{discordSocial?.verified ? ( {discordSocial?.verified ? (
<p className="text-sm text-green-400"> <button
{discordSocial.display_name || discordSocial.username || 'Linked'} onClick={() => handleUnlink('discord')}
</p> className="px-3 py-1 text-sm text-red-400 border border-red-400/50 rounded hover:bg-red-400/10"
) : discordLinking ? ( >
<p className="text-sm text-yellow-400">Linking...</p> Unlink
</button>
) : ( ) : (
<p className="text-sm text-zinc-500">Not linked</p> <button
onClick={handleLinkDiscord}
disabled={discordLinking}
className="px-4 py-2 bg-[#fbeb0a] text-black font-medium rounded-lg hover:bg-[#d4c708] disabled:opacity-50"
>
{discordLinking ? 'Linking...' : 'Link'}
</button>
)} )}
</div> </div>
{discordError && (
<div className="mt-4 pt-4 border-t border-zinc-800">
<p className="text-sm text-red-400">{discordError}</p>
</div>
)}
</div> </div>
{discordSocial?.verified ? ( </>
<button ) : (
onClick={() => handleUnlink('discord')} <div className="bg-zinc-900 rounded-xl p-4 mb-4">
className="px-3 py-1 text-sm text-red-400 border border-red-400/50 rounded hover:bg-red-400/10" <p className="text-sm text-zinc-500">
> Linking Telegram and Discord isn&apos;t available for this wallet. Your Smirk
Unlink username above is how others find and tip you.
</button> </p>
) : (
<button
onClick={handleLinkDiscord}
disabled={discordLinking}
className="px-4 py-2 bg-[#fbeb0a] text-black font-medium rounded-lg hover:bg-[#d4c708] disabled:opacity-50"
>
{discordLinking ? 'Linking...' : 'Link'}
</button>
)}
</div> </div>
)}
{discordError && (
<div className="mt-4 pt-4 border-t border-zinc-800">
<p className="text-sm text-red-400">{discordError}</p>
</div>
)}
</div>
{/* Future platforms */} {/* Future platforms */}
<div className="space-y-3"> <div className="space-y-3">
+5 -2
View File
@@ -1,6 +1,9 @@
// API client for Smirk backend // API client for Smirk backend
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'; const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
// Public counters (user count, stats) may live on a different backend than
// sign-in. Unset means the same backend.
const STATS_API_URL = process.env.NEXT_PUBLIC_STATS_API_URL || API_URL;
export interface ChallengeResponse { export interface ChallengeResponse {
challenge: string; challenge: string;
@@ -398,7 +401,7 @@ export interface UserCountResponse {
} }
export async function getUserCount(): Promise<UserCountResponse> { export async function getUserCount(): Promise<UserCountResponse> {
const res = await fetch(`${API_URL}/api/v1/users/count`); const res = await fetch(`${STATS_API_URL}/api/v1/users/count`);
if (!res.ok) { if (!res.ok) {
throw new Error('Failed to get user count'); throw new Error('Failed to get user count');
@@ -419,7 +422,7 @@ export interface PublicStats {
} }
export async function getPublicStats(): Promise<PublicStats> { export async function getPublicStats(): Promise<PublicStats> {
const res = await fetch(`${API_URL}/api/v1/stats/public`); const res = await fetch(`${STATS_API_URL}/api/v1/stats/public`);
if (!res.ok) { if (!res.ok) {
throw new Error('Failed to get stats'); throw new Error('Failed to get stats');