From 7cf90c08dbf77534c97e62e6a78070c02d83c61d Mon Sep 17 00:00:00 2001 From: jwinterm Date: Mon, 26 Jan 2026 11:38:43 -0500 Subject: [PATCH] feat: add settings page with Telegram account linking --- TODO.md | 150 ++++++++++---------- src/app/page.tsx | 24 +++- src/app/settings/page.tsx | 285 ++++++++++++++++++++++++++++++++++++++ src/lib/api.ts | 69 +++++++++ 4 files changed, 443 insertions(+), 85 deletions(-) create mode 100644 src/app/settings/page.tsx diff --git a/TODO.md b/TODO.md index 63fbcb0..a468ce7 100644 --- a/TODO.md +++ b/TODO.md @@ -1,84 +1,99 @@ # Smirk Website TODO -## Phase 1: Foundation (Critical Path) +See [smirk-backend/docs/SOCIAL_TIPPING.md](../smirk-backend/docs/SOCIAL_TIPPING.md) for full architecture. + +## Completed ### Extension API (DONE - 2026-01-22) -- [x] **Inject `window.smirk` API** - Content script injects API object into pages -- [x] **`window.smirk.connect()`** - Returns public keys for all 5 assets -- [x] **`window.smirk.signMessage(message)`** - Signs message with all 5 asset keys -- [x] **`window.smirk.isConnected()`** - Check if site is connected -- [x] **`window.smirk.disconnect()`** - Revoke site access -- [x] **`window.smirk.getPublicKeys()`** - Get public keys if already connected -- [x] **Approval popup UI** - Show user what site is requesting, allow approve/deny -- [x] **Origin allowlist** - Track which origins user has approved (persisted to storage) +- [x] Inject `window.smirk` API - Content script injects API object into pages +- [x] `window.smirk.connect()` - Returns public keys for all 5 assets +- [x] `window.smirk.signMessage(message)` - Signs message with user's chosen asset +- [x] `window.smirk.isConnected()` - Check if site is connected +- [x] `window.smirk.disconnect()` - Revoke site access +- [x] `window.smirk.getPublicKeys()` - Get public keys if already connected +- [x] Approval popup UI - Show user what site is requesting, allow approve/deny +- [x] Origin allowlist - Track which origins user has approved ### Backend Auth Endpoints (DONE - 2026-01-24) - [x] `POST /api/v1/auth/website/challenge` - Generate challenge with nonce + origin -- [x] `POST /api/v1/auth/website/verify` - Verify signatures, issue JWT session token +- [x] `POST /api/v1/auth/website/verify` - Verify signature, issue JWT session token - [x] `GET /api/v1/auth/me` - Get current user info from JWT -- [ ] JWT middleware for protected routes (frontend side) +- [x] Single-asset authentication - User picks favorite coin to sign with -### Website Basics -- [ ] Initialize Next.js project with App Router -- [ ] Tailwind CSS setup -- [ ] Basic layout (header, footer) -- [ ] Landing page with "Connect with Smirk" button -- [ ] Extension detection (`window.smirk` check) -- [ ] "Install Smirk" fallback if extension not detected +### Website Foundation (DONE - 2026-01-25) +- [x] Next.js 16 project with App Router +- [x] Tailwind CSS setup +- [x] Landing page with connect button +- [x] Extension detection (`window.smirk` check) +- [x] Authentication flow (connect → choose coin → sign → logged in) +- [x] JWT storage in localStorage +- [x] Logout functionality + +### Linked Accounts (DONE - 2026-01-26) +- [x] Settings page at `/settings` +- [x] List currently linked platforms +- [x] "Link Telegram" button → verification flow +- [x] Verification UI: enter username → get code → send to bot → confirm +- [x] Unlink platform button + +--- ## Phase 2: Core Dashboard -### Authentication Flow -- [ ] Connect button triggers `window.smirk.connect()` -- [ ] Challenge generation and signing -- [ ] Backend verification and JWT storage -- [ ] Protected route middleware -- [ ] Logout functionality - -### Dashboard - Basic +### Dashboard Home - [ ] Display user UUID -- [ ] Display balances for all 5 assets -- [ ] Auto-refresh balances -- [ ] Loading states and error handling +- [ ] Display balances for all 5 assets (fetched via backend) +- [ ] Auto-refresh balances (polling or WebSocket) +- [ ] Loading skeletons and error states -### Dashboard - Tip History -- [ ] List of tips sent +### Tip History (Basic) +- [ ] List of tips sent (existing tip_links) - [ ] List of tips received - [ ] Pagination - [ ] Filter by asset type - [ ] Click to view tip details -## Phase 3: Account Management +## Phase 3: Social Tipping Dashboard -### Connected Accounts -- [ ] Link Telegram account -- [ ] Link Discord account -- [ ] Link other platforms (Twitter/X, etc.) -- [ ] Manage/unlink accounts -- [ ] Display linked accounts on dashboard +### Tips Sent (`/tips/sent`) +- [ ] List social tips with status (pending/claimed/clawed-back) +- [ ] Filter by platform (Telegram/Discord/direct) +- [ ] Clawback button (enabled anytime for unclaimed tips) +- [ ] Confirmation modal for clawback +- [ ] "Resend notification" option -### Settings -- [ ] Notification preferences -- [ ] Privacy settings -- [ ] Export account data +### Tips Received (`/tips/received`) +- [ ] List tips received (claimed and pending) +- [ ] Claim pending tips (verify platform ownership first) +- [ ] Link to transaction on blockchain explorer -## Phase 4: Social Features (Future) +### Public Tips (`/tips/public`) - Phase 2 +- [ ] View public tips sent by user +- [ ] Claim status tracking +- [ ] Share link/payload copy button + +## Phase 5: Additional Platforms + +- [ ] Discord linking flow +- [ ] Signal linking flow (future) +- [ ] Simplex linking flow (future) +- [ ] Matrix linking flow (future) + +## Phase 6: Social Features (Future) ### Direct Tipping - [ ] Send tip to another Smirk user by UUID -- [ ] Send tip via username/handle +- [ ] Send tip via username/handle (from website) - [ ] Tip confirmation flow -- [ ] Tip notifications ### Messaging (Future) - [ ] Direct messages between users -- [ ] Encrypted messaging (E2E?) -- [ ] Message notifications +- [ ] Encrypted messaging (E2E) ### Discovery (Future) - [ ] Public profiles (opt-in) - [ ] Search users -- [ ] Leaderboards? +- [ ] Tipping leaderboards (opt-in) ## Technical Debt / Polish @@ -104,41 +119,20 @@ ## Notes -### Extension Work Required (add to smirk-extension/TODO.md) +### Signing Algorithm Reference -The website cannot function without these extension features: - -1. **Content script injection** - Must inject `window.smirk` on all pages (or configurable origins) -2. **Message passing** - Content script ↔ Background script communication -3. **Approval UI** - Popup for connect/sign requests -4. **Security** - Origin tracking, user approval persistence - -### Signing Algorithm Notes - -For message signing (all implemented in extension): -- **BTC/LTC**: ECDSA signature (secp256k1) with Bitcoin message signing format -- **XMR/WOW**: Ed25519 signature using private spend key -- **Grin**: Ed25519 signature using slatepack key - -Backend must implement verification for all signature types. +For message signing (implemented in extension, verified by backend): +- **BTC/LTC**: ECDSA (secp256k1) with Bitcoin message signing format +- **XMR/WOW**: Ed25519 using private spend key +- **Grin**: Ed25519 using slatepack key ### Challenge Message Format ``` -Sign this message to authenticate with smirk.cash - -Timestamp: 2026-01-22T12:00:00Z +Smirk Authentication Challenge Nonce: abc123... -Origin: https://smirk.cash +Origin: https://smirk.to +Timestamp: 1706000000 ``` -Challenge should expire after ~5 minutes to prevent replay attacks. - -### Single-Asset Authentication (DONE - 2026-01-25) - -User picks their favorite coin to sign with: -- Simpler UX - choose one coin, sign once -- User data - "What's your favorite coin?" insight -- Any of the 5 coins proves wallet ownership - -This positions Smirk as infrastructure for wallet-based authentication across multiple cryptocurrencies. +Challenge expires after 5 minutes. diff --git a/src/app/page.tsx b/src/app/page.tsx index cc38d6e..5faf20a 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'; import Image from 'next/image'; +import Link from 'next/link'; import { getChallenge, verifySignature, type AuthResponse } from '@/lib/api'; type Asset = 'btc' | 'ltc' | 'xmr' | 'wow' | 'grin'; @@ -220,13 +221,22 @@ export default function Home() { )} - +
+ + Settings + + +
)} diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx new file mode 100644 index 0000000..ee54c09 --- /dev/null +++ b/src/app/settings/page.tsx @@ -0,0 +1,285 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import Image from 'next/image'; +import Link from 'next/link'; +import { + getMe, + getLinkedSocials, + registerSocial, + unlinkSocial, + type MeResponse, + type LinkedSocial, +} from '@/lib/api'; + +type LinkStep = 'idle' | 'entering' | 'verifying'; + +export default function SettingsPage() { + const [user, setUser] = useState(null); + const [socials, setSocials] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Telegram linking state + const [linkStep, setLinkStep] = useState('idle'); + const [telegramUsername, setTelegramUsername] = useState(''); + const [verificationCode, setVerificationCode] = useState(null); + const [botLink, setBotLink] = useState(null); + const [linkError, setLinkError] = useState(null); + + const token = typeof window !== 'undefined' ? localStorage.getItem('smirk_token') : null; + + useEffect(() => { + if (!token) { + setLoading(false); + return; + } + + Promise.all([getMe(token), getLinkedSocials(token)]) + .then(([userData, socialsData]) => { + setUser(userData); + setSocials(socialsData.socials); + }) + .catch((err) => setError(err.message)) + .finally(() => setLoading(false)); + }, [token]); + + const handleStartLink = () => { + setLinkStep('entering'); + setLinkError(null); + }; + + const handleSubmitUsername = async () => { + if (!token || !telegramUsername.trim()) return; + + setLinkError(null); + try { + const result = await registerSocial(token, 'telegram', telegramUsername.trim()); + setVerificationCode(result.verification_code); + setBotLink(result.bot_link); + setLinkStep('verifying'); + } catch (err) { + setLinkError(err instanceof Error ? err.message : 'Failed to start verification'); + } + }; + + const handleUnlink = async (platform: string) => { + if (!token) return; + if (!confirm(`Unlink ${platform}?`)) return; + + try { + await unlinkSocial(token, platform); + setSocials(socials.filter((s) => s.platform !== platform)); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to unlink'); + } + }; + + const handleCancelLink = () => { + setLinkStep('idle'); + setTelegramUsername(''); + setVerificationCode(null); + setBotLink(null); + setLinkError(null); + }; + + const handleVerificationDone = async () => { + if (!token) return; + // Refresh socials to check if verified + try { + const socialsData = await getLinkedSocials(token); + setSocials(socialsData.socials); + const telegram = socialsData.socials.find((s) => s.platform === 'telegram'); + if (telegram?.verified) { + handleCancelLink(); + } else { + setLinkError('Verification not complete. Send the code to the bot and try again.'); + } + } catch { + setLinkError('Failed to check verification status'); + } + }; + + if (loading) { + return ( +
+
+
+ ); + } + + if (!token || !user) { + return ( +
+

Please sign in first

+ + Go to home + +
+ ); + } + + const telegramSocial = socials.find((s) => s.platform === 'telegram'); + + return ( +
+ {/* Header */} +
+ + Smirk + SMIRK + +
+ +

Settings

+ + {error && ( +
+

{error}

+
+ )} + + {/* User Info */} +
+

Account

+
+

User ID

+

{user.id}

+
+
+ + {/* Linked Accounts */} +
+

Linked Accounts

+ + {/* Telegram */} +
+
+
+
+ + + +
+
+

Telegram

+ {telegramSocial?.verified ? ( +

@{telegramSocial.username}

+ ) : telegramSocial?.pending_verification ? ( +

Pending: @{telegramSocial.username}

+ ) : ( +

Not linked

+ )} +
+
+ + {telegramSocial?.verified ? ( + + ) : linkStep === 'idle' ? ( + + ) : null} +
+ + {/* Link flow UI */} + {linkStep === 'entering' && ( +
+ +
+ setTelegramUsername(e.target.value)} + placeholder="@username" + className="flex-1 px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white placeholder:text-zinc-500 focus:outline-none focus:border-[#fbeb0a]" + /> + + +
+ {linkError &&

{linkError}

} +
+ )} + + {linkStep === 'verifying' && verificationCode && ( +
+
+

Your verification code:

+

{verificationCode}

+
+ +
    +
  1. + 1. Open{' '} + + @smirk_wallet_bot + {' '} + on Telegram +
  2. +
  3. 2. Send: /verify {verificationCode}
  4. +
  5. 3. Click "I've verified" below
  6. +
+ +
+ + +
+ {linkError &&

{linkError}

} +
+ )} +
+ + {/* Future platforms */} +
+ {['Discord', 'Signal', 'Matrix'].map((platform) => ( +
+
+
+
+

{platform}

+

Coming soon

+
+
+
+ ))} +
+
+
+ ); +} diff --git a/src/lib/api.ts b/src/lib/api.ts index 61fbcea..a0daeb6 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -73,3 +73,72 @@ export async function getMe(token: string): Promise { return res.json(); } + +// ============================================================================= +// Social Account Linking +// ============================================================================= + +export interface RegisterSocialResponse { + verification_code: string; + expires_at: string; + bot_link: string; + instructions: string; +} + +export interface LinkedSocial { + platform: string; + username: string; + verified: boolean; + verified_at: string | null; + pending_verification: boolean; +} + +export interface LinkedSocialsResponse { + socials: LinkedSocial[]; +} + +export async function registerSocial( + token: string, + platform: string, + username: string +): Promise { + const res = await fetch(`${API_URL}/api/v1/socials/register`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ platform, username }), + }); + + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.error || 'Failed to start verification'); + } + + return res.json(); +} + +export async function getLinkedSocials(token: string): Promise { + const res = await fetch(`${API_URL}/api/v1/socials/me`, { + headers: { Authorization: `Bearer ${token}` }, + }); + + if (!res.ok) { + throw new Error('Failed to get linked accounts'); + } + + return res.json(); +} + +export async function unlinkSocial(token: string, platform: string): Promise { + const res = await fetch(`${API_URL}/api/v1/socials/${platform}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${token}` }, + }); + + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.error || 'Failed to unlink account'); + } +}