mirror of
https://github.com/Such-Software/smirk-website.git
synced 2026-10-20 06:18:39 +02:00
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:
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
Vendored
+35
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user