mirror of
https://github.com/logos-messaging/lab.waku.org.git
synced 2026-01-02 22:03:07 +00:00
chore: complete UI overhaul, cypherpunk
This commit is contained in:
parent
b3b261fc26
commit
4f7735e6cb
@ -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
|
||||
1051
examples/keystore-management/package-lock.json
generated
1051
examples/keystore-management/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>;
|
||||
}
|
||||
63
examples/keystore-management/src/components/ui/button.tsx
Normal file
63
examples/keystore-management/src/components/ui/button.tsx
Normal 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 };
|
||||
@ -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,
|
||||
};
|
||||
28
examples/keystore-management/src/components/ui/slider.tsx
Normal file
28
examples/keystore-management/src/components/ui/slider.tsx
Normal 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 };
|
||||
59
examples/keystore-management/src/components/ui/tabs.tsx
Normal file
59
examples/keystore-management/src/components/ui/tabs.tsx
Normal 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 };
|
||||
@ -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 };
|
||||
28
examples/keystore-management/src/components/ui/toast.tsx
Normal file
28
examples/keystore-management/src/components/ui/toast.tsx
Normal 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 });
|
||||
}
|
||||
}
|
||||
31
examples/keystore-management/src/components/ui/toaster.tsx
Normal file
31
examples/keystore-management/src/components/ui/toaster.tsx
Normal 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",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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 };
|
||||
@ -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
|
||||
|
||||
17
examples/keystore-management/src/lib/utils.ts
Normal file
17
examples/keystore-management/src/lib/utils.ts
Normal 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}))`;
|
||||
}
|
||||
@ -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();
|
||||
});
|
||||
};
|
||||
80
examples/keystore-management/src/utils/keystore.ts
Normal file
80
examples/keystore-management/src/utils/keystore.ts
Normal 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();
|
||||
});
|
||||
};
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user