chore: complete UI overhaul, cypherpunk

This commit is contained in:
Danish Arora 2025-04-04 02:08:07 +05:30
parent b3b261fc26
commit 4f7735e6cb
No known key found for this signature in database
GPG Key ID: 1C6EF37CDAE1426E
29 changed files with 2518 additions and 679 deletions

View File

@ -1,6 +1,6 @@
# Waku Keystore Management
A simple Next.js application to manage Waku RLN keystores.
Application to manage Waku RLN keystores.
## Overview
@ -8,29 +8,14 @@ This application provides an interface for managing keystores for Waku's rate-li
## Features
- Connect to MetaMask wallet
- Connect to MetaMask wallet with dropdown menu for account details
- Terminal-inspired UI with cyberpunk styling
- View wallet information including address, network, and balance
- Support for Linea Sepolia testnet only
- Keystore management functionality
- Keystore management with copy, view, export, and remove functionality
- Token approval for RLN membership registration
- Light/standard RLN implementation toggle
## Getting Started
1. First, install the dependencies:
```bash
npm install
```
2. Run the development server:
```bash
npm run dev
```
3. Open [http://localhost:3000](http://localhost:3000) with your browser.
4. Connect your MetaMask wallet (Linea Sepolia testnet is required).
## Linea Sepolia Network
@ -53,3 +38,9 @@ When registering for RLN membership, you'll need to complete two transactions:
If you encounter an "ERC20: insufficient allowance" error, it means the token approval transaction was not completed successfully. Please try again and make sure to approve the token spending in your wallet.
## TODO
- [ ] add help type info on the webapp
- [ ] update descriptions, and link specs/resources
- [ ] footer for discord help
- [ ] add info about exporting/using keystore/credential and using with nwaku/nwaku-compose/waku-simulator
- [ x ] exporting entire keystore

File diff suppressed because it is too large Load Diff

View File

@ -10,10 +10,27 @@
"lint": "next lint"
},
"dependencies": {
"@fontsource-variable/inter": "^5.2.5",
"@fontsource-variable/jetbrains-mono": "^5.2.5",
"@next/font": "^14.2.15",
"@radix-ui/react-dropdown-menu": "^2.1.6",
"@radix-ui/react-slider": "^1.2.3",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.3",
"@radix-ui/react-toggle": "^1.1.2",
"@radix-ui/react-toggle-group": "^1.1.2",
"@radix-ui/react-tooltip": "^1.1.8",
"@waku/rln": "0.1.5-6997987.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"framer-motion": "^12.6.3",
"lucide-react": "^0.487.0",
"next": "15.1.7",
"next-themes": "^0.4.6",
"react": "^19.0.0",
"react-dom": "^19.0.0"
"react-dom": "^19.0.0",
"sonner": "^2.0.3",
"tailwind-merge": "^3.1.0"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
@ -24,6 +41,7 @@
"eslint-config-next": "15.1.7",
"postcss": "^8",
"tailwindcss": "^3.4.1",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5"
}
}

View File

@ -2,20 +2,176 @@
@tailwind components;
@tailwind utilities;
:root {
--background: #ffffff;
--foreground: #171717;
}
@media (prefers-color-scheme: dark) {
@layer base {
:root {
--background: #0a0a0a;
--foreground: #ededed;
--font-sans: 'Inter', sans-serif;
--font-mono: 'JetBrains Mono', monospace;
}
body {
@apply bg-background text-foreground;
font-feature-settings: "rlig" 1, "calt" 1;
}
}
body {
color: var(--foreground);
background: var(--background);
font-family: Arial, Helvetica, sans-serif;
@layer utilities {
.animate-in {
animation: animateIn 0.3s ease-in-out forwards;
}
.fade-in-50 {
opacity: 0;
animation-name: fadeIn50;
}
@keyframes fadeIn50 {
from { opacity: 0; }
to { opacity: 1; }
}
.duration-300 {
animation-duration: 300ms;
}
.typing-effect {
overflow: hidden;
white-space: nowrap;
border-right: 2px solid theme('colors.primary.DEFAULT');
width: 0;
animation: typing 3s steps(40, end) forwards, blink-caret 1s step-end infinite;
}
@keyframes typing {
from { width: 0 }
to { width: 100% }
}
@keyframes blink-caret {
from, to { border-color: transparent }
50% { border-color: theme('colors.primary.DEFAULT') }
}
}
@layer components {
.terminal-window {
@apply bg-terminal-background border border-terminal-border relative rounded-md overflow-hidden;
box-shadow: 0 0 5px theme('colors.terminal.border'), inset 0 0 5px rgba(0, 0, 0, 0.5);
}
.terminal-header {
@apply bg-muted px-4 py-2 flex items-center justify-between border-b border-terminal-border;
}
.terminal-content {
@apply p-4 font-mono text-sm relative;
}
.terminal-content::before {
content: '';
@apply absolute top-0 left-0 right-0 bottom-0 pointer-events-none;
background: repeating-linear-gradient(
0deg,
rgba(0, 0, 0, 0.1),
rgba(0, 0, 0, 0.1) 1px,
transparent 1px,
transparent 2px
);
}
.terminal-text {
@apply text-terminal-text;
}
.scan-line {
@apply absolute top-0 left-0 w-full h-[2px] bg-white/10 opacity-75 pointer-events-none;
animation: scan-line 6s linear infinite;
}
.cursor-blink::after {
content: '|';
@apply animate-blink ml-[1px];
}
.glow-text {
text-shadow: 0 0 5px currentColor;
}
.button-glow {
@apply transition-all duration-300;
box-shadow: 0 0 5px theme('colors.primary.DEFAULT');
}
.button-glow:hover {
box-shadow: 0 0 10px theme('colors.primary.DEFAULT'), 0 0 20px theme('colors.primary.DEFAULT');
}
.hexagon-bg {
background-image: url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M30 5.66987L55 20.8349V51.1651L30 66.3301L5 51.1651V20.8349L30 5.66987ZM30 0L0 17.3205V51.9616L30 69.2821L60 51.9616V17.3205L30 0Z' fill='%2320202A' fill-opacity='0.3'/%3E%3C/svg%3E");
background-size: 60px 60px;
}
.circuit-bg {
background-image: url("data:image/svg+xml,%3Csvg width='100' height='100' viewBox='0 0 100 100' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M10 10 H90 V90 H10 Z' fill='none' stroke='%2320202A' stroke-width='1'/%3E%3Cpath d='M30 30 H70 V70 H30 Z' fill='none' stroke='%2320202A' stroke-width='1'/%3E%3Cpath d='M50 10 V30 M50 70 V90 M10 50 H30 M70 50 H90' stroke='%2320202A' stroke-width='1'/%3E%3C/svg%3E");
background-size: 100px 100px;
}
.glitch {
position: relative;
animation: glitch-animation 2s infinite;
}
@keyframes glitch-animation {
0% {
transform: none;
opacity: 1;
}
7% {
transform: skew(-0.5deg, -0.9deg);
opacity: 0.75;
}
10% {
transform: none;
opacity: 1;
}
27% {
transform: none;
opacity: 1;
}
30% {
transform: skew(0.8deg, -0.1deg);
opacity: 0.75;
}
35% {
transform: none;
opacity: 1;
}
52% {
transform: none;
opacity: 1;
}
55% {
transform: skew(-1deg, 0.2deg);
opacity: 0.75;
}
50% {
transform: none;
opacity: 1;
}
72% {
transform: none;
opacity: 1;
}
75% {
transform: skew(0.4deg, 1deg);
opacity: 0.75;
}
80% {
transform: none;
opacity: 1;
}
100% {
transform: none;
opacity: 1;
}
}
}

View File

@ -1,46 +1,71 @@
import type { Metadata } from "next";
import { Inter } from 'next/font/google'
import "./globals.css";
import { Inter as FontSans, JetBrains_Mono as FontMono } from "next/font/google";
import { cn } from "../lib/utils";
import { ThemeProvider } from "../components/theme-provider";
import { Toaster } from "../components/ui/toaster";
import "@fontsource-variable/inter";
import "@fontsource-variable/jetbrains-mono";
import { WalletProvider, RLNImplementationProvider, KeystoreProvider, RLNProvider } from "../contexts/index";
import { Header } from "../components/Header";
import { AppStateProvider } from "../contexts/AppStateContext";
const inter = Inter({
variable: "--font-inter",
export const fontSans = FontSans({
subsets: ["latin"],
variable: "--font-sans",
});
export const metadata: Metadata = {
export const fontMono = FontMono({
subsets: ["latin"],
variable: "--font-mono",
});
export const metadata = {
title: "Waku Keystore Management",
description: "Manage your Waku RLN keystores securely",
description: "A simple application to manage Waku RLN keystores",
};
export default function RootLayout({
children,
}: Readonly<{
interface RootLayoutProps {
children: React.ReactNode;
}>) {
}
export default function RootLayout({ children }: RootLayoutProps) {
return (
<html lang="en">
<html lang="en" suppressHydrationWarning>
<head />
<body
className={`${inter.variable} antialiased`}
className={cn(
"min-h-screen bg-background font-sans antialiased",
fontSans.variable,
fontMono.variable,
"circuit-bg"
)}
>
<AppStateProvider>
<WalletProvider>
<RLNImplementationProvider>
<KeystoreProvider>
<RLNProvider>
<div className="flex flex-col min-h-screen">
<Header />
<main className="flex-grow">
{children}
</main>
</div>
</RLNProvider>
</KeystoreProvider>
</RLNImplementationProvider>
</WalletProvider>
</AppStateProvider>
<ThemeProvider
attribute="class"
defaultTheme="dark"
enableSystem={false}
storageKey="waku-keystore-theme"
>
<AppStateProvider>
<WalletProvider>
<RLNImplementationProvider>
<KeystoreProvider>
<RLNProvider>
<div className="flex flex-col min-h-screen">
<Header />
<main className="flex-grow">
{children}
</main>
</div>
<Toaster />
</RLNProvider>
</KeystoreProvider>
</RLNImplementationProvider>
</WalletProvider>
</AppStateProvider>
</ThemeProvider>
</body>
</html>
);

View File

@ -1,16 +1,19 @@
"use client";
import React from 'react';
import { WalletInfo } from './WalletInfo';
import { WalletDropdown } from "./WalletDropdown";
export function Header() {
return (
<header className="bg-gray-900 border-b border-gray-800">
<div className="container mx-auto px-6 h-16 flex justify-between items-center">
<div className="flex items-center">
<h1 className="text-xl font-medium text-white">Waku Keystore Management</h1>
<header className="sticky top-0 z-50 w-full border-b border-terminal-border bg-terminal-background/80 backdrop-blur-sm">
<div className="container mx-auto flex h-16 items-center justify-between py-4">
<div className="font-mono text-lg font-bold">
<span className="text-primary glitch">Waku Keystore</span>{" "}
<span className="text-foreground opacity-80">Management</span>
</div>
<div className="flex items-center space-x-4">
<WalletDropdown />
</div>
<WalletInfo />
</div>
</header>
);

View File

@ -0,0 +1,87 @@
import { useState } from "react";
import { useKeystore } from "@/contexts/keystore";
import { toast } from "sonner";
import { ArrowUpToLine } from "lucide-react";
import { Button } from "./ui/button";
export function KeystoreExporter() {
const [showPasswordInput, setShowPasswordInput] = useState(false);
const [password, setPassword] = useState("");
const [isExporting, setIsExporting] = useState(false);
const { exportEntireKeystore, hasStoredCredentials } = useKeystore();
const handleExport = async () => {
if (!password) {
toast.error("Password is required");
return;
}
try {
setIsExporting(true);
await exportEntireKeystore(password);
toast.success("Keystore exported successfully");
setPassword("");
setShowPasswordInput(false);
setIsExporting(false);
} catch (error) {
setIsExporting(false);
toast.error("Failed to export keystore: " + (error instanceof Error ? error.message : String(error)));
}
};
return (
<div className="space-y-3">
{!showPasswordInput ? (
<Button
onClick={() => setShowPasswordInput(true)}
variant="terminal"
className="group relative overflow-hidden"
disabled={!hasStoredCredentials}
>
<span className="relative z-10 flex items-center">
<ArrowUpToLine className="w-4 h-4 mr-2" />
Export Keystore
</span>
<span className="absolute inset-0 bg-primary/10 transform translate-y-full group-hover:translate-y-0 transition-transform duration-200"></span>
</Button>
) : (
<div className="animate-in fade-in-50 duration-300 space-y-3">
<div className="flex flex-col space-y-3 sm:flex-row sm:space-y-0 sm:space-x-3">
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter keystore password"
className="flex-1 rounded-sm border border-terminal-border bg-background/80 px-3 py-2 font-mono text-xs text-terminal-text placeholder:text-terminal-text/50 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
disabled={isExporting}
autoFocus
/>
<div className="flex space-x-2">
<Button
onClick={handleExport}
variant="terminal"
disabled={!password || isExporting}
className="whitespace-nowrap"
>
{isExporting ? "Exporting..." : "Export"}
</Button>
<Button
onClick={() => {
setShowPasswordInput(false);
setPassword("");
}}
variant="outline"
className="text-muted-foreground hover:text-muted-foreground"
disabled={isExporting}
>
Cancel
</Button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@ -1,10 +1,10 @@
"use client";
import React, { Children, isValidElement } from 'react';
import { TabItem, TabNavigation } from './Tabs/TabNavigation';
import { useAppState } from '@/contexts/AppStateContext';
import { useAppState } from '../contexts/AppStateContext';
import { Tabs, TabsList, TabsTrigger, TabsContent } from './ui/tabs';
const tabs: TabItem [] = [
const tabs = [
{
id: 'membership',
label: 'Membership Registration',
@ -24,28 +24,47 @@ export function Layout({ children }: { children: React.ReactNode }) {
const { activeTab, setActiveTab } = useAppState();
const childrenArray = Children.toArray(children);
const getTabContent = (tabId: string) => {
return childrenArray.find((child) => {
if (isValidElement(child) && typeof child.type === 'function') {
const componentName = child.type.name;
return componentToTabId[componentName] === tabId;
}
return false;
});
};
return (
<div className="min-h-screen bg-white dark:bg-gray-900">
<main className="container mx-auto px-4 py-6">
<TabNavigation
tabs={tabs}
activeTab={activeTab}
onTabChange={setActiveTab}
/>
<div className="mt-6">
{childrenArray.map((child) => {
if (isValidElement(child) && typeof child.type === 'function') {
const componentName = child.type.name;
const tabId = componentToTabId[componentName];
<div className="w-full">
<div className="container mx-auto px-4 py-6 max-w-7xl">
<div className="grid grid-cols-1 md:grid-cols-1 lg:grid-cols-1 gap-6">
<div className="col-span-1">
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="w-full"
>
<TabsList className="w-full justify-start max-w-md">
{tabs.map((tab) => (
<TabsTrigger
key={tab.id}
value={tab.id}
className="px-6"
>
{tab.label}
</TabsTrigger>
))}
</TabsList>
if (tabId === activeTab) {
return child;
}
}
return null;
})}
{tabs.map((tab) => (
<TabsContent key={tab.id} value={tab.id} className="mt-6">
{getTabContent(tab.id)}
</TabsContent>
))}
</Tabs>
</div>
</div>
</main>
</div>
</div>
);
}

View File

@ -1,42 +1,38 @@
"use client";
import { useRLNImplementation } from '@/contexts';
import { useRLNImplementation } from '../contexts';
import React from 'react';
import { ToggleGroup, ToggleGroupItem } from "./ui/toggle-group";
export function RLNImplementationToggle() {
const { implementation, setImplementation } = useRLNImplementation();
return (
<div className="flex flex-col space-y-2">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
<div className="flex flex-col space-y-3">
<label className="text-sm font-mono text-muted-foreground">
RLN Implementation
</label>
<div className="flex space-x-4">
<button
onClick={() => setImplementation('light')}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
implementation === 'light'
? 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'
}`}
>
<ToggleGroup
type="single"
value={implementation}
onValueChange={(value) => {
if (value) setImplementation(value as 'light' | 'standard');
}}
className="w-full max-w-md"
>
<ToggleGroupItem value="light" className="flex-1">
Light
</button>
<button
onClick={() => setImplementation('standard')}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
implementation === 'standard'
? 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'
}`}
>
</ToggleGroupItem>
<ToggleGroupItem value="standard" className="flex-1">
Standard
</button>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400">
</ToggleGroupItem>
</ToggleGroup>
<p className="text-xs font-mono text-muted-foreground opacity-80">
{implementation === 'light'
? 'Light implementation, without Zerokit. Instant initalisation.'
: 'Standard implementation, with Zerokit. Initialisation takes 10-15 seconds for WASM module'
? 'Light implementation, without Zerokit. Instant initialisation.'
: 'Standard implementation, with Zerokit. Initialisation takes 10-15 seconds for WASM module.'
}
</p>
</div>

View File

@ -1,7 +1,8 @@
"use client";
import { useRLN } from '@/contexts';
import { useRLN } from '../contexts';
import React from 'react';
import { Button } from './ui/button';
export function RLNInitButton() {
const { initializeRLN, isInitialized, isStarted, error, isLoading } = useRLN();
@ -14,49 +15,30 @@ export function RLNInitButton() {
}
};
const getButtonText = () => {
if (isLoading) return 'Initializing...';
if (isInitialized && isStarted) return 'RLN Initialized';
return 'Initialize RLN';
};
return (
<div className="flex flex-col items-start gap-2">
<button
<div className="flex flex-col gap-2 font-mono">
<Button
onClick={handleInitialize}
disabled={isLoading || (isInitialized && isStarted)}
className={`
px-4 py-2 rounded-lg font-medium transition-colors relative
${
isLoading
? 'bg-gray-200 text-gray-500 cursor-not-allowed dark:bg-gray-700 dark:text-gray-400'
: isInitialized && isStarted
? 'bg-green-600 text-white cursor-default dark:bg-green-500'
: 'bg-blue-600 text-white hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600'
}
`}
variant={isInitialized && isStarted ? "default" : "terminal"}
className="w-full sm:w-auto font-mono"
>
{isLoading && (
<span className="absolute left-2 top-1/2 -translate-y-1/2">
<svg className="animate-spin h-5 w-5 text-current" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</span>
<svg className="mr-2 h-4 w-4 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
)}
{isInitialized && isStarted && (
<span className="absolute left-2 top-1/2 -translate-y-1/2">
<svg className="h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</span>
<svg className="mr-2 h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
)}
<span className={(isLoading || (isInitialized && isStarted)) ? 'pl-7' : ''}>
{getButtonText()}
</span>
</button>
{isLoading ? 'Initializing...' : (isInitialized && isStarted) ? 'RLN Initialized' : 'Initialize RLN'}
</Button>
{error && (
<p className="text-sm text-red-600 dark:text-red-400">
<p className="text-xs text-destructive">
{error}
</p>
)}

View File

@ -1,11 +1,14 @@
"use client";
import React, { useState } from 'react';
import { useKeystore } from '@/contexts/keystore';
import { useRLN } from '@/contexts/rln';
import { saveKeystoreToFile, readKeystoreFromFile } from '../../../utils/file';
import { useKeystore } from '../../../contexts/keystore';
import { readKeystoreFromFile, saveKeystoreCredentialToFile } from '../../../utils/keystore';
import { DecryptedCredentials } from '@waku/rln';
import { useAppState } from '@/contexts/AppStateContext';
import { useAppState } from '../../../contexts/AppStateContext';
import { TerminalWindow } from '../../ui/terminal-window';
import { Button } from '../../ui/button';
import { Copy, Eye, Download, Trash2, ArrowDownToLine } from 'lucide-react';
import { KeystoreExporter } from '../../KeystoreExporter';
export function KeystoreManagement() {
const {
@ -18,13 +21,13 @@ export function KeystoreManagement() {
getDecryptedCredential
} = useKeystore();
const { setGlobalError } = useAppState();
const { isInitialized, isStarted } = useRLN();
const [exportPassword, setExportPassword] = useState<string>('');
const [selectedCredential, setSelectedCredential] = useState<string | null>(null);
const [viewPassword, setViewPassword] = useState<string>('');
const [viewingCredential, setViewingCredential] = useState<string | null>(null);
const [decryptedInfo, setDecryptedInfo] = useState<DecryptedCredentials | null>(null);
const [isDecrypting, setIsDecrypting] = useState(false);
const [copiedHash, setCopiedHash] = useState<string | null>(null);
React.useEffect(() => {
if (error) {
@ -32,14 +35,14 @@ export function KeystoreManagement() {
}
}, [error, setGlobalError]);
const handleExportCredential = async (hash: string) => {
const handleExportKeystoreCredential = async (hash: string) => {
try {
if (!exportPassword) {
setGlobalError('Please enter your keystore password to export');
return;
}
const keystoreJson = await exportCredential(hash, exportPassword);
saveKeystoreToFile(keystoreJson, `waku-rln-credential-${hash.slice(0, 8)}.json`);
const keystore = await exportCredential(hash, exportPassword);
saveKeystoreCredentialToFile(keystore);
setExportPassword('');
setSelectedCredential(null);
} catch (err) {
@ -47,10 +50,10 @@ export function KeystoreManagement() {
}
};
const handleImport = async () => {
const handleImportKeystore = async () => {
try {
const keystoreJson = await readKeystoreFromFile();
const success = importKeystore(keystoreJson);
const keystore = await readKeystoreFromFile();
const success = importKeystore(keystore);
if (!success) {
setGlobalError('Failed to import keystore');
}
@ -97,36 +100,54 @@ export function KeystoreManagement() {
}
}, [viewingCredential, selectedCredential]);
// Add a function to copy text to clipboard with visual feedback
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text)
.then(() => {
setCopiedHash(text);
setTimeout(() => setCopiedHash(null), 2000);
})
.catch(err => {
console.error('Failed to copy: ', err);
});
};
return (
<div className="space-y-6">
<div className="bg-white dark:bg-gray-800 shadow sm:rounded-lg p-6">
<h2 className="text-lg font-medium text-gray-900 dark:text-white mb-4">
<TerminalWindow className="w-full">
<h2 className="text-lg font-mono font-medium text-primary mb-4 cursor-blink">
Keystore Management
</h2>
<div className="space-y-6">
{/* Import Action */}
<div>
<button
onClick={handleImport}
className="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800"
<div className="flex flex-wrap gap-3">
<Button
onClick={handleImportKeystore}
variant="terminal"
className="group relative overflow-hidden"
>
Import Keystore
</button>
<span className="relative z-10 flex items-center">
<ArrowDownToLine className="w-4 h-4 mr-2" />
Import Keystore
</span>
<span className="absolute inset-0 bg-primary/10 transform translate-y-full group-hover:translate-y-0 transition-transform duration-200"></span>
</Button>
<KeystoreExporter />
</div>
{/* RLN Status */}
{!isInitialized || !isStarted ? (
<div className="bg-yellow-50 dark:bg-yellow-900 p-4 rounded-lg">
<p className="text-sm text-yellow-700 dark:text-yellow-300">
Please initialize RLN before managing credentials
{/* Warning - RLN not initialized */}
{!hasStoredCredentials && (
<div className="my-4 p-3 border border-warning-DEFAULT/20 bg-warning-DEFAULT/5 rounded">
<p className="text-sm text-warning-DEFAULT font-mono flex items-center">
<span className="mr-2"></span>
Please initialize RLN before managing credentials
</p>
</div>
) : null}
)}
{/* Stored Credentials */}
<div className="border-t border-gray-200 dark:border-gray-700 pt-6">
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-4">
<div className="border-t border-terminal-border pt-6">
<h3 className="text-sm font-mono font-medium text-muted-foreground mb-4">
Stored Credentials
</h3>
@ -135,116 +156,149 @@ export function KeystoreManagement() {
{storedCredentialsHashes.map((hash) => (
<div
key={hash}
className="p-4 rounded-lg border border-gray-200 dark:border-gray-700"
className="p-4 rounded-md border border-terminal-border bg-terminal-background/30 hover:border-terminal-border/80 transition-colors"
>
<div className="flex flex-col space-y-3">
<div className="flex items-start justify-between">
<code className="text-sm text-gray-600 dark:text-gray-400 break-all">
{hash}
</code>
<div className="flex space-x-2">
<button
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<code className="text-sm text-muted-foreground font-mono">
{hash.slice(0, 10)}...{hash.slice(-6)}
</code>
<Button
variant="ghost"
size="sm"
className={`h-6 w-6 p-0 ${copiedHash === hash ? 'text-success-DEFAULT' : 'text-muted-foreground hover:text-primary'}`}
onClick={() => copyToClipboard(hash)}
>
<Copy className="h-3.5 w-3.5" />
</Button>
{copiedHash === hash && (
<span className="text-xs text-success-DEFAULT">Copied!</span>
)}
</div>
<div className="flex items-center space-x-2">
<Button
onClick={() => {
setViewingCredential(hash === viewingCredential ? null : hash);
setSelectedCredential(null);
setViewPassword('');
setDecryptedInfo(null);
}}
className="text-indigo-600 hover:text-indigo-700 dark:text-indigo-400 dark:hover:text-indigo-300"
variant="outline"
size="sm"
className="text-accent hover:text-accent hover:border-accent flex items-center gap-1 py-1"
>
View
</button>
<button
<Eye className="w-3 h-3" />
<span>View</span>
</Button>
<Button
onClick={() => {
setSelectedCredential(hash === selectedCredential ? null : hash);
setViewingCredential(null);
setExportPassword('');
}}
className="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
variant="outline"
size="sm"
className="text-primary hover:text-primary hover:border-primary flex items-center gap-1 py-1"
>
Export
</button>
<button
<Download className="w-3 h-3" />
<span>Export</span>
</Button>
<Button
onClick={() => handleRemoveCredential(hash)}
className="text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
variant="outline"
size="sm"
className="text-destructive hover:text-destructive hover:border-destructive flex items-center gap-1 py-1"
>
Remove
</button>
<Trash2 className="w-3 h-3" />
<span>Remove</span>
</Button>
</div>
</div>
{/* View Credential Section */}
{viewingCredential === hash && (
<div className="mt-2 space-y-2">
<div className="mt-3 space-y-3 border-t border-terminal-border/40 pt-3 animate-in fade-in-50 duration-300">
<div className="flex gap-2">
<input
type="password"
value={viewPassword}
onChange={(e) => setViewPassword(e.target.value)}
placeholder="Enter keystore password"
className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
placeholder="Enter credential password"
className="flex-1 px-3 py-2 border border-terminal-border rounded-md bg-terminal-background text-foreground font-mono focus:ring-1 focus:ring-accent focus:border-accent text-sm"
disabled={isDecrypting}
/>
<button
<Button
onClick={() => handleViewCredential(hash)}
disabled={!viewPassword || isDecrypting}
className={`px-4 py-2 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 dark:focus:ring-offset-gray-800 ${
!viewPassword || isDecrypting
? 'bg-gray-300 text-gray-500 cursor-not-allowed dark:bg-gray-600 dark:text-gray-400'
: 'bg-indigo-600 text-white hover:bg-indigo-700 focus:ring-indigo-500'
}`}
variant="terminal"
size="sm"
>
{isDecrypting ? 'Decrypting...' : 'Decrypt'}
</button>
</Button>
</div>
{/* Decrypted Information Display */}
{decryptedInfo && (
<div className="mt-4 p-3 bg-gray-50 dark:bg-gray-700 rounded-md">
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Decrypted Credential Information
</h4>
<div className="space-y-2">
<pre className="text-xs text-gray-600 dark:text-gray-400 whitespace-pre-wrap bg-gray-100 dark:bg-gray-800 p-2 rounded overflow-auto max-h-60">
<div className="grid grid-cols-1 gap-1">
<div className="flex flex-col">
<span className="font-semibold">ID Commitment:</span>
<span className="break-all">{decryptedInfo.identity.IDCommitment}</span>
</div>
<div className="flex flex-col">
<span className="font-semibold">ID Nullifier:</span>
<span className="break-all">{decryptedInfo.identity.IDNullifier}</span>
</div>
<div className="flex flex-col">
<span className="font-semibold">ID Secret Hash:</span>
<span className="break-all">{decryptedInfo.identity.IDSecretHash}</span>
</div>
<div className="flex flex-col">
<span className="font-semibold">Membership Address:</span>
<span className="break-all">{decryptedInfo.membership.address}</span>
</div>
<div className="flex flex-col">
<span className="font-semibold">Chain ID:</span>
<span>{decryptedInfo.membership.chainId}</span>
</div>
<div className="flex flex-col">
<span className="font-semibold">Tree Index:</span>
<span>{decryptedInfo.membership.treeIndex}</span>
</div>
<div className="flex flex-col">
<span className="font-semibold">Rate Limit:</span>
<span>{decryptedInfo.membership.rateLimit}</span>
<div className="mt-3 space-y-2 border-t border-terminal-border/40 pt-3 animate-in fade-in-50 duration-300">
<div className="flex items-center mb-2">
<span className="text-primary font-mono font-medium mr-2">{">"}</span>
<h3 className="text-sm font-mono font-semibold text-primary">
Credential Details
</h3>
</div>
<div className="space-y-2 text-xs font-mono">
<div className="grid grid-cols-1 gap-2">
<div className="flex flex-col">
<span className="text-muted-foreground">ID Commitment:</span>
<div className="flex items-center mt-1">
<span className="break-all text-accent truncate">{decryptedInfo.identity.IDCommitment}</span>
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0 ml-1 text-muted-foreground hover:text-accent"
onClick={() => copyToClipboard(decryptedInfo.identity.IDCommitment.toString())}
>
<Copy className="h-3 w-3" />
</Button>
</div>
</div>
{/* {JSON.stringify(decryptedInfo, null, 2)} */}
</pre>
<button
<div className="flex flex-col border-t border-terminal-border/20 pt-2">
<span className="text-muted-foreground">ID Nullifier:</span>
<div className="flex items-center mt-1">
<span className="break-all text-accent truncate">{decryptedInfo.identity.IDNullifier}</span>
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0 ml-1 text-muted-foreground hover:text-accent"
onClick={() => copyToClipboard(decryptedInfo.identity.IDNullifier.toString())}
>
<Copy className="h-3 w-3" />
</Button>
</div>
</div>
<div className="flex flex-col border-t border-terminal-border/20 pt-2">
<span className="text-muted-foreground">Membership Details:</span>
<div className="grid grid-cols-2 gap-4 mt-2">
<div>
<span className="text-muted-foreground text-xs">Chain ID:</span>
<div className="text-accent">{decryptedInfo.membership.chainId}</div>
</div>
<div>
<span className="text-muted-foreground text-xs">Rate Limit:</span>
<div className="text-accent">{decryptedInfo.membership.rateLimit}</div>
</div>
</div>
</div>
</div>
<Button
onClick={() => setDecryptedInfo(null)}
className="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
variant="ghost"
size="sm"
className="mt-2 text-xs text-muted-foreground hover:text-accent"
>
Hide Details
</button>
</Button>
</div>
</div>
)}
@ -253,20 +307,24 @@ export function KeystoreManagement() {
{/* Export Credential Section */}
{selectedCredential === hash && (
<div className="mt-2 space-y-2">
<div className="mt-3 space-y-3 border-t border-terminal-border/40 pt-3 animate-in fade-in-50 duration-300">
<input
type="password"
value={exportPassword}
onChange={(e) => setExportPassword(e.target.value)}
placeholder="Enter keystore password"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
className="w-full px-3 py-2 border border-terminal-border rounded-md bg-terminal-background text-foreground font-mono focus:ring-1 focus:ring-primary focus:border-primary text-sm"
/>
<button
onClick={() => handleExportCredential(hash)}
className="w-full px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800"
<Button
onClick={() => handleExportKeystoreCredential(hash)}
variant="default"
size="sm"
className="w-full font-mono"
disabled={!exportPassword}
>
<Download className="w-3 h-3 mr-1" />
Export Credential
</button>
</Button>
</div>
)}
</div>
@ -274,13 +332,13 @@ export function KeystoreManagement() {
))}
</div>
) : (
<p className="text-sm text-gray-500 dark:text-gray-400">
<div className="text-sm text-muted-foreground font-mono bg-terminal-background/30 p-4 border border-terminal-border/50 rounded-md text-center">
No credentials stored
</p>
</div>
)}
</div>
</div>
</div>
</TerminalWindow>
</div>
);
}

View File

@ -3,10 +3,13 @@
import React, { useState, useEffect } from 'react';
import { RLNImplementationToggle } from '../../RLNImplementationToggle';
import { KeystoreEntity } from '@waku/rln';
import { useAppState } from '@/contexts/AppStateContext';
import { useRLN } from '@/contexts/rln/RLNContext';
import { useWallet } from '@/contexts/wallet';
import { RLNInitButton } from '@/components/RLNinitButton';
import { useAppState } from '../../../contexts/AppStateContext';
import { useRLN } from '../../../contexts/rln/RLNContext';
import { useWallet } from '../../../contexts/wallet';
import { RLNInitButton } from '../../RLNinitButton';
import { TerminalWindow } from '../../ui/terminal-window';
import { Slider } from '../../ui/slider';
import { Button } from '../../ui/button';
export function MembershipRegistration() {
const { setGlobalError } = useAppState();
@ -34,11 +37,6 @@ export function MembershipRegistration() {
}
}, [error, setGlobalError]);
const handleRateLimitChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = parseInt(e.target.value);
setRateLimit(isNaN(value) ? rateMinLimit : value);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@ -100,165 +98,172 @@ export function MembershipRegistration() {
};
return (
<div className="space-y-6">
<div className="bg-white dark:bg-gray-800 shadow sm:rounded-lg p-6">
<h2 className="text-lg font-medium text-gray-900 dark:text-white mb-4">
<div className="space-y-6 max-w-full">
<TerminalWindow className="w-full">
<h2 className="text-lg font-mono font-medium text-primary mb-4 cursor-blink">
RLN Membership Registration
</h2>
<div className="space-y-6">
<div className="border-b border-gray-200 dark:border-gray-700 pb-6">
<div className="border-b border-terminal-border pb-6">
<RLNImplementationToggle />
</div>
{/* Network Warning */}
{isConnected && !isLineaSepolia && (
<div className="bg-orange-50 dark:bg-orange-900 p-4 rounded-lg">
<p className="text-sm text-orange-700 dark:text-orange-400">
<strong>Warning:</strong> You are not connected to Linea Sepolia network. Please switch networks to register.
<div className="mb-4 p-3 border border-destructive/20 bg-destructive/5 rounded">
<p className="text-sm text-destructive font-mono flex items-center">
<span className="mr-2"></span>
<span>You are not connected to Linea Sepolia network. Please switch networks to register.</span>
</p>
</div>
)}
{/* Informational Box */}
<div className="bg-blue-50 dark:bg-blue-900 p-4 rounded-lg">
<h3 className="text-md font-semibold text-blue-800 dark:text-blue-300 mb-2">
About RLN Membership on Linea Sepolia
</h3>
<p className="text-sm text-blue-700 dark:text-blue-400 mb-2">
RLN (Rate Limiting Nullifier) membership allows you to participate in Waku RLN Relay with rate limiting protection,
without exposing your private keys on your node.
</p>
<p className="text-sm text-blue-700 dark:text-blue-400 mb-2">
This application is configured to use the <strong>Linea Sepolia</strong> testnet for RLN registrations.
</p>
<p className="text-sm text-blue-700 dark:text-blue-400">
When you register, your wallet will sign a message that will be used to generate a cryptographic identity
for your membership. This allows your node to prove it has permission to send messages without revealing your identity.
</p>
{/* Informational Box - Now part of main terminal */}
<div className="border-t border-terminal-border pt-4 mt-4">
<div className="flex items-center mb-3">
<span className="text-primary font-mono font-medium mr-2">{">"}</span>
<h3 className="text-md font-mono font-semibold text-primary">
RLN Membership Info
</h3>
</div>
<div className="space-y-3">
<h4 className="text-md font-mono font-semibold text-primary cursor-blink">
About RLN Membership on Linea Sepolia
</h4>
<p className="text-sm text-foreground mb-2 opacity-90">
RLN (Rate Limiting Nullifier) membership allows you to participate in Waku RLN Relay with rate limiting protection,
without exposing your private keys on your node.
</p>
<p className="text-sm text-foreground mb-2 opacity-90">
This application is configured to use the <span className="text-primary">Linea Sepolia</span> testnet for RLN registrations.
</p>
<p className="text-sm text-foreground opacity-90">
When you register, your wallet will sign a message that will be used to generate a cryptographic identity
for your membership. This allows your node to prove it has permission to send messages without revealing your identity.
</p>
</div>
</div>
<div className="flex items-center space-x-2">
<RLNInitButton />
</div>
<div className="border-t border-terminal-border pt-6 mt-4">
<div className="flex items-center space-x-2">
<RLNInitButton />
</div>
{!isConnected ? (
<div className="text-amber-600 dark:text-amber-400">
Please connect your wallet to register a membership
</div>
) : !isInitialized || !isStarted ? (
<div className="text-amber-600 dark:text-amber-400">
Please initialize RLN before registering a membership
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label
htmlFor="rateLimit"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Rate Limit (messages per epoch)
</label>
<div className="flex items-center space-x-4">
<input
type="range"
id="rateLimit"
name="rateLimit"
min={rateMinLimit}
max={rateMaxLimit}
value={rateLimit}
onChange={handleRateLimitChange}
className="w-full"
/>
<span className="text-sm text-gray-600 dark:text-gray-400 w-12">
{rateLimit}
</span>
</div>
{!isConnected ? (
<div className="text-warning-DEFAULT font-mono text-sm mt-4 flex items-center">
<span className="mr-2"></span>
Please connect your wallet to register a membership
</div>
<div>
<div className="flex items-center mb-2">
<input
type="checkbox"
id="saveToKeystore"
checked={saveToKeystore}
onChange={(e) => setSaveToKeystore(e.target.checked)}
className="h-4 w-4 text-blue-600 rounded border-gray-300"
/>
<label
htmlFor="saveToKeystore"
className="ml-2 text-sm text-gray-700 dark:text-gray-300"
) : !isInitialized || !isStarted ? (
<div className="text-warning-DEFAULT font-mono text-sm mt-4 flex items-center">
<span className="mr-2"></span>
Please initialize RLN before registering a membership
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-4 mt-4">
<div>
<label
htmlFor="rateLimit"
className="block text-sm font-mono text-muted-foreground mb-2"
>
Save credentials to keystore
Rate Limit (messages per epoch)
</label>
</div>
{saveToKeystore && (
<div>
<label
htmlFor="keystorePassword"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Keystore Password (min 8 characters)
</label>
<input
type="password"
id="keystorePassword"
value={keystorePassword}
onChange={(e) => setKeystorePassword(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
placeholder="Enter password to encrypt credentials"
<div className="flex items-center space-x-4 py-2">
<Slider
id="rateLimit"
min={rateMinLimit}
max={rateMaxLimit}
value={[rateLimit]}
onValueChange={(value) => setRateLimit(value[0])}
className="w-full"
/>
<span className="text-sm text-muted-foreground w-12 font-mono">
{rateLimit}
</span>
</div>
)}
</div>
</div>
<button
type="submit"
disabled={isRegistering || !isLineaSepolia || (saveToKeystore && !keystorePassword)}
className={`w-full px-4 py-2 text-sm font-medium rounded-md ${
isRegistering || !isLineaSepolia || (saveToKeystore && !keystorePassword)
? 'bg-gray-300 text-gray-500 cursor-not-allowed'
: 'bg-blue-600 text-white hover:bg-blue-700'
}`}
>
{isRegistering ? 'Registering...' : 'Register Membership'}
</button>
</form>
)}
<div className="space-y-2">
<div className="flex items-center mb-2">
<input
type="checkbox"
id="saveToKeystore"
checked={saveToKeystore}
onChange={(e) => setSaveToKeystore(e.target.checked)}
className="h-4 w-4 rounded bg-terminal-background border-terminal-border text-primary focus:ring-primary"
/>
<label
htmlFor="saveToKeystore"
className="ml-2 text-sm font-mono text-foreground"
>
Save credentials to keystore
</label>
</div>
{saveToKeystore && (
<div>
<label
htmlFor="keystorePassword"
className="block text-sm font-mono text-muted-foreground mb-1"
>
Keystore Password (min 8 characters)
</label>
<input
type="password"
id="keystorePassword"
value={keystorePassword}
onChange={(e) => setKeystorePassword(e.target.value)}
className="w-full px-3 py-2 border border-terminal-border rounded-md bg-terminal-background text-foreground font-mono focus:ring-1 focus:ring-primary focus:border-primary text-sm"
placeholder="Enter password to encrypt credentials"
/>
</div>
)}
</div>
<Button
type="submit"
disabled={isRegistering || !isLineaSepolia || (saveToKeystore && !keystorePassword)}
variant={isRegistering ? "outline" : "default"}
className="w-full"
>
{isRegistering ? 'Registering...' : 'Register Membership'}
</Button>
</form>
)}
</div>
{/* Registration Result */}
{registrationResult.warning && (
<div className="mt-4 p-4 bg-yellow-50 dark:bg-yellow-900 rounded-lg">
<p className="text-sm text-yellow-700 dark:text-yellow-400">
<div className="mt-4 p-3 border border-warning-DEFAULT/20 bg-warning-DEFAULT/5 rounded">
<p className="text-sm text-warning-DEFAULT font-mono flex items-center">
<span className="mr-2"></span>
{registrationResult.warning}
</p>
</div>
)}
{registrationResult.error && (
<div className="mt-4 p-4 bg-red-50 dark:bg-red-900 rounded-lg">
<p className="text-sm text-red-700 dark:text-red-400">
<div className="mt-4 p-3 border border-destructive/20 bg-destructive/5 rounded">
<p className="text-sm text-destructive font-mono flex items-center">
<span className="mr-2"></span>
{registrationResult.error}
</p>
</div>
)}
{registrationResult.success && (
<div className="mt-4 p-4 bg-green-50 dark:bg-green-900 rounded-lg">
<p className="text-sm text-green-700 dark:text-green-400 mb-2">
Membership registered successfully!
<div className="mt-4 p-3 border border-success-DEFAULT/20 bg-success-DEFAULT/5 rounded">
<p className="text-sm text-success-DEFAULT font-mono mb-2 flex items-center">
<span className="mr-2"></span>
Membership registered successfully!
</p>
{registrationResult.txHash && (
<p className="text-xs text-green-600 dark:text-green-500">
<p className="text-xs text-success-DEFAULT font-mono opacity-80 break-all">
Transaction Hash: {registrationResult.txHash}
</p>
)}
{registrationResult.keystoreHash && (
<p className="text-xs text-green-600 dark:text-green-500">
Credentials saved to keystore with hash: {registrationResult.keystoreHash}
</p>
)}
</div>
)}
</div>
</div>
</TerminalWindow>
</div>
);
}

View File

@ -1,40 +0,0 @@
"use client";
import React from 'react';
export type TabItem = {
id: string;
label: string;
};
interface TabNavigationProps {
tabs: TabItem[];
activeTab: string;
onTabChange: (tabId: string) => void;
}
export function TabNavigation({ tabs, activeTab, onTabChange }: TabNavigationProps) {
return (
<div className="border-b border-gray-200 dark:border-gray-700">
<nav className="flex space-x-8" aria-label="Tabs">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => onTabChange(tab.id)}
className={`
py-4 px-1 border-b-2 font-medium text-sm
${
activeTab === tab.id
? 'border-blue-500 text-blue-600 dark:border-blue-400 dark:text-blue-400'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300'
}
`}
aria-current={activeTab === tab.id ? 'page' : undefined}
>
{tab.label}
</button>
))}
</nav>
</div>
);
}

View File

@ -0,0 +1,75 @@
"use client";
import React from 'react';
import { useWallet } from '../contexts/wallet';
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator
} from '../components/ui/dropdown-menu';
import { Button } from '../components/ui/button';
import { ChevronDown } from 'lucide-react';
export function WalletDropdown() {
const { isConnected, address, chainId, connectWallet, disconnectWallet, balance } = useWallet();
if (!isConnected || !address) {
return (
<Button
onClick={connectWallet}
variant="terminal"
size="sm"
className="text-xs"
>
Connect Wallet
</Button>
);
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="font-mono text-xs border-terminal-border hover:border-primary hover:text-primary"
>
<span
className={`inline-block h-2 w-2 rounded-full mr-1.5 bg-success-DEFAULT`}
/>
<span className="cursor-blink">
{address.slice(0, 6)}...{address.slice(-4)}
</span>
<ChevronDown className="ml-1 h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>Wallet</DropdownMenuLabel>
<DropdownMenuItem className="text-xs flex justify-between">
<span className="text-muted-foreground">Address:</span>
<span className="text-primary truncate ml-2">{address}</span>
</DropdownMenuItem>
<DropdownMenuItem className="text-xs flex justify-between">
<span className="text-muted-foreground">Network:</span>
<span className="text-primary ml-2">{chainId === 59141 ? "Linea Sepolia" : `Unknown (${chainId})`}</span>
</DropdownMenuItem>
{balance && (
<DropdownMenuItem className="text-xs flex justify-between">
<span className="text-muted-foreground">Balance:</span>
<span className="text-primary ml-2">{parseFloat(balance).toFixed(4)} ETH</span>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={disconnectWallet}
className="text-destructive focus:text-destructive cursor-pointer"
>
Disconnect
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@ -1,173 +0,0 @@
"use client";
import React, { useState, useRef, useEffect } from 'react';
import { useWallet } from '../contexts/index';
function getNetworkName(chainId: number | null): string {
if (!chainId) return 'Unknown';
switch (chainId) {
case 59141: return 'Linea Sepolia (Supported)';
default: return `Unsupported Network (Chain ID: ${chainId})`;
}
}
// Linea Sepolia Chain ID
const LINEA_SEPOLIA_CHAIN_ID = '0xe705'; // 59141 in hex
// Define interface for provider errors
interface ProviderRpcError extends Error {
code: number;
data?: unknown;
}
export function WalletInfo() {
const { isConnected, address, balance, chainId, connectWallet, disconnectWallet, error } = useWallet();
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
// Close dropdown when clicking outside
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// Function to switch to Linea Sepolia network
const switchToLineaSepolia = async () => {
if (!window.ethereum) {
console.error("MetaMask not installed");
return;
}
try {
await window.ethereum.request({
method: 'wallet_switchEthereumChain',
params: [{ chainId: LINEA_SEPOLIA_CHAIN_ID }],
});
} catch (err) {
const providerError = err as ProviderRpcError;
if (providerError.code === 4902) {
try {
await window.ethereum.request({
method: 'wallet_addEthereumChain',
params: [
{
chainId: LINEA_SEPOLIA_CHAIN_ID,
chainName: 'Linea Sepolia Testnet',
nativeCurrency: {
name: 'Linea Sepolia ETH',
symbol: 'ETH',
decimals: 18,
},
rpcUrls: ['https://linea-sepolia.infura.io/v3/', 'https://rpc.sepolia.linea.build'],
blockExplorerUrls: ['https://sepolia.lineascan.build'],
},
],
});
} catch (addError) {
console.error("Error adding Linea Sepolia chain", addError);
}
} else {
console.error("Error switching to Linea Sepolia chain", providerError);
}
}
};
// Check if user is on unsupported network
const isUnsupportedNetwork = isConnected && chainId !== 59141;
if (!isConnected) {
return (
<div className="flex items-center">
<button
onClick={connectWallet}
className="h-7 px-3 text-xs bg-blue-500/20 text-blue-300 rounded hover:bg-blue-500/30 transition-colors"
>
Connect Wallet
</button>
</div>
);
}
return (
<div className="relative" ref={dropdownRef}>
{error && (
<div className="absolute right-0 -top-2 transform -translate-y-full mb-2 px-2 py-1 text-xs bg-red-500/20 text-red-300 rounded whitespace-nowrap">
<p>{error}</p>
</div>
)}
<button
onClick={() => setIsOpen(!isOpen)}
className={`flex items-center gap-2 px-3 h-8 rounded transition-colors ${
isOpen ? 'bg-gray-800' : 'hover:bg-gray-800'
}`}
>
<div className="flex items-center gap-2">
<span className="font-mono text-xs text-gray-300 truncate max-w-[140px]">
{address}
</span>
<span className="font-mono text-xs text-gray-400">
{balance ? `${parseFloat(balance).toFixed(4)} ETH` : 'Loading...'}
</span>
</div>
<svg
className={`w-4 h-4 text-gray-400 transition-transform ${isOpen ? 'rotate-180' : ''}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{isOpen && (
<div className="absolute right-0 mt-2 w-72 rounded-lg bg-gray-800 shadow-lg border border-gray-700 overflow-hidden">
<div className="p-3">
<div className="flex flex-col gap-2 text-gray-300">
<div className="flex items-center justify-between gap-2">
<span className="text-xs text-gray-400">Address:</span>
<span className="font-mono text-xs truncate">{address}</span>
</div>
<div className="flex items-center justify-between gap-2">
<span className="text-xs text-gray-400">Network:</span>
<span className={`font-mono text-xs ${isUnsupportedNetwork ? 'text-orange-400' : ''}`}>
{getNetworkName(chainId)}
</span>
</div>
<div className="flex items-center justify-between gap-2">
<span className="text-xs text-gray-400">Balance:</span>
<span className="font-mono text-xs">
{balance ? `${parseFloat(balance).toFixed(4)} ETH` : 'Loading...'}
</span>
</div>
</div>
</div>
<div className="border-t border-gray-700 p-2 flex flex-col gap-2">
{isUnsupportedNetwork && (
<button
onClick={switchToLineaSepolia}
className="w-full h-7 px-3 text-xs bg-orange-500/20 text-orange-300 rounded hover:bg-orange-500/30 transition-colors"
>
Switch to Linea Sepolia
</button>
)}
<button
onClick={disconnectWallet}
className="w-full h-7 px-3 text-xs bg-red-500/20 text-red-300 rounded hover:bg-red-500/30 transition-colors"
>
Disconnect
</button>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,10 @@
"use client";
import * as React from "react";
import { ThemeProvider as NextThemesProvider } from "next-themes";
type ThemeProviderProps = Parameters<typeof NextThemesProvider>[0];
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}

View File

@ -0,0 +1,63 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "../../lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center font-mono rounded-sm text-sm whitespace-nowrap transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-glow-sm shadow-primary hover:shadow-glow hover:shadow-primary border border-primary/30",
secondary:
"bg-secondary text-secondary-foreground shadow-glow-sm shadow-secondary hover:shadow-glow hover:shadow-secondary border border-secondary/30",
accent:
"bg-accent text-accent-foreground shadow-glow-sm shadow-accent hover:shadow-glow hover:shadow-accent border border-accent/30",
destructive:
"bg-destructive text-destructive-foreground shadow-glow-sm shadow-destructive hover:shadow-glow hover:shadow-destructive border border-destructive/30",
outline:
"border border-primary text-foreground bg-transparent hover:shadow-glow-sm hover:shadow-primary",
terminal:
"bg-terminal-background border border-terminal-border text-terminal-text hover:shadow-glow-sm hover:shadow-terminal-text relative overflow-hidden",
ghost:
"hover:bg-muted hover:text-foreground",
link:
"text-primary hover:text-primary/80 underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-8 px-3 text-xs",
lg: "h-12 px-6 text-base",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = "Button";
export { Button, buttonVariants };

View File

@ -0,0 +1,77 @@
"use client";
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { cn } from "../../lib/utils";
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border border-terminal-border bg-terminal-background p-1 shadow-md terminal-window",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm font-mono outline-none focus:bg-primary/10 focus:text-primary data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-xs font-mono text-muted-foreground", className)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-terminal-border", className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
};

View File

@ -0,0 +1,28 @@
"use client";
import * as React from "react";
import * as SliderPrimitive from "@radix-ui/react-slider";
import { cn } from "../../lib/utils";
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn(
"relative flex w-full touch-none select-none items-center",
className
)}
{...props}
>
<SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-muted">
<SliderPrimitive.Range className="absolute h-full bg-primary" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-4 w-4 rounded-sm border border-primary bg-terminal-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 shadow-glow-sm shadow-primary" />
</SliderPrimitive.Root>
));
Slider.displayName = SliderPrimitive.Root.displayName;
export { Slider };

View File

@ -0,0 +1,59 @@
"use client";
import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "../../lib/utils";
const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex items-center justify-center border-b border-muted p-1 font-mono text-muted-foreground mb-4",
className
)}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap py-3 px-4 text-sm font-mono transition-all relative",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
"data-[state=active]:text-primary data-[state=active]:after:content-[''] data-[state=active]:after:absolute data-[state=active]:after:bottom-0 data-[state=active]:after:left-0 data-[state=active]:after:right-0 data-[state=active]:after:h-[2px] data-[state=active]:after:bg-primary",
"hover:text-primary/80",
"data-[state=active]:before:content-['>_'] data-[state=active]:before:mr-1 data-[state=active]:before:text-primary data-[state=active]:before:animate-blink",
className
)}
{...props}
/>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
className
)}
{...props}
/>
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent };

View File

@ -0,0 +1,58 @@
"use client";
import * as React from "react";
import { cn } from "../../lib/utils";
interface TerminalWindowProps extends React.HTMLAttributes<HTMLDivElement> {
title?: string;
variant?: "default" | "primary" | "success" | "warning" | "error";
children: React.ReactNode;
}
const TerminalWindow = React.forwardRef<HTMLDivElement, TerminalWindowProps>(
({ className, children, title, variant = "default", ...props }, ref) => {
const variantClasses = {
default: "border-terminal-border",
primary: "border-primary",
success: "border-success-DEFAULT",
warning: "border-warning-DEFAULT",
error: "border-destructive",
};
return (
<div
className={cn(
"terminal-window",
variantClasses[variant],
className
)}
ref={ref}
{...props}
>
{/* Terminal header */}
<div className="terminal-header">
<div className="flex items-center space-x-1">
<div className="h-3 w-3 rounded-full bg-destructive/80"></div>
<div className="h-3 w-3 rounded-full bg-warning/80"></div>
<div className="h-3 w-3 rounded-full bg-success/80"></div>
</div>
{title && (
<div className="text-xs font-mono text-muted-foreground truncate">
{title}
</div>
)}
<div className="w-4"></div> {/* Spacer for alignment */}
</div>
{/* Terminal content */}
<div className="terminal-content">
{children}
<div className="scan-line" />
</div>
</div>
);
}
);
TerminalWindow.displayName = "TerminalWindow";
export { TerminalWindow };

View File

@ -0,0 +1,28 @@
"use client";
import { toast as sonnerToast } from "sonner";
type ToastProps = {
title?: string;
message: string;
type?: "default" | "success" | "error" | "warning";
};
export function toast({ title, message, type = "default" }: ToastProps) {
const displayTitle = title || (
type === "success" ? "Operation Successful" :
type === "error" ? "Error Detected" :
type === "warning" ? "Warning" :
"System Notification"
);
if (type === "success") {
return sonnerToast.success(displayTitle, { description: message });
} else if (type === "error") {
return sonnerToast.error(displayTitle, { description: message });
} else if (type === "warning") {
return sonnerToast.warning(displayTitle, { description: message });
} else {
return sonnerToast(displayTitle, { description: message });
}
}

View File

@ -0,0 +1,31 @@
"use client";
import { Toaster as Sonner } from "sonner";
export function Toaster() {
return (
<Sonner
theme="dark"
position="top-right"
closeButton
richColors
className="font-mono"
toastOptions={{
classNames: {
toast:
"terminal-window group terminal-content border-terminal-border bg-terminal-background text-terminal-text text-sm relative",
title: "text-terminal-text font-mono",
description: "text-terminal-text/80 font-mono text-xs",
actionButton:
"bg-primary text-primary-foreground shadow-glow-sm shadow-primary hover:shadow-glow hover:shadow-primary border border-primary/30",
cancelButton:
"bg-muted text-muted-foreground border border-muted hover:bg-muted/80",
success:
"group-[]:border-success-DEFAULT group-[]:text-success-DEFAULT",
error: "group-[]:border-destructive group-[]:text-destructive-DEFAULT",
warning: "group-[]:border-warning-DEFAULT group-[]:text-warning-DEFAULT",
},
}}
/>
);
}

View File

@ -0,0 +1,67 @@
"use client";
import * as React from "react";
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "../../lib/utils";
const toggleGroupVariants = cva(
"inline-flex bg-terminal-background border border-terminal-border rounded-md p-1 font-mono text-sm",
{
variants: {
variant: {
default: "bg-terminal-background",
outline: "bg-background",
},
},
defaultVariants: {
variant: "default",
},
}
);
const ToggleGroup = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleGroupVariants>
>(({ className, variant, ...props }, ref) => (
<ToggleGroupPrimitive.Root
ref={ref}
className={cn(toggleGroupVariants({ variant }), className)}
{...props}
/>
));
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName;
const toggleGroupItemVariants = cva(
"inline-flex items-center justify-center px-3 py-2 rounded transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default:
"data-[state=on]:bg-primary/20 data-[state=on]:text-primary data-[state=on]:shadow-[0_0_5px] data-[state=on]:shadow-primary hover:text-primary/80",
outline:
"data-[state=on]:bg-primary data-[state=on]:text-primary-foreground hover:bg-muted hover:text-primary",
},
},
defaultVariants: {
variant: "default",
},
}
);
const ToggleGroupItem = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleGroupItemVariants>
>(({ className, variant, ...props }, ref) => (
<ToggleGroupPrimitive.Item
ref={ref}
className={cn(toggleGroupItemVariants({ variant }), className)}
{...props}
/>
));
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName;
export { ToggleGroup, ToggleGroupItem };

View File

@ -12,8 +12,9 @@ interface KeystoreContextType {
hasStoredCredentials: boolean;
storedCredentialsHashes: string[];
saveCredentials: (credentials: KeystoreEntity, password: string) => Promise<string>;
exportCredential: (hash: string, password: string) => Promise<string>;
importKeystore: (keystoreJson: string) => boolean;
exportCredential: (hash: string, password: string) => Promise<Keystore>;
exportEntireKeystore: (password: string) => Promise<void>;
importKeystore: (keystore: Keystore) => boolean;
removeCredential: (hash: string) => void;
getDecryptedCredential: (hash: string, password: string) => Promise<KeystoreEntity | null>;
}
@ -97,7 +98,7 @@ export function KeystoreProvider({ children }: { children: ReactNode }) {
}
};
const exportCredential = async (hash: string, password: string): Promise<string> => {
const exportCredential = async (hash: string, password: string): Promise<Keystore> => {
if (!keystore) {
throw new Error("Keystore not initialized");
}
@ -114,19 +115,54 @@ export function KeystoreProvider({ children }: { children: ReactNode }) {
// Add the credential to the new keystore
await singleCredentialKeystore.addCredential(credential, password);
console.log("Single credential keystore:", singleCredentialKeystore.toString());
return singleCredentialKeystore.toString();
return singleCredentialKeystore;
};
const importKeystore = (keystoreJson: string): boolean => {
const exportEntireKeystore = async (password: string): Promise<void> => {
if (!keystore) {
throw new Error("Keystore not initialized");
}
if (storedCredentialsHashes.length === 0) {
throw new Error("No credentials to export");
}
try {
const imported = Keystore.fromString(keystoreJson);
if (imported) {
setKeystore(imported);
setStoredCredentialsHashes(imported.keys());
localStorage.setItem(LOCAL_STORAGE_KEYSTORE_KEY, keystoreJson);
return true;
// Verify the password works for at least one credential
const firstHash = storedCredentialsHashes[0];
const testCredential = await keystore.readCredential(firstHash, password);
if (!testCredential) {
throw new Error("Invalid password");
}
return false;
// If password is verified, export the entire keystore
const filename = 'waku-rln-keystore.json';
const blob = new Blob([keystore.toString()], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
URL.revokeObjectURL(url);
document.body.removeChild(link);
} catch (err) {
console.error("Error exporting keystore:", err);
throw err;
}
};
const importKeystore = (keystore: Keystore): boolean => {
try {
setKeystore(keystore);
setStoredCredentialsHashes(keystore.keys());
localStorage.setItem(LOCAL_STORAGE_KEYSTORE_KEY, keystore.toString());
return true;
} catch (err) {
console.error("Error importing keystore:", err);
setError(err instanceof Error ? err.message : "Failed to import keystore");
@ -152,6 +188,7 @@ export function KeystoreProvider({ children }: { children: ReactNode }) {
storedCredentialsHashes,
saveCredentials,
exportCredential,
exportEntireKeystore,
importKeystore,
removeCredential,
getDecryptedCredential

View File

@ -0,0 +1,17 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
/**
* Combines multiple class names into a single string, using clsx for conditional classes
* and twMerge to handle Tailwind CSS class conflicts
*/
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
/**
* Converts HSL color values to CSS variables
*/
export function hslToVar(hsl: string): string {
return `hsl(var(${hsl}))`;
}

View File

@ -1,62 +0,0 @@
/**
* Utility functions for handling keystore file operations
*/
/**
* Save a keystore JSON string to a file
* @param keystoreJson The keystore JSON as a string
* @param filename Optional filename (defaults to 'waku-rln-keystore.json')
*/
export const saveKeystoreToFile = (keystoreJson: string, filename: string = 'waku-rln-keystore.json'): void => {
const blob = new Blob([keystoreJson], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
URL.revokeObjectURL(url);
document.body.removeChild(link);
};
/**
* Read a keystore file and return its content as a string
* @returns Promise resolving to the file content as a string
*/
export const readKeystoreFromFile = (): Promise<string> => {
return new Promise((resolve, reject) => {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'application/json,.json';
input.onchange = (event) => {
const target = event.target as HTMLInputElement;
const file = target.files?.[0];
if (!file) {
reject(new Error('No file selected'));
return;
}
const reader = new FileReader();
reader.onload = () => {
const content = reader.result as string;
resolve(content);
};
reader.onerror = () => {
reject(new Error('Failed to read file'));
};
reader.readAsText(file);
};
input.click();
});
};

View File

@ -0,0 +1,80 @@
/**
* Utility functions for handling keystore file operations
*/
import { Keystore } from "@waku/rln";
/**
* Save a keystore JSON string to a file
* @param keystoreJson The keystore JSON as a string
* @param filename Optional filename (defaults to 'waku-rln-keystore.json')
*/
export const saveKeystoreCredentialToFile = (keystore: Keystore): void => {
let filename: string;
if (keystore.keys.length > 1) {
filename = 'waku-rln-keystore.json';
} else {
const slicedHash = keystore.keys()[0].slice(0,8)
filename = `waku-rln-credential-${slicedHash}.json`;
}
const blob = new Blob([keystore.toString()], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
URL.revokeObjectURL(url);
document.body.removeChild(link);
};
/**
* Read a keystore file and return it as a Keystore object
* @returns Promise resolving to a Keystore object
*/
export const readKeystoreFromFile = (): Promise<Keystore> => {
return new Promise<Keystore>((resolve, reject) => {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'application/json,.json';
input.onchange = (event: Event) => {
const target = event.target as HTMLInputElement;
const file: File | undefined = target.files?.[0];
if (!file) {
reject(new Error('No file selected'));
return;
}
const reader = new FileReader();
reader.onload = () => {
try {
const content = reader.result as string;
const keystore = Keystore.fromString(content);
if (!keystore) {
reject(new Error('Failed to create keystore from file'));
return;
}
resolve(keystore);
} catch (e) {
reject(new Error(`Invalid keystore file format: ${e instanceof Error ? e.message : String(e)}`));
}
};
reader.onerror = () => {
reject(new Error('Failed to read file'));
};
reader.readAsText(file);
};
input.click();
});
};

View File

@ -1,4 +1,6 @@
import type { Config } from "tailwindcss";
import { fontFamily } from "tailwindcss/defaultTheme";
import animatePlugin from "tailwindcss-animate";
export default {
content: [
@ -6,13 +8,112 @@ export default {
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
],
darkMode: ["class"],
theme: {
extend: {
fontFamily: {
sans: ["var(--font-sans)", ...fontFamily.sans],
mono: ["var(--font-mono)", ...fontFamily.mono],
},
colors: {
background: "var(--background)",
foreground: "var(--foreground)",
// Cypherpunk inspired color palette
background: "hsl(230, 25%, 7%)", // Deep space background
foreground: "hsl(213, 31%, 91%)",
border: "hsl(230, 15%, 20%)",
input: "hsl(230, 15%, 15%)",
// Base accent colors
primary: {
DEFAULT: "hsl(150, 100%, 54%)", // Neon green
foreground: "hsl(230, 25%, 7%)",
muted: "hsla(150, 100%, 54%, 0.2)",
},
secondary: {
DEFAULT: "hsl(280, 100%, 60%)", // Electric purple
foreground: "hsl(230, 25%, 7%)",
muted: "hsla(280, 100%, 60%, 0.2)",
},
accent: {
DEFAULT: "hsl(190, 100%, 50%)", // Digital blue
foreground: "hsl(230, 25%, 7%)",
muted: "hsla(190, 100%, 50%, 0.2)",
},
// Status colors
success: {
DEFAULT: "hsl(142, 76%, 50%)",
muted: "hsla(142, 76%, 50%, 0.2)",
},
warning: {
DEFAULT: "hsl(38, 100%, 50%)",
muted: "hsla(38, 100%, 50%, 0.2)",
},
destructive: {
DEFAULT: "hsl(0, 100%, 55%)",
muted: "hsla(0, 100%, 55%, 0.2)",
foreground: "hsl(210, 40%, 98%)",
},
// UI component colors
muted: {
DEFAULT: "hsl(230, 15%, 25%)",
foreground: "hsl(213, 20%, 65%)",
},
card: {
DEFAULT: "hsl(229, 20%, 10%)",
foreground: "hsl(213, 31%, 91%)",
},
popover: {
DEFAULT: "hsl(229, 20%, 10%)",
foreground: "hsl(213, 31%, 91%)",
},
// Specialized elements
terminal: {
background: "hsl(230, 25%, 5%)",
border: "hsl(150, 100%, 54%, 0.3)",
text: "hsl(150, 100%, 54%)",
muted: "hsl(150, 100%, 25%)",
},
},
borderRadius: {
lg: "0.5rem",
md: "calc(0.5rem - 2px)",
sm: "calc(0.5rem - 4px)",
},
boxShadow: {
glow: "0 0 10px var(--tw-shadow-color), 0 0 20px var(--tw-shadow-color)",
"glow-sm": "0 0 5px var(--tw-shadow-color), 0 0 10px var(--tw-shadow-color)",
},
keyframes: {
"scan-line": {
"0%": { transform: "translateY(0%)" },
"100%": { transform: "translateY(100%)" },
},
blink: {
"0%, 100%": { opacity: "1" },
"50%": { opacity: "0" },
},
"terminal-typing": {
"0%": { width: "0" },
"100%": { width: "100%" },
},
flicker: {
"0%, 100%": { opacity: "1" },
"5%, 25%, 75%, 95%": { opacity: "0.8" },
"10%, 30%, 80%, 90%": { opacity: "0.9" },
"15%, 45%, 65%, 85%": { opacity: "0.85" },
"20%, 40%, 60%, 70%": { opacity: "0.75" },
},
},
animation: {
"scan-line": "scan-line 6s linear infinite",
blink: "blink 1s step-end infinite",
"terminal-typing": "terminal-typing 3s steps(40, end)",
flicker: "flicker 5s linear infinite",
},
},
},
plugins: [],
plugins: [animatePlugin],
} satisfies Config;