feat: initialize smirk-website with connect flow

- Next.js 16 with TypeScript and Tailwind CSS
- Single-asset authentication (user picks favorite coin to sign)
- Logo with glow animation
- Coin picker UI with all 5 assets
- API client for backend auth endpoints
- TypeScript types for window.smirk extension API
This commit is contained in:
jwinterm
2026-01-25 08:18:14 -05:00
commit 20a27d3fd2
26 changed files with 7516 additions and 0 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+69
View File
@@ -0,0 +1,69 @@
@import "tailwindcss";
:root {
--background: #0a0a0a;
--foreground: #ededed;
--smirk-yellow: #fbeb0a;
--smirk-yellow-dark: #d4c708;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-smirk: var(--smirk-yellow);
--font-mono: var(--font-geist-mono);
}
body {
background: var(--background);
color: var(--foreground);
font-family: var(--font-mono), monospace;
}
/* Logo animation */
@keyframes pulse-glow {
0%, 100% {
filter: drop-shadow(0 0 8px rgba(251, 235, 10, 0.4));
}
50% {
filter: drop-shadow(0 0 20px rgba(251, 235, 10, 0.8));
}
}
.logo-glow {
animation: pulse-glow 2s ease-in-out infinite;
}
/* Coin button styles */
.coin-btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 1rem;
border-radius: 0.75rem;
border: 2px solid #3f3f46;
transition: all 0.2s;
cursor: pointer;
}
.coin-btn:hover {
border-color: #fbeb0a;
background: rgba(39, 39, 42, 0.5);
}
.coin-btn.selected {
border-color: #fbeb0a;
background: rgba(39, 39, 42, 0.5);
}
.coin-btn img {
width: 3rem;
height: 3rem;
}
.coin-btn span {
font-size: 0.875rem;
font-weight: 500;
text-transform: uppercase;
}
+27
View File
@@ -0,0 +1,27 @@
import type { Metadata } from "next";
import { Geist_Mono } from "next/font/google";
import "./globals.css";
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Smirk Wallet",
description: "Sign in with your Smirk wallet",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className={`${geistMono.variable} font-mono antialiased`}>
{children}
</body>
</html>
);
}
+234
View File
@@ -0,0 +1,234 @@
'use client';
import { useState, useEffect } from 'react';
import Image from 'next/image';
import { getChallenge, verifySignature, type AuthResponse } from '@/lib/api';
type Asset = 'btc' | 'ltc' | 'xmr' | 'wow' | 'grin';
const COINS: { id: Asset; name: string; icon: string }[] = [
{ id: 'btc', name: 'Bitcoin', icon: '/coins/bitcoin.svg' },
{ id: 'ltc', name: 'Litecoin', icon: '/coins/litecoin.svg' },
{ id: 'xmr', name: 'Monero', icon: '/coins/monero.svg' },
{ id: 'wow', name: 'Wownero', icon: '/coins/wownero.svg' },
{ id: 'grin', name: 'Grin', icon: '/coins/grin.svg' },
];
type Step = 'initial' | 'choose-coin' | 'signing' | 'logged-in';
export default function Home() {
const [step, setStep] = useState<Step>('initial');
const [selectedCoin, setSelectedCoin] = useState<Asset | null>(null);
const [error, setError] = useState<string | null>(null);
const [user, setUser] = useState<AuthResponse['user'] | null>(null);
const [hasExtension, setHasExtension] = useState<boolean | null>(null);
// Check for extension on mount
useEffect(() => {
const check = () => setHasExtension(!!window.smirk);
// Check immediately and after a delay (extension might inject late)
check();
const timeout = setTimeout(check, 500);
return () => clearTimeout(timeout);
}, []);
const handleConnect = async () => {
if (!window.smirk) {
setError('Smirk extension not found');
return;
}
try {
setError(null);
// Connect to extension (get public keys)
await window.smirk.connect();
setStep('choose-coin');
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to connect');
}
};
const handleCoinSelect = async (coin: Asset) => {
if (!window.smirk) return;
setSelectedCoin(coin);
setStep('signing');
setError(null);
try {
// Get challenge from backend
const challenge = await getChallenge(window.location.origin);
// Sign with the selected coin
const signatures = await window.smirk.signMessage(challenge.challenge);
const sig = signatures[coin];
// Verify with backend
const auth = await verifySignature(challenge.challenge_id, {
asset: coin,
signature: sig.signature,
public_key: sig.public_key,
});
// Store token and show logged in
localStorage.setItem('smirk_token', auth.access_token);
localStorage.setItem('smirk_refresh', auth.refresh_token);
setUser(auth.user);
setStep('logged-in');
} catch (err) {
setError(err instanceof Error ? err.message : 'Authentication failed');
setStep('choose-coin');
}
};
const handleLogout = () => {
localStorage.removeItem('smirk_token');
localStorage.removeItem('smirk_refresh');
setUser(null);
setSelectedCoin(null);
setStep('initial');
};
// Check for existing session on mount
useEffect(() => {
const token = localStorage.getItem('smirk_token');
if (token) {
// TODO: Validate token with /auth/me
// For now, just clear it and require re-auth
}
}, []);
return (
<div className="min-h-screen flex flex-col items-center justify-center p-8">
{/* Logo */}
<div className="mb-12 logo-glow">
<Image
src="/logo_w_text.svg"
alt="Smirk Wallet"
width={200}
height={180}
priority
/>
</div>
{/* Initial state - Connect button */}
{step === 'initial' && (
<div className="flex flex-col items-center gap-6">
{hasExtension === false && (
<p className="text-zinc-400 text-sm mb-4">
Install the{' '}
<a
href="https://chrome.google.com/webstore/detail/smirk-wallet"
target="_blank"
rel="noopener noreferrer"
className="text-[#fbeb0a] hover:underline"
>
Smirk extension
</a>{' '}
to continue
</p>
)}
<button
onClick={handleConnect}
disabled={hasExtension === false}
className="px-8 py-4 bg-[#fbeb0a] text-black font-bold rounded-xl
hover:bg-[#d4c708] transition-colors disabled:opacity-50
disabled:cursor-not-allowed text-lg"
>
Connect with Smirk
</button>
</div>
)}
{/* Choose coin step */}
{step === 'choose-coin' && (
<div className="flex flex-col items-center gap-6">
<h2 className="text-xl font-semibold mb-2">Choose your coin to sign in</h2>
<p className="text-zinc-400 text-sm mb-4">
Pick your favorite cryptocurrency to authenticate
</p>
<div className="grid grid-cols-5 gap-4">
{COINS.map((coin) => (
<button
key={coin.id}
onClick={() => handleCoinSelect(coin.id)}
className={`coin-btn ${selectedCoin === coin.id ? 'selected' : ''}`}
>
<Image src={coin.icon} alt={coin.name} width={48} height={48} />
<span>{coin.id}</span>
</button>
))}
</div>
</div>
)}
{/* Signing step */}
{step === 'signing' && (
<div className="flex flex-col items-center gap-4">
<div className="animate-spin w-8 h-8 border-2 border-[#fbeb0a] border-t-transparent rounded-full" />
<p className="text-zinc-400">
Signing with {selectedCoin?.toUpperCase()}...
</p>
<p className="text-zinc-500 text-sm">Check your extension</p>
</div>
)}
{/* Logged in state */}
{step === 'logged-in' && user && (
<div className="flex flex-col items-center gap-6 text-center">
<div className="w-16 h-16 bg-[#fbeb0a] rounded-full flex items-center justify-center">
<svg
className="w-8 h-8 text-black"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={3}
d="M5 13l4 4L19 7"
/>
</svg>
</div>
<div>
<h2 className="text-xl font-semibold mb-2">Logged in!</h2>
<p className="text-zinc-400 text-sm">
Signed in with {selectedCoin?.toUpperCase()}
</p>
</div>
<div className="bg-zinc-900 rounded-xl p-6 w-full max-w-md">
<p className="text-zinc-500 text-xs mb-1">User ID</p>
<p className="font-mono text-sm break-all">{user.id}</p>
{user.telegram_username && (
<>
<p className="text-zinc-500 text-xs mt-4 mb-1">Telegram</p>
<p className="text-sm">@{user.telegram_username}</p>
</>
)}
</div>
<button
onClick={handleLogout}
className="px-6 py-2 border border-zinc-700 rounded-lg
hover:border-zinc-500 transition-colors text-sm"
>
Disconnect
</button>
</div>
)}
{/* Error display */}
{error && (
<div className="mt-6 p-4 bg-red-900/20 border border-red-500/50 rounded-lg">
<p className="text-red-400 text-sm">{error}</p>
</div>
)}
</div>
);
}
+75
View File
@@ -0,0 +1,75 @@
// API client for Smirk backend
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
export interface ChallengeResponse {
challenge: string;
challenge_id: string;
expires_at: string;
}
export interface AuthResponse {
access_token: string;
refresh_token: string;
expires_in: number;
user: {
id: string;
telegram_id: number | null;
telegram_username: string | null;
};
}
export interface MeResponse {
id: string;
username: string | null;
telegram_id: number | null;
telegram_username: string | null;
}
export async function getChallenge(origin: string): Promise<ChallengeResponse> {
const res = await fetch(`${API_URL}/api/v1/auth/website/challenge`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ origin }),
});
if (!res.ok) {
const error = await res.json().catch(() => ({ message: 'Request failed' }));
throw new Error(error.message || 'Failed to get challenge');
}
return res.json();
}
export async function verifySignature(
challengeId: string,
signature: { asset: string; signature: string; public_key: string }
): Promise<AuthResponse> {
const res = await fetch(`${API_URL}/api/v1/auth/website/verify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
challenge_id: challengeId,
signature,
}),
});
if (!res.ok) {
const error = await res.json().catch(() => ({ message: 'Request failed' }));
throw new Error(error.message || 'Failed to verify signature');
}
return res.json();
}
export async function getMe(token: string): Promise<MeResponse> {
const res = await fetch(`${API_URL}/api/v1/auth/me`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) {
throw new Error('Failed to get user info');
}
return res.json();
}
+35
View File
@@ -0,0 +1,35 @@
// TypeScript types for the Smirk extension's injected window.smirk API
interface SmirkPublicKeys {
btc: string;
ltc: string;
xmr: string;
wow: string;
grin: string;
}
interface SmirkSignature {
asset: string;
signature: string;
public_key: string;
}
interface SmirkSignatures {
btc: SmirkSignature;
ltc: SmirkSignature;
xmr: SmirkSignature;
wow: SmirkSignature;
grin: SmirkSignature;
}
interface SmirkAPI {
connect(): Promise<SmirkPublicKeys>;
disconnect(): Promise<void>;
isConnected(): Promise<boolean>;
getPublicKeys(): Promise<SmirkPublicKeys | null>;
signMessage(message: string): Promise<SmirkSignatures>;
}
interface Window {
smirk?: SmirkAPI;
}