|
1 | 1 | 'use client'; |
2 | 2 |
|
3 | | -import React, { useCallback, useEffect, useRef, useState } from 'react'; |
| 3 | +import React, { useEffect, useState } from 'react'; |
4 | 4 | import { z } from 'zod'; |
5 | 5 | import { useStellarWallet } from '@/contexts/StellarWalletContext'; |
6 | 6 | import { getAdmin } from '@/lib/stellarContract'; |
7 | 7 | import LandingPage from '@/components/LandingPage'; |
8 | 8 |
|
9 | | -const stellarAddressSchema = z.string().length(56).startsWith('G'); |
| 9 | +/** Zod schema for validating a Stellar public key (56-char G-prefixed string). */ |
| 10 | +export const stellarAddressSchema = z.string().length(56).startsWith('G'); |
| 11 | + |
| 12 | +/** Inferred TypeScript type for a validated Stellar address. */ |
| 13 | +export type StellarAddress = z.infer<typeof stellarAddressSchema>; |
10 | 14 |
|
11 | 15 | interface AdminGuardProps { |
12 | 16 | children: React.ReactNode; |
13 | 17 | } |
14 | 18 |
|
15 | 19 | /** |
16 | 20 | * High-order component to guard admin routes. |
17 | | - * |
18 | | - * @param children - The components to render if authentication passes. |
19 | | - * |
20 | | - * @example |
21 | | - * ```tsx |
22 | | - * <AdminGuard> |
23 | | - * <AdminDashboard /> |
24 | | - * </AdminGuard> |
25 | | - * ``` |
26 | | - * |
27 | | - * Architecture: |
28 | | - * 1. **Session Check**: Verifies if a Stellar wallet is currently connected via `useStellarWallet`. |
29 | | - * 2. **Blockchain Veracity**: Fetches the authorized admin address directly from the on-chain smart contract |
30 | | - * using the `getAdmin()` helper. This bypasses local storage or session variables that could be tampered with. |
31 | | - * 3. **Identity Comparison**: Compares the connected `G...` address against the contract's reported admin. |
32 | | - * 4. **Offline Retry Queue**: When the network is unavailable the check is queued and automatically |
33 | | - * retried as soon as the browser comes back online, so admins are never permanently locked out |
34 | | - * by a transient connectivity loss. |
35 | | - * 5. **Conditional Rendering**: |
36 | | - * - If match: Renders `children`. |
37 | | - * - If mismatch or no wallet: Redirects to `LandingPage`. |
38 | | - * - If error: Displays a recovery UI with "Try Again" option. |
39 | | - * - If offline with queued retry: Displays an offline banner. |
40 | | - * |
41 | | - * This implementation ensures that administrative privileges are strictly tied to the on-chain state, |
42 | | - * providing a robust security layer against front-end spoofing. |
| 21 | + * Checks if the connected wallet address matches the admin address in the smart contract. |
43 | 22 | */ |
44 | 23 | export default function AdminGuard({ children }: AdminGuardProps) { |
45 | 24 | const { connection } = useStellarWallet(); |
46 | 25 | const [isAdmin, setIsAdmin] = useState<boolean | null>(null); |
47 | 26 | const [loading, setLoading] = useState(true); |
48 | 27 | const [error, setError] = useState<string | null>(null); |
49 | | - const [isOnline, setIsOnline] = useState( |
50 | | - typeof navigator !== 'undefined' ? navigator.onLine : true, |
51 | | - ); |
52 | | - const [retryQueued, setRetryQueued] = useState(false); |
53 | | - |
54 | | - // Stable ref so the online handler can call the latest checkAdmin without a |
55 | | - // stale closure, even if connection.address changes between renders. |
56 | | - const checkAdminRef = useRef<(() => Promise<void>) | undefined>(undefined); |
57 | | - |
58 | | - const checkAdmin = useCallback(async () => { |
59 | | - if (!navigator.onLine) { |
60 | | - setRetryQueued(true); |
61 | | - setLoading(false); |
62 | | - return; |
63 | | - } |
64 | 28 |
|
65 | | - setRetryQueued(false); |
66 | | - setLoading(true); |
67 | | - setError(null); |
68 | | - |
69 | | - if (!connection.address) { |
70 | | - setIsAdmin(false); |
71 | | - setLoading(false); |
72 | | - return; |
73 | | - } |
74 | | - |
75 | | - const connectedParsed = stellarAddressSchema.safeParse(connection.address); |
76 | | - if (!connectedParsed.success) { |
77 | | - console.error('Invalid connected wallet address format:', connectedParsed.error); |
78 | | - setError('Invalid wallet address format. Access denied.'); |
79 | | - setIsAdmin(false); |
80 | | - setLoading(false); |
81 | | - return; |
82 | | - } |
| 29 | + useEffect(() => { |
| 30 | + async function checkAdmin() { |
| 31 | + if (!connection.address) { |
| 32 | + setIsAdmin(false); |
| 33 | + setLoading(false); |
| 34 | + return; |
| 35 | + } |
83 | 36 |
|
84 | | - try { |
85 | | - const adminAddress = await getAdmin(); |
86 | | - const adminParsed = stellarAddressSchema.safeParse(adminAddress); |
87 | | - if (!adminParsed.success) { |
88 | | - console.error('Invalid admin address configured in contract:', adminParsed.error); |
89 | | - setError('Invalid contract configuration. Access denied.'); |
| 37 | + const connectedParsed = stellarAddressSchema.safeParse(connection.address); |
| 38 | + if (!connectedParsed.success) { |
| 39 | + console.error('Invalid connected wallet address format:', connectedParsed.error); |
| 40 | + setError('Invalid wallet address format. Access denied.'); |
90 | 41 | setIsAdmin(false); |
| 42 | + setLoading(false); |
91 | 43 | return; |
92 | 44 | } |
93 | 45 |
|
94 | | - setIsAdmin(connectedParsed.data === adminParsed.data); |
95 | | - } catch (err) { |
96 | | - console.error('Failed to verify admin status:', err); |
97 | | - setError('Failed to verify admin status. Please try again.'); |
98 | | - setIsAdmin(false); |
99 | | - } finally { |
100 | | - setLoading(false); |
| 46 | + try { |
| 47 | + const adminAddress = await getAdmin(); |
| 48 | + const adminParsed = stellarAddressSchema.safeParse(adminAddress); |
| 49 | + if (!adminParsed.success) { |
| 50 | + console.error('Invalid admin address configured in contract:', adminParsed.error); |
| 51 | + setError('Invalid contract configuration. Access denied.'); |
| 52 | + setIsAdmin(false); |
| 53 | + return; |
| 54 | + } |
| 55 | + |
| 56 | + setIsAdmin(connectedParsed.data === adminParsed.data); |
| 57 | + } catch (err) { |
| 58 | + console.error('Failed to verify admin status:', err); |
| 59 | + setError('Failed to verify admin status. Please try again.'); |
| 60 | + setIsAdmin(false); |
| 61 | + } finally { |
| 62 | + setLoading(false); |
| 63 | + } |
101 | 64 | } |
102 | | - }, [connection.address]); |
103 | | - |
104 | | - // Keep the ref in sync so the online handler always calls the latest version. |
105 | | - checkAdminRef.current = checkAdmin; |
106 | 65 |
|
107 | | - // Run admin check whenever the connected address changes. |
108 | | - useEffect(() => { |
109 | 66 | checkAdmin(); |
110 | | - }, [checkAdmin]); |
111 | | - |
112 | | - // #490: Scroll to top smoothly whenever admin access is granted so the |
113 | | - // dashboard renders from the top of the page, not wherever the user |
114 | | - // navigated from. Only fires when loading completes and access is confirmed. |
115 | | - useEffect(() => { |
116 | | - if (!loading && isAdmin) { |
117 | | - window.scrollTo({ top: 0, behavior: 'smooth' }); |
118 | | - } |
119 | | - }, [loading, isAdmin]); |
120 | | - |
121 | | - // Attach online/offline listeners once. |
122 | | - useEffect(() => { |
123 | | - const handleOnline = () => { |
124 | | - setIsOnline(true); |
125 | | - // Flush the queue: retry the admin check that was skipped while offline. |
126 | | - checkAdminRef.current?.(); |
127 | | - }; |
128 | | - const handleOffline = () => { |
129 | | - setIsOnline(false); |
130 | | - }; |
131 | | - |
132 | | - window.addEventListener('online', handleOnline); |
133 | | - window.addEventListener('offline', handleOffline); |
134 | | - return () => { |
135 | | - window.removeEventListener('online', handleOnline); |
136 | | - window.removeEventListener('offline', handleOffline); |
137 | | - }; |
138 | | - }, []); |
139 | | - |
140 | | - if (retryQueued && !isOnline) { |
141 | | - return ( |
142 | | - <div className="flex h-screen flex-col items-center justify-center bg-[var(--color-surface)] p-6 text-center"> |
143 | | - <svg |
144 | | - className="mx-auto mb-4 h-12 w-12" |
145 | | - style={{ color: 'var(--color-text-muted)' }} |
146 | | - fill="none" |
147 | | - viewBox="0 0 24 24" |
148 | | - stroke="currentColor" |
149 | | - aria-hidden="true" |
150 | | - > |
151 | | - <path |
152 | | - strokeLinecap="round" |
153 | | - strokeLinejoin="round" |
154 | | - strokeWidth={2} |
155 | | - d="M18.364 5.636a9 9 0 010 12.728M15.536 8.464a5 5 0 010 7.072M6.343 6.343a9 9 0 000 12.728m2.829-2.829a5 5 0 000-7.07" |
156 | | - /> |
157 | | - </svg> |
158 | | - <h2 |
159 | | - className="mb-2 text-xl font-bold" |
160 | | - style={{ color: 'var(--color-text-primary)' }} |
161 | | - > |
162 | | - You are offline |
163 | | - </h2> |
164 | | - <p |
165 | | - className="text-sm" |
166 | | - style={{ color: 'var(--color-text-muted)' }} |
167 | | - > |
168 | | - Admin verification will retry automatically when your connection is restored. |
169 | | - </p> |
170 | | - </div> |
171 | | - ); |
172 | | - } |
| 67 | + }, [connection.address]); |
173 | 68 |
|
174 | 69 | if (loading) { |
175 | 70 | return ( |
176 | | - <div className="flex h-screen items-center justify-center bg-[var(--color-surface)] text-[var(--color-text-primary)]"> |
| 71 | + <div className="theme-app flex h-screen items-center justify-center"> |
177 | 72 | <div |
178 | 73 | className="h-8 w-8 animate-spin rounded-full border-4" |
179 | 74 | style={{ |
180 | | - borderColor: 'var(--color-primary)', |
181 | | - borderTopColor: 'transparent', |
| 75 | + borderColor: 'var(--color-border)', |
| 76 | + borderTopColor: 'var(--color-primary)', |
182 | 77 | }} |
183 | | - ></div> |
184 | | - <span className="ml-3 font-medium" style={{ color: 'var(--color-text-primary)' }}> |
185 | | - Verifying admin access... |
186 | | - </span> |
| 78 | + /> |
| 79 | + <span className="theme-text-secondary ml-3 font-medium">Verifying admin access...</span> |
187 | 80 | </div> |
188 | 81 | ); |
189 | 82 | } |
190 | 83 |
|
191 | 84 | if (error) { |
192 | 85 | return ( |
193 | | - <div |
194 | | - className="flex h-screen flex-col items-center justify-center bg-[var(--color-surface)] text-[var(--color-text-primary)] p-6 text-center" |
195 | | - > |
| 86 | + <div className="theme-app flex h-screen flex-col items-center justify-center p-6 text-center"> |
196 | 87 | <div className="mb-4" style={{ color: 'var(--color-danger)' }}> |
197 | | - <svg className="mx-auto h-12 w-12" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> |
| 88 | + <svg className="mx-auto h-12 w-12" fill="none" viewBox="0 0 24 24" stroke="currentColor"> |
198 | 89 | <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /> |
199 | 90 | </svg> |
200 | 91 | </div> |
201 | | - <h2 className="mb-2 text-xl font-bold" style={{ color: 'var(--color-text-primary)' }}> |
202 | | - {error} |
203 | | - </h2> |
| 92 | + <h2 className="theme-text-primary text-xl font-bold mb-2">{error}</h2> |
204 | 93 | <button |
205 | 94 | onClick={() => window.location.reload()} |
206 | 95 | className="theme-primary-button rounded-lg px-4 py-2 text-sm font-medium" |
|
0 commit comments