From 62211da2a412b769763b7bdf3eede10ced6b2a12 Mon Sep 17 00:00:00 2001 From: Randika Dilshan Date: Mon, 14 Jul 2025 11:57:49 +0530 Subject: [PATCH 01/16] Add Paymaster interface to chain-db service --- services/chain-db/paymaster.ts | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 services/chain-db/paymaster.ts diff --git a/services/chain-db/paymaster.ts b/services/chain-db/paymaster.ts new file mode 100644 index 00000000..e0d37c66 --- /dev/null +++ b/services/chain-db/paymaster.ts @@ -0,0 +1,9 @@ +import 'server-only'; + +export interface Paymaster { + contract: string; + paymaster: string; + alias: string; + name: string; + published?: string; +} From 57ebc34ac072c8751fba5ade9f8d0e8e7e7b8863 Mon Sep 17 00:00:00 2001 From: Randika Dilshan Date: Mon, 14 Jul 2025 11:58:04 +0530 Subject: [PATCH 02/16] simple paymaster page create --- app/[alias]/(dashboard)/paymaster/page.tsx | 16 + .../(dashboard)/paymaster/paymaster-table.tsx | 301 ++++++++++++++++++ 2 files changed, 317 insertions(+) create mode 100644 app/[alias]/(dashboard)/paymaster/page.tsx create mode 100644 app/[alias]/(dashboard)/paymaster/paymaster-table.tsx diff --git a/app/[alias]/(dashboard)/paymaster/page.tsx b/app/[alias]/(dashboard)/paymaster/page.tsx new file mode 100644 index 00000000..2be3c8a6 --- /dev/null +++ b/app/[alias]/(dashboard)/paymaster/page.tsx @@ -0,0 +1,16 @@ +import PaymasterTable from "./paymaster-table"; + +export default function page() { + return ( +
+
+
+

Paymaster

+

update paymaster

+
+ +
+ +
+ ) +} diff --git a/app/[alias]/(dashboard)/paymaster/paymaster-table.tsx b/app/[alias]/(dashboard)/paymaster/paymaster-table.tsx new file mode 100644 index 00000000..1d575703 --- /dev/null +++ b/app/[alias]/(dashboard)/paymaster/paymaster-table.tsx @@ -0,0 +1,301 @@ +'use client' + +import UrlPagination from '@/components/custom/pagination-via-url'; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { DataTable } from "@/components/ui/data-table"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Separator } from '@/components/ui/separator'; +import { Paymaster } from "@/services/chain-db/paymaster"; +import { Row } from "@tanstack/react-table"; +import { Loader2, Plus, Trash2 } from "lucide-react"; +import { useState } from 'react'; + +export default function PaymasterTable() { + + + const [paymasterdata, setPaymasterdata] = useState([{ + contract: "0x1234567890123456789012345678901234567890", + paymaster: "0x1234567890123456789012345678901234567890", + alias: "Paymaster", + name: "Paymaster", + published: "0x1234567890123456789012345678901234567890efwefwef" + }]); + const [loadingId, setLoadingId] = useState(null); + const [editingItemId, setEditingItemId] = useState(null); + const [editingField, setEditingField] = useState< + 'contract' | 'name' | 'published' | null + >(null); + + const [editingContract, setEditingContract] = useState(''); + const [editingName, setEditingName] = useState(''); + + const [isAddDialogOpen, setIsAddDialogOpen] = useState(false); + + + //for contract editing + const handleContractClick = (paymaster: Paymaster) => { + setEditingItemId(paymaster.contract); + setEditingField('contract'); + setEditingContract(paymaster.contract || ''); + }; + const handleContractKeyDown = ( + e: React.KeyboardEvent, + paymaster: Paymaster + ) => { + if (e.key === 'Enter') { + handleContractSave(paymaster); + } else if (e.key === 'Escape') { + setEditingItemId(null); + setEditingField(null); + } + }; + const handleContractSave = async (paymaster: Paymaster) => { + if (editingContract === paymaster.contract) { + setEditingItemId(null); + setEditingField(null); + return; + } + + try { + + const updatedPaymaster = paymasterdata.map((p: Paymaster) => + p.contract === paymaster.contract ? { ...p, contract: editingContract } : p + ); + setPaymasterdata(updatedPaymaster); + + } catch (error) { + console.error(`Failed to update place name:`, error); + } + // Save logic would go here + }; + const handleContractChange = (e: React.ChangeEvent) => { + setEditingContract(e.target.value); + }; + + + //for name editing + const handleNameClick = (paymaster: Paymaster) => { + setEditingItemId(paymaster.contract); + setEditingField('name'); + setEditingName(paymaster.name || ''); + }; + const handleNameKeyDown = ( + e: React.KeyboardEvent, + paymaster: Paymaster + ) => { + if (e.key === 'Enter') { + handleContractSave(paymaster); + } else if (e.key === 'Escape') { + setEditingItemId(null); + setEditingField(null); + } + }; + const handleNameSave = async (paymaster: Paymaster) => { + if (editingName === paymaster.name) { + setEditingItemId(null); + setEditingField(null); + return; + } + + try { + + const updatedPaymaster = paymasterdata.map((p: Paymaster) => + p.contract === paymaster.contract ? { ...p, contract: editingContract } : p + ); + setPaymasterdata(updatedPaymaster); + + } catch (error) { + console.error(`Failed to update place name:`, error); + } + // Save logic would go here + }; + const handleNameChange = (e: React.ChangeEvent) => { + setEditingName(e.target.value); + }; + + const handleDelete = (contract: string) => { + setLoadingId(contract); + } + + return ( + <> + + + +
+ +
+
+ + + + Add new whitelisted address + Add a new whitelisted address + + + + + + + + + + + + + + + + + +
+ +
+
+ }) => { + return ( +
+ {editingItemId === row.original.contract && editingField === 'contract' ? ( + handleContractKeyDown(e, row.original)} + onBlur={() => handleContractSave(row.original)} + autoFocus + data-item-id={row.original.id} + className="w-full rounded border border-gray-300 p-1" + placeholder="Enter contract" + /> + ) : ( +
handleContractClick(row.original)} + className="cursor-pointer rounded p-1 hover:bg-gray-100" + > + {row.original.contract} +
+ )} +
+ ); + } + }, + { + header: "Name", + accessorKey: "name", + cell: ({ row }: { row: Row }) => { + return ( +
+ {editingItemId === row.original.contract && editingField === 'name' ? ( + handleNameKeyDown(e, row.original)} + onBlur={() => handleNameSave(row.original)} + autoFocus + data-item-id={row.original.id} + className="w-full rounded border border-gray-300 p-1" + placeholder="Enter name" + /> + ) : ( +
handleNameClick(row.original)} + className="cursor-pointer rounded p-1 hover:bg-gray-100" + > + {row.original.name} +
+ )} +
+ ); + } + }, + { + header: "Published", + accessorKey: "published", + cell: ({ row }: { row: Row }) => { + return ( +
+ {row.original.published ? + <> + + Yes + + : + <> + + No + + + } +
+ ) + } + }, + { + header: "Actions", + cell: ({ row }) => { + return ( + + ) + } + } + + ]} data={paymasterdata} /> +
+
+ + + +
+

+ Total: 11 +

+ +
+ + ) +} From 2717860b68b7d9eb46dda32e620721f2a2983c75 Mon Sep 17 00:00:00 2001 From: Randika Dilshan Date: Tue, 15 Jul 2025 06:45:53 +0530 Subject: [PATCH 03/16] Add isAdmin state to DashboardLayout and AppSidebar for conditional rendering of paymaster link --- .../(dashboard)/_components/app-sidebar.tsx | 12 +++++++++++- app/[alias]/(dashboard)/layout.tsx | 18 +++++++++++++++++- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/app/[alias]/(dashboard)/_components/app-sidebar.tsx b/app/[alias]/(dashboard)/_components/app-sidebar.tsx index 597d6634..df373cc5 100644 --- a/app/[alias]/(dashboard)/_components/app-sidebar.tsx +++ b/app/[alias]/(dashboard)/_components/app-sidebar.tsx @@ -12,6 +12,7 @@ import { UserT } from '@/services/top-db/users'; import { Config } from '@citizenwallet/sdk'; import { ArrowLeft, + CreditCard, Hammer, Home, Landmark, @@ -33,6 +34,7 @@ interface AppSidebarProps extends React.ComponentProps { config: Config; user: UserT | null; hasAccess: boolean; + isAdmin: boolean; } export function AppSidebar({ @@ -40,8 +42,11 @@ export function AppSidebar({ config, user, hasAccess, + isAdmin, ...props }: AppSidebarProps) { + + const data = { user: { name: user?.name ?? '', @@ -97,7 +102,12 @@ export function AppSidebar({ icon: Webhook } ] - } + }, + ...(isAdmin ? [{ + name: 'paymaster', + url: `/${config?.community.alias}/paymaster`, + icon: CreditCard + }] : []) ] }; diff --git a/app/[alias]/(dashboard)/layout.tsx b/app/[alias]/(dashboard)/layout.tsx index 892577cd..164620b3 100644 --- a/app/[alias]/(dashboard)/layout.tsx +++ b/app/[alias]/(dashboard)/layout.tsx @@ -18,6 +18,7 @@ export default async function DashboardLayout({ }) { const { alias } = await params; let hasAccess = false; + let isAdmin = false; const { community } = await getCommunity(alias); @@ -46,8 +47,17 @@ export default async function DashboardLayout({ } } + if (role === 'user' && accessList.length > 0) { + accessList.forEach(access => { + if (access.alias === alias && access.role === 'owner') { + isAdmin = true; + } + }); + } + if (role === 'admin') { hasAccess = true; + isAdmin = true; } @@ -55,7 +65,13 @@ export default async function DashboardLayout({ return ( - +
From 0a4cc7be140f5d4b54cd52744c2f1705f3133ae6 Mon Sep 17 00:00:00 2001 From: Randika Dilshan Date: Tue, 15 Jul 2025 07:29:04 +0530 Subject: [PATCH 04/16] Add getPaymasterByAlias function to retrieve paymaster data by alias from Supabase --- services/chain-db/paymaster.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/services/chain-db/paymaster.ts b/services/chain-db/paymaster.ts index e0d37c66..70f7018b 100644 --- a/services/chain-db/paymaster.ts +++ b/services/chain-db/paymaster.ts @@ -1,5 +1,10 @@ import 'server-only'; +import { PostgrestResponse, SupabaseClient } from '@supabase/supabase-js'; + +const TABLE_NAME = 'paymaster_whitelisted_contracts'; +const PAGE_SIZE = 25; + export interface Paymaster { contract: string; paymaster: string; @@ -7,3 +12,18 @@ export interface Paymaster { name: string; published?: string; } + +export const getPaymasterByAlias = async (args: { + client: SupabaseClient; + alias: string; + page: number; +}): Promise> => { + const { client, alias, page } = args; + const offset = (page - 1) * PAGE_SIZE; + return client + .from(TABLE_NAME) + .select('*') + .eq('alias', alias) + .range(offset, offset + PAGE_SIZE - 1) + .limit(PAGE_SIZE); +}; From e721fae545ca22ed579b445d19fd44a4f3ad3b7e Mon Sep 17 00:00:00 2001 From: Randika Dilshan Date: Tue, 15 Jul 2025 07:29:49 +0530 Subject: [PATCH 05/16] Refactor Paymaster page to use async data loading and introduce fallback component and update PaymasterTable to accept initial data --- .../paymaster/_components/fallback.tsx | 21 +++++++++ app/[alias]/(dashboard)/paymaster/page.tsx | 45 +++++++++++++++++-- .../(dashboard)/paymaster/paymaster-table.tsx | 32 ++++++------- 3 files changed, 80 insertions(+), 18 deletions(-) create mode 100644 app/[alias]/(dashboard)/paymaster/_components/fallback.tsx diff --git a/app/[alias]/(dashboard)/paymaster/_components/fallback.tsx b/app/[alias]/(dashboard)/paymaster/_components/fallback.tsx new file mode 100644 index 00000000..f2fa4eb5 --- /dev/null +++ b/app/[alias]/(dashboard)/paymaster/_components/fallback.tsx @@ -0,0 +1,21 @@ +"use client"; +import { Skeleton } from '@/components/ui/skeleton'; +import { Paymaster } from '@/services/chain-db/paymaster'; +import { ColumnDef } from '@tanstack/react-table'; +import { DataTable } from "@/components/ui/data-table"; + +export const placeholderData: (Paymaster)[] = Array(5); + +export const skeletonColumns: ColumnDef[] = [ + +]; + +export default function PaymasterFallback() { + return ( +
+
+ +
+
+ ); +} \ No newline at end of file diff --git a/app/[alias]/(dashboard)/paymaster/page.tsx b/app/[alias]/(dashboard)/paymaster/page.tsx index 2be3c8a6..7ba1564f 100644 --- a/app/[alias]/(dashboard)/paymaster/page.tsx +++ b/app/[alias]/(dashboard)/paymaster/page.tsx @@ -1,6 +1,22 @@ +import { getServiceRoleClient } from '@/services/chain-db'; +import { getPaymasterByAlias } from "@/services/chain-db/paymaster"; +import { getCommunity } from "@/services/cw"; +import { Config } from "@citizenwallet/sdk"; +import { Suspense } from "react"; +import PaymasterFallback from "./_components/fallback"; import PaymasterTable from "./paymaster-table"; -export default function page() { +export default async function page(props: { + params: Promise<{ alias: string }>; + searchParams: Promise<{ + page?: string; + }>; +}) { + const { alias } = await props.params; + const { community: config } = await getCommunity(alias); + const { page: pageParam } = await props.searchParams; + const page = pageParam || '1'; + return (
@@ -8,9 +24,32 @@ export default function page() {

Paymaster

update paymaster

-
- + } key={alias + config.community.alias + page}> + +
) } + +async function PageLoader({ + config, + page +}: { + config: Config; + page?: string; +}) { + const { chain_id: chainId } = config.community.profile; + + const supabase = getServiceRoleClient(chainId); + + const { data: paymasterData } = await getPaymasterByAlias({ + client: supabase, + alias: config.community.alias, + page: parseInt(page || '1') + }); + + return ( + + ) +} \ No newline at end of file diff --git a/app/[alias]/(dashboard)/paymaster/paymaster-table.tsx b/app/[alias]/(dashboard)/paymaster/paymaster-table.tsx index 1d575703..e08d5550 100644 --- a/app/[alias]/(dashboard)/paymaster/paymaster-table.tsx +++ b/app/[alias]/(dashboard)/paymaster/paymaster-table.tsx @@ -21,16 +21,18 @@ import { Row } from "@tanstack/react-table"; import { Loader2, Plus, Trash2 } from "lucide-react"; import { useState } from 'react'; -export default function PaymasterTable() { +const PAGE_SIZE = 25; + +export default function PaymasterTable( + { + initialData + }: { + initialData: Paymaster[] + } +) { - const [paymasterdata, setPaymasterdata] = useState([{ - contract: "0x1234567890123456789012345678901234567890", - paymaster: "0x1234567890123456789012345678901234567890", - alias: "Paymaster", - name: "Paymaster", - published: "0x1234567890123456789012345678901234567890efwefwef" - }]); + const [paymasterdata, setPaymasterdata] = useState(initialData); const [loadingId, setLoadingId] = useState(null); const [editingItemId, setEditingItemId] = useState(null); const [editingField, setEditingField] = useState< @@ -187,7 +189,7 @@ export default function PaymasterTable() { { header: "Whitelisted Address", accessorKey: "contract", - cell: ({ row }: { row: Row }) => { + cell: ({ row }: { row: Row }) => { return (
{editingItemId === row.original.contract && editingField === 'contract' ? ( @@ -198,7 +200,7 @@ export default function PaymasterTable() { onKeyDown={(e) => handleContractKeyDown(e, row.original)} onBlur={() => handleContractSave(row.original)} autoFocus - data-item-id={row.original.id} + data-item-id={row.original.contract} className="w-full rounded border border-gray-300 p-1" placeholder="Enter contract" /> @@ -217,7 +219,7 @@ export default function PaymasterTable() { { header: "Name", accessorKey: "name", - cell: ({ row }: { row: Row }) => { + cell: ({ row }: { row: Row }) => { return (
{editingItemId === row.original.contract && editingField === 'name' ? ( @@ -228,7 +230,7 @@ export default function PaymasterTable() { onKeyDown={(e) => handleNameKeyDown(e, row.original)} onBlur={() => handleNameSave(row.original)} autoFocus - data-item-id={row.original.id} + data-item-id={row.original.contract} className="w-full rounded border border-gray-300 p-1" placeholder="Enter name" /> @@ -247,7 +249,7 @@ export default function PaymasterTable() { { header: "Published", accessorKey: "published", - cell: ({ row }: { row: Row }) => { + cell: ({ row }: { row: Row }) => { return (
{row.original.published ? @@ -292,9 +294,9 @@ export default function PaymasterTable() {

- Total: 11 + Total: {paymasterdata.length}

- +
) From 1e7ac222611f2c81d47a05d07d87727da61aa130 Mon Sep 17 00:00:00 2001 From: Randika Dilshan Date: Tue, 15 Jul 2025 10:03:17 +0530 Subject: [PATCH 06/16] Enhance Paymaster page by implementing a new Fallback component with skeleton loading states and updating data fetching logic to streamline initial data handling in PaymasterTable. --- .../paymaster/_components/fallback.tsx | 51 ++++++++++++++----- app/[alias]/(dashboard)/paymaster/page.tsx | 46 ++++++++--------- 2 files changed, 61 insertions(+), 36 deletions(-) diff --git a/app/[alias]/(dashboard)/paymaster/_components/fallback.tsx b/app/[alias]/(dashboard)/paymaster/_components/fallback.tsx index f2fa4eb5..7658b55e 100644 --- a/app/[alias]/(dashboard)/paymaster/_components/fallback.tsx +++ b/app/[alias]/(dashboard)/paymaster/_components/fallback.tsx @@ -1,21 +1,46 @@ -"use client"; +'use client'; + import { Skeleton } from '@/components/ui/skeleton'; import { Paymaster } from '@/services/chain-db/paymaster'; import { ColumnDef } from '@tanstack/react-table'; -import { DataTable } from "@/components/ui/data-table"; export const placeholderData: (Paymaster)[] = Array(5); export const skeletonColumns: ColumnDef[] = [ - -]; - -export default function PaymasterFallback() { - return ( -
-
- + { + header: 'Whitelisted Address', + cell: () => ( +
+ +
+ + +
-
- ); -} \ No newline at end of file + ) + }, + { + header: 'Name', + cell: () => ( +
+ +
+ ) + }, + { + header: 'Published', + cell: () => ( +
+ +
+ ) + }, + { + header: 'Action', + cell: () => ( +
+ +
+ ) + } +]; diff --git a/app/[alias]/(dashboard)/paymaster/page.tsx b/app/[alias]/(dashboard)/paymaster/page.tsx index 7ba1564f..9d06c7b1 100644 --- a/app/[alias]/(dashboard)/paymaster/page.tsx +++ b/app/[alias]/(dashboard)/paymaster/page.tsx @@ -1,9 +1,9 @@ +import { DataTable } from '@/components/ui/data-table'; import { getServiceRoleClient } from '@/services/chain-db'; import { getPaymasterByAlias } from "@/services/chain-db/paymaster"; import { getCommunity } from "@/services/cw"; -import { Config } from "@citizenwallet/sdk"; import { Suspense } from "react"; -import PaymasterFallback from "./_components/fallback"; +import { placeholderData, skeletonColumns } from "./_components/fallback"; import PaymasterTable from "./paymaster-table"; export default async function page(props: { @@ -17,6 +17,17 @@ export default async function page(props: { const { page: pageParam } = await props.searchParams; const page = pageParam || '1'; + + const { chain_id: chainId } = config.community.profile; + + const supabase = getServiceRoleClient(chainId); + + const { data: paymasterData } = await getPaymasterByAlias({ + client: supabase, + alias: config.community.alias, + page: parseInt(page) + }); + return (
@@ -25,31 +36,20 @@ export default async function page(props: {

update paymaster

- } key={alias + config.community.alias + page}> - + } key={paymasterData?.length}> +
) } -async function PageLoader({ - config, - page -}: { - config: Config; - page?: string; -}) { - const { chain_id: chainId } = config.community.profile; - - const supabase = getServiceRoleClient(chainId); - - const { data: paymasterData } = await getPaymasterByAlias({ - client: supabase, - alias: config.community.alias, - page: parseInt(page || '1') - }); +function Fallback() { return ( - - ) -} \ No newline at end of file +
+
+ +
+
+ ); +} From 5491bd1a4bc3ebdbac94f23fda2cfbc92669684d Mon Sep 17 00:00:00 2001 From: Randika Dilshan Date: Tue, 15 Jul 2025 10:03:36 +0530 Subject: [PATCH 07/16] Add updatePaymasterName action and service function to handle paymaster name updates with authorization checks --- app/[alias]/(dashboard)/paymaster/action.ts | 44 +++++++++++++++++++++ services/chain-db/paymaster.ts | 14 +++++++ 2 files changed, 58 insertions(+) create mode 100644 app/[alias]/(dashboard)/paymaster/action.ts diff --git a/app/[alias]/(dashboard)/paymaster/action.ts b/app/[alias]/(dashboard)/paymaster/action.ts new file mode 100644 index 00000000..921cbd12 --- /dev/null +++ b/app/[alias]/(dashboard)/paymaster/action.ts @@ -0,0 +1,44 @@ +'use server'; + +import { + getAuthUserRoleInAppAction, + getAuthUserRoleInCommunityAction +} from '@/app/_actions/user-actions'; +import { getServiceRoleClient } from '@/services/chain-db'; +import { updatePaymasterName } from '@/services/chain-db/paymaster'; +import { CommunityConfig, Config } from '@citizenwallet/sdk'; +import { revalidatePath } from 'next/cache'; + +export const updatePaymasterNameAction = async (args: { + config: Config; + paymaster: string; + name: string; +}) => { + try { + const { config, paymaster, name } = args; + + const community = new CommunityConfig(config); + const chainId = community.primaryToken.chain_id; + const alias = community.community.alias; + + const roleInApp = await getAuthUserRoleInAppAction(); + const roleResult = await getAuthUserRoleInCommunityAction({ alias }); + + if (roleInApp != 'admin' && roleResult != 'owner') { + throw new Error('You are not authorized to update profile image'); + } + + const supabase = getServiceRoleClient(chainId); + + await updatePaymasterName({ + client: supabase, + contract: paymaster, + name: name, + alias: alias + }); + + revalidatePath(`/${alias}/paymaster`, 'page'); + } catch (error) { + console.error(error); + } +}; diff --git a/services/chain-db/paymaster.ts b/services/chain-db/paymaster.ts index 70f7018b..b72c9520 100644 --- a/services/chain-db/paymaster.ts +++ b/services/chain-db/paymaster.ts @@ -27,3 +27,17 @@ export const getPaymasterByAlias = async (args: { .range(offset, offset + PAGE_SIZE - 1) .limit(PAGE_SIZE); }; + +export const updatePaymasterName = async (args: { + client: SupabaseClient; + contract: string; + name: string; + alias: string; +}) => { + const { client, contract, name, alias } = args; + return client + .from(TABLE_NAME) + .update({ name }) + .eq('contract', contract) + .eq('alias', alias); +}; From 7613a970887cdec0523a886ee88cee5474f86aec Mon Sep 17 00:00:00 2001 From: Randika Dilshan Date: Tue, 15 Jul 2025 10:03:45 +0530 Subject: [PATCH 08/16] Refactor PaymasterTable to streamline name editing and introduce ContractRow component for contract display and clipboard functionality --- .../paymaster/_components/ContractRow.tsx | 36 ++++++ .../(dashboard)/paymaster/paymaster-table.tsx | 110 ++++++------------ 2 files changed, 69 insertions(+), 77 deletions(-) create mode 100644 app/[alias]/(dashboard)/paymaster/_components/ContractRow.tsx diff --git a/app/[alias]/(dashboard)/paymaster/_components/ContractRow.tsx b/app/[alias]/(dashboard)/paymaster/_components/ContractRow.tsx new file mode 100644 index 00000000..abb37989 --- /dev/null +++ b/app/[alias]/(dashboard)/paymaster/_components/ContractRow.tsx @@ -0,0 +1,36 @@ +'use client'; + +import { formatAddress } from "@/lib/utils"; +import { Check, Copy } from "lucide-react"; +import { useState } from "react"; + +export const ContractRow = ({ account }: { account: string }) => { + const [isCopied, setIsCopied] = useState(false); + + const copyToClipboard = () => { + navigator.clipboard.writeText(account); + setIsCopied(true); + + setTimeout(() => { + setIsCopied(false); + }, 2000); + }; + + return ( +
+
+ + {formatAddress(account)} + + {isCopied ? ( + + ) : ( + + )} +
+
+ ); +}; \ No newline at end of file diff --git a/app/[alias]/(dashboard)/paymaster/paymaster-table.tsx b/app/[alias]/(dashboard)/paymaster/paymaster-table.tsx index e08d5550..4cfa1852 100644 --- a/app/[alias]/(dashboard)/paymaster/paymaster-table.tsx +++ b/app/[alias]/(dashboard)/paymaster/paymaster-table.tsx @@ -17,75 +17,36 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Separator } from '@/components/ui/separator'; import { Paymaster } from "@/services/chain-db/paymaster"; +import { Config } from '@citizenwallet/sdk'; import { Row } from "@tanstack/react-table"; import { Loader2, Plus, Trash2 } from "lucide-react"; import { useState } from 'react'; +import { toast } from 'sonner'; +import { ContractRow } from './_components/ContractRow'; +import { updatePaymasterNameAction } from './action'; const PAGE_SIZE = 25; export default function PaymasterTable( { - initialData + initialData, + config }: { - initialData: Paymaster[] + initialData: Paymaster[], + config: Config } ) { - const [paymasterdata, setPaymasterdata] = useState(initialData); const [loadingId, setLoadingId] = useState(null); const [editingItemId, setEditingItemId] = useState(null); - const [editingField, setEditingField] = useState< - 'contract' | 'name' | 'published' | null - >(null); + const [editingField, setEditingField] = useState<'name' | null>(null); - const [editingContract, setEditingContract] = useState(''); const [editingName, setEditingName] = useState(''); const [isAddDialogOpen, setIsAddDialogOpen] = useState(false); - //for contract editing - const handleContractClick = (paymaster: Paymaster) => { - setEditingItemId(paymaster.contract); - setEditingField('contract'); - setEditingContract(paymaster.contract || ''); - }; - const handleContractKeyDown = ( - e: React.KeyboardEvent, - paymaster: Paymaster - ) => { - if (e.key === 'Enter') { - handleContractSave(paymaster); - } else if (e.key === 'Escape') { - setEditingItemId(null); - setEditingField(null); - } - }; - const handleContractSave = async (paymaster: Paymaster) => { - if (editingContract === paymaster.contract) { - setEditingItemId(null); - setEditingField(null); - return; - } - - try { - - const updatedPaymaster = paymasterdata.map((p: Paymaster) => - p.contract === paymaster.contract ? { ...p, contract: editingContract } : p - ); - setPaymasterdata(updatedPaymaster); - - } catch (error) { - console.error(`Failed to update place name:`, error); - } - // Save logic would go here - }; - const handleContractChange = (e: React.ChangeEvent) => { - setEditingContract(e.target.value); - }; - - //for name editing const handleNameClick = (paymaster: Paymaster) => { setEditingItemId(paymaster.contract); @@ -97,7 +58,7 @@ export default function PaymasterTable( paymaster: Paymaster ) => { if (e.key === 'Enter') { - handleContractSave(paymaster); + handleNameSave(paymaster); } else if (e.key === 'Escape') { setEditingItemId(null); setEditingField(null); @@ -111,16 +72,29 @@ export default function PaymasterTable( } try { + setLoadingId(paymaster.contract); + await updatePaymasterNameAction({ + config: config, + paymaster: paymaster.contract, + name: editingName + }); + + setPaymasterdata(paymasterdata.map((p: Paymaster) => + p.contract === paymaster.contract ? { ...p, name: editingName } : p + )); - const updatedPaymaster = paymasterdata.map((p: Paymaster) => - p.contract === paymaster.contract ? { ...p, contract: editingContract } : p - ); - setPaymasterdata(updatedPaymaster); + setEditingItemId(null); + setEditingField(null); + + toast.success('Paymaster whitelist name updated'); } catch (error) { console.error(`Failed to update place name:`, error); + toast.error('Failed to update paymaster whitelist name'); + } finally { + setLoadingId(null); } - // Save logic would go here + }; const handleNameChange = (e: React.ChangeEvent) => { setEditingName(e.target.value); @@ -191,29 +165,8 @@ export default function PaymasterTable( accessorKey: "contract", cell: ({ row }: { row: Row }) => { return ( -
- {editingItemId === row.original.contract && editingField === 'contract' ? ( - handleContractKeyDown(e, row.original)} - onBlur={() => handleContractSave(row.original)} - autoFocus - data-item-id={row.original.contract} - className="w-full rounded border border-gray-300 p-1" - placeholder="Enter contract" - /> - ) : ( -
handleContractClick(row.original)} - className="cursor-pointer rounded p-1 hover:bg-gray-100" - > - {row.original.contract} -
- )} -
- ); + + ) } }, { @@ -301,3 +254,6 @@ export default function PaymasterTable( ) } + + + From d7d9c199eff2aa62eccb7ccb3af158c7bc4b1752 Mon Sep 17 00:00:00 2001 From: Randika Dilshan Date: Tue, 15 Jul 2025 11:11:46 +0530 Subject: [PATCH 09/16] Add checkPaymasterWhitelistAddressExists action and update error messages for authorization checks in paymaster actions --- app/[alias]/(dashboard)/paymaster/action.ts | 34 +++++++++++++++++++-- services/chain-db/paymaster.ts | 14 +++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/app/[alias]/(dashboard)/paymaster/action.ts b/app/[alias]/(dashboard)/paymaster/action.ts index 921cbd12..3ee66e23 100644 --- a/app/[alias]/(dashboard)/paymaster/action.ts +++ b/app/[alias]/(dashboard)/paymaster/action.ts @@ -5,7 +5,10 @@ import { getAuthUserRoleInCommunityAction } from '@/app/_actions/user-actions'; import { getServiceRoleClient } from '@/services/chain-db'; -import { updatePaymasterName } from '@/services/chain-db/paymaster'; +import { + getPaymasterByAddress, + updatePaymasterName +} from '@/services/chain-db/paymaster'; import { CommunityConfig, Config } from '@citizenwallet/sdk'; import { revalidatePath } from 'next/cache'; @@ -25,7 +28,7 @@ export const updatePaymasterNameAction = async (args: { const roleResult = await getAuthUserRoleInCommunityAction({ alias }); if (roleInApp != 'admin' && roleResult != 'owner') { - throw new Error('You are not authorized to update profile image'); + throw new Error('You are not authorized to update paymaster whitelist'); } const supabase = getServiceRoleClient(chainId); @@ -42,3 +45,30 @@ export const updatePaymasterNameAction = async (args: { console.error(error); } }; + +export const checkPaymasterWhitelistAddressExistsAction = async (args: { + config: Config; + address: string; +}) => { + const { config, address } = args; + + const community = new CommunityConfig(config); + const chainId = community.primaryToken.chain_id; + const alias = community.community.alias; + + const roleInApp = await getAuthUserRoleInAppAction(); + const roleResult = await getAuthUserRoleInCommunityAction({ alias }); + + if (roleInApp != 'admin' && roleResult != 'owner') { + throw new Error( + 'You are not authorized to check paymaster whitelist address' + ); + } + + const supabase = getServiceRoleClient(chainId); + return await getPaymasterByAddress({ + client: supabase, + address: address, + alias: alias + }); +}; diff --git a/services/chain-db/paymaster.ts b/services/chain-db/paymaster.ts index b72c9520..02b73555 100644 --- a/services/chain-db/paymaster.ts +++ b/services/chain-db/paymaster.ts @@ -11,6 +11,7 @@ export interface Paymaster { alias: string; name: string; published?: string; + required: boolean; } export const getPaymasterByAlias = async (args: { @@ -41,3 +42,16 @@ export const updatePaymasterName = async (args: { .eq('contract', contract) .eq('alias', alias); }; + +export const getPaymasterByAddress = async (args: { + client: SupabaseClient; + address: string; + alias: string; +}) => { + const { client, address, alias } = args; + return client + .from(TABLE_NAME) + .select('*') + .eq('contract', address) + .eq('alias', alias); +}; From 90ccd29125dd7dc2dc09d6cf780cdb3d16c5b912 Mon Sep 17 00:00:00 2001 From: Randika Dilshan Date: Tue, 15 Jul 2025 11:12:04 +0530 Subject: [PATCH 10/16] Enhance PaymasterTable with address validation and refresh functionality for whitelisted addresses --- .../(dashboard)/paymaster/paymaster-table.tsx | 183 +++++++++++++----- 1 file changed, 131 insertions(+), 52 deletions(-) diff --git a/app/[alias]/(dashboard)/paymaster/paymaster-table.tsx b/app/[alias]/(dashboard)/paymaster/paymaster-table.tsx index 4cfa1852..a4c5cdaf 100644 --- a/app/[alias]/(dashboard)/paymaster/paymaster-table.tsx +++ b/app/[alias]/(dashboard)/paymaster/paymaster-table.tsx @@ -17,13 +17,15 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Separator } from '@/components/ui/separator'; import { Paymaster } from "@/services/chain-db/paymaster"; -import { Config } from '@citizenwallet/sdk'; +import { CommunityConfig, Config } from '@citizenwallet/sdk'; import { Row } from "@tanstack/react-table"; -import { Loader2, Plus, Trash2 } from "lucide-react"; -import { useState } from 'react'; +import { Loader2, Plus, RefreshCcw, Trash2 } from "lucide-react"; +import { useEffect, useState } from 'react'; import { toast } from 'sonner'; import { ContractRow } from './_components/ContractRow'; -import { updatePaymasterNameAction } from './action'; +import { checkPaymasterWhitelistAddressExistsAction, updatePaymasterNameAction } from './action'; +import { useDebounce } from 'use-debounce'; +import { isAddress } from 'ethers'; const PAGE_SIZE = 25; @@ -45,6 +47,11 @@ export default function PaymasterTable( const [editingName, setEditingName] = useState(''); const [isAddDialogOpen, setIsAddDialogOpen] = useState(false); + const [isRefresh, setIsRefresh] = useState(false); + + const [newWhitelistedAddress, setNewWhitelistedAddress] = useState(''); + const [isValidAddress, setIsValidAddress] = useState(true); + const [newName, setNewName] = useState(''); //for name editing @@ -100,62 +107,134 @@ export default function PaymasterTable( setEditingName(e.target.value); }; - const handleDelete = (contract: string) => { - setLoadingId(contract); + const handleDelete = async (contract: string) => { + setPaymasterdata(paymasterdata.filter((p: Paymaster) => p.contract !== contract)); + checkIfChanges(); + } + + const checkIfChanges = () => { + const hasExistingChanges = paymasterdata == initialData; + setIsRefresh(hasExistingChanges); + } + + //for checking if whitelisted address is already in the database + const [debouncedNewWhitelistedAddress] = useDebounce(newWhitelistedAddress, 1000); + useEffect(() => { + const validateAddress = async () => { + if (debouncedNewWhitelistedAddress) { + const isValid = isAddress(debouncedNewWhitelistedAddress); + if (isValid) { + setIsValidAddress(true); + const exists = await checkPaymasterWhitelistAddressExistsAction({ + config: config, + address: debouncedNewWhitelistedAddress + }); + if (exists.data && exists.data.length > 0) { + setIsValidAddress(false); + toast.error('Whitelisted address already exists'); + } else { + setIsValidAddress(true); + } + } else { + setIsValidAddress(false); + toast.error('Invalid whitelisted address'); + } + } + }; + validateAddress(); + }, [debouncedNewWhitelistedAddress, config]); + + + const handleAdd = () => { + + const community = new CommunityConfig(config); + const paymaster = community.primaryAccountConfig.paymaster_address; + + setPaymasterdata([...paymasterdata, { + contract: debouncedNewWhitelistedAddress, + name: newName, + required: false, + paymaster: paymaster, + alias: config.community.alias + }]); + setIsAddDialogOpen(false); + setIsRefresh(true); } return ( <> +
- - -
-
-
- - - - Add new whitelisted address - Add a new whitelisted address - - - - - - - - - - - - - + ) : ( +
+ )} + + + +
+ +
+
+ + + + Add new whitelisted address + Add a new whitelisted address + + + + setNewWhitelistedAddress(e.target.value)} + className={`${isValidAddress ? '' : 'border-red-500'}`} + /> + + + + + setNewName(e.target.value)} + /> + + + + + + + + +
+ + +
+ - - - -
From a0838e09e0f729e953478b78428718af43110cf7 Mon Sep 17 00:00:00 2001 From: Randika Dilshan Date: Tue, 15 Jul 2025 15:31:30 +0530 Subject: [PATCH 11/16] Add refreshPaymasterWhitelistAction to handle updating the paymaster whitelist --- app/[alias]/(dashboard)/paymaster/action.ts | 86 ++- .../paymaster/contract/paymaster_contract.ts | 488 ++++++++++++++++++ services/chain-db/paymaster.ts | 10 + 3 files changed, 583 insertions(+), 1 deletion(-) create mode 100644 app/[alias]/(dashboard)/paymaster/contract/paymaster_contract.ts diff --git a/app/[alias]/(dashboard)/paymaster/action.ts b/app/[alias]/(dashboard)/paymaster/action.ts index 3ee66e23..6f05fa07 100644 --- a/app/[alias]/(dashboard)/paymaster/action.ts +++ b/app/[alias]/(dashboard)/paymaster/action.ts @@ -7,10 +7,29 @@ import { import { getServiceRoleClient } from '@/services/chain-db'; import { getPaymasterByAddress, - updatePaymasterName + Paymaster, + updatePaymasterName, + upsertPaymasterWhitelist } from '@/services/chain-db/paymaster'; import { CommunityConfig, Config } from '@citizenwallet/sdk'; +import { ethers } from 'ethers'; import { revalidatePath } from 'next/cache'; +import { PAYMASTER_ABI } from './contract/paymaster_contract'; + +const CHAIN_ID_TO_RPC_URL = (chainId: string) => { + switch (chainId) { + case '137': + return process.env.POLYGON_RPC_URL; + case '100': + return process.env.GNOSIS_RPC_URL; + case '42220': + return process.env.CELO_RPC_URL; + case '42161': + return process.env.ARBITRUM_RPC_URL; + default: + return process.env.BASE_RPC_URL; + } +}; export const updatePaymasterNameAction = async (args: { config: Config; @@ -72,3 +91,68 @@ export const checkPaymasterWhitelistAddressExistsAction = async (args: { alias: alias }); }; + +export const refreshPaymasterWhitelistAction = async (args: { + config: Config; + data: Paymaster[]; +}) => { + const { config, data } = args; + + try { + const communityConfig = new CommunityConfig(config); + const chainId = communityConfig.primaryToken.chain_id; + const paymasteraddress = + communityConfig.primaryAccountConfig.paymaster_address; + const alias = communityConfig.community.alias; + + const roleInApp = await getAuthUserRoleInAppAction(); + const roleResult = await getAuthUserRoleInCommunityAction({ alias }); + + if (roleInApp != 'admin' && roleResult != 'owner') { + throw new Error('You are not authorized to upload paymaster whitelist'); + } + + if (!process.env.WHITELIST_ACCOUNT_WALLET_PRIVATE_KEY) { + throw new Error('WHITELIST_ACCOUNT_WALLET_PRIVATE_KEY is not set'); + } + + const provider = new ethers.JsonRpcProvider( + CHAIN_ID_TO_RPC_URL(chainId.toString()) + ); + + const wallet = new ethers.Wallet( + process.env.WHITELIST_ACCOUNT_WALLET_PRIVATE_KEY, + provider + ); + + const paymasterContract = new ethers.Contract( + paymasteraddress, + PAYMASTER_ABI, + wallet + ); + + const whitelist = data.map((item) => item.contract); + + const tx = await paymasterContract.updateWhitelist(whitelist); + + const receipt = await tx.wait(); + const txHash = receipt.hash; + + const supabase = getServiceRoleClient(chainId); + const newdata = data.map((item) => ({ + ...item, + published: txHash + })); + + await upsertPaymasterWhitelist({ + client: supabase, + data: newdata + }); + + revalidatePath(`/${alias}/paymaster`, 'page'); + return { success: true }; + } catch (error) { + console.error(error); + return { success: false }; + } +}; diff --git a/app/[alias]/(dashboard)/paymaster/contract/paymaster_contract.ts b/app/[alias]/(dashboard)/paymaster/contract/paymaster_contract.ts new file mode 100644 index 00000000..79fd6feb --- /dev/null +++ b/app/[alias]/(dashboard)/paymaster/contract/paymaster_contract.ts @@ -0,0 +1,488 @@ +export const PAYMASTER_ABI = [ + { + type: 'function', + name: 'UPGRADE_INTERFACE_VERSION', + inputs: [], + outputs: [ + { + name: '', + type: 'string', + internalType: 'string' + } + ], + stateMutability: 'view' + }, + { + type: 'function', + name: 'getHash', + inputs: [ + { + name: 'userOp', + type: 'tuple', + internalType: 'struct UserOperation', + components: [ + { + name: 'sender', + type: 'address', + internalType: 'address' + }, + { + name: 'nonce', + type: 'uint256', + internalType: 'uint256' + }, + { + name: 'initCode', + type: 'bytes', + internalType: 'bytes' + }, + { + name: 'callData', + type: 'bytes', + internalType: 'bytes' + }, + { + name: 'callGasLimit', + type: 'uint256', + internalType: 'uint256' + }, + { + name: 'verificationGasLimit', + type: 'uint256', + internalType: 'uint256' + }, + { + name: 'preVerificationGas', + type: 'uint256', + internalType: 'uint256' + }, + { + name: 'maxFeePerGas', + type: 'uint256', + internalType: 'uint256' + }, + { + name: 'maxPriorityFeePerGas', + type: 'uint256', + internalType: 'uint256' + }, + { + name: 'paymasterAndData', + type: 'bytes', + internalType: 'bytes' + }, + { + name: 'signature', + type: 'bytes', + internalType: 'bytes' + } + ] + }, + { + name: 'validUntil', + type: 'uint48', + internalType: 'uint48' + }, + { + name: 'validAfter', + type: 'uint48', + internalType: 'uint48' + } + ], + outputs: [ + { + name: '', + type: 'bytes32', + internalType: 'bytes32' + } + ], + stateMutability: 'view' + }, + { + type: 'function', + name: 'initialize', + inputs: [ + { + name: 'aSponsor', + type: 'address', + internalType: 'address' + }, + { + name: 'addresses', + type: 'address[]', + internalType: 'address[]' + } + ], + outputs: [], + stateMutability: 'nonpayable' + }, + { + type: 'function', + name: 'owner', + inputs: [], + outputs: [ + { + name: '', + type: 'address', + internalType: 'address' + } + ], + stateMutability: 'view' + }, + { + type: 'function', + name: 'postOp', + inputs: [ + { + name: 'mode', + type: 'uint8', + internalType: 'enum IPaymaster.PostOpMode' + }, + { + name: 'context', + type: 'bytes', + internalType: 'bytes' + }, + { + name: 'actualGasCost', + type: 'uint256', + internalType: 'uint256' + } + ], + outputs: [], + stateMutability: 'view' + }, + { + type: 'function', + name: 'proxiableUUID', + inputs: [], + outputs: [ + { + name: '', + type: 'bytes32', + internalType: 'bytes32' + } + ], + stateMutability: 'view' + }, + { + type: 'function', + name: 'renounceOwnership', + inputs: [], + outputs: [], + stateMutability: 'nonpayable' + }, + { + type: 'function', + name: 'sponsor', + inputs: [], + outputs: [ + { + name: '', + type: 'address', + internalType: 'address' + } + ], + stateMutability: 'view' + }, + { + type: 'function', + name: 'transferOwnership', + inputs: [ + { + name: 'newOwner', + type: 'address', + internalType: 'address' + } + ], + outputs: [], + stateMutability: 'nonpayable' + }, + { + type: 'function', + name: 'updateSponsor', + inputs: [ + { + name: 'newSponsor', + type: 'address', + internalType: 'address' + } + ], + outputs: [], + stateMutability: 'nonpayable' + }, + { + type: 'function', + name: 'updateWhitelist', + inputs: [ + { + name: 'addresses', + type: 'address[]', + internalType: 'address[]' + } + ], + outputs: [], + stateMutability: 'nonpayable' + }, + { + type: 'function', + name: 'upgradeToAndCall', + inputs: [ + { + name: 'newImplementation', + type: 'address', + internalType: 'address' + }, + { + name: 'data', + type: 'bytes', + internalType: 'bytes' + } + ], + outputs: [], + stateMutability: 'payable' + }, + { + type: 'function', + name: 'validatePaymasterUserOp', + inputs: [ + { + name: 'userOp', + type: 'tuple', + internalType: 'struct UserOperation', + components: [ + { + name: 'sender', + type: 'address', + internalType: 'address' + }, + { + name: 'nonce', + type: 'uint256', + internalType: 'uint256' + }, + { + name: 'initCode', + type: 'bytes', + internalType: 'bytes' + }, + { + name: 'callData', + type: 'bytes', + internalType: 'bytes' + }, + { + name: 'callGasLimit', + type: 'uint256', + internalType: 'uint256' + }, + { + name: 'verificationGasLimit', + type: 'uint256', + internalType: 'uint256' + }, + { + name: 'preVerificationGas', + type: 'uint256', + internalType: 'uint256' + }, + { + name: 'maxFeePerGas', + type: 'uint256', + internalType: 'uint256' + }, + { + name: 'maxPriorityFeePerGas', + type: 'uint256', + internalType: 'uint256' + }, + { + name: 'paymasterAndData', + type: 'bytes', + internalType: 'bytes' + }, + { + name: 'signature', + type: 'bytes', + internalType: 'bytes' + } + ] + }, + { + name: 'userOpHash', + type: 'bytes32', + internalType: 'bytes32' + }, + { + name: 'maxCost', + type: 'uint256', + internalType: 'uint256' + } + ], + outputs: [ + { + name: 'context', + type: 'bytes', + internalType: 'bytes' + }, + { + name: 'validationData', + type: 'uint256', + internalType: 'uint256' + } + ], + stateMutability: 'view' + }, + { + type: 'event', + name: 'Initialized', + inputs: [ + { + name: 'version', + type: 'uint64', + indexed: false, + internalType: 'uint64' + } + ], + anonymous: false + }, + { + type: 'event', + name: 'OwnershipTransferred', + inputs: [ + { + name: 'previousOwner', + type: 'address', + indexed: true, + internalType: 'address' + }, + { + name: 'newOwner', + type: 'address', + indexed: true, + internalType: 'address' + } + ], + anonymous: false + }, + { + type: 'event', + name: 'Upgraded', + inputs: [ + { + name: 'implementation', + type: 'address', + indexed: true, + internalType: 'address' + } + ], + anonymous: false + }, + { + type: 'error', + name: 'AddressEmptyCode', + inputs: [ + { + name: 'target', + type: 'address', + internalType: 'address' + } + ] + }, + { + type: 'error', + name: 'ECDSAInvalidSignature', + inputs: [] + }, + { + type: 'error', + name: 'ECDSAInvalidSignatureLength', + inputs: [ + { + name: 'length', + type: 'uint256', + internalType: 'uint256' + } + ] + }, + { + type: 'error', + name: 'ECDSAInvalidSignatureS', + inputs: [ + { + name: 's', + type: 'bytes32', + internalType: 'bytes32' + } + ] + }, + { + type: 'error', + name: 'ERC1967InvalidImplementation', + inputs: [ + { + name: 'implementation', + type: 'address', + internalType: 'address' + } + ] + }, + { + type: 'error', + name: 'ERC1967NonPayable', + inputs: [] + }, + { + type: 'error', + name: 'FailedInnerCall', + inputs: [] + }, + { + type: 'error', + name: 'InvalidInitialization', + inputs: [] + }, + { + type: 'error', + name: 'NotInitializing', + inputs: [] + }, + { + type: 'error', + name: 'OwnableInvalidOwner', + inputs: [ + { + name: 'owner', + type: 'address', + internalType: 'address' + } + ] + }, + { + type: 'error', + name: 'OwnableUnauthorizedAccount', + inputs: [ + { + name: 'account', + type: 'address', + internalType: 'address' + } + ] + }, + { + type: 'error', + name: 'UUPSUnauthorizedCallContext', + inputs: [] + }, + { + type: 'error', + name: 'UUPSUnsupportedProxiableUUID', + inputs: [ + { + name: 'slot', + type: 'bytes32', + internalType: 'bytes32' + } + ] + } +]; \ No newline at end of file diff --git a/services/chain-db/paymaster.ts b/services/chain-db/paymaster.ts index 02b73555..536713b2 100644 --- a/services/chain-db/paymaster.ts +++ b/services/chain-db/paymaster.ts @@ -55,3 +55,13 @@ export const getPaymasterByAddress = async (args: { .eq('contract', address) .eq('alias', alias); }; + +export const upsertPaymasterWhitelist = async (args: { + client: SupabaseClient; + data: Paymaster[]; +}) => { + const { client, data } = args; + return client + .from(TABLE_NAME) + .upsert(data, { onConflict: 'contract,paymaster' }); +}; From 98bbf205c4cd48d7808174e5a0148f1c8b066bd9 Mon Sep 17 00:00:00 2001 From: Randika Dilshan Date: Tue, 15 Jul 2025 15:31:54 +0530 Subject: [PATCH 12/16] Implement upload functionality for paymaster whitelist in PaymasterTable, including loading state and error handling. --- .../(dashboard)/paymaster/paymaster-table.tsx | 68 ++++++++++++++----- 1 file changed, 51 insertions(+), 17 deletions(-) diff --git a/app/[alias]/(dashboard)/paymaster/paymaster-table.tsx b/app/[alias]/(dashboard)/paymaster/paymaster-table.tsx index a4c5cdaf..483bd18b 100644 --- a/app/[alias]/(dashboard)/paymaster/paymaster-table.tsx +++ b/app/[alias]/(dashboard)/paymaster/paymaster-table.tsx @@ -19,13 +19,18 @@ import { Separator } from '@/components/ui/separator'; import { Paymaster } from "@/services/chain-db/paymaster"; import { CommunityConfig, Config } from '@citizenwallet/sdk'; import { Row } from "@tanstack/react-table"; -import { Loader2, Plus, RefreshCcw, Trash2 } from "lucide-react"; +import { isAddress } from 'ethers'; +import { Loader2, Plus, Trash2, Upload } from "lucide-react"; +import { useRouter } from 'next/navigation'; import { useEffect, useState } from 'react'; import { toast } from 'sonner'; -import { ContractRow } from './_components/ContractRow'; -import { checkPaymasterWhitelistAddressExistsAction, updatePaymasterNameAction } from './action'; import { useDebounce } from 'use-debounce'; -import { isAddress } from 'ethers'; +import { ContractRow } from './_components/ContractRow'; +import { + checkPaymasterWhitelistAddressExistsAction, + refreshPaymasterWhitelistAction, + updatePaymasterNameAction +} from './action'; const PAGE_SIZE = 25; @@ -38,7 +43,7 @@ export default function PaymasterTable( config: Config } ) { - + const router = useRouter(); const [paymasterdata, setPaymasterdata] = useState(initialData); const [loadingId, setLoadingId] = useState(null); const [editingItemId, setEditingItemId] = useState(null); @@ -52,6 +57,7 @@ export default function PaymasterTable( const [newWhitelistedAddress, setNewWhitelistedAddress] = useState(''); const [isValidAddress, setIsValidAddress] = useState(true); const [newName, setNewName] = useState(''); + const [uploading, setUploading] = useState(false); //for name editing @@ -161,15 +167,38 @@ export default function PaymasterTable( setIsRefresh(true); } + const handleRefresh = async () => { + try { + setUploading(true); + const result = await refreshPaymasterWhitelistAction({ + config: config, + data: paymasterdata + }); + if (result.success) { + toast.success('Whitelist uploaded'); + router.refresh(); + } else { + toast.error('Failed to upload whitelist'); + } + } catch (error) { + console.error(error); + toast.error('Failed to upload whitelist'); + } finally { + setUploading(false); + } + + } + + return ( <>
{isRefresh ? (
-
) : ( @@ -304,16 +333,21 @@ export default function PaymasterTable( header: "Actions", cell: ({ row }) => { return ( - } - - + ) } } From d3bc689e45eb6499213750e5c12c30d3c712adba Mon Sep 17 00:00:00 2001 From: Randika Dilshan Date: Tue, 15 Jul 2025 15:43:07 +0530 Subject: [PATCH 13/16] Update AppSidebar to conditionally render paymaster link for admin users --- app/[alias]/(dashboard)/_components/app-sidebar.tsx | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/app/[alias]/(dashboard)/_components/app-sidebar.tsx b/app/[alias]/(dashboard)/_components/app-sidebar.tsx index df373cc5..08e13860 100644 --- a/app/[alias]/(dashboard)/_components/app-sidebar.tsx +++ b/app/[alias]/(dashboard)/_components/app-sidebar.tsx @@ -100,14 +100,15 @@ export function AppSidebar({ name: 'Webhooks', url: `/${config?.community.alias}/webhooks`, icon: Webhook - } + }, + ...(isAdmin ? [{ + name: 'paymaster', + url: `/${config?.community.alias}/paymaster`, + icon: CreditCard + }] : []) ] }, - ...(isAdmin ? [{ - name: 'paymaster', - url: `/${config?.community.alias}/paymaster`, - icon: CreditCard - }] : []) + ] }; From 8340cc5752cf285d9ffdf296e10469a5513ec08b Mon Sep 17 00:00:00 2001 From: Randika Dilshan Date: Tue, 15 Jul 2025 15:43:23 +0530 Subject: [PATCH 14/16] add the fallback --- app/[alias]/(dashboard)/paymaster/page.tsx | 51 +++++++++++++++------- 1 file changed, 36 insertions(+), 15 deletions(-) diff --git a/app/[alias]/(dashboard)/paymaster/page.tsx b/app/[alias]/(dashboard)/paymaster/page.tsx index 9d06c7b1..e7b3b499 100644 --- a/app/[alias]/(dashboard)/paymaster/page.tsx +++ b/app/[alias]/(dashboard)/paymaster/page.tsx @@ -5,6 +5,7 @@ import { getCommunity } from "@/services/cw"; import { Suspense } from "react"; import { placeholderData, skeletonColumns } from "./_components/fallback"; import PaymasterTable from "./paymaster-table"; +import { Config } from '@citizenwallet/sdk'; export default async function page(props: { params: Promise<{ alias: string }>; @@ -17,6 +18,29 @@ export default async function page(props: { const { page: pageParam } = await props.searchParams; const page = pageParam || '1'; + return ( +
+
+
+

Paymaster

+

update whitelist on paymaster contract

+
+
+ } > + + +
+ ) +} + + +async function PageLoader({ + config, + page +}: { + config: Config, + page: string; +}) { const { chain_id: chainId } = config.community.profile; @@ -28,28 +52,25 @@ export default async function page(props: { page: parseInt(page) }); + return ( -
-
-
-

Paymaster

-

update paymaster

-
-
- } key={paymasterData?.length}> - - -
+ ) } + function Fallback() { return ( -
-
- + <> +
+
+ +
-
+ ); } From 9521b2b4eac0d3bd4e78a3291df6483595c104cb Mon Sep 17 00:00:00 2001 From: Randika Dilshan Date: Tue, 15 Jul 2025 16:16:08 +0530 Subject: [PATCH 15/16] beforeunload event listener to handle unsaved changes warning. --- .../(dashboard)/paymaster/paymaster-table.tsx | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/app/[alias]/(dashboard)/paymaster/paymaster-table.tsx b/app/[alias]/(dashboard)/paymaster/paymaster-table.tsx index 483bd18b..261660d3 100644 --- a/app/[alias]/(dashboard)/paymaster/paymaster-table.tsx +++ b/app/[alias]/(dashboard)/paymaster/paymaster-table.tsx @@ -60,6 +60,7 @@ export default function PaymasterTable( const [uploading, setUploading] = useState(false); + //for name editing const handleNameClick = (paymaster: Paymaster) => { setEditingItemId(paymaster.contract); @@ -190,6 +191,21 @@ export default function PaymasterTable( } + useEffect(() => { + const handleBeforeUnload = (e: BeforeUnloadEvent) => { + if (isRefresh) { + e.preventDefault(); + e.returnValue = ""; + } + }; + + window.addEventListener("beforeunload", handleBeforeUnload); + + return () => { + window.removeEventListener("beforeunload", handleBeforeUnload); + }; + }, [isRefresh]); + return ( <>
From 2c948673e10f58bf6ac9e6b474a624ade2578e32 Mon Sep 17 00:00:00 2001 From: Randika Dilshan Date: Wed, 16 Jul 2025 15:19:58 +0530 Subject: [PATCH 16/16] used BundlerService for updating the paymaster whitelist --- .env.example | 90 +- .gitignore | 74 +- app/(home)/_table/communities-table.tsx | 102 +- app/(home)/page.tsx | 74 +- app/(home)/user.tsx | 130 +- .../(dashboard)/_components/app-sidebar.tsx | 308 +- .../_components/community-switcher.tsx | 236 +- .../(dashboard)/_components/nav-projects.tsx | 180 +- .../(dashboard)/_components/nav-user.tsx | 320 +- .../admins/_table/admins-client-table.tsx | 118 +- .../admins/_table/admins-table.tsx | 140 +- .../(dashboard)/admins/_table/columns.tsx | 418 +- app/[alias]/(dashboard)/admins/add/actions.ts | 246 +- app/[alias]/(dashboard)/admins/add/page.tsx | 80 +- app/[alias]/(dashboard)/layout.tsx | 174 +- .../(dashboard)/members/[account]/action.ts | 312 +- .../members/[account]/add/page.tsx | 102 +- .../members/[account]/add/profile.tsx | 528 +- .../members/[account]/edit/page.tsx | 160 +- .../members/[account]/edit/profile.tsx | 788 +- .../(dashboard)/members/_table/columns.tsx | 746 +- .../members/_table/members-client-table.tsx | 38 +- .../members/_table/members-table.tsx | 96 +- app/[alias]/(dashboard)/members/page.tsx | 140 +- app/[alias]/(dashboard)/page.tsx | 256 +- .../paymaster/_components/ContractRow.tsx | 70 +- .../paymaster/_components/fallback.tsx | 92 +- app/[alias]/(dashboard)/paymaster/action.ts | 315 +- .../paymaster/contract/paymaster_contract.ts | 974 +- app/[alias]/(dashboard)/paymaster/page.tsx | 152 +- .../(dashboard)/paymaster/paymaster-table.tsx | 776 +- app/[alias]/(dashboard)/roles/RolePage.tsx | 866 +- app/[alias]/(dashboard)/roles/action.ts | 204 +- app/[alias]/(dashboard)/roles/page.tsx | 198 +- app/[alias]/(dashboard)/token/actions.ts | 340 +- app/[alias]/(dashboard)/token/burn/form.tsx | 888 +- app/[alias]/(dashboard)/token/burn/page.tsx | 64 +- app/[alias]/(dashboard)/token/mint/form.tsx | 810 +- app/[alias]/(dashboard)/token/mint/page.tsx | 64 +- .../(dashboard)/transfers/_table/columns.tsx | 394 +- .../_table/transfers-client-table.tsx | 44 +- .../transfers/_table/transfers-table.tsx | 110 +- app/[alias]/(dashboard)/transfers/page.tsx | 134 +- .../treasury/_table/treasury-table.tsx | 180 +- .../(dashboard)/webhooks/edit/[id]/page.tsx | 246 +- app/[alias]/(dashboard)/webhooks/page.tsx | 230 +- app/[alias]/login/email-form.tsx | 316 +- app/[alias]/login/otp-form.tsx | 422 +- app/[alias]/login/page.tsx | 34 +- app/_actions/community-actions.ts | 312 +- app/login/actions.ts | 506 +- app/login/email-form.tsx | 290 +- app/login/form-schema.tsx | 64 +- app/login/otp-form.tsx | 392 +- app/login/page.tsx | 290 +- app/onramp/page.tsx | 250 +- auth.config.ts | 326 +- helpers/formatting.ts | 6 +- lib/utils.ts | 30 +- middleware.ts | 40 +- next.config.ts | 52 +- package-lock.json | 17352 ++++++++-------- package.json | 132 +- private-key.ts | 46 +- services/chain-db/event.ts | 98 +- services/chain-db/paymaster.ts | 134 +- services/chain-db/transfers.ts | 234 +- services/cw/communities.json | 4042 ++-- services/cw/index.ts | 26 +- services/pinata/pinata.ts | 144 +- services/storage/index.ts | 58 +- services/top-db/users.ts | 252 +- state/session/action.ts | 212 +- state/session/state.ts | 94 +- tailwind.config.js | 190 +- 75 files changed, 19675 insertions(+), 19676 deletions(-) diff --git a/.env.example b/.env.example index 1b057c22..a727b71f 100644 --- a/.env.example +++ b/.env.example @@ -1,46 +1,46 @@ -NEXTAUTH_URL=http://localhost:3000 -NEXT_PUBLIC_APP_URL=http://localhost:3000 -AUTH_SECRET= # https://generate-secret.vercel.app/32 - -COMMUNITIES_CONFIG_URL='' - -# Brevo -BREVO_API_KEY= -BREVO_SENDER_EMAIL= -BREVO_SENDER_NAME= - -# Server Account -SERVER_PRIVATE_KEY= -SERVER_ACCOUNT_ADDRESS= - -# Supabase top level -SUPABASE_URL=https://... -SUPABASE_ANON_KEY=... -SUPABASE_SERVICE_ROLE_KEY=... -SUPABASE_DB_PASSWORD= - -# Supabase for chain 100 (Gnosis Chain) -SUPABASE_100_URL=https://... -SUPABASE_100_ANON_KEY=... -SUPABASE_100_SERVICE_ROLE_KEY=... - -# Supabase for chain 137 (Polygon) -SUPABASE_137_URL=https://... -SUPABASE_137_ANON_KEY=... -SUPABASE_137_SERVICE_ROLE_KEY=... - -# Supabase for chain 42220 (Celo) -SUPABASE_42220_URL=https://... -SUPABASE_42220_ANON_KEY=... -SUPABASE_42220_SERVICE_ROLE_KEY=... -SERVER_42220_ACCOUNT_ADDRESS= -SERVER_42220_WALLET_PRIVATE_KEY= - - -# Supabase for chain 8453 (Base) -SUPABASE_8453_URL=https://... -SUPABASE_8453_ANON_KEY=... -SUPABASE_8453_SERVICE_ROLE_KEY=... - -# ONRAMP +NEXTAUTH_URL=http://localhost:3000 +NEXT_PUBLIC_APP_URL=http://localhost:3000 +AUTH_SECRET= # https://generate-secret.vercel.app/32 + +COMMUNITIES_CONFIG_URL='' + +# Brevo +BREVO_API_KEY= +BREVO_SENDER_EMAIL= +BREVO_SENDER_NAME= + +# Server Account +SERVER_PRIVATE_KEY= +SERVER_ACCOUNT_ADDRESS= + +# Supabase top level +SUPABASE_URL=https://... +SUPABASE_ANON_KEY=... +SUPABASE_SERVICE_ROLE_KEY=... +SUPABASE_DB_PASSWORD= + +# Supabase for chain 100 (Gnosis Chain) +SUPABASE_100_URL=https://... +SUPABASE_100_ANON_KEY=... +SUPABASE_100_SERVICE_ROLE_KEY=... + +# Supabase for chain 137 (Polygon) +SUPABASE_137_URL=https://... +SUPABASE_137_ANON_KEY=... +SUPABASE_137_SERVICE_ROLE_KEY=... + +# Supabase for chain 42220 (Celo) +SUPABASE_42220_URL=https://... +SUPABASE_42220_ANON_KEY=... +SUPABASE_42220_SERVICE_ROLE_KEY=... +SERVER_42220_ACCOUNT_ADDRESS= +SERVER_42220_WALLET_PRIVATE_KEY= + + +# Supabase for chain 8453 (Base) +SUPABASE_8453_URL=https://... +SUPABASE_8453_ANON_KEY=... +SUPABASE_8453_SERVICE_ROLE_KEY=... + +# ONRAMP TRANSAK_API_KEY=... \ No newline at end of file diff --git a/.gitignore b/.gitignore index 961cdbcb..f7091ea0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,37 +1,37 @@ -# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. - -# dependencies -/node_modules -/.pnp -.pnp.js - -# testing -/coverage - -# next.js -/.next/ -/out/ - -# production -/build - -# misc -.DS_Store -*.pem - -# debug -npm-debug.log* -yarn-debug.log* -yarn-error.log* -.pnpm-debug.log* - -# env files -.env* -!.env.example - -# vercel -.vercel - -# typescript -*.tsbuildinfo -next-env.d.ts \ No newline at end of file +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files +!.env.example + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts +config.bat diff --git a/app/(home)/_table/communities-table.tsx b/app/(home)/_table/communities-table.tsx index 53707ce4..f0ead667 100644 --- a/app/(home)/_table/communities-table.tsx +++ b/app/(home)/_table/communities-table.tsx @@ -1,51 +1,51 @@ -import { columns } from './columns'; -import { DataTable } from '@/components/ui/data-table'; -import { Config } from '@citizenwallet/sdk'; -import { fetchCommunitiesForAdminAction } from '@/app/_actions/community-actions'; -import { Separator } from '@/components/ui/separator'; - -interface CommunitiesTableProps { - query: string; - page: number; -} - -export async function CommunitiesTable({ query }: CommunitiesTableProps) { - let communities: Config[] = []; - let total: number = 0; - - try { - const result = await fetchCommunitiesForAdminAction({ - query: query - }); - - communities = result.communities; - total = result.total; - } catch (error) { - console.error(error); - } - - return ( -
-
-
-

Communities

-

Browse communities

-
-
- -
-
- -
-
- - - -
-

- Total: {total} -

-
-
- ); -} +import { columns } from './columns'; +import { DataTable } from '@/components/ui/data-table'; +import { Config } from '@citizenwallet/sdk'; +import { fetchCommunitiesForAdminAction } from '@/app/_actions/community-actions'; +import { Separator } from '@/components/ui/separator'; + +interface CommunitiesTableProps { + query: string; + page: number; +} + +export async function CommunitiesTable({ query }: CommunitiesTableProps) { + let communities: Config[] = []; + let total: number = 0; + + try { + const result = await fetchCommunitiesForAdminAction({ + query: query + }); + + communities = result.communities; + total = result.total; + } catch (error) { + console.error(error); + } + + return ( +
+
+
+

Communities

+

Browse communities

+
+
+ +
+
+ +
+
+ + + +
+

+ Total: {total} +

+
+
+ ); +} diff --git a/app/(home)/page.tsx b/app/(home)/page.tsx index cbc9a974..df6aeafa 100644 --- a/app/(home)/page.tsx +++ b/app/(home)/page.tsx @@ -1,37 +1,37 @@ -import { Suspense } from 'react'; -import { skeletonColumns } from './_table/columns'; -import { DataTable } from '@/components/ui/data-table'; -import { CommunitiesTable } from './_table/communities-table'; -import { placeholderData } from './_table/columns'; - -export default async function Page(props: { - searchParams: Promise<{ query?: string; page?: string }>; -}) { - const { query: queryParam, page: pageParam } = await props.searchParams; - const query = queryParam || ''; - const page = pageParam || '1'; - - return ( - }> - - - ); -} - -function Fallback() { - return ( -
-
-
-

Communities

-
-
- -
-
- -
-
-
- ); -} +import { Suspense } from 'react'; +import { skeletonColumns } from './_table/columns'; +import { DataTable } from '@/components/ui/data-table'; +import { CommunitiesTable } from './_table/communities-table'; +import { placeholderData } from './_table/columns'; + +export default async function Page(props: { + searchParams: Promise<{ query?: string; page?: string }>; +}) { + const { query: queryParam, page: pageParam } = await props.searchParams; + const query = queryParam || ''; + const page = pageParam || '1'; + + return ( + }> + + + ); +} + +function Fallback() { + return ( +
+
+
+

Communities

+
+
+ +
+
+ +
+
+
+ ); +} diff --git a/app/(home)/user.tsx b/app/(home)/user.tsx index e32e9a91..448387c1 100644 --- a/app/(home)/user.tsx +++ b/app/(home)/user.tsx @@ -1,65 +1,65 @@ -'use client'; - -import { Button } from '@/components/ui/button'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger -} from '@/components/ui/dropdown-menu'; -import { signOutAction } from '@/app/_actions/user-actions'; -import { UserT } from '@/services/top-db/users'; -import { Avatar, AvatarImage, AvatarFallback } from '@radix-ui/react-avatar'; - -interface UserProps { - user: UserT | null; -} - -export default function User(props: UserProps) { - const { user } = props; - - const getInitials = (name?: string) => { - if (!name) return 'U'; - const nameParts = name.split(' '); - if (nameParts.length >= 2) { - return `${nameParts[0][0]}${nameParts[1][0]}`.toUpperCase(); - } - return name.slice(0, 2).toUpperCase(); - }; - - return ( - - - - - - -
{ - await signOutAction(); - }} - > - -
-
-
-
- ); -} +'use client'; + +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu'; +import { signOutAction } from '@/app/_actions/user-actions'; +import { UserT } from '@/services/top-db/users'; +import { Avatar, AvatarImage, AvatarFallback } from '@radix-ui/react-avatar'; + +interface UserProps { + user: UserT | null; +} + +export default function User(props: UserProps) { + const { user } = props; + + const getInitials = (name?: string) => { + if (!name) return 'U'; + const nameParts = name.split(' '); + if (nameParts.length >= 2) { + return `${nameParts[0][0]}${nameParts[1][0]}`.toUpperCase(); + } + return name.slice(0, 2).toUpperCase(); + }; + + return ( + + + + + + +
{ + await signOutAction(); + }} + > + +
+
+
+
+ ); +} diff --git a/app/[alias]/(dashboard)/_components/app-sidebar.tsx b/app/[alias]/(dashboard)/_components/app-sidebar.tsx index 08e13860..8f416e65 100644 --- a/app/[alias]/(dashboard)/_components/app-sidebar.tsx +++ b/app/[alias]/(dashboard)/_components/app-sidebar.tsx @@ -1,154 +1,154 @@ -'use client'; - -import { - Sidebar, - SidebarContent, - SidebarFooter, - SidebarHeader, - SidebarMenuButton, - SidebarRail -} from '@/components/ui/sidebar'; -import { UserT } from '@/services/top-db/users'; -import { Config } from '@citizenwallet/sdk'; -import { - ArrowLeft, - CreditCard, - Hammer, - Home, - Landmark, - List, - LucideLineChart, - Shield, - Users, - Webhook, - Wrench -} from 'lucide-react'; -import Link from 'next/link'; -import type * as React from 'react'; -import { CommunitySwitcher } from './community-switcher'; -import { NavProjects } from './nav-projects'; -import { NavUser } from './nav-user'; - -interface AppSidebarProps extends React.ComponentProps { - communities: Config[]; - config: Config; - user: UserT | null; - hasAccess: boolean; - isAdmin: boolean; -} - -export function AppSidebar({ - communities, - config, - user, - hasAccess, - isAdmin, - ...props -}: AppSidebarProps) { - - - const data = { - user: { - name: user?.name ?? '', - email: user?.email ?? '', - avatar: user?.avatar ?? '' - }, - projects: [ - { - name: 'Overview', - url: `/${config?.community.alias}`, - icon: Home - }, - { - name: 'Members', - url: `/${config?.community.alias}/members`, - icon: Users - }, - { - name: 'Transfers', - url: `/${config?.community.alias}/transfers`, - icon: LucideLineChart - }, - { - name: 'Treasury', - url: `/${config?.community.alias}/treasury`, - icon: Landmark, - items: [ - { - name: 'History', - url: `/${config?.community.alias}/treasury`, - icon: List - }, - { - name: 'Minters', - url: `/${config?.community.alias}/roles`, - icon: Hammer - } - ] - }, - { - name: 'Admins', - url: `/${config?.community.alias}/admins`, - icon: Shield - }, - { - name: 'Developer', - url: `/${config?.community.alias}`, - icon: Wrench, - items: [ - { - name: 'Webhooks', - url: `/${config?.community.alias}/webhooks`, - icon: Webhook - }, - ...(isAdmin ? [{ - name: 'paymaster', - url: `/${config?.community.alias}/paymaster`, - icon: CreditCard - }] : []) - ] - }, - - ] - }; - - return ( - - - - - - - project.name == 'Overview') - } - /> - - - - - - - ); -} - -function BackToAllCommunities() { - const handleClick = () => { - document.cookie = `lastViewedAlias=; path=/; max-age=0`; - }; - - return ( - - - - Home - - - ); -} +'use client'; + +import { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarHeader, + SidebarMenuButton, + SidebarRail +} from '@/components/ui/sidebar'; +import { UserT } from '@/services/top-db/users'; +import { Config } from '@citizenwallet/sdk'; +import { + ArrowLeft, + CreditCard, + Hammer, + Home, + Landmark, + List, + LucideLineChart, + Shield, + Users, + Webhook, + Wrench +} from 'lucide-react'; +import Link from 'next/link'; +import type * as React from 'react'; +import { CommunitySwitcher } from './community-switcher'; +import { NavProjects } from './nav-projects'; +import { NavUser } from './nav-user'; + +interface AppSidebarProps extends React.ComponentProps { + communities: Config[]; + config: Config; + user: UserT | null; + hasAccess: boolean; + isAdmin: boolean; +} + +export function AppSidebar({ + communities, + config, + user, + hasAccess, + isAdmin, + ...props +}: AppSidebarProps) { + + + const data = { + user: { + name: user?.name ?? '', + email: user?.email ?? '', + avatar: user?.avatar ?? '' + }, + projects: [ + { + name: 'Overview', + url: `/${config?.community.alias}`, + icon: Home + }, + { + name: 'Members', + url: `/${config?.community.alias}/members`, + icon: Users + }, + { + name: 'Transfers', + url: `/${config?.community.alias}/transfers`, + icon: LucideLineChart + }, + { + name: 'Treasury', + url: `/${config?.community.alias}/treasury`, + icon: Landmark, + items: [ + { + name: 'History', + url: `/${config?.community.alias}/treasury`, + icon: List + }, + { + name: 'Minters', + url: `/${config?.community.alias}/roles`, + icon: Hammer + } + ] + }, + { + name: 'Admins', + url: `/${config?.community.alias}/admins`, + icon: Shield + }, + { + name: 'Developer', + url: `/${config?.community.alias}`, + icon: Wrench, + items: [ + { + name: 'Webhooks', + url: `/${config?.community.alias}/webhooks`, + icon: Webhook + }, + ...(isAdmin ? [{ + name: 'paymaster', + url: `/${config?.community.alias}/paymaster`, + icon: CreditCard + }] : []) + ] + }, + + ] + }; + + return ( + + + + + + + project.name == 'Overview') + } + /> + + + + + + + ); +} + +function BackToAllCommunities() { + const handleClick = () => { + document.cookie = `lastViewedAlias=; path=/; max-age=0`; + }; + + return ( + + + + Home + + + ); +} diff --git a/app/[alias]/(dashboard)/_components/community-switcher.tsx b/app/[alias]/(dashboard)/_components/community-switcher.tsx index a8ec2f95..c542ec46 100644 --- a/app/[alias]/(dashboard)/_components/community-switcher.tsx +++ b/app/[alias]/(dashboard)/_components/community-switcher.tsx @@ -1,118 +1,118 @@ -'use client'; - -import * as React from 'react'; -import { ChevronsUpDown } from 'lucide-react'; - -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuTrigger -} from '@/components/ui/dropdown-menu'; -import { - SidebarMenu, - SidebarMenuButton, - SidebarMenuItem, - useSidebar -} from '@/components/ui/sidebar'; -import { Config, CommunityConfig, ConfigToken } from '@citizenwallet/sdk'; -import { CommunityLogo } from '@/components/icons'; -import { useRouter } from 'next/navigation'; - -export function CommunitySwitcher({ - communities, - selectedCommunity -}: { - communities: Config[]; - selectedCommunity?: Config; -}) { - const { isMobile } = useSidebar(); - const [activeCommunity, setActiveCommunity] = - React.useState(selectedCommunity); - const router = useRouter(); - - if (!activeCommunity) { - return null; - } - - const communityConfig = new CommunityConfig(activeCommunity); - const primaryToken: ConfigToken = communityConfig.primaryToken; - const logo: string = communityConfig.community.logo; - - return ( - - - - - -
- -
-
- - {activeCommunity.community.name} - - - {activeCommunity.community.alias} - -
- -
-
- - - Communities - - {communities.map((community) => { - const communityConfig = new CommunityConfig(community); - const alias = communityConfig.community.alias; - const primaryToken = communityConfig.primaryToken; - const logo = communityConfig.community.logo; - - const onSelectCommunity = () => { - document.cookie = `lastViewedAlias=${alias}; path=/; max-age=31536000`; - setActiveCommunity(community); - router.push(`/${alias}`); - }; - - return ( - -
- -
- {community.community.name} -
- ); - })} - {/* */} - {/* -
- -
-
Add team
-
*/} -
-
-
-
- ); -} +'use client'; + +import * as React from 'react'; +import { ChevronsUpDown } from 'lucide-react'; + +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu'; +import { + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + useSidebar +} from '@/components/ui/sidebar'; +import { Config, CommunityConfig, ConfigToken } from '@citizenwallet/sdk'; +import { CommunityLogo } from '@/components/icons'; +import { useRouter } from 'next/navigation'; + +export function CommunitySwitcher({ + communities, + selectedCommunity +}: { + communities: Config[]; + selectedCommunity?: Config; +}) { + const { isMobile } = useSidebar(); + const [activeCommunity, setActiveCommunity] = + React.useState(selectedCommunity); + const router = useRouter(); + + if (!activeCommunity) { + return null; + } + + const communityConfig = new CommunityConfig(activeCommunity); + const primaryToken: ConfigToken = communityConfig.primaryToken; + const logo: string = communityConfig.community.logo; + + return ( + + + + + +
+ +
+
+ + {activeCommunity.community.name} + + + {activeCommunity.community.alias} + +
+ +
+
+ + + Communities + + {communities.map((community) => { + const communityConfig = new CommunityConfig(community); + const alias = communityConfig.community.alias; + const primaryToken = communityConfig.primaryToken; + const logo = communityConfig.community.logo; + + const onSelectCommunity = () => { + document.cookie = `lastViewedAlias=${alias}; path=/; max-age=31536000`; + setActiveCommunity(community); + router.push(`/${alias}`); + }; + + return ( + +
+ +
+ {community.community.name} +
+ ); + })} + {/* */} + {/* +
+ +
+
Add team
+
*/} +
+
+
+
+ ); +} diff --git a/app/[alias]/(dashboard)/_components/nav-projects.tsx b/app/[alias]/(dashboard)/_components/nav-projects.tsx index 1a58161c..3805057d 100644 --- a/app/[alias]/(dashboard)/_components/nav-projects.tsx +++ b/app/[alias]/(dashboard)/_components/nav-projects.tsx @@ -1,91 +1,91 @@ -'use client'; - -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "@/components/ui/collapsible"; -import { - SidebarGroup, - SidebarMenu, - SidebarMenuButton, - SidebarMenuItem, - SidebarMenuSub, - SidebarMenuSubButton, - SidebarMenuSubItem -} from '@/components/ui/sidebar'; -import { ChevronRight, type LucideIcon } from 'lucide-react'; -import Link from 'next/link'; - -export function NavProjects({ - projects -}: { - projects: { - name: string; - url: string; - icon: LucideIcon; - items?: { - name: string; - url: string; - icon: LucideIcon; - }[]; - }[]; -}) { - return ( - - - - {projects.map((item) => ( - - - {item.items && item.items.length > 0 ? ( - - - - - {item.icon && } - {item.name} - - - - - - {item.items?.map((subItem) => ( - - - - - {subItem.name} - - - - ))} - - - - - ) : ( - - - - - - {item.name} - - - - - )} - - - ))} - - - - - ); +'use client'; + +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { + SidebarGroup, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarMenuSub, + SidebarMenuSubButton, + SidebarMenuSubItem +} from '@/components/ui/sidebar'; +import { ChevronRight, type LucideIcon } from 'lucide-react'; +import Link from 'next/link'; + +export function NavProjects({ + projects +}: { + projects: { + name: string; + url: string; + icon: LucideIcon; + items?: { + name: string; + url: string; + icon: LucideIcon; + }[]; + }[]; +}) { + return ( + + + + {projects.map((item) => ( + + + {item.items && item.items.length > 0 ? ( + + + + + {item.icon && } + {item.name} + + + + + + {item.items?.map((subItem) => ( + + + + + {subItem.name} + + + + ))} + + + + + ) : ( + + + + + + {item.name} + + + + + )} + + + ))} + + + + + ); } \ No newline at end of file diff --git a/app/[alias]/(dashboard)/_components/nav-user.tsx b/app/[alias]/(dashboard)/_components/nav-user.tsx index 848b2759..abf28f13 100644 --- a/app/[alias]/(dashboard)/_components/nav-user.tsx +++ b/app/[alias]/(dashboard)/_components/nav-user.tsx @@ -1,160 +1,160 @@ -'use client'; - -import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger -} from '@/components/ui/dropdown-menu'; -import { - SidebarMenu, - SidebarMenuButton, - SidebarMenuItem, - useSidebar -} from '@/components/ui/sidebar'; -import { StorageService } from '@/services/storage'; -import { CommunityConfig, Config, revokeSession } from '@citizenwallet/sdk'; -import { Wallet } from "ethers"; -import { ChevronsUpDown, LogOut } from 'lucide-react'; -import { useSession as useNextAuthSession } from "next-auth/react"; -import { useRouter } from 'next/navigation'; -import { toast } from 'sonner'; -import { useSession } from 'state/session/action'; - -export function NavUser({ - user, - config -}: { - user: { - name: string; - email: string; - avatar: string; - }; - config?: Config; -}) { - const { isMobile } = useSidebar(); - const { data: session, update } = useNextAuthSession(); - const router = useRouter(); - const sessionActions = useSession(config as Config); - - - const removeSession = async () => { - - try { - - if (!config) { - return; - } - - const privateKey = sessionActions[1].storage.getKey("session_private_key"); - const account = await sessionActions[1].getAccountAddress(); - const communityConfig = new CommunityConfig(config); - const storageService = new StorageService(config.community.alias); - - if (!privateKey || !account) { - return; - } - - const signer = new Wallet(privateKey); - - const tx = await revokeSession({ - community: communityConfig, - signer, - account, - }); - - - if (!tx) { - toast.error("Signout failed"); - return; - } - sessionActions[1].clear(); - storageService.deleteKey("session_private_key"); - storageService.deleteKey("session_source_type"); - storageService.deleteKey("session_source_value"); - storageService.deleteKey("session_hash"); - - const removeChainIds = config?.community.profile.chain_id; - const updateChainIds = session?.user.chainIds?.filter((chainId: number) => chainId !== removeChainIds); - - //remove chainId from session - await update({ - chainIds: updateChainIds - }); - - toast.success("Signout successful"); - - } catch (error) { - console.error(error); - toast.error("Signout failed"); - - } finally { - router.push("/"); - } - - - } - - return ( - - - - - - - - - {user.name.slice(0, 2)} - - -
- {user.name} - {user.email} -
- -
-
- - -
- - - - {user.name.slice(0, 2)} - - -
- {user.name} - {user.email} -
-
-
- - - - - - - -
-
-
-
- ); -} +'use client'; + +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu'; +import { + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + useSidebar +} from '@/components/ui/sidebar'; +import { StorageService } from '@/services/storage'; +import { CommunityConfig, Config, revokeSession } from '@citizenwallet/sdk'; +import { Wallet } from "ethers"; +import { ChevronsUpDown, LogOut } from 'lucide-react'; +import { useSession as useNextAuthSession } from "next-auth/react"; +import { useRouter } from 'next/navigation'; +import { toast } from 'sonner'; +import { useSession } from 'state/session/action'; + +export function NavUser({ + user, + config +}: { + user: { + name: string; + email: string; + avatar: string; + }; + config?: Config; +}) { + const { isMobile } = useSidebar(); + const { data: session, update } = useNextAuthSession(); + const router = useRouter(); + const sessionActions = useSession(config as Config); + + + const removeSession = async () => { + + try { + + if (!config) { + return; + } + + const privateKey = sessionActions[1].storage.getKey("session_private_key"); + const account = await sessionActions[1].getAccountAddress(); + const communityConfig = new CommunityConfig(config); + const storageService = new StorageService(config.community.alias); + + if (!privateKey || !account) { + return; + } + + const signer = new Wallet(privateKey); + + const tx = await revokeSession({ + community: communityConfig, + signer, + account, + }); + + + if (!tx) { + toast.error("Signout failed"); + return; + } + sessionActions[1].clear(); + storageService.deleteKey("session_private_key"); + storageService.deleteKey("session_source_type"); + storageService.deleteKey("session_source_value"); + storageService.deleteKey("session_hash"); + + const removeChainIds = config?.community.profile.chain_id; + const updateChainIds = session?.user.chainIds?.filter((chainId: number) => chainId !== removeChainIds); + + //remove chainId from session + await update({ + chainIds: updateChainIds + }); + + toast.success("Signout successful"); + + } catch (error) { + console.error(error); + toast.error("Signout failed"); + + } finally { + router.push("/"); + } + + + } + + return ( + + + + + + + + + {user.name.slice(0, 2)} + + +
+ {user.name} + {user.email} +
+ +
+
+ + +
+ + + + {user.name.slice(0, 2)} + + +
+ {user.name} + {user.email} +
+
+
+ + + + + + + +
+
+
+
+ ); +} diff --git a/app/[alias]/(dashboard)/admins/_table/admins-client-table.tsx b/app/[alias]/(dashboard)/admins/_table/admins-client-table.tsx index 1cb03b69..5efae925 100644 --- a/app/[alias]/(dashboard)/admins/_table/admins-client-table.tsx +++ b/app/[alias]/(dashboard)/admins/_table/admins-client-table.tsx @@ -1,59 +1,59 @@ -'use client'; - -import { DataTable } from '@/components/ui/data-table'; -import { - UserT, - UserCommunityAccessT, - CommunityAccessRoleT -} from '@/services/top-db/users'; - -import { createColumns } from './columns'; -import { useOptimistic, useTransition } from 'react'; -import { removeUserFromCommunityAction } from '@/app/[alias]/(dashboard)/admins/action'; - -interface AdminsClientTableProps { - data: (UserCommunityAccessT & { user: UserT })[]; - communityRole?: CommunityAccessRoleT; - alias: string; -} - -export function AdminsClientTable({ - data, - alias, - communityRole -}: AdminsClientTableProps) { - const [isPending, startTransition] = useTransition(); - const [optimisticAdmins, addOptimisticRemoval] = useOptimistic( - data, - (state, adminIdToRemove: number) => - state.filter((admin) => admin.user_id !== adminIdToRemove) - ); - - const handleRemoveAdmin = async (args: { userId: number }) => { - const { userId } = args; - - startTransition(async () => { - // Optimistically remove the admin from the UI - addOptimisticRemoval(userId); - - try { - await removeUserFromCommunityAction({ - userIdToRemove: userId, - alias: alias - }); - } catch (error) { - // If the removal fails, the state will automatically revert - console.error('Failed to remove admin:', error); - } - }); - }; - - const columns = createColumns({ - communityRole: communityRole, - alias: alias, - onRemoveAdmin: handleRemoveAdmin, - isPending: isPending - }); - - return ; -} +'use client'; + +import { DataTable } from '@/components/ui/data-table'; +import { + UserT, + UserCommunityAccessT, + CommunityAccessRoleT +} from '@/services/top-db/users'; + +import { createColumns } from './columns'; +import { useOptimistic, useTransition } from 'react'; +import { removeUserFromCommunityAction } from '@/app/[alias]/(dashboard)/admins/action'; + +interface AdminsClientTableProps { + data: (UserCommunityAccessT & { user: UserT })[]; + communityRole?: CommunityAccessRoleT; + alias: string; +} + +export function AdminsClientTable({ + data, + alias, + communityRole +}: AdminsClientTableProps) { + const [isPending, startTransition] = useTransition(); + const [optimisticAdmins, addOptimisticRemoval] = useOptimistic( + data, + (state, adminIdToRemove: number) => + state.filter((admin) => admin.user_id !== adminIdToRemove) + ); + + const handleRemoveAdmin = async (args: { userId: number }) => { + const { userId } = args; + + startTransition(async () => { + // Optimistically remove the admin from the UI + addOptimisticRemoval(userId); + + try { + await removeUserFromCommunityAction({ + userIdToRemove: userId, + alias: alias + }); + } catch (error) { + // If the removal fails, the state will automatically revert + console.error('Failed to remove admin:', error); + } + }); + }; + + const columns = createColumns({ + communityRole: communityRole, + alias: alias, + onRemoveAdmin: handleRemoveAdmin, + isPending: isPending + }); + + return ; +} diff --git a/app/[alias]/(dashboard)/admins/_table/admins-table.tsx b/app/[alias]/(dashboard)/admins/_table/admins-table.tsx index b0557b4d..3eacb0b7 100644 --- a/app/[alias]/(dashboard)/admins/_table/admins-table.tsx +++ b/app/[alias]/(dashboard)/admins/_table/admins-table.tsx @@ -1,70 +1,70 @@ -import UrlPagination from '@/components/custom/pagination-via-url'; -import { getUsersOfCommunityAction } from '@/app/[alias]/(dashboard)/admins/action'; -import { AdminsClientTable } from './admins-client-table'; -import { getAuthUserRoleInCommunityAction } from '@/app/_actions/user-actions'; -import { fetchCommunityByAliasAction } from '@/app/_actions/community-actions'; -import { Separator } from '@/components/ui/separator'; -import AddAdmin from '@/app/[alias]/(dashboard)/admins/_components/add-admin'; - -interface AdminsTableProps { - alias: string; -} - -export default async function AdminsTable({ alias }: AdminsTableProps) { - const { community: config } = await fetchCommunityByAliasAction(alias); - - const [usersResult, roleResult] = await Promise.allSettled([ - getUsersOfCommunityAction({ - alias - }), - getAuthUserRoleInCommunityAction({ - alias - }) - ]); - - const data = usersResult.status === 'fulfilled' ? usersResult.value.data : []; - - const totalCount = - usersResult.status === 'fulfilled' ? usersResult.value.count : 0; - - const communityRole = - roleResult.status === 'fulfilled' ? roleResult.value : undefined; - - const totalPages = 1; - - return ( -
-
-
-

Admins

-

{config.community.name}

-
- - {communityRole === 'owner' && ( -
- -
- )} -
- -
-
- -
-
- - - -
-

- Total: {totalCount} -

- -
-
- ); -} +import UrlPagination from '@/components/custom/pagination-via-url'; +import { getUsersOfCommunityAction } from '@/app/[alias]/(dashboard)/admins/action'; +import { AdminsClientTable } from './admins-client-table'; +import { getAuthUserRoleInCommunityAction } from '@/app/_actions/user-actions'; +import { fetchCommunityByAliasAction } from '@/app/_actions/community-actions'; +import { Separator } from '@/components/ui/separator'; +import AddAdmin from '@/app/[alias]/(dashboard)/admins/_components/add-admin'; + +interface AdminsTableProps { + alias: string; +} + +export default async function AdminsTable({ alias }: AdminsTableProps) { + const { community: config } = await fetchCommunityByAliasAction(alias); + + const [usersResult, roleResult] = await Promise.allSettled([ + getUsersOfCommunityAction({ + alias + }), + getAuthUserRoleInCommunityAction({ + alias + }) + ]); + + const data = usersResult.status === 'fulfilled' ? usersResult.value.data : []; + + const totalCount = + usersResult.status === 'fulfilled' ? usersResult.value.count : 0; + + const communityRole = + roleResult.status === 'fulfilled' ? roleResult.value : undefined; + + const totalPages = 1; + + return ( +
+
+
+

Admins

+

{config.community.name}

+
+ + {communityRole === 'owner' && ( +
+ +
+ )} +
+ +
+
+ +
+
+ + + +
+

+ Total: {totalCount} +

+ +
+
+ ); +} diff --git a/app/[alias]/(dashboard)/admins/_table/columns.tsx b/app/[alias]/(dashboard)/admins/_table/columns.tsx index ce83e974..b0832278 100644 --- a/app/[alias]/(dashboard)/admins/_table/columns.tsx +++ b/app/[alias]/(dashboard)/admins/_table/columns.tsx @@ -1,209 +1,209 @@ -'use client'; -import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; - -import { - UserT, - UserCommunityAccessT, - CommunityAccessRoleT -} from '@/services/top-db/users'; -import { ColumnDef } from '@tanstack/react-table'; -import { Skeleton } from '@/components/ui/skeleton'; -import { cn } from '@/lib/utils'; -import { Button } from '@/components/ui/button'; -import { Trash2 } from 'lucide-react'; -import { - Dialog, - DialogClose, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle -} from '@/components/ui/dialog'; -import { useState } from 'react'; -import { toast } from 'sonner'; - -interface CreateColumnsProps { - communityRole?: CommunityAccessRoleT; - alias: string; - onRemoveAdmin: (args: { userId: number }) => Promise; - isPending: boolean; -} - -export const createColumns = ( - props: CreateColumnsProps -): ColumnDef[] => { - const baseColumns: ColumnDef[] = [ - { - header: 'Member', - cell: ({ row }) => { - const { user } = row.original; - const { avatar, name, email } = user; - return ( -
- - - {name?.slice(0, 2) ?? ''} - -
- {name} - {email} -
-
- ); - } - }, - { - header: 'Role', - accessorKey: 'role', - cell: ({ row }) => { - const role = row.original.role; - return ( -
- - {role} - -
- ); - } - }, - { - header: 'Created at', - accessorKey: 'created_at', - cell: ({ row }) => { - const createdAt = new Date(row.original.created_at); - return ( -
- - {createdAt.toLocaleString()} - -
- ); - } - } - ]; - - const ownerColumns: ColumnDef[] = [ - { - id: 'remove', - cell: function RemoveCell({ row }) { - const [isDialogOpen, setIsDialogOpen] = useState(false); - - const { - user_id, - user: { name } - } = row.original; - - const handleOpenDialog = () => { - setIsDialogOpen(true); - }; - - const handleCloseDialog = () => { - setIsDialogOpen(false); - }; - - const onRemoveAdmin = async () => { - try { - await props.onRemoveAdmin({ - userId: user_id - }); - handleCloseDialog(); - toast.success('Admin removed successfully'); - } catch (error) { - if (error instanceof Error) { - toast.error(error.message); - } else { - toast.error('Could not remove admin'); - } - } - }; - - return ( - <> - - - - - - Remove Admin - - Are you sure you want to remove{' '} - {name} as an admin? - - - - - - - - - - - - ); - } - } - ]; - - return props.communityRole === 'owner' - ? [...baseColumns, ...ownerColumns] - : baseColumns; -}; - -export const skeletonColumns: ColumnDef< - UserCommunityAccessT & { admin: UserT } ->[] = [ - { - header: 'Member', - cell: () => ( -
- -
- {/* name */} - {/* email */} -
-
- ) - }, - { - header: 'Created at', - cell: () => ( -
- -
- ) - } -]; - -export const placeholderData: (UserCommunityAccessT & { admin: UserT })[] = - Array(5); +'use client'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; + +import { + UserT, + UserCommunityAccessT, + CommunityAccessRoleT +} from '@/services/top-db/users'; +import { ColumnDef } from '@tanstack/react-table'; +import { Skeleton } from '@/components/ui/skeleton'; +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; +import { Trash2 } from 'lucide-react'; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog'; +import { useState } from 'react'; +import { toast } from 'sonner'; + +interface CreateColumnsProps { + communityRole?: CommunityAccessRoleT; + alias: string; + onRemoveAdmin: (args: { userId: number }) => Promise; + isPending: boolean; +} + +export const createColumns = ( + props: CreateColumnsProps +): ColumnDef[] => { + const baseColumns: ColumnDef[] = [ + { + header: 'Member', + cell: ({ row }) => { + const { user } = row.original; + const { avatar, name, email } = user; + return ( +
+ + + {name?.slice(0, 2) ?? ''} + +
+ {name} + {email} +
+
+ ); + } + }, + { + header: 'Role', + accessorKey: 'role', + cell: ({ row }) => { + const role = row.original.role; + return ( +
+ + {role} + +
+ ); + } + }, + { + header: 'Created at', + accessorKey: 'created_at', + cell: ({ row }) => { + const createdAt = new Date(row.original.created_at); + return ( +
+ + {createdAt.toLocaleString()} + +
+ ); + } + } + ]; + + const ownerColumns: ColumnDef[] = [ + { + id: 'remove', + cell: function RemoveCell({ row }) { + const [isDialogOpen, setIsDialogOpen] = useState(false); + + const { + user_id, + user: { name } + } = row.original; + + const handleOpenDialog = () => { + setIsDialogOpen(true); + }; + + const handleCloseDialog = () => { + setIsDialogOpen(false); + }; + + const onRemoveAdmin = async () => { + try { + await props.onRemoveAdmin({ + userId: user_id + }); + handleCloseDialog(); + toast.success('Admin removed successfully'); + } catch (error) { + if (error instanceof Error) { + toast.error(error.message); + } else { + toast.error('Could not remove admin'); + } + } + }; + + return ( + <> + + + + + + Remove Admin + + Are you sure you want to remove{' '} + {name} as an admin? + + + + + + + + + + + + ); + } + } + ]; + + return props.communityRole === 'owner' + ? [...baseColumns, ...ownerColumns] + : baseColumns; +}; + +export const skeletonColumns: ColumnDef< + UserCommunityAccessT & { admin: UserT } +>[] = [ + { + header: 'Member', + cell: () => ( +
+ +
+ {/* name */} + {/* email */} +
+
+ ) + }, + { + header: 'Created at', + cell: () => ( +
+ +
+ ) + } +]; + +export const placeholderData: (UserCommunityAccessT & { admin: UserT })[] = + Array(5); diff --git a/app/[alias]/(dashboard)/admins/add/actions.ts b/app/[alias]/(dashboard)/admins/add/actions.ts index 6abeb546..9fa54630 100644 --- a/app/[alias]/(dashboard)/admins/add/actions.ts +++ b/app/[alias]/(dashboard)/admins/add/actions.ts @@ -1,123 +1,123 @@ -'use server'; - -import { getServiceRoleClient as getTopDbClient } from '@/services/top-db'; -import { z } from 'zod'; -import { inviteAdminFormSchema } from './form-schema'; - -import { addUserToApp, addUserToCommunity } from '@/services/top-db/users'; -import { saveOTP } from '@/services/top-db/otp'; - -import { sendCommunityInvitationEmail } from '@/services/brevo'; -import { generateOTP } from '@/lib/utils'; -import { getAuthUserRoleInCommunityAction } from '@/app/_actions/user-actions'; -import { Config } from '@citizenwallet/sdk'; -import { revalidatePath } from 'next/cache'; - -export async function submitAdminInvitation(args: { - formData: z.infer; - chainId: number; -}) { - const { formData, chainId } = args; - - const result = inviteAdminFormSchema.safeParse(formData); - - if (!result.success) { - console.error(result.error); - throw new Error('Invalid form data'); - } - - const userRole = await getAuthUserRoleInCommunityAction({ - alias: formData.alias - }); - - if (userRole !== 'owner') { - throw new Error('You are not authorized to add admins to this community'); - } - - const { email, name, avatar, alias, role } = result.data; - - const topDbClient = getTopDbClient(); - - const { data: user, error: addUserToAppError } = await addUserToApp({ - client: topDbClient, - data: { - email, - name, - avatar: avatar ?? null - } - }); - - if (addUserToAppError) { - console.error(addUserToAppError); - throw new Error('Failed to add user to app'); - } - - const { error: addUserToCommunityError } = await addUserToCommunity({ - client: topDbClient, - data: { - user_id: user.id, - alias, - role, - chain_id: chainId - } - }); - - if (addUserToCommunityError) { - console.error(addUserToCommunityError); - throw new Error('Failed to add user to community'); - } - - revalidatePath(`/${alias}/admins`); -} - -export async function sendAdminSignInInvitationAction(args: { - email: string; - config: Config; -}) { - const { email, config } = args; - - const { alias: communityAlias, name: communityName } = config.community; - - const userRole = await getAuthUserRoleInCommunityAction({ - alias: communityAlias - }); - - if (userRole !== 'owner') { - throw new Error('You are not authorized to add admins to this community'); - } - - const topDbClient = getTopDbClient(); - const otp = generateOTP(); - - // brevo - try { - sendCommunityInvitationEmail({ - email, - otp, - communityAlias, - communityName - }); - } catch (error) { - console.error(error); - // Preserve the original error message - if (error instanceof Error) { - throw error; - } - throw new Error('Failed to send invitation email'); - } - - // db - const { error: saveOTPError } = await saveOTP({ - client: topDbClient, - data: { - source: email, - code: otp, - source_type: 'email' - } - }); - - if (saveOTPError) { - console.error(saveOTPError); - throw new Error('Failed to save OTP'); - } -} +'use server'; + +import { getServiceRoleClient as getTopDbClient } from '@/services/top-db'; +import { z } from 'zod'; +import { inviteAdminFormSchema } from './form-schema'; + +import { addUserToApp, addUserToCommunity } from '@/services/top-db/users'; +import { saveOTP } from '@/services/top-db/otp'; + +import { sendCommunityInvitationEmail } from '@/services/brevo'; +import { generateOTP } from '@/lib/utils'; +import { getAuthUserRoleInCommunityAction } from '@/app/_actions/user-actions'; +import { Config } from '@citizenwallet/sdk'; +import { revalidatePath } from 'next/cache'; + +export async function submitAdminInvitation(args: { + formData: z.infer; + chainId: number; +}) { + const { formData, chainId } = args; + + const result = inviteAdminFormSchema.safeParse(formData); + + if (!result.success) { + console.error(result.error); + throw new Error('Invalid form data'); + } + + const userRole = await getAuthUserRoleInCommunityAction({ + alias: formData.alias + }); + + if (userRole !== 'owner') { + throw new Error('You are not authorized to add admins to this community'); + } + + const { email, name, avatar, alias, role } = result.data; + + const topDbClient = getTopDbClient(); + + const { data: user, error: addUserToAppError } = await addUserToApp({ + client: topDbClient, + data: { + email, + name, + avatar: avatar ?? null + } + }); + + if (addUserToAppError) { + console.error(addUserToAppError); + throw new Error('Failed to add user to app'); + } + + const { error: addUserToCommunityError } = await addUserToCommunity({ + client: topDbClient, + data: { + user_id: user.id, + alias, + role, + chain_id: chainId + } + }); + + if (addUserToCommunityError) { + console.error(addUserToCommunityError); + throw new Error('Failed to add user to community'); + } + + revalidatePath(`/${alias}/admins`); +} + +export async function sendAdminSignInInvitationAction(args: { + email: string; + config: Config; +}) { + const { email, config } = args; + + const { alias: communityAlias, name: communityName } = config.community; + + const userRole = await getAuthUserRoleInCommunityAction({ + alias: communityAlias + }); + + if (userRole !== 'owner') { + throw new Error('You are not authorized to add admins to this community'); + } + + const topDbClient = getTopDbClient(); + const otp = generateOTP(); + + // brevo + try { + sendCommunityInvitationEmail({ + email, + otp, + communityAlias, + communityName + }); + } catch (error) { + console.error(error); + // Preserve the original error message + if (error instanceof Error) { + throw error; + } + throw new Error('Failed to send invitation email'); + } + + // db + const { error: saveOTPError } = await saveOTP({ + client: topDbClient, + data: { + source: email, + code: otp, + source_type: 'email' + } + }); + + if (saveOTPError) { + console.error(saveOTPError); + throw new Error('Failed to save OTP'); + } +} diff --git a/app/[alias]/(dashboard)/admins/add/page.tsx b/app/[alias]/(dashboard)/admins/add/page.tsx index 219072b1..e0b15af0 100644 --- a/app/[alias]/(dashboard)/admins/add/page.tsx +++ b/app/[alias]/(dashboard)/admins/add/page.tsx @@ -1,40 +1,40 @@ -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle -} from '@/components/ui/card'; -import InviteAdminForm from './form'; -import { fetchCommunityByAliasAction } from '@/app/_actions/community-actions'; -import { getAuthUserRoleInCommunityAction } from '@/app/_actions/user-actions'; -import { redirect } from 'next/navigation'; - -export default async function Page(props: { - params: Promise<{ alias: string }>; -}) { - const { alias } = await props.params; - const { community: config } = await fetchCommunityByAliasAction(alias); - - const authRole = await getAuthUserRoleInCommunityAction({ - alias - }); - - if (authRole !== 'owner') { - redirect(`/${alias}/admins`); - } - - return ( -
- - - Invite admin - Invite an admin to your community - - - - - -
- ); -} +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle +} from '@/components/ui/card'; +import InviteAdminForm from './form'; +import { fetchCommunityByAliasAction } from '@/app/_actions/community-actions'; +import { getAuthUserRoleInCommunityAction } from '@/app/_actions/user-actions'; +import { redirect } from 'next/navigation'; + +export default async function Page(props: { + params: Promise<{ alias: string }>; +}) { + const { alias } = await props.params; + const { community: config } = await fetchCommunityByAliasAction(alias); + + const authRole = await getAuthUserRoleInCommunityAction({ + alias + }); + + if (authRole !== 'owner') { + redirect(`/${alias}/admins`); + } + + return ( +
+ + + Invite admin + Invite an admin to your community + + + + + +
+ ); +} diff --git a/app/[alias]/(dashboard)/layout.tsx b/app/[alias]/(dashboard)/layout.tsx index 164620b3..4025521e 100644 --- a/app/[alias]/(dashboard)/layout.tsx +++ b/app/[alias]/(dashboard)/layout.tsx @@ -1,87 +1,87 @@ -import { - SidebarInset, - SidebarProvider, - SidebarTrigger -} from '@/components/ui/sidebar'; -import { AppSidebar } from './_components/app-sidebar'; -import { fetchCommunitiesAction } from '@/app/_actions/community-actions'; -import { redirect } from 'next/navigation'; -import { getAuthUserAction } from '@/app/_actions/user-actions'; -import { getCommunity } from '@/services/cw'; - -export default async function DashboardLayout({ - children, - params -}: { - children: React.ReactNode; - params: Promise<{ alias: string }>; -}) { - const { alias } = await params; - let hasAccess = false; - let isAdmin = false; - - - const { community } = await getCommunity(alias); - const community_chain_id = community.community.primary_token.chain_id; - - const response = await getAuthUserAction({ chain_id: community_chain_id }); - if (!response || !response.data) { - redirect(`/${alias}/login`); - } - const { data: user } = response; - - - const { role } = user; - const accessList = user?.users_community_access.map((access) => access) ?? []; - - if (role === 'user' && accessList.length < 1) { - redirect('/'); - } - - - if (role === 'user' && accessList.length > 0) { - const chain_id = accessList[0].chain_id; - - if (chain_id == community_chain_id && accessList[0].alias == alias) { - hasAccess = true; - } - } - - if (role === 'user' && accessList.length > 0) { - accessList.forEach(access => { - if (access.alias === alias && access.role === 'owner') { - isAdmin = true; - } - }); - } - - if (role === 'admin') { - hasAccess = true; - isAdmin = true; - } - - - const { communities } = await fetchCommunitiesAction({ alias }); - - return ( - - - -
-
- -
-
-
- {children} -
-
-
- ); -} +import { + SidebarInset, + SidebarProvider, + SidebarTrigger +} from '@/components/ui/sidebar'; +import { AppSidebar } from './_components/app-sidebar'; +import { fetchCommunitiesAction } from '@/app/_actions/community-actions'; +import { redirect } from 'next/navigation'; +import { getAuthUserAction } from '@/app/_actions/user-actions'; +import { getCommunity } from '@/services/cw'; + +export default async function DashboardLayout({ + children, + params +}: { + children: React.ReactNode; + params: Promise<{ alias: string }>; +}) { + const { alias } = await params; + let hasAccess = false; + let isAdmin = false; + + + const { community } = await getCommunity(alias); + const community_chain_id = community.community.primary_token.chain_id; + + const response = await getAuthUserAction({ chain_id: community_chain_id }); + if (!response || !response.data) { + redirect(`/${alias}/login`); + } + const { data: user } = response; + + + const { role } = user; + const accessList = user?.users_community_access.map((access) => access) ?? []; + + if (role === 'user' && accessList.length < 1) { + redirect('/'); + } + + + if (role === 'user' && accessList.length > 0) { + const chain_id = accessList[0].chain_id; + + if (chain_id == community_chain_id && accessList[0].alias == alias) { + hasAccess = true; + } + } + + if (role === 'user' && accessList.length > 0) { + accessList.forEach(access => { + if (access.alias === alias && access.role === 'owner') { + isAdmin = true; + } + }); + } + + if (role === 'admin') { + hasAccess = true; + isAdmin = true; + } + + + const { communities } = await fetchCommunitiesAction({ alias }); + + return ( + + + +
+
+ +
+
+
+ {children} +
+
+
+ ); +} diff --git a/app/[alias]/(dashboard)/members/[account]/action.ts b/app/[alias]/(dashboard)/members/[account]/action.ts index a3829cb5..88e7e0f0 100644 --- a/app/[alias]/(dashboard)/members/[account]/action.ts +++ b/app/[alias]/(dashboard)/members/[account]/action.ts @@ -1,156 +1,156 @@ -'use server'; - -import { - getAuthUserRoleInAppAction, - getAuthUserRoleInCommunityAction -} from '@/app/_actions/user-actions'; -import { pinFileToIPFS, pinJSONToIPFS, unpin } from '@/services/pinata/pinata'; -import { - BundlerService, - CommunityConfig, - Config, - waitForTxSuccess -} from '@citizenwallet/sdk'; -import { Wallet } from 'ethers'; -import { getServiceRoleClient } from '@/services/chain-db'; -import { - MemberT, - removeMember, - updateMember -} from '@/services/chain-db/members'; -import { revalidatePath } from 'next/cache'; - -export type Profile = Pick< - MemberT, - | 'account' - | 'description' - | 'image' - | 'image_medium' - | 'image_small' - | 'name' - | 'username' ->; - -function convertIpfsUrl(ipfsUrl: string) { - if (ipfsUrl.startsWith('ipfs://')) { - return ipfsUrl.replace( - 'ipfs://', - 'https://ipfs.internal.citizenwallet.xyz/' - ); - } - return ipfsUrl; -} - -export async function updateProfileImageAction(file: File, alias: string) { - const roleInApp = await getAuthUserRoleInAppAction(); - const roleResult = await getAuthUserRoleInCommunityAction({ alias }); - - if (roleInApp != 'admin' && roleResult != 'owner') { - throw new Error('You are not authorized to update profile image'); - } - - const result = await pinFileToIPFS(file); - return result; -} - -export async function updateProfileAction( - profile: Profile, - alias: string, - config: Config -) { - const roleInApp = await getAuthUserRoleInAppAction(); - const roleResult = await getAuthUserRoleInCommunityAction({ alias }); - - if (roleInApp != 'admin' && roleResult != 'owner') { - throw new Error('You are not authorized to update profile image'); - } - - const result = await pinJSONToIPFS(profile); - const profileCid = result.IpfsHash; - - const community = new CommunityConfig(config); - const bundler = new BundlerService(community); - - const signer = new Wallet(process.env.SERVER_PRIVATE_KEY as string); - const signerAccountAddress = process.env.SERVER_ACCOUNT_ADDRESS as string; - - const account = profile.account; - const username = profile.username; - - const txHash = await bundler.setProfile( - signer, - signerAccountAddress, - account, - username, - profileCid - ); - const isSuccess = await waitForTxSuccess(community, txHash); - - if (isSuccess) { - const supabase = getServiceRoleClient(config.community.profile.chain_id); - const profileContract = config.community.profile.address; - - //convert ipfs url to https url - profile.image = convertIpfsUrl(profile.image); - profile.image_medium = convertIpfsUrl(profile.image_medium); - profile.image_small = convertIpfsUrl(profile.image_small); - - await updateMember({ - client: supabase, - account, - profileContract, - profile - }); - - revalidatePath(`/${alias}/members`, 'page'); - } -} - -export async function deleteProfileAction( - imageCid: string, - alias: string, - config: Config, - account: string -) { - const roleInApp = await getAuthUserRoleInAppAction(); - const roleResult = await getAuthUserRoleInCommunityAction({ alias }); - - if (roleInApp != 'admin' && roleResult != 'owner') { - throw new Error('You are not authorized to update profile image'); - } - - try { - //unpin profile image - const cid = imageCid.split('/').pop(); - await unpin(cid as string); - - const community = new CommunityConfig(config); - const bundler = new BundlerService(community); - - const signer = new Wallet(process.env.SERVER_PRIVATE_KEY as string); - const signerAccountAddress = process.env.SERVER_ACCOUNT_ADDRESS as string; - - const txHash = await bundler.burnProfile( - signer, - signerAccountAddress, - account - ); - - const isSuccess = await waitForTxSuccess(community, txHash); - - if (isSuccess) { - const supabase = getServiceRoleClient(config.community.profile.chain_id); - const profileContract = config.community.profile.address; - - await removeMember({ - client: supabase, - account, - profileContract - }); - - revalidatePath(`/${alias}/members`, 'page'); - } - } catch (error) { - console.error(error); - } -} +'use server'; + +import { + getAuthUserRoleInAppAction, + getAuthUserRoleInCommunityAction +} from '@/app/_actions/user-actions'; +import { pinFileToIPFS, pinJSONToIPFS, unpin } from '@/services/pinata/pinata'; +import { + BundlerService, + CommunityConfig, + Config, + waitForTxSuccess +} from '@citizenwallet/sdk'; +import { Wallet } from 'ethers'; +import { getServiceRoleClient } from '@/services/chain-db'; +import { + MemberT, + removeMember, + updateMember +} from '@/services/chain-db/members'; +import { revalidatePath } from 'next/cache'; + +export type Profile = Pick< + MemberT, + | 'account' + | 'description' + | 'image' + | 'image_medium' + | 'image_small' + | 'name' + | 'username' +>; + +function convertIpfsUrl(ipfsUrl: string) { + if (ipfsUrl.startsWith('ipfs://')) { + return ipfsUrl.replace( + 'ipfs://', + 'https://ipfs.internal.citizenwallet.xyz/' + ); + } + return ipfsUrl; +} + +export async function updateProfileImageAction(file: File, alias: string) { + const roleInApp = await getAuthUserRoleInAppAction(); + const roleResult = await getAuthUserRoleInCommunityAction({ alias }); + + if (roleInApp != 'admin' && roleResult != 'owner') { + throw new Error('You are not authorized to update profile image'); + } + + const result = await pinFileToIPFS(file); + return result; +} + +export async function updateProfileAction( + profile: Profile, + alias: string, + config: Config +) { + const roleInApp = await getAuthUserRoleInAppAction(); + const roleResult = await getAuthUserRoleInCommunityAction({ alias }); + + if (roleInApp != 'admin' && roleResult != 'owner') { + throw new Error('You are not authorized to update profile image'); + } + + const result = await pinJSONToIPFS(profile); + const profileCid = result.IpfsHash; + + const community = new CommunityConfig(config); + const bundler = new BundlerService(community); + + const signer = new Wallet(process.env.SERVER_PRIVATE_KEY as string); + const signerAccountAddress = process.env.SERVER_ACCOUNT_ADDRESS as string; + + const account = profile.account; + const username = profile.username; + + const txHash = await bundler.setProfile( + signer, + signerAccountAddress, + account, + username, + profileCid + ); + const isSuccess = await waitForTxSuccess(community, txHash); + + if (isSuccess) { + const supabase = getServiceRoleClient(config.community.profile.chain_id); + const profileContract = config.community.profile.address; + + //convert ipfs url to https url + profile.image = convertIpfsUrl(profile.image); + profile.image_medium = convertIpfsUrl(profile.image_medium); + profile.image_small = convertIpfsUrl(profile.image_small); + + await updateMember({ + client: supabase, + account, + profileContract, + profile + }); + + revalidatePath(`/${alias}/members`, 'page'); + } +} + +export async function deleteProfileAction( + imageCid: string, + alias: string, + config: Config, + account: string +) { + const roleInApp = await getAuthUserRoleInAppAction(); + const roleResult = await getAuthUserRoleInCommunityAction({ alias }); + + if (roleInApp != 'admin' && roleResult != 'owner') { + throw new Error('You are not authorized to update profile image'); + } + + try { + //unpin profile image + const cid = imageCid.split('/').pop(); + await unpin(cid as string); + + const community = new CommunityConfig(config); + const bundler = new BundlerService(community); + + const signer = new Wallet(process.env.SERVER_PRIVATE_KEY as string); + const signerAccountAddress = process.env.SERVER_ACCOUNT_ADDRESS as string; + + const txHash = await bundler.burnProfile( + signer, + signerAccountAddress, + account + ); + + const isSuccess = await waitForTxSuccess(community, txHash); + + if (isSuccess) { + const supabase = getServiceRoleClient(config.community.profile.chain_id); + const profileContract = config.community.profile.address; + + await removeMember({ + client: supabase, + account, + profileContract + }); + + revalidatePath(`/${alias}/members`, 'page'); + } + } catch (error) { + console.error(error); + } +} diff --git a/app/[alias]/(dashboard)/members/[account]/add/page.tsx b/app/[alias]/(dashboard)/members/[account]/add/page.tsx index 5da17789..4595c631 100644 --- a/app/[alias]/(dashboard)/members/[account]/add/page.tsx +++ b/app/[alias]/(dashboard)/members/[account]/add/page.tsx @@ -1,51 +1,51 @@ -import { Skeleton } from '@/components/ui/skeleton'; -import { getCommunity } from '@/services/cw'; -import { Config } from '@citizenwallet/sdk'; -import { Suspense } from 'react'; -import Profile from './profile'; - -interface PageProps { - params: Promise<{ - account: string; - alias: string; - }>; -} - -export default async function page(props: PageProps) { - const { account, alias } = await props.params; - const { community: config } = await getCommunity(alias); - - return ( -
-
-
-

Member Profile

-

- {config.community.name} -

-
-
- -
-
- } - > - - -
-
-
- ); -} - -async function AsyncPage({ - config, - account -}: { - config: Config; - account: string; -}) { - return ; -} +import { Skeleton } from '@/components/ui/skeleton'; +import { getCommunity } from '@/services/cw'; +import { Config } from '@citizenwallet/sdk'; +import { Suspense } from 'react'; +import Profile from './profile'; + +interface PageProps { + params: Promise<{ + account: string; + alias: string; + }>; +} + +export default async function page(props: PageProps) { + const { account, alias } = await props.params; + const { community: config } = await getCommunity(alias); + + return ( +
+
+
+

Member Profile

+

+ {config.community.name} +

+
+
+ +
+
+ } + > + + +
+
+
+ ); +} + +async function AsyncPage({ + config, + account +}: { + config: Config; + account: string; +}) { + return ; +} diff --git a/app/[alias]/(dashboard)/members/[account]/add/profile.tsx b/app/[alias]/(dashboard)/members/[account]/add/profile.tsx index 92c9aefb..3754d048 100644 --- a/app/[alias]/(dashboard)/members/[account]/add/profile.tsx +++ b/app/[alias]/(dashboard)/members/[account]/add/profile.tsx @@ -1,264 +1,264 @@ -'use client'; - -import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; -import { Button } from '@/components/ui/button'; -import { Card, CardContent, CardFooter } from '@/components/ui/card'; -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage -} from '@/components/ui/form'; -import { Input } from '@/components/ui/input'; -import { Textarea } from '@/components/ui/textarea'; -import { - CommunityConfig, - Config, - checkUsernameAvailability -} from '@citizenwallet/sdk'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { Upload, User } from 'lucide-react'; -import { useRouter } from 'next/navigation'; -import { useEffect, useMemo, useRef, useState } from 'react'; -import { useForm } from 'react-hook-form'; -import { toast } from 'sonner'; -import { useDebounce } from 'use-debounce'; -import * as z from 'zod'; -import type { Profile } from '../action'; -import { updateProfileAction, updateProfileImageAction } from '../action'; - -const profileFormSchema = z.object({ - username: z.string().min(1, 'Username is required'), - name: z.string().optional(), - description: z.string().optional() -}); - -export default function Profile({ - config, - account -}: { - config: Config; - account: string; -}) { - const community = useMemo(() => new CommunityConfig(config), [config]); - const router = useRouter(); - - const [imageFile, setImageFile] = useState(null); - const [avatarUrl, setAvatarUrl] = useState(''); - const [isAvailable, setIsAvailable] = useState(true); - const [usernameEdit, setUsernameEdit] = useState(false); - const fileInputRef = useRef(null); - const [isLoading, setIsLoading] = useState(false); - - const form = useForm>({ - resolver: zodResolver(profileFormSchema), - defaultValues: { - username: '', - name: '', - description: '' - } - }); - - const username = form.watch('username'); - const [debouncedUsername] = useDebounce(username, 1000); - - useEffect(() => { - if (debouncedUsername && usernameEdit) { - const checkUsername = async () => { - try { - const isAvailable = await checkUsernameAvailability( - community, - debouncedUsername - ); - if (!isAvailable) { - toast.error('Username is already taken'); - setIsAvailable(false); - } else { - setIsAvailable(true); - } - } catch (error) { - console.error('Error checking username availability:', error); - } - }; - - checkUsername(); - } - }, [debouncedUsername, usernameEdit, community]); - - const handleImageUpload = (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (file) { - setImageFile(file); - const imageUrl = URL.createObjectURL(file); - setAvatarUrl(imageUrl); - } - }; - - const triggerFileInput = () => { - fileInputRef.current?.click(); - }; - - const onSubmit = async (values: z.infer) => { - try { - setIsLoading(true); - - if (!values.username || !values.name) { - toast.error('Please enter a username and name'); - setIsLoading(false); - return; - } - - if (!isAvailable) { - toast.error('Username is already taken, you cannot save it'); - setIsLoading(false); - return; - } - - // Default image - let cid = 'QmZjzYmcbxj6Yr9EBmuMu3knYd25oYvnTu92yLWhiajvMr'; - - if (imageFile) { - const response = await updateProfileImageAction( - imageFile, - community.community.alias - ); - cid = response.IpfsHash; - } - - const profile: Profile = { - account, - description: values.description || '', - image: `ipfs://${cid}`, - image_medium: `ipfs://${cid}`, - image_small: `ipfs://${cid}`, - name: values.name || '', - username: values.username - }; - - await updateProfileAction(profile, config.community.alias, config); - toast.success('Profile updated successfully'); - router.push(`/${config.community.alias}/members`); - } catch (error) { - console.error('Error adding member:', error); - toast.error('Error updating profile'); - } finally { - setIsLoading(false); - } - }; - - return ( - - -
- -
- - - - - - - - -
- -
-
- ( - - Username - - { - setUsernameEdit(true); - field.onChange(e); - }} - className={`bg-white ${isAvailable ? '' : 'border-red-500'}`} - /> - - - - )} - /> - ( - - Name - - - - - - )} - /> -
- ( - - Description - -