diff --git a/infrastructure/control-panel/README.md b/infrastructure/control-panel/README.md index b5b29507..f8a10a8d 100644 --- a/infrastructure/control-panel/README.md +++ b/infrastructure/control-panel/README.md @@ -1,38 +1,167 @@ -# sv +# Control Panel -Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli). +A SvelteKit-based control panel for monitoring and managing various services and platforms. -## Creating a project +## Features -If you're seeing this, you've probably already done this step. Congrats! +### eVault Monitoring + +- **Real-time eVault Discovery**: Automatically discovers eVault pods across all Kubernetes namespaces +- **Pod Information**: Displays comprehensive pod details including status, readiness, restarts, age, IP, and node +- **Live Logs**: View real-time logs from eVault pods with automatic refresh +- **Pod Details**: Access detailed pod information including YAML configuration and resource usage +- **Metrics**: View pod performance metrics (when metrics-server is available) + +## Prerequisites + +### Kubernetes Access + +- `kubectl` must be installed and configured +- Access to the Kubernetes cluster where eVaults are running +- Proper RBAC permissions to list and describe pods + +### System Requirements + +- Node.js 18+ +- Access to execute `kubectl` commands + +## Installation + +1. Install dependencies: ```bash -# create a new project in the current directory -npx sv create +npm install +``` + +2. Ensure kubectl is configured: -# create a new project in my-app -npx sv create my-app +```bash +kubectl cluster-info ``` -## Developing +## Usage -Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: +### Development ```bash npm run dev - -# or start the server and open the app in a new browser tab -npm run dev -- --open ``` -## Building - -To create a production version of your app: +### Building ```bash npm run build ``` -You can preview the production build with `npm run preview`. +## eVault Monitoring + +### Main Dashboard + +The main page displays a table of all eVault pods found across your Kubernetes cluster: + +- **Name**: Clickable link to detailed pod view +- **Namespace**: Kubernetes namespace where the pod is running +- **Status**: Current pod status (Running, Pending, Failed, etc.) +- **Ready**: Number of ready containers vs total containers +- **Restarts**: Number of container restarts +- **Age**: How long the pod has been running +- **IP**: Pod IP address +- **Node**: Kubernetes node where the pod is scheduled + +### Detailed Pod View + +Click on any eVault name to access detailed monitoring: + +#### Logs Tab + +- Real-time pod logs with automatic refresh +- Configurable log tail length +- Terminal-style display for easy reading + +#### Details Tab + +- Complete pod description from `kubectl describe pod` +- YAML configuration from `kubectl get pod -o yaml` +- Resource requests and limits +- Environment variables and volume mounts + +#### Metrics Tab + +- CPU and memory usage (requires metrics-server) +- Resource consumption trends +- Performance monitoring data + +### API Endpoints + +#### GET /api/evaults + +Returns a list of all eVault pods across all namespaces. + +#### GET /api/evaults/[namespace]/[pod]/logs?tail=[number] + +Returns the most recent logs from a specific pod. + +#### GET /api/evaults/[namespace]/[pod]/details + +Returns detailed information about a specific pod. + +## Configuration + +### eVault Detection + +The system automatically detects eVault pods by filtering for pods with names containing: + +- `evault` +- `vault` +- `web3` + +You can modify the filter in `src/routes/api/evaults/+server.ts` to adjust detection criteria. + +### Log Tail Length + +Default log tail length is 100 lines. This can be configured via the `tail` query parameter. + +## Troubleshooting + +### No eVaults Found + +1. Verify kubectl is configured: `kubectl cluster-info` +2. Check if eVault pods are running: `kubectl get pods --all-namespaces` +3. Verify pod names contain expected keywords +4. Check RBAC permissions for pod listing + +### Permission Denied + +Ensure your kubectl context has permissions to: + +- List pods across namespaces +- Describe pods +- Access pod logs +- View pod metrics (if using metrics-server) + +### Metrics Not Available + +If the metrics tab shows no data: + +1. Verify metrics-server is installed: `kubectl get pods -n kube-system | grep metrics` +2. Check metrics-server logs for errors +3. Ensure HPA (Horizontal Pod Autoscaler) is configured if needed + +## Security Considerations + +- The control panel executes kubectl commands on the server +- Ensure proper access controls and authentication +- Consider implementing role-based access control +- Monitor and audit kubectl command execution + +## Contributing + +1. Fork the repository +2. Create a feature branch +3. Make your changes +4. Add tests if applicable +5. Submit a pull request + +## License -> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment. +[Add your license information here] diff --git a/infrastructure/control-panel/package.json b/infrastructure/control-panel/package.json index fc478f2c..78ced26d 100644 --- a/infrastructure/control-panel/package.json +++ b/infrastructure/control-panel/package.json @@ -50,6 +50,7 @@ "flowbite": "^3.1.2", "flowbite-svelte": "^1.10.7", "flowbite-svelte-icons": "^2.2.1", + "lucide-svelte": "^0.539.0", "tailwind-merge": "^3.0.2" } } diff --git a/infrastructure/control-panel/src/lib/services/evaultService.ts b/infrastructure/control-panel/src/lib/services/evaultService.ts new file mode 100644 index 00000000..e9455f1f --- /dev/null +++ b/infrastructure/control-panel/src/lib/services/evaultService.ts @@ -0,0 +1,57 @@ +import type { EVault } from '../../routes/api/evaults/+server'; + +export class EVaultService { + static async getEVaults(): Promise { + try { + const response = await fetch('/api/evaults'); + if (!response.ok) { + throw new Error('Failed to fetch eVaults'); + } + const data = await response.json(); + return data.evaults || []; + } catch (error) { + console.error('Error fetching eVaults:', error); + return []; + } + } + + static async getEVaultLogs(namespace: string, podName: string, tail: number = 100): Promise { + try { + const response = await fetch(`/api/evaults/${encodeURIComponent(namespace)}/${encodeURIComponent(podName)}/logs?tail=${tail}`); + if (!response.ok) { + throw new Error('Failed to fetch logs'); + } + const data = await response.json(); + return data.logs || []; + } catch (error) { + console.error('Error fetching logs:', error); + return []; + } + } + + static async getEVaultDetails(namespace: string, podName: string): Promise { + try { + const response = await fetch(`/api/evaults/${encodeURIComponent(namespace)}/${encodeURIComponent(podName)}/details`); + if (!response.ok) { + throw new Error('Failed to fetch eVault details'); + } + return await response.json(); + } catch (error) { + console.error('Error fetching eVault details:', error); + return null; + } + } + + static async getEVaultMetrics(namespace: string, podName: string): Promise { + try { + const response = await fetch(`/api/evaults/${encodeURIComponent(namespace)}/${encodeURIComponent(podName)}/metrics`); + if (!response.ok) { + throw new Error('Failed to fetch metrics'); + } + return await response.json(); + } catch (error) { + console.error('Error fetching eVault metrics:', error); + return null; + } + } +} \ No newline at end of file diff --git a/infrastructure/control-panel/src/routes/+page.svelte b/infrastructure/control-panel/src/routes/+page.svelte index 195945ae..e9a9cd8f 100644 --- a/infrastructure/control-panel/src/routes/+page.svelte +++ b/infrastructure/control-panel/src/routes/+page.svelte @@ -1,24 +1,28 @@
- + + {#if isLoading} +
+
+
+ {:else if error} +
+ {error} + +
+ {:else if evaults.length === 0} +
+ No eVault pods found. Make sure kubectl is configured and eVault pods are running. +
+ {:else} +
+ {/if} diff --git a/infrastructure/control-panel/src/routes/api/evaults/+server.ts b/infrastructure/control-panel/src/routes/api/evaults/+server.ts new file mode 100644 index 00000000..5e058744 --- /dev/null +++ b/infrastructure/control-panel/src/routes/api/evaults/+server.ts @@ -0,0 +1,175 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from '@sveltejs/kit'; +import { promisify } from 'util'; +import { exec } from 'child_process'; + +const execAsync = promisify(exec); + +export interface EVault { + id: string; + name: string; + namespace: string; + status: string; + age: string; + ready: string; + restarts: number; + image: string; + ip: string; + node: string; + evaultId: string; + serviceUrl?: string; + podName?: string; // Add pod name for logs access +} + +export const GET: RequestHandler = async () => { + try { + // Get minikube IP for NodePort services + let minikubeIP = 'localhost'; + try { + const { stdout: minikubeIPOutput } = await execAsync('minikube ip 2>/dev/null || echo "localhost"'); + if (minikubeIPOutput.trim()) { + minikubeIP = minikubeIPOutput.trim(); + } + } catch (ipError) { + console.log('Could not get minikube IP, using localhost'); + } + + console.log('Using IP:', minikubeIP); + + // Get all namespaces + const { stdout: namespacesOutput } = await execAsync('kubectl get namespaces -o json'); + const namespaces = JSON.parse(namespacesOutput); + + // Filter for eVault namespaces + const evaultNamespaces = namespaces.items + .filter((ns: any) => ns.metadata.name.startsWith('evault-')) + .map((ns: any) => ns.metadata.name); + + console.log('Found eVault namespaces:', evaultNamespaces); + + let allEVaults: EVault[] = []; + + // Get services and pods from each eVault namespace + for (const namespace of evaultNamespaces) { + try { + // Get services in this namespace as JSON + const { stdout: servicesOutput } = await execAsync(`kubectl get services -n ${namespace} -o json`); + const services = JSON.parse(servicesOutput); + + // Get pods in this namespace as JSON + const { stdout: podsOutput } = await execAsync(`kubectl get pods -n ${namespace} -o json`); + const pods = JSON.parse(podsOutput); + + console.log(`=== SERVICES FOR ${namespace} ===`); + console.log(JSON.stringify(services, null, 2)); + console.log(`=== PODS FOR ${namespace} ===`); + console.log(JSON.stringify(pods, null, 2)); + console.log(`=== END DATA ===`); + + if (services.items && services.items.length > 0) { + for (const service of services.items) { + const serviceName = service.metadata.name; + const serviceType = service.spec.type; + const ports = service.spec.ports; + + console.log(`Service: ${serviceName}, Type: ${serviceType}, Ports:`, ports); + + // Find NodePort for NodePort services + let nodePort = null; + if (serviceType === 'NodePort' && ports) { + for (const port of ports) { + if (port.nodePort) { + nodePort = port.nodePort; + break; + } + } + } + + console.log(`NodePort: ${nodePort}`); + + // Get pod data for this service + let podData = { + status: 'Unknown', + age: 'N/A', + ready: '0/0', + restarts: 0, + image: 'N/A', + ip: 'N/A', + node: 'N/A', + podName: 'N/A' + }; + + if (pods.items && pods.items.length > 0) { + // Find pod that matches this service (usually same name or has service label) + const matchingPod = pods.items.find((pod: any) => + pod.metadata.name.includes(serviceName.replace('-service', '')) || + pod.metadata.labels?.app === 'evault' + ); + + if (matchingPod) { + const pod = matchingPod; + const readyCount = pod.status.containerStatuses?.filter((cs: any) => cs.ready).length || 0; + const totalCount = pod.status.containerStatuses?.length || 0; + const restarts = pod.status.containerStatuses?.reduce((sum: number, cs: any) => sum + (cs.restartCount || 0), 0) || 0; + + // Calculate age + const creationTime = new Date(pod.metadata.creationTimestamp); + const now = new Date(); + const ageMs = now.getTime() - creationTime.getTime(); + const ageDays = Math.floor(ageMs / (1000 * 60 * 60 * 24)); + const ageHours = Math.floor((ageMs % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)); + const age = ageDays > 0 ? `${ageDays}d${ageHours}h` : `${ageHours}h`; + + podData = { + status: pod.status.phase || 'Unknown', + age: age, + ready: `${readyCount}/${totalCount}`, + restarts: restarts, + image: pod.spec.containers?.[0]?.image || 'N/A', + ip: pod.status.podIP || 'N/A', + node: pod.spec.nodeName || 'N/A', + podName: pod.metadata.name || 'N/A' + }; + } + } + + // Extract the eVault ID from the namespace + const evaultId = namespace.replace('evault-', ''); + + // Generate service URL + let serviceUrl = ''; + if (nodePort) { + serviceUrl = `http://${minikubeIP}:${nodePort}`; + } + + console.log(`Service URL: ${serviceUrl}`); + + allEVaults.push({ + id: serviceName, + name: serviceName, + namespace: namespace, + status: podData.status, + age: podData.age, + ready: podData.ready, + restarts: podData.restarts, + image: podData.image, + ip: podData.ip, + node: podData.node, + evaultId: evaultId, + serviceUrl: serviceUrl, + podName: podData.podName + }); + } + } + } catch (namespaceError) { + console.log(`Error accessing namespace ${namespace}:`, namespaceError); + } + } + + console.log(`Total eVaults found: ${allEVaults.length}`); + return json({ evaults: allEVaults }); + } catch (error) { + console.error('Error fetching eVaults:', error); + return json({ error: 'Failed to fetch eVaults', evaults: [] }, { status: 500 }); + } +}; \ No newline at end of file diff --git a/infrastructure/control-panel/src/routes/api/evaults/[namespace]/[pod]/details/+server.ts b/infrastructure/control-panel/src/routes/api/evaults/[namespace]/[pod]/details/+server.ts new file mode 100644 index 00000000..27bd353c --- /dev/null +++ b/infrastructure/control-panel/src/routes/api/evaults/[namespace]/[pod]/details/+server.ts @@ -0,0 +1,37 @@ +import { exec } from 'child_process'; +import { promisify } from 'util'; +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; + +const execAsync = promisify(exec); + +export const GET: RequestHandler = async ({ params }) => { + const { namespace, pod } = params; + + try { + // Get detailed pod information + const { stdout: podInfo } = await execAsync(`kubectl describe pod -n ${namespace} ${pod}`); + + // Get pod YAML + const { stdout: podYaml } = await execAsync(`kubectl get pod -n ${namespace} ${pod} -o yaml`); + + // Get pod metrics if available + let metrics = null; + try { + const { stdout: metricsOutput } = await execAsync(`kubectl top pod -n ${namespace} ${pod}`); + metrics = metricsOutput.trim(); + } catch (metricsError) { + // Metrics might not be available + console.log('Metrics not available:', metricsError); + } + + return json({ + podInfo: podInfo.trim(), + podYaml: podYaml.trim(), + metrics + }); + } catch (error) { + console.error('Error fetching pod details:', error); + return json({ error: 'Failed to fetch pod details' }, { status: 500 }); + } +}; \ No newline at end of file diff --git a/infrastructure/control-panel/src/routes/api/evaults/[namespace]/[pod]/logs/+server.ts b/infrastructure/control-panel/src/routes/api/evaults/[namespace]/[pod]/logs/+server.ts new file mode 100644 index 00000000..73502994 --- /dev/null +++ b/infrastructure/control-panel/src/routes/api/evaults/[namespace]/[pod]/logs/+server.ts @@ -0,0 +1,21 @@ +import { exec } from 'child_process'; +import { promisify } from 'util'; +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; + +const execAsync = promisify(exec); + +export const GET: RequestHandler = async ({ params, url }) => { + const { namespace, pod } = params; + const tail = url.searchParams.get('tail') || '100'; + + try { + const { stdout } = await execAsync(`kubectl logs -n ${namespace} ${pod} -c evault --tail=${tail}`); + const logs = stdout.trim().split('\n').filter(line => line.trim()); + + return json({ logs }); + } catch (error) { + console.error('Error fetching logs:', error); + return json({ error: 'Failed to fetch logs', logs: [] }, { status: 500 }); + } +}; \ No newline at end of file diff --git a/infrastructure/control-panel/src/routes/api/evaults/[namespace]/[pod]/metrics/+server.ts b/infrastructure/control-panel/src/routes/api/evaults/[namespace]/[pod]/metrics/+server.ts new file mode 100644 index 00000000..29a605f9 --- /dev/null +++ b/infrastructure/control-panel/src/routes/api/evaults/[namespace]/[pod]/metrics/+server.ts @@ -0,0 +1,155 @@ +import { exec } from 'child_process'; +import { promisify } from 'util'; +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; + +const execAsync = promisify(exec); + +export const GET: RequestHandler = async ({ params }) => { + const { namespace, pod } = params; + + console.log('Metrics API called with namespace:', namespace, 'pod:', pod); + + try { + // Get pod resource usage (this might fail if metrics server not enabled) + console.log('Running kubectl top pod...'); + let topOutput = ''; + try { + const { stdout } = await execAsync(`kubectl top pod ${pod} -n ${namespace}`); + topOutput = stdout; + } catch (topError) { + console.log('kubectl top pod failed (metrics server not available):', topError); + topOutput = 'No metrics available'; + } + console.log('kubectl top pod output:', topOutput); + + // Get pod status details + console.log('Running kubectl describe pod...'); + const { stdout: describeOutput } = await execAsync(`kubectl describe pod ${pod} -n ${namespace} 2>/dev/null || echo "No pod details available"`); + console.log('kubectl describe pod output length:', describeOutput?.length || 0); + + // Get container logs count (last 100 lines) + console.log('Running kubectl logs...'); + const { stdout: logsOutput } = await execAsync(`kubectl logs -n ${namespace} ${pod} -c evault --tail=100 2>/dev/null || echo ""`); + console.log('kubectl logs output length:', logsOutput?.length || 0); + + const logLines = logsOutput.trim().split('\n').filter(line => line.trim()); + + // Parse top output for CPU and Memory + let cpu = 'N/A'; + let memory = 'N/A'; + if (topOutput && !topOutput.includes('No metrics available') && !topOutput.includes('Metrics API not available')) { + console.log('Parsing top output...'); + const lines = topOutput.trim().split('\n'); + console.log('Top output lines:', lines); + if (lines.length > 1) { + const podLine = lines[1]; // First line after header + console.log('Pod line:', podLine); + const parts = podLine.split(/\s+/); + console.log('Pod line parts:', parts); + if (parts.length >= 4) { + cpu = parts[2] || 'N/A'; + memory = parts[3] || 'N/A'; + console.log('Extracted CPU:', cpu, 'Memory:', memory); + } + } + } + + console.log('Final CPU:', cpu, 'Memory:', memory); + + // Parse describe output for events and conditions + const events: string[] = []; + const conditions: string[] = []; + + if (describeOutput && !describeOutput.includes('No pod details available')) { + const lines = describeOutput.split('\n'); + let inEvents = false; + let inConditions = false; + + for (const line of lines) { + if (line.includes('Events:')) { + inEvents = true; + inConditions = false; + continue; + } + if (line.includes('Conditions:')) { + inConditions = true; + inEvents = false; + continue; + } + if (line.includes('Volumes:') || line.includes('QoS Class:')) { + inEvents = false; + inConditions = false; + continue; + } + + if (inEvents && line.trim() && !line.startsWith(' ')) { + // Handle case where Events shows "" + if (line.trim() === '') { + events.push('No recent events'); + } else { + events.push(line.trim()); + } + } + if (inConditions && line.trim() && !line.startsWith(' ')) { + conditions.push(line.trim()); + } + } + } + + // Calculate basic stats + const totalLogLines = logLines.length; + const errorLogs = logLines.filter(line => + line.toLowerCase().includes('error') || + line.toLowerCase().includes('fail') || + line.toLowerCase().includes('exception') + ).length; + const warningLogs = logLines.filter(line => + line.toLowerCase().includes('warn') || + line.toLowerCase().includes('warning') + ).length; + + // Get additional pod info for alternative metrics + let podAge = 'N/A'; + let podStatus = 'Unknown'; + try { + const { stdout: getPodOutput } = await execAsync(`kubectl get pod ${pod} -n ${namespace} -o json`); + const podInfo = JSON.parse(getPodOutput); + podAge = podInfo.metadata?.creationTimestamp ? + Math.floor((Date.now() - new Date(podInfo.metadata.creationTimestamp).getTime()) / (1000 * 60 * 60 * 24)) + 'd' : 'N/A'; + podStatus = podInfo.status?.phase || 'Unknown'; + } catch (podError) { + console.log('Failed to get pod info:', podError); + } + + const metrics = { + resources: { + cpu, + memory, + note: topOutput.includes('Metrics API not available') ? 'Metrics server not enabled' : undefined + }, + logs: { + totalLines: totalLogLines, + errorCount: errorLogs, + warningCount: warningLogs, + lastUpdate: new Date().toISOString() + }, + status: { + events: events.length > 0 ? events : ['No recent events'], + conditions: conditions.length > 0 ? conditions : ['No conditions available'], + podAge, + podStatus + } + }; + + return json(metrics); + } catch (error) { + console.error('Error fetching metrics:', error); + return json({ + error: 'Failed to fetch metrics', + resources: { cpu: 'N/A', memory: 'N/A' }, + logs: { totalLines: 0, errorCount: 0, warningCount: 0, lastUpdate: new Date().toISOString() }, + status: { events: [], conditions: [] } + }, { status: 500 }); + } +}; \ No newline at end of file diff --git a/infrastructure/control-panel/src/routes/evaults/[namespace]/[pod]/+page.svelte b/infrastructure/control-panel/src/routes/evaults/[namespace]/[pod]/+page.svelte new file mode 100644 index 00000000..392b711d --- /dev/null +++ b/infrastructure/control-panel/src/routes/evaults/[namespace]/[pod]/+page.svelte @@ -0,0 +1,149 @@ + + +
+
+

+ eVault: {podName || 'Unknown'} +

+

+ Namespace: {namespace || 'Unknown'} +

+
+ + {#if isLoading} +
+
+
+ {:else if error} +
+ {error} + +
+ {:else} + +
+ +
+ + + {#if selectedTab === 'logs'} +
+
+

Pod Logs

+ +
+
+ {#each logs as log} +
{log}
+ {/each} +
+
+ {:else if selectedTab === 'details'} +
+

Pod Details

+
+
{details?.podInfo ||
+							'No details available'}
+
+
+ {:else if selectedTab === 'metrics'} +
+

Pod Metrics

+ {#if details?.metrics} +
+
{details.metrics}
+
+ {:else} +
+ Metrics not available. Make sure metrics-server is installed and running. +
+ {/if} +
+ {/if} + {/if} +
diff --git a/infrastructure/control-panel/src/routes/monitoring/[namespace]/[service]/+page.svelte b/infrastructure/control-panel/src/routes/monitoring/[namespace]/[service]/+page.svelte new file mode 100644 index 00000000..11608ee8 --- /dev/null +++ b/infrastructure/control-panel/src/routes/monitoring/[namespace]/[service]/+page.svelte @@ -0,0 +1,423 @@ + + + + eVault {evaultId} - Control Panel + + +
+ +
+
+

eVault {evaultId}

+

Namespace: {namespace} | Service: {service}

+
+
+ + + {isLoading ? 'Refreshing...' : 'Refresh'} + + Clear Logs + goto('/')}> + + Back to Dashboard + +
+
+ + +
+
+
eVault ID
+
+ + {evaultId} +
+
+ +
+
Namespace
+
+ + {namespace} +
+
+ +
+
Service
+
+ + {service} +
+
+ +
+
Auto-refresh
+
+ + Every 5s +
+
+
+ + +
+ +
+ + + {#if activeTab === 'logs'} +
+

eVault Container Logs

+

+ Live logs from the eVault container. Auto-refreshing every 5 seconds. +

+ + {#if error} +
+ {error} +
+ {:else} +
+ {#if isLoading} +
+ + Loading logs... +
+ {:else if logs} +
{logs}
+ {:else} + No logs available + {/if} +
+ {/if} +
+ {:else if activeTab === 'details'} +
+

eVault Details

+

Detailed information about this eVault instance.

+ +
+
+
Namespace
+

{namespace}

+
+
+
Service Name
+

{service}

+
+
+
eVault ID
+

{evaultId}

+
+
+
Pod Name
+

{evaultData?.podName || 'Loading...'}

+
+
+
+ {:else if activeTab === 'metrics'} +
+
+

eVault Metrics

+ + + {metricsLoading ? 'Refreshing...' : 'Refresh Metrics'} + +
+ + {#if metricsLoading} +
+ +

Loading metrics...

+
+ {:else if metricsData} +
+ +
+
+

CPU Usage

+

+ {metricsData.resources?.cpu || 'N/A'} +

+ {#if metricsData.resources?.note} +

+ {metricsData.resources.note} +

+ {/if} +
+
+

Memory Usage

+

+ {metricsData.resources?.memory || 'N/A'} +

+ {#if metricsData.resources?.note} +

+ {metricsData.resources.note} +

+ {/if} +
+
+ + +
+
+

Pod Status

+

+ {metricsData.status?.podStatus || 'Unknown'} +

+
+
+

Pod Age

+

+ {metricsData.status?.podAge || 'N/A'} +

+
+
+ + +
+

Log Statistics

+
+
+

+ {metricsData.logs?.totalLines || 0} +

+

Total Lines

+
+
+

+ {metricsData.logs?.errorCount || 0} +

+

Errors

+
+
+

+ {metricsData.logs?.warningCount || 0} +

+

Warnings

+
+
+ {#if metricsData.logs?.lastUpdate} +

+ Last updated: {new Date( + metricsData.logs.lastUpdate + ).toLocaleTimeString()} +

+ {/if} +
+ + +
+ +
+

Recent Events

+ {#if metricsData.status?.events && metricsData.status.events.length > 0} +
+ {#each metricsData.status.events as event} +

{event}

+ {/each} +
+ {:else} +

No recent events

+ {/if} +
+ + +
+

Pod Conditions

+ {#if metricsData.status?.conditions && metricsData.status.conditions.length > 0} +
+ {#each metricsData.status.conditions as condition} +

{condition}

+ {/each} +
+ {:else} +

No conditions available

+ {/if} +
+
+
+ {:else} +
+ +

No metrics available

+
+ {/if} +
+ {/if} +
diff --git a/infrastructure/control-panel/src/routes/monitoring/[namespace]/[service]/+page.ts b/infrastructure/control-panel/src/routes/monitoring/[namespace]/[service]/+page.ts new file mode 100644 index 00000000..74c868ca --- /dev/null +++ b/infrastructure/control-panel/src/routes/monitoring/[namespace]/[service]/+page.ts @@ -0,0 +1,7 @@ +export const load = ({ params }) => { + console.log('+page.ts load called with params:', params); + return { + namespace: params.namespace, + service: params.service + }; +}; \ No newline at end of file diff --git a/infrastructure/eVoting/src/components/signing-interface.tsx b/infrastructure/eVoting/src/components/signing-interface.tsx new file mode 100644 index 00000000..0519ecba --- /dev/null +++ b/infrastructure/eVoting/src/components/signing-interface.tsx @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/infrastructure/eid-wallet/package.json b/infrastructure/eid-wallet/package.json index 22a98f9f..f68df63f 100644 --- a/infrastructure/eid-wallet/package.json +++ b/infrastructure/eid-wallet/package.json @@ -27,6 +27,7 @@ "@tauri-apps/api": "^2", "@tauri-apps/plugin-barcode-scanner": "^2.2.0", "@tauri-apps/plugin-biometric": "^2.2.0", + "@tauri-apps/plugin-deep-link": "^2.4.1", "@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-store": "^2.2.0", "@veriff/incontext-sdk": "^2.4.0", diff --git a/infrastructure/eid-wallet/src-tauri/Cargo.lock b/infrastructure/eid-wallet/src-tauri/Cargo.lock index a0409a49..186f90e7 100644 --- a/infrastructure/eid-wallet/src-tauri/Cargo.lock +++ b/infrastructure/eid-wallet/src-tauri/Cargo.lock @@ -544,6 +544,26 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.15", + "once_cell", + "tiny-keccak", +] + [[package]] name = "convert_case" version = "0.4.0" @@ -633,6 +653,12 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-common" version = "0.1.6" @@ -810,6 +836,15 @@ dependencies = [ "syn 2.0.100", ] +[[package]] +name = "dlv-list" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" +dependencies = [ + "const-random", +] + [[package]] name = "dpi" version = "0.1.1" @@ -859,6 +894,7 @@ dependencies = [ "tauri-plugin-barcode-scanner", "tauri-plugin-biometric", "tauri-plugin-crypto-hw", + "tauri-plugin-deep-link", "tauri-plugin-opener", "tauri-plugin-store", "thiserror 2.0.12", @@ -1442,6 +1478,12 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + [[package]] name = "hashbrown" version = "0.15.2" @@ -2428,6 +2470,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-multimap" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" +dependencies = [ + "dlv-list", + "hashbrown 0.14.5", +] + [[package]] name = "ordered-stream" version = "0.2.0" @@ -3002,7 +3054,17 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", - "windows-registry", + "windows-registry 0.4.0", +] + +[[package]] +name = "rust-ini" +version = "0.21.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7295b7ce3bf4806b419dc3420745998b447178b7005e2011947b38fc5aa6791" +dependencies = [ + "cfg-if", + "ordered-multimap", ] [[package]] @@ -3734,6 +3796,26 @@ dependencies = [ "thiserror 2.0.12", ] +[[package]] +name = "tauri-plugin-deep-link" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fec67f32d7a06d80bd3dc009fdb678c35a66116d9cb8cd2bb32e406c2b5bbd2" +dependencies = [ + "dunce", + "rust-ini", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.12", + "tracing", + "url", + "windows-registry 0.5.3", + "windows-result", +] + [[package]] name = "tauri-plugin-opener" version = "2.2.6" @@ -3964,6 +4046,15 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + [[package]] name = "tinystr" version = "0.7.6" @@ -4746,6 +4837,17 @@ dependencies = [ "windows-targets 0.53.0", ] +[[package]] +name = "windows-registry" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings 0.4.2", +] + [[package]] name = "windows-result" version = "0.3.4" diff --git a/infrastructure/eid-wallet/src-tauri/Cargo.toml b/infrastructure/eid-wallet/src-tauri/Cargo.toml index 48c4e03b..f0dc5b89 100644 --- a/infrastructure/eid-wallet/src-tauri/Cargo.toml +++ b/infrastructure/eid-wallet/src-tauri/Cargo.toml @@ -20,6 +20,7 @@ tauri-build = { version = "2", features = [] } [dependencies] tauri = { version = "2", features = [] } tauri-plugin-opener = "2" +tauri-plugin-deep-link = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" tauri-plugin-store = "2.2.0" @@ -33,4 +34,3 @@ thiserror = { version = "2.0.11" } tauri-plugin-barcode-scanner = "2" tauri-plugin-biometric = "2.2.0" tauri-plugin-crypto-hw = "0.1.0" - diff --git a/infrastructure/eid-wallet/src-tauri/Info.ios.plist b/infrastructure/eid-wallet/src-tauri/Info.ios.plist index b5ceb8f5..b14462d1 100644 --- a/infrastructure/eid-wallet/src-tauri/Info.ios.plist +++ b/infrastructure/eid-wallet/src-tauri/Info.ios.plist @@ -12,5 +12,16 @@ NSAllowsArbitraryLoads + CFBundleURLTypes + + + CFBundleURLName + foundation.metastate.eid-wallet + CFBundleURLSchemes + + w3ds + + + diff --git a/infrastructure/eid-wallet/src-tauri/capabilities/mobile.json b/infrastructure/eid-wallet/src-tauri/capabilities/mobile.json index e6b65a59..fa6ee646 100644 --- a/infrastructure/eid-wallet/src-tauri/capabilities/mobile.json +++ b/infrastructure/eid-wallet/src-tauri/capabilities/mobile.json @@ -8,7 +8,8 @@ "opener:default", "store:default", "biometric:default", - "barcode-scanner:default" + "barcode-scanner:default", + "deep-link:default" ], "platforms": ["iOS", "android"] } diff --git a/infrastructure/eid-wallet/src-tauri/gen/android/app/src/main/AndroidManifest.xml b/infrastructure/eid-wallet/src-tauri/gen/android/app/src/main/AndroidManifest.xml index 04a1dfd6..49ddc90f 100644 --- a/infrastructure/eid-wallet/src-tauri/gen/android/app/src/main/AndroidManifest.xml +++ b/infrastructure/eid-wallet/src-tauri/gen/android/app/src/main/AndroidManifest.xml @@ -22,6 +22,12 @@ + + + + + + UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight + CFBundleURLTypes + + + CFBundleURLName + foundation.metastate.eid-wallet + CFBundleURLSchemes + + w3ds + + + \ No newline at end of file diff --git a/infrastructure/eid-wallet/src-tauri/src/lib.rs b/infrastructure/eid-wallet/src-tauri/src/lib.rs index 410df29f..586f8506 100644 --- a/infrastructure/eid-wallet/src-tauri/src/lib.rs +++ b/infrastructure/eid-wallet/src-tauri/src/lib.rs @@ -37,6 +37,7 @@ pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_store::Builder::new().build()) + .plugin(tauri_plugin_deep_link::init()) .setup(move |_app| { #[cfg(mobile)] { diff --git a/infrastructure/eid-wallet/src/routes/(app)/+layout.svelte b/infrastructure/eid-wallet/src/routes/(app)/+layout.svelte index 2338f8dd..f1261fe2 100644 --- a/infrastructure/eid-wallet/src/routes/(app)/+layout.svelte +++ b/infrastructure/eid-wallet/src/routes/(app)/+layout.svelte @@ -1,17 +1,49 @@ diff --git a/infrastructure/eid-wallet/src/routes/(app)/scan-qr/+page.svelte b/infrastructure/eid-wallet/src/routes/(app)/scan-qr/+page.svelte index ec701977..89ef3601 100644 --- a/infrastructure/eid-wallet/src/routes/(app)/scan-qr/+page.svelte +++ b/infrastructure/eid-wallet/src/routes/(app)/scan-qr/+page.svelte @@ -1,107 +1,500 @@ @@ -124,10 +517,6 @@ onDestroy(async () => { Point the camera at the code -
- { Confirm + + {#if isSigningRequest === false} +
+

+ After confirmation, you may return to {platform} and continue there +

+
+ {/if}
@@ -212,9 +609,17 @@ onDestroy(async () => {

You're logged in!

You're now connected to {platform}

-
+
+ {#if redirect && platform} +
+

+ You may return to {platform} and continue there +

+
+ {/if} + { loggedInDrawerOpen = false; @@ -222,7 +627,186 @@ onDestroy(async () => { startScan(); }} > - Close + Stay in App
+ + + +
+
+
+ +
+ +

Sign Vote Request

+

+ You're being asked to sign a vote for the following poll +

+ +
+

Poll ID

+

+ {signingData?.pollId ?? "Unknown"} +

+
+ +
+

Your Vote

+
+ {#if signingData?.voteData?.optionId !== undefined} + +

+ You selected: Option {parseInt(signingData.voteData.optionId) + + 1} +

+

+ (This is the option number from the poll) +

+ {:else if signingData?.voteData?.ranks} + +

Your ranking order:

+
+ {#each Object.entries(signingData.voteData.ranks).sort(([a], [b]) => parseInt(a) - parseInt(b)) as [rank, optionIndex]} +
+ + {rank === "1" + ? "1st" + : rank === "2" + ? "2nd" + : rank === "3" + ? "3rd" + : `${rank}th`} + + Option {parseInt(String(optionIndex)) + + 1} +
+ {/each} +
+

+ (1st = most preferred, 2nd = second choice, etc.) +

+ {:else if signingData?.voteData?.points} + +

Your point distribution:

+
+ {#each Object.entries(signingData.voteData.points) + .filter(([_, points]) => (points as number) > 0) + .sort(([a], [b]) => parseInt(a) - parseInt(b)) as [optionIndex, points]} +
+ + {points} pts + + Option {parseInt(String(optionIndex)) + + 1} +
+ {/each} +
+

+ (Total: {Object.values(signingData.voteData.points).reduce( + (sum, points) => + (sum as number) + ((points as number) || 0), + 0, + )}/100 points) +

+ {:else} +

Vote data not available

+ {/if} +
+
+ +
+ { + signingDrawerOpen = false; + startScan(); + }} + > + Decline + + + {loading ? "Signing..." : "Sign Vote"} + +
+ +
+

+ After signing, you'll be redirected back to the platform +

+
+
+ + +{#if signingSuccess} +
+
+
+ +
+

+ Vote Signed Successfully! +

+

You can return to the platform

+ + {#if signingData?.redirect_uri} +
+ { + try { + window.location.href = signingData.redirect_uri; + } catch (error) { + console.error("Manual redirect failed:", error); + } + }} + class="w-full" + > + Return to Platform Now + +
+ {/if} +
+
+{/if} diff --git a/infrastructure/eid-wallet/src/routes/(app)/sign/+page.svelte b/infrastructure/eid-wallet/src/routes/(app)/sign/+page.svelte new file mode 100644 index 00000000..d8f3af44 --- /dev/null +++ b/infrastructure/eid-wallet/src/routes/(app)/sign/+page.svelte @@ -0,0 +1,322 @@ + + + + +
+
+ {#if signingStatus === "pending" && signingData && decodedData} + +
+
+ 📝 +
+ +
+

+ Sign Your Vote +

+

+ You're about to sign a vote for the following poll: +

+
+ + +
+

+ Poll Details +

+
+
+ Poll ID: + {decodedData.pollId?.slice(0, 8)}... +
+
+ Voting Mode: + {decodedData.voteData?.optionId + ? "Single Choice" + : "Ranked Choice"} +
+ {#if decodedData.voteData?.optionId} +
+ Selected Option: + Option {decodedData.voteData.optionId + 1} +
+ {:else if decodedData.voteData?.ranks} +
+ Rankings: +
+ {#each Object.entries(decodedData.voteData.ranks) as [rank, optionIndex]} +
+ {rank === "1" + ? "1st" + : rank === "2" + ? "2nd" + : "3rd"}: Option {(optionIndex as number) + + 1} +
+ {/each} +
+
+ {/if} +
+
+ + +
+
+ 🛡️ +
+

Security Notice

+

+ By signing this message, you're confirming your + vote. This action cannot be undone. +

+
+
+
+ + +
+ + Cancel + + + Sign Vote + +
+
+ {:else if signingStatus === "signing"} + +
+
+ +
+ +
+

+ Signing Your Vote +

+

+ Please wait while we process your signature... +

+
+ +
+
+
+ Processing signature... +
+
+
+ {:else if signingStatus === "success"} + +
+
+ +
+ +
+

+ Vote Signed Successfully! +

+

+ Your vote has been signed and submitted to the voting + system. +

+
+ +
+
+ + Signature verified and vote submitted +
+
+ +

Redirecting to main app...

+
+ {:else if signingStatus === "error"} + +
+
+ +
+ +
+

+ Signing Failed +

+

{errorMessage}

+
+ +
+
+ ⚠️ + Unable to complete signing process +
+
+ + +
+ + Go Back + + + Try Again + +
+
+ {/if} +
+
diff --git a/infrastructure/eid-wallet/src/routes/(auth)/login/+page.svelte b/infrastructure/eid-wallet/src/routes/(auth)/login/+page.svelte index eccb1720..07c860b1 100644 --- a/infrastructure/eid-wallet/src/routes/(auth)/login/+page.svelte +++ b/infrastructure/eid-wallet/src/routes/(auth)/login/+page.svelte @@ -1,84 +1,152 @@
{ Enter your 4-digit PIN code {/snippet} + + {#if hasPendingDeepLink} +
+
+
+ + + +
+
+

+ Authentication Request Pending
+ Complete login to process the authentication request +

+
+
+
+ {/if} + handlePinInput(pin)} />

Your PIN does not match, try again. diff --git a/infrastructure/eid-wallet/src/routes/+layout.svelte b/infrastructure/eid-wallet/src/routes/+layout.svelte index c76277ec..adda265a 100644 --- a/infrastructure/eid-wallet/src/routes/+layout.svelte +++ b/infrastructure/eid-wallet/src/routes/+layout.svelte @@ -1,102 +1,302 @@ {#if showSplashScreen} @@ -106,7 +306,9 @@ onNavigate((navigation) => { class="fixed top-0 left-0 right-0 h-[env(safe-area-inset-top)] bg-primary z-50" >

- {@render children?.()} + {#if children} + {@render children()} + {/if}
{/if} diff --git a/infrastructure/eid-wallet/test-deep-link.html b/infrastructure/eid-wallet/test-deep-link.html new file mode 100644 index 00000000..f9dcba7f --- /dev/null +++ b/infrastructure/eid-wallet/test-deep-link.html @@ -0,0 +1,119 @@ + + + + + + Test Deep Links for eID Wallet + + + +

Test Deep Links for eID Wallet

+

Click on these links to test deep link functionality in your eID Wallet app:

+ +

Authentication Deep Links

+ + + Test Auth Deep Link +
+ w3ds://auth?session=test-session-123&platform=TestPlatform&redirect=https://example.com/auth-callback +
+
+ + + Another Auth Test +
+ w3ds://auth?session=another-session&platform=DemoApp&redirect=https://demo.example.com/callback +
+
+ +

Signing Deep Links

+ + + Test Sign Deep Link (Simple Vote) +
+ w3ds://sign?session=sign-session-456&data=eyJwb2xsSWQiOiJwb2xsLTEyMyIsInZvdGVEYXRhIjp7Im9wdGlvbklkIjoiMCJ9LCJ1c2VySWQiOiJ1c2VyLTQ1NiJ9&redirect_uri=https://example.com/sign-callback +
+
+ + + Test Sign Deep Link (Ranked Vote) +
+ w3ds://sign?session=ranked-vote-session&data=eyJwb2xsSWQiOiJyYW5rZWQtcG9sbCIsInZvdGVEYXRhIjp7InJhbmtzIjp7IjEiOiIwIiwiMiI6IjEiLCIzIjoiMiJ9fSwidXNlcklkIjoicmFuay11c2VyIn0%3D&redirect_uri=https://example.com/ranked-callback +
+
+ +

How to Test

+
    +
  1. Make sure your eID Wallet app is installed on your device
  2. +
  3. Click on any of the test links above
  4. +
  5. Your device should prompt you to open the eID Wallet app
  6. +
  7. If not logged in: App will redirect to login page with a blue notification about pending authentication
  8. +
  9. After login: App will automatically redirect to scan-qr page and show the appropriate modal
  10. +
  11. Check the console logs in your app for debugging information
  12. +
+ +

Expected Behavior

+
    +
  • Security First: Deep links will NOT bypass authentication
  • +
  • If not logged in: Redirects to login page with pending request notification
  • +
  • After login: Automatically processes the deep link and shows the appropriate modal
  • +
  • Auth links: Should open the authentication confirmation modal
  • +
  • Sign links: Should open the vote signing confirmation modal
  • +
  • Return to Platform: After completion, users are automatically redirected back to the originating platform
  • +
  • Manual Return: Users can manually return to the platform using provided buttons
  • +
  • Console should show detailed logging of the deep link processing
  • +
+ +

Troubleshooting

+
    +
  • If the app doesn't open, check that the deep link scheme is properly registered
  • +
  • If the modal doesn't appear, check the console logs for errors
  • +
  • Make sure the app is not already running (deep links work best when app is launched fresh)
  • +
  • Try testing on both Android and iOS if possible
  • +
+ + + + \ No newline at end of file diff --git a/platforms/blabsy/AUTHENTICATION_SECURITY.md b/platforms/blabsy/AUTHENTICATION_SECURITY.md deleted file mode 100644 index a7f4c670..00000000 --- a/platforms/blabsy/AUTHENTICATION_SECURITY.md +++ /dev/null @@ -1,57 +0,0 @@ -# Authentication Security Changes - -## Overview - -This document outlines the security changes made to prevent automatic user creation when users sign in but don't exist in the database. - -## Problem - -Previously, when a user signed in with a custom token or Google authentication, if they didn't exist in the database, the system would automatically create a new user record. This was a security vulnerability as it allowed unauthorized users to create accounts. - -## Solution - -The following changes were implemented to prevent automatic user creation: - -### 1. Frontend Authentication Context (`src/lib/context/auth-context.tsx`) - -- **Modified `manageUser` function**: Removed automatic user creation logic -- **Added error handling**: When a user doesn't exist in the database, an error is set and the user is signed out -- **Cleaned up imports**: Removed unused imports related to user creation - -### 2. Authentication Layout (`src/components/layout/auth-layout.tsx`) - -- **Added error display**: Shows authentication errors to users with a clear message -- **Added retry functionality**: Users can retry authentication if needed -- **Improved UX**: Clear messaging about contacting support for account registration - -### 3. Firestore Security Rules (`firestore.rules`) - -- **Restricted user creation**: Only admin users can create new user documents -- **Maintained read access**: Authenticated users can still read user data -- **Controlled updates**: Users can only update their own data or admin can update any user - -## User Experience - -When a user tries to sign in but doesn't exist in the database: - -1. They will see an error message: "User not found in database. Please contact support to register your account." -2. They will be automatically signed out -3. They can retry authentication or contact support -4. No new user record is created - -## Admin User Creation - -Only users with admin privileges (username: 'ccrsxx') can create new user records. This ensures that user creation is controlled and audited. - -## Backend Considerations - -The webhook controller in `blabsy-w3ds-auth-api` can still create users through webhooks, but this is controlled by the external system and not by user authentication attempts. - -## Testing - -To test this fix: - -1. Try to sign in with a custom token for a user that doesn't exist in the database -2. Verify that no new user record is created -3. Verify that an appropriate error message is displayed -4. Verify that the user is signed out automatically diff --git a/platforms/blabsy/src/components/login/login-main.tsx b/platforms/blabsy/src/components/login/login-main.tsx index 252ed2c6..f2d0baec 100644 --- a/platforms/blabsy/src/components/login/login-main.tsx +++ b/platforms/blabsy/src/components/login/login-main.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'; import { useAuth } from '@lib/context/auth-context'; import { NextImage } from '@components/ui/next-image'; import Image from 'next/image'; +import { isMobileDevice, getDeepLinkUrl } from '@lib/utils/mobile-detection'; export function LoginMain(): JSX.Element { const { signInWithCustomToken } = useAuth(); @@ -62,7 +63,7 @@ export function LoginMain(): JSX.Element { useSkeleton /> -
+

@@ -73,9 +74,23 @@ export function LoginMain(): JSX.Element { Join Blabsy today.

-
- {qr && } -
+ {isMobileDevice() ? ( +
+ + Login with eID Wallet + +
+ Click the button to open your eID wallet app +
+
+ ) : ( +
+ {qr && } +
+ )}
}) { const { id } = use(params); - const pollId = id ? Number.parseInt(id) : null; + const pollId = id || null; const { toast } = useToast(); const { isAuthenticated, isLoading: authLoading } = useAuth(); const [selectedOption, setSelectedOption] = useState(null); + + const [rankVotes, setRankVotes] = useState<{ [key: number]: number }>({}); + const [pointVotes, setPointVotes] = useState<{ [key: number]: number }>({}); const [timeRemaining, setTimeRemaining] = useState(""); + // Calculate total points for points-based voting + const totalPoints = Object.values(pointVotes).reduce((sum, points) => sum + (points || 0), 0); + // TODO: Redirect to login if not authenticated // useEffect(() => { // if (!authLoading && !isAuthenticated) { @@ -50,13 +57,31 @@ export default function Vote({ params }: { params: Promise<{ id: string }> }) { } }, [pollId]); - const { data: polls = [], isLoading } = { data: [], isLoading: false }; // TODO: replace with actual data fetching logic + const [selectedPoll, setSelectedPoll] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [showSigningInterface, setShowSigningInterface] = useState(false); + + // Fetch poll data + const fetchPoll = async () => { + if (!pollId) return; + + try { + const poll = await pollApi.getPollById(pollId); + setSelectedPoll(poll); + } catch (error) { + console.error("Failed to fetch poll:", error); + } finally { + setIsLoading(false); + } + }; - const selectedPoll = polls.find((p) => p.id === pollId); + useEffect(() => { + fetchPoll(); + }, [pollId]); // Check if voting is still allowed const isVotingAllowed = - selectedPoll?.isActive && + selectedPoll && (!selectedPoll?.deadline || new Date() < new Date(selectedPoll.deadline)); @@ -114,14 +139,61 @@ export default function Vote({ params }: { params: Promise<{ id: string }> }) { return () => clearInterval(interval); }, [selectedPoll?.deadline, pollExists]); - const { data: voteStatus } = { data: null }; // TODO: replace with actual vote status fetching logic + const [voteStatus, setVoteStatus] = useState<{ hasVoted: boolean; vote: any } | null>(null); + const [resultsData, setResultsData] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + // Fetch vote status and results + const fetchVoteData = async () => { + if (!pollId) return; + + try { + + const [voteStatusData, resultsData] = await Promise.all([ + pollApi.getUserVote(pollId), + pollApi.getPollResults(pollId) + ]); + setVoteStatus(voteStatusData); + setResultsData(resultsData); + } catch (error) { + console.error("Failed to fetch vote data:", error); + } + }; - const { data: resultsData } = { data: null }; // TODO: replace with actual results fetching logic + useEffect(() => { + fetchVoteData(); + }, [pollId]); - const handleVoteSubmit = () => { - if (selectedPoll && selectedOption !== null) { - // TODO: replace with actual vote submission logic + const handleVoteSubmit = async () => { + if (!selectedPoll || !pollId) return; + + // Validate based on voting mode + let isValid = false; + if (selectedPoll.mode === "normal") { + isValid = selectedOption !== null; + } else if (selectedPoll.mode === "rank") { + const totalRanks = Object.keys(rankVotes).length; + const maxRanks = Math.min(selectedPoll.options.length, 3); + isValid = totalRanks === maxRanks; + } else if (selectedPoll.mode === "point") { + isValid = totalPoints === 100; } + + if (!isValid) { + toast({ + title: "Invalid Vote", + description: selectedPoll.mode === "rank" + ? "Please rank all options" + : selectedPoll.mode === "point" + ? "Please distribute exactly 100 points" + : "Please select an option", + variant: "destructive", + }); + return; + } + + // Show signing interface instead of submitting directly + setShowSigningInterface(true); }; if (isLoading) { @@ -206,10 +278,11 @@ export default function Vote({ params }: { params: Promise<{ id: string }> }) { {voteStatus?.hasVoted === true ? (
+ {/* Vote Distribution */}
- {resultsData?.results.map((option) => { + {resultsData?.results.map((option, index) => { const percentage = resultsData.totalVotes > 0 ? ( @@ -219,14 +292,14 @@ export default function Vote({ params }: { params: Promise<{ id: string }> }) { ).toFixed(1) : 0; const isUserChoice = - option.id === voteStatus.vote?.optionId; + option.option === selectedPoll.options[index]; const isLeading = resultsData.results.every( (r) => option.votes >= r.votes ); return (
0 ? "bg-red-50 border-red-200" @@ -246,7 +319,7 @@ export default function Vote({ params }: { params: Promise<{ id: string }> }) { : "text-gray-900" }`} > - {option.text} + {option.option} }) { : "text-gray-600" }`} > - {option.votes || 0} votes ( + {selectedPoll.mode === "rank" + ? `${option.votes || 0} points` + : selectedPoll.mode === "point" + ? `${option.votes || 0} points` + : `${option.votes || 0} votes`} ( {percentage}%)
@@ -291,11 +368,9 @@ export default function Vote({ params }: { params: Promise<{ id: string }> }) {

You voted for:{" "} { - selectedPoll.options.find( - (opt) => - opt.id === - voteStatus.vote?.optionId - )?.text + selectedPoll.options[ + parseInt(voteStatus.vote?.optionId || "0") + ] }

@@ -308,7 +383,7 @@ export default function Vote({ params }: { params: Promise<{ id: string }> }) {

{/* Poll Statistics */} -
+
@@ -316,7 +391,7 @@ export default function Vote({ params }: { params: Promise<{ id: string }> }) {

- Votes + {selectedPoll.mode === "rank" ? "Points" : "Votes"}

{resultsData?.totalVotes || 0} @@ -326,22 +401,6 @@ export default function Vote({ params }: { params: Promise<{ id: string }> }) {

-
-
- -
-
-

- Turnout -

-

- 100% -

-
-
-
- -
@@ -352,13 +411,13 @@ export default function Vote({ params }: { params: Promise<{ id: string }> }) {

- {selectedPoll?.isActive + {isVotingAllowed ? "Active" : "Ended"} @@ -394,41 +453,63 @@ export default function Vote({ params }: { params: Promise<{ id: string }> }) { Final Results
- {selectedPoll.options.map((option) => { - const percentage = - selectedPoll.totalVotes > 0 - ? ( - ((option.votes || 0) / - selectedPoll.totalVotes) * - 100 - ).toFixed(1) - : 0; - - return ( -
-
- - {option.text} - - - {option.votes || 0}{" "} - votes ({percentage}%) - -
-
-
+ {resultsData ? ( + <> + + + {resultsData.results && resultsData.results.length > 0 ? ( + resultsData.results.map((result, index) => { + const isWinner = result.votes === Math.max(...resultsData.results.map(r => r.votes)); + return ( +
+
+
+ + {result.option || `Option ${index + 1}`} + + {isWinner && ( + + 🏆 Winner + + )} +
+ + {selectedPoll.mode === "rank" + ? `${result.votes} points` + : `${result.votes} votes`} ({result.percentage.toFixed(1)}%) + +
+
+
+
+
+ ); + }) + ) : ( +
+ No results data available.
-
- ); - })} + )} + + ) : ( +
+ No results available yet. +
+ )}
@@ -452,62 +533,224 @@ export default function Vote({ params }: { params: Promise<{ id: string }> }) {
) : (
-
-

- Select your choice: -

- - setSelectedOption(Number.parseInt(value)) - } - disabled={!isVotingAllowed} - > -
- {selectedPoll.options.map((option) => ( + + {/* Voting Interface based on poll mode */} + {selectedPoll.mode === "normal" && ( +
+

+ Select your choice: +

+ + setSelectedOption(Number.parseInt(value)) + } + disabled={!isVotingAllowed} + > +
+ {selectedPoll.options.map((option, index) => ( +
+ + +
+ ))} +
+
+
+ )} + + {selectedPoll.mode === "point" && ( +
+
+

+ Distribute your points +

+ +
+
+

+ You have 100 points to distribute. Assign points to each option based on your preference. +

+
+
+ {selectedPoll.options.map((option, index) => (
- - +
+ +
+
+ { + const value = parseInt(e.target.value) || 0; + setPointVotes(prev => ({ + ...prev, + [index]: value + })); + }} + className="w-20 px-3 py-2 border border-gray-300 rounded-md text-center" + disabled={!isVotingAllowed} + /> + points +
))} +
+
+ + Total Points Used: + + + {totalPoints}/100 + +
+
- -
+
+ )} + + {selectedPoll.mode === "rank" && ( +
+
+

+ {(() => { + const currentRank = Object.keys(rankVotes).length + 1; + const maxRanks = Math.min(selectedPoll.options.length, 3); + + if (currentRank > maxRanks) { + return "Ranking Complete"; + } + + const rankText = currentRank === 1 ? "1st" : currentRank === 2 ? "2nd" : currentRank === 3 ? "3rd" : `${currentRank}th`; + return `What's your ${rankText} choice?`; + })()} +

+ +
+
+

+ Select your choices one by one, starting with your most preferred option. +

+
+ { + const optionIndex = parseInt(value); + const currentRank = Object.keys(rankVotes).length + 1; + setRankVotes(prev => ({ + ...prev, + [currentRank]: optionIndex + })); + setSelectedOption(optionIndex); + }} + disabled={!isVotingAllowed} + > +
+ {selectedPoll.options.map((option, index) => { + const isRanked = Object.values(rankVotes).includes(index); + const rank = Object.entries(rankVotes).find(([_, optionIndex]) => optionIndex === index)?.[0]; + + return ( +
+
+ {!isRanked ? ( + + ) : ( +
+ {rank} +
+ )} + +
+ {isRanked && ( + + {rank === "1" ? "1st Choice" : rank === "2" ? "2nd Choice" : rank === "3" ? "3rd Choice" : `${rank}th Choice`} + + )} +
+ ); + })} +
+
+
+ )}
@@ -567,6 +810,45 @@ export default function Vote({ params }: { params: Promise<{ id: string }> }) {
)} + + {/* Signing Interface Modal */} + {showSigningInterface && ( +
+
+ { + setShowSigningInterface(false); + + // Add a small delay to ensure backend has processed the vote + setTimeout(async () => { + try { + await fetchPoll(); + await fetchVoteData(); + } catch (error) { + console.error("Error during data refresh:", error); + } + }, 2000); // 2 second delay + + toast({ + title: "Success!", + description: "Your vote has been signed and submitted.", + }); + }} + onCancel={() => { + setShowSigningInterface(false); + }} + /> +
+
+ )}
); diff --git a/platforms/eVoting/src/app/(app)/create/page.tsx b/platforms/eVoting/src/app/(app)/create/page.tsx index 15784b75..7e04fdd0 100644 --- a/platforms/eVoting/src/app/(app)/create/page.tsx +++ b/platforms/eVoting/src/app/(app)/create/page.tsx @@ -18,6 +18,8 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { useToast } from "@/hooks/use-toast"; +import { pollApi } from "@/lib/pollApi"; +import { useRouter } from "next/navigation"; import Link from "next/link"; const createPollSchema = z.object({ @@ -41,7 +43,9 @@ type CreatePollForm = z.infer; export default function CreatePoll() { const { toast } = useToast(); + const router = useRouter(); const [options, setOptions] = useState(["", ""]); + const [isSubmitting, setIsSubmitting] = useState(false); const { register, @@ -60,10 +64,7 @@ export default function CreatePoll() { }, }); - handleSubmit((data) => { - console.log("Form submitted:", data); - console.log(data); - }); + const watchedMode = watch("mode"); const watchedVisibility = watch("visibility"); @@ -89,8 +90,33 @@ export default function CreatePoll() { setValue("options", newOptions); }; - const onSubmit = (data: CreatePollForm) => { - // TODO: replace with actual API call to create poll + const onSubmit = async (data: CreatePollForm) => { + setIsSubmitting(true); + try { + await pollApi.createPoll({ + title: data.title, + mode: data.mode, + visibility: data.visibility, + options: data.options.filter(option => option.trim() !== ""), + deadline: data.deadline || undefined + }); + + toast({ + title: "Success!", + description: "Poll created successfully", + }); + + router.push("/"); + } catch (error) { + console.error("Failed to create poll:", error); + toast({ + title: "Error", + description: "Failed to create poll. Please try again.", + variant: "destructive", + }); + } finally { + setIsSubmitting(false); + } }; return ( @@ -137,14 +163,14 @@ export default function CreatePoll() { } className="mt-2" > -
+