feat: discord integration

This commit is contained in:
jwinterm
2026-02-01 10:13:57 -05:00
parent b95877ac9c
commit f63bafbc48
2 changed files with 146 additions and 1 deletions
+103 -1
View File
@@ -10,6 +10,8 @@ import {
unlinkSocial,
getMyUsername,
setUsername,
getDiscordAuthUrl,
completeDiscordOAuth,
type MeResponse,
type LinkedSocial,
} from '@/lib/api';
@@ -35,8 +37,44 @@ export default function SettingsPage() {
const [linkError, setLinkError] = useState<string | null>(null);
const [polling, setPolling] = useState(false);
// Discord linking state
const [discordLinking, setDiscordLinking] = useState(false);
const [discordError, setDiscordError] = useState<string | null>(null);
const token = typeof window !== 'undefined' ? localStorage.getItem('smirk_token') : null;
// Handle Discord OAuth callback (code + state in URL params)
useEffect(() => {
if (typeof window === 'undefined' || !token) return;
const params = new URLSearchParams(window.location.search);
const code = params.get('code');
const state = params.get('state');
if (code && state) {
// Clean URL immediately
window.history.replaceState({}, '', '/settings');
// Complete Discord OAuth
setDiscordLinking(true);
setDiscordError(null);
completeDiscordOAuth(token, code, state)
.then((linkedSocial) => {
// Refresh socials list
return getLinkedSocials(token).then((data) => {
setSocials(data.socials);
});
})
.catch((err) => {
setDiscordError(err instanceof Error ? err.message : 'Failed to link Discord');
})
.finally(() => {
setDiscordLinking(false);
});
}
}, [token]);
useEffect(() => {
if (!token) {
setLoading(false);
@@ -144,6 +182,21 @@ export default function SettingsPage() {
setPolling(false);
};
const handleLinkDiscord = async () => {
if (!token) return;
setDiscordLinking(true);
setDiscordError(null);
try {
const { auth_url } = await getDiscordAuthUrl(token);
// Redirect to Discord OAuth
window.location.href = auth_url;
} catch (err) {
setDiscordError(err instanceof Error ? err.message : 'Failed to start Discord link');
setDiscordLinking(false);
}
};
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
@@ -164,6 +217,7 @@ export default function SettingsPage() {
}
const telegramSocial = socials.find((s) => s.platform === 'telegram');
const discordSocial = socials.find((s) => s.platform === 'discord');
return (
<div className="min-h-screen p-8 max-w-2xl mx-auto">
@@ -352,9 +406,57 @@ export default function SettingsPage() {
)}
</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 ? (
<p className="text-sm text-green-400">
{discordSocial.display_name || discordSocial.username || 'Linked'}
</p>
) : discordLinking ? (
<p className="text-sm text-yellow-400">Linking...</p>
) : (
<p className="text-sm text-zinc-500">Not linked</p>
)}
</div>
</div>
{discordSocial?.verified ? (
<button
onClick={() => handleUnlink('discord')}
className="px-3 py-1 text-sm text-red-400 border border-red-400/50 rounded hover:bg-red-400/10"
>
Unlink
</button>
) : (
<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>
{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 */}
<div className="space-y-3">
{['Discord', 'Signal', 'Matrix'].map((platform) => (
{['Signal', 'Matrix'].map((platform) => (
<div key={platform} className="bg-zinc-900/50 rounded-xl p-4 opacity-50">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-zinc-700 rounded-full" />
+43
View File
@@ -294,6 +294,49 @@ export async function getClaimableTips(token: string): Promise<{ tips: Claimable
return res.json();
}
// =============================================================================
// Discord OAuth2 Linking
// =============================================================================
export interface DiscordAuthUrlResponse {
auth_url: string;
}
export async function getDiscordAuthUrl(token: string): Promise<DiscordAuthUrlResponse> {
const res = await fetch(`${API_URL}/api/v1/socials/discord/auth-url`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || 'Failed to get Discord auth URL');
}
return res.json();
}
export async function completeDiscordOAuth(
token: string,
code: string,
state: string
): Promise<LinkedSocial> {
const res = await fetch(`${API_URL}/api/v1/socials/discord/callback`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ code, state }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || 'Failed to link Discord account');
}
return res.json();
}
// =============================================================================
// Username Management
// =============================================================================