summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPawel Zelawski <pawel.zelawski@outlook.com>2025-04-10 10:55:22 +0200
committerPawel Zelawski <pawel.zelawski@outlook.com>2025-04-10 10:55:22 +0200
commitc852aa92f84d0c18b1bd7361163498a542461d45 (patch)
treefeb1b34c35c4e7a2d35c70dba35b3367c7f638b0
parent326caf949ec8622c04b0e3352c5eac5370f161e4 (diff)
feat(client): implement react frontend application
- Created main entry point `src/client/main.tsx`. - Created main `App` component `src/client/App.tsx`. - Implemented state management (`initial`, `waiting`, `success`, `failed`) using `useState`. - Implemented `handleStart` to call backend `/api/digiid/start` and transition to `waiting` state. - Implemented `useEffect` hook for polling backend `/api/digiid/status/:sessionId` in `waiting` state. - Updates UI state based on polling response (`success`/`failed`). - Handles polling errors and cleanup. - Implemented `handleReset` to return to `initial` state. - Implemented views for each state: - `initial`: Welcome message, Start button. - `waiting`: QR code display, status message, Cancel button. - `success`: Success message, verified address, address type, Start Over button. - `failed`: Failure message, error details, Try Again button. - Added client-side address type helper `getDigiByteAddressType` in `src/client/utils.ts`. - Added basic CSS styling in `src/client/index.css` with light/dark mode support. - Added frontend-related scripts (`dev:frontend`, `dev`, `build`, `lint`, `preview`) to `package.json`. - Fixed linter type issue with `setInterval` return value.
-rw-r--r--package.json7
-rw-r--r--src/client/App.tsx193
-rw-r--r--src/client/index.css115
-rw-r--r--src/client/main.tsx10
-rw-r--r--src/client/utils.ts20
5 files changed, 344 insertions, 1 deletions
diff --git a/package.json b/package.json
index 9224785..113d16c 100644
--- a/package.json
+++ b/package.json
@@ -6,7 +6,12 @@
"type": "module",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
- "dev:backend": "nodemon --watch src/server --ext ts --exec \"node --loader ts-node/esm src/server/main.ts\""
+ "dev:frontend": "vite",
+ "dev:backend": "nodemon --watch src/server --ext ts --exec \"node --loader ts-node/esm src/server/main.ts\"",
+ "dev": "npm-run-all --parallel dev:frontend dev:backend",
+ "build": "tsc && vite build",
+ "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
+ "preview": "vite preview"
},
"repository": {
"type": "git",
diff --git a/src/client/App.tsx b/src/client/App.tsx
new file mode 100644
index 0000000..00280cd
--- /dev/null
+++ b/src/client/App.tsx
@@ -0,0 +1,193 @@
+import React, { useState, useEffect } from 'react';
+import { getDigiByteAddressType } from './utils'; // Import the address type helper
+
+// Define the possible UI states
+type UiState = 'initial' | 'waiting' | 'success' | 'failed';
+
+// Define the structure for result data (success or failure)
+interface ResultData {
+ address?: string; // Present on success
+ error?: string; // Present on failure
+ addressType?: string; // Added later
+}
+
+function App() {
+ const [uiState, setUiState] = useState<UiState>('initial');
+ const [sessionId, setSessionId] = useState<string | null>(null);
+ const [qrCodeDataUrl, setQrCodeDataUrl] = useState<string | null>(null);
+ const [resultData, setResultData] = useState<ResultData | null>(null);
+ const [isLoading, setIsLoading] = useState<boolean>(false);
+ const [error, setError] = useState<string | null>(null); // For general fetch errors
+
+ // Polling interval for status check (in milliseconds)
+ const POLLING_INTERVAL = 2000; // Check every 2 seconds
+
+ // Effect for polling the status endpoint when in 'waiting' state
+ useEffect(() => {
+ // Only poll if we are in the waiting state and have a session ID
+ if (uiState !== 'waiting' || !sessionId) {
+ return; // Exit if not applicable
+ }
+
+ console.log(`Starting status polling for session: ${sessionId}`);
+ let intervalId: any = null; // Use 'any' to avoid browser/node type conflict for setInterval return type
+
+ const checkStatus = async () => {
+ if (!sessionId) return; // Should not happen here, but type guard
+
+ console.log(`Checking status for session: ${sessionId}`);
+ try {
+ const response = await fetch(`/api/digiid/status/${sessionId}`);
+ if (!response.ok) {
+ // Handle specific errors like 404 (session not found/expired)
+ if (response.status === 404) {
+ console.warn(`Session ${sessionId} not found or expired during polling.`);
+ setError('Session expired or could not be found.');
+ setUiState('failed'); // Transition to failed state
+ setResultData({ error: 'Session expired or could not be found.' });
+ } else {
+ const errorData = await response.json().catch(() => ({ message: 'Error fetching status' }));
+ throw new Error(errorData.message || `Server responded with ${response.status}`);
+ }
+ if (intervalId) clearInterval(intervalId); // Stop polling on error
+ return;
+ }
+
+ const data: { status: UiState, address?: string, error?: string } = await response.json();
+ console.log('Received status data:', data);
+
+ // If status changed from pending, update UI and stop polling
+ if (data.status === 'success') {
+ setResultData({ address: data.address });
+ setUiState('success');
+ if (intervalId) clearInterval(intervalId);
+ } else if (data.status === 'failed') {
+ setResultData({ error: data.error || 'Authentication failed.' });
+ setUiState('failed');
+ if (intervalId) clearInterval(intervalId);
+ }
+ // If status is still 'pending', the interval will continue
+
+ } catch (err) {
+ console.error('Error polling status:', err);
+ const message = err instanceof Error ? err.message : 'An unknown error occurred during status check';
+ setError(`Status polling failed: ${message}`);
+ // Decide if we should stop polling or transition state on generic fetch error
+ // For now, let's stop polling and show error, moving to failed state
+ setResultData({ error: `Status polling failed: ${message}` });
+ setUiState('failed');
+ if (intervalId) clearInterval(intervalId);
+ }
+ };
+
+ // Start the interval
+ intervalId = setInterval(checkStatus, POLLING_INTERVAL);
+
+ // Cleanup function to clear the interval when the component unmounts
+ // or when the dependencies (uiState, sessionId) change
+ return () => {
+ if (intervalId) {
+ console.log(`Stopping status polling for session: ${sessionId}`);
+ clearInterval(intervalId);
+ }
+ };
+ }, [uiState, sessionId]); // Dependencies for the effect
+
+ const handleStart = async () => {
+ setIsLoading(true);
+ setError(null);
+ setQrCodeDataUrl(null); // Clear previous QR code if any
+ setSessionId(null);
+ setResultData(null);
+ console.log('Requesting new DigiID session...');
+
+ try {
+ const response = await fetch('/api/digiid/start');
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => ({ message: 'Failed to start session. Server responded with status: ' + response.status }));
+ throw new Error(errorData.message || 'Failed to start session');
+ }
+ const data = await response.json();
+ console.log('Received session data:', data);
+ setSessionId(data.sessionId);
+ setQrCodeDataUrl(data.qrCodeDataUrl);
+ setUiState('waiting'); // Move to waiting state
+ } catch (err) {
+ console.error('Error starting DigiID session:', err);
+ const message = err instanceof Error ? err.message : 'An unknown error occurred';
+ setError(`Failed to initiate DigiID: ${message}`);
+ setUiState('initial'); // Stay in initial state on error
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const handleReset = () => {
+ // TODO: Implement reset logic
+ console.log('Resetting flow...');
+ setUiState('initial');
+ setSessionId(null);
+ setQrCodeDataUrl(null);
+ setResultData(null);
+ setError(null);
+ setIsLoading(false);
+ };
+
+ return (
+ <div className="app-container">
+ <h1>DigiID-TS Demo</h1>
+
+ {/* --- Initial State --- */}
+ {uiState === 'initial' && (
+ <div className="initial-view">
+ <h2>Welcome</h2>
+ <p>Click the button below to generate a DigiID login QR code.</p>
+ {/* TODO: Add Icon here */}
+ {/* <img src="/assets/YOUR_ICON_FILENAME.png" alt="DigiID Icon" width="100" /> */}
+ <button onClick={handleStart} disabled={isLoading}>
+ {isLoading ? 'Generating QR...' : 'Start DigiID Login'}
+ </button>
+ </div>
+ )}
+
+ {/* --- Waiting State --- */}
+ {uiState === 'waiting' && qrCodeDataUrl && (
+ <div className="waiting-view">
+ <h2>Scan the QR Code</h2>
+ <p>Scan the QR code below using your DigiID compatible mobile wallet.</p>
+ <img src={qrCodeDataUrl} alt="DigiID QR Code" width="250" />
+ <p>Waiting for authentication...</p>
+ {/* Optional: Add a cancel button here */}
+ <button onClick={handleReset}>Cancel</button>
+ </div>
+ )}
+
+ {/* --- Success State --- */}
+ {uiState === 'success' && resultData?.address && (
+ <div className="success-view">
+ <h2>Authentication Successful!</h2>
+ <p>Verified Address:</p>
+ <p className="address">{resultData.address}</p>
+ <p>Address Type: {getDigiByteAddressType(resultData.address)}</p>
+ <button onClick={handleReset}>Start Over</button>
+ </div>
+ )}
+
+ {/* --- Failed State --- */}
+ {uiState === 'failed' && (
+ <div className="failed-view">
+ <h2>Authentication Failed</h2>
+ <p className="error-message">
+ Reason: {resultData?.error || error || 'An unknown error occurred.'}
+ </p>
+ <button onClick={handleReset}>Try Again</button>
+ </div>
+ )}
+
+ {/* General error display (e.g., for initial start error) */}
+ {error && uiState === 'initial' && <p className="error-message">Error: {error}</p>}
+ </div>
+ );
+}
+
+export default App; \ No newline at end of file
diff --git a/src/client/index.css b/src/client/index.css
new file mode 100644
index 0000000..2349c9b
--- /dev/null
+++ b/src/client/index.css
@@ -0,0 +1,115 @@
+:root {
+ font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
+ line-height: 1.5;
+ font-weight: 400;
+ color-scheme: light dark;
+ color: rgba(255, 255, 255, 0.87);
+ background-color: #242424;
+
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+body {
+ margin: 0;
+ display: flex;
+ place-items: center;
+ min-width: 320px;
+ min-height: 100vh;
+}
+
+#root {
+ max-width: 1280px;
+ margin: 0 auto;
+ padding: 2rem;
+ text-align: center;
+}
+
+.app-container {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 1.5rem;
+}
+
+h1 {
+ font-size: 2.5em;
+ line-height: 1.1;
+ margin-bottom: 1rem;
+}
+
+button {
+ border-radius: 8px;
+ border: 1px solid transparent;
+ padding: 0.6em 1.2em;
+ font-size: 1em;
+ font-weight: 500;
+ font-family: inherit;
+ background-color: #1a1a1a;
+ cursor: pointer;
+ transition: border-color 0.25s;
+}
+button:hover {
+ border-color: #646cff;
+}
+button:focus,
+button:focus-visible {
+ outline: 4px auto -webkit-focus-ring-color;
+}
+button:disabled {
+ background-color: #333;
+ cursor: not-allowed;
+ opacity: 0.6;
+}
+
+.initial-view, .waiting-view, .success-view, .failed-view {
+ padding: 1.5rem;
+ border: 1px solid #555;
+ border-radius: 8px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 1rem;
+ min-width: 300px;
+}
+
+.waiting-view img {
+ background-color: white; /* Ensure QR code background is white */
+ padding: 10px;
+ border-radius: 4px;
+}
+
+.address {
+ font-family: monospace;
+ background-color: #333;
+ padding: 0.5em;
+ border-radius: 4px;
+ word-break: break-all;
+}
+
+.error-message {
+ color: #ff6b6b;
+ font-weight: bold;
+}
+
+
+@media (prefers-color-scheme: light) {
+ :root {
+ color: #213547;
+ background-color: #ffffff;
+ }
+ button {
+ background-color: #f9f9f9;
+ }
+ .app-container {
+ /* Add light mode adjustments if needed */
+ }
+ .initial-view, .waiting-view, .success-view, .failed-view {
+ border-color: #ccc;
+ }
+ .address {
+ background-color: #eee;
+ }
+} \ No newline at end of file
diff --git a/src/client/main.tsx b/src/client/main.tsx
new file mode 100644
index 0000000..b895c80
--- /dev/null
+++ b/src/client/main.tsx
@@ -0,0 +1,10 @@
+import React from 'react';
+import ReactDOM from 'react-dom/client';
+import App from './App';
+import './index.css'; // We'll create this file later for styles
+
+ReactDOM.createRoot(document.getElementById('root')!).render(
+ <React.StrictMode>
+ <App />
+ </React.StrictMode>
+); \ No newline at end of file
diff --git a/src/client/utils.ts b/src/client/utils.ts
new file mode 100644
index 0000000..cd9c8ba
--- /dev/null
+++ b/src/client/utils.ts
@@ -0,0 +1,20 @@
+// Simple utility to determine DigiByte address type based on prefix
+
+export type DigiByteAddressType = 'DigiByte (DGB)' | 'DigiAsset (DGA)' | 'Unknown';
+
+export function getDigiByteAddressType(address: string | undefined | null): DigiByteAddressType {
+ if (!address) {
+ return 'Unknown';
+ }
+ if (address.startsWith('dgb1')) {
+ return 'DigiByte (DGB)';
+ }
+ // Add other prefixes if DigiAssets use a distinct one, e.g., 'dga1'
+ // For now, assume non-DGB is DigiAsset, but this might need refinement
+ // depending on actual DigiAsset address formats.
+ else {
+ // Assuming DigiAssets might start differently or be the fallback
+ // This is a placeholder assumption.
+ return 'DigiAsset (DGA)'; // Placeholder - ADJUST BASED ON ACTUAL DGA PREFIX
+ }
+} \ No newline at end of file