diff --git a/docs/docs/Infrastructure/eID-Wallet.md b/docs/docs/Infrastructure/eID-Wallet.md index 8ad842b09..bc4db5dcd 100644 --- a/docs/docs/Infrastructure/eID-Wallet.md +++ b/docs/docs/Infrastructure/eID-Wallet.md @@ -170,14 +170,16 @@ When a new user first opens the wallet: ``` Wallet → Registry: GET /entropy Registry → Wallet: JWT entropy token -Wallet → Provisioner: POST /provision (entropy, namespace, publicKey) -Provisioner → Registry: Request key binding certificate -Registry → Provisioner: JWT certificate +Wallet → Provisioner: POST /provision (entropy, namespace, publicKey?) +Provisioner → Registry: Request key binding certificate (if publicKey provided) +Registry → Provisioner: JWT certificate (if publicKey provided) Provisioner → Wallet: w3id, evaultUri ``` **Note**: The `/provision` endpoint is part of the Provisioner service, not eVault Core. This is the **provisioning protocol** - any vault provider should expose such an endpoint to enable eVault creation. +**Note**: The `publicKey` parameter is optional. User eVaults require it for signature verification and key binding, while keyless eVaults (platforms, groups) can be provisioned without it. + ### Platform Authentication User authenticating their eName to a platform: @@ -343,14 +345,14 @@ const provisionResponse = await fetch(`${provisionerUrl}/provision`, { registryEntropy: entropyToken, namespace: namespace, verificationId: verificationCode, - publicKey: publicKey + publicKey: publicKey // Optional: omit for keyless eVaults (platforms, groups) }) }); const { w3id, uri } = await provisionResponse.json(); ``` -**Note**: The `/provision` endpoint is hosted by the Provisioner service, not eVault Core. +**Note**: The `/provision` endpoint is hosted by the Provisioner service, not eVault Core. The `publicKey` parameter is optional - it's required for user eVaults that need signature verification, but can be omitted for keyless eVaults like platforms or groups. ### Platform Authentication diff --git a/docs/docs/Infrastructure/eVault-Key-Delegation.md b/docs/docs/Infrastructure/eVault-Key-Delegation.md index b43f5c665..bfc67d1af 100644 --- a/docs/docs/Infrastructure/eVault-Key-Delegation.md +++ b/docs/docs/Infrastructure/eVault-Key-Delegation.md @@ -25,7 +25,7 @@ The default key ID is `"default"` and is used for all signing operations. ### Setting Keys During eVault Creation -During the eVault provisioning process (onboarding), the public key can be set directly when creating the eVault. The `/provision` endpoint accepts a `publicKey` parameter: +During the eVault provisioning process (onboarding), the public key can be set directly when creating the eVault. The `/provision` endpoint accepts an optional `publicKey` parameter: **Provision Request:** ```http @@ -36,17 +36,21 @@ Content-Type: application/json "registryEntropy": "", "namespace": "", "verificationId": "", - "publicKey": "z3059301306072a8648ce3d020106082a8648ce3d03010703420004..." + "publicKey": "z3059301306072a8648ce3d020106082a8648ce3d03010703420004..." // Optional } ``` -When provisioning an eVault during onboarding, the eID wallet: +**Note**: The `publicKey` parameter is optional. It is required for user eVaults that need key binding for signature verification, but can be omitted for keyless eVaults (such as platform or group eVaults) that don't require cryptographic identity. + +When provisioning a user eVault during onboarding, the eID wallet: 1. Generates or retrieves the public key using `getApplicationPublicKey()` 2. Includes the `publicKey` in the provision request 3. The eVault stores the public key and generates a key binding certificate automatically This eliminates the need for a separate sync step when the eVault is first created. +For platform or group eVaults that don't need key binding, the `publicKey` can be omitted entirely. + ### Syncing Public Keys to eVault The public key syncing is an autonomous process done by the eID Wallet when linking new devices to the same eName. @@ -155,7 +159,7 @@ X-ENAME: @user.w3id ### Setting Public Key During eVault Creation ```typescript -// During onboarding - provision eVault with public key +// During onboarding - provision user eVault with public key const publicKey = await getApplicationPublicKey(); // Get public key from KeyService const provisionResponse = await axios.post( @@ -164,11 +168,28 @@ const provisionResponse = await axios.post( registryEntropy, namespace: uuidv4(), verificationId, - publicKey: publicKey, // Public key included in provision request + publicKey: publicKey, // Optional: include for user eVaults, omit for keyless eVaults + } +); + +// eVault is created with the public key already stored (if provided) +const { w3id, uri } = provisionResponse.data; +``` + +For keyless eVaults (platforms, groups), omit the `publicKey` parameter: + +```typescript +// Provision a keyless eVault (e.g., for a platform or group) +const provisionResponse = await axios.post( + new URL("/provision", provisionerUrl).toString(), + { + registryEntropy, + namespace: uuidv4(), + verificationId, + // No publicKey - this is a keyless eVault } ); -// eVault is created with the public key already stored const { w3id, uri } = provisionResponse.data; ``` diff --git a/docs/docs/W3DS Protocol/Signature-Formats.md b/docs/docs/W3DS Protocol/Signature-Formats.md index 204e87c3c..5dbd34133 100644 --- a/docs/docs/W3DS Protocol/Signature-Formats.md +++ b/docs/docs/W3DS Protocol/Signature-Formats.md @@ -328,8 +328,9 @@ The provisioning process creates an eVault tied to your generated public key: - `registryEntropy`: JWT token from step 1 - `namespace`: Identifier from step 2 - `verificationId`: Verification code (demo code or your verification ID) - - `publicKey`: Multibase-encoded public key from key generation - - Provisioner validates entropy, generates W3ID, creates eVault, stores public key, and requests key binding certificate from Registry + - `publicKey` (optional): Multibase-encoded public key from key generation + - Provisioner validates entropy, generates W3ID, creates eVault, and if publicKey is provided, stores it and requests key binding certificate from Registry + - **Note**: `publicKey` is required for user eVaults that need signature verification, but optional for keyless eVaults (platforms, groups) 4. **Receive Credentials** - Receive `w3id` (eName) and `uri` (eVault URI) in response diff --git a/infrastructure/evault-core/src/core/http/server.ts b/infrastructure/evault-core/src/core/http/server.ts index acbbaf7a1..699a880b1 100644 --- a/infrastructure/evault-core/src/core/http/server.ts +++ b/infrastructure/evault-core/src/core/http/server.ts @@ -682,7 +682,6 @@ export async function registerHttpRoutes( "registryEntropy", "namespace", "verificationId", - "publicKey", ], properties: { registryEntropy: { type: "string" }, @@ -808,8 +807,10 @@ export async function registerHttpRoutes( console.log( `[MIGRATION] No metaEnvelopes found for eName: ${eName}`, ); - return reply.status(400).send({ - error: `No metaEnvelopes found for eName: ${eName}`, + return reply.status(200).send({ + success: true, + count: 0, + message: "No metaEnvelopes to copy", }); } diff --git a/infrastructure/evault-core/src/services/ProvisioningService.ts b/infrastructure/evault-core/src/services/ProvisioningService.ts index 5d342fbe3..d08fbce73 100644 --- a/infrastructure/evault-core/src/services/ProvisioningService.ts +++ b/infrastructure/evault-core/src/services/ProvisioningService.ts @@ -8,7 +8,7 @@ export interface ProvisionRequest { registryEntropy: string; namespace: string; verificationId: string; - publicKey: string; + publicKey?: string; } export interface ProvisionResponse { @@ -41,17 +41,21 @@ export class ProvisioningService { if ( !registryEntropy || !namespace || - !verificationId || - !publicKey + !verificationId ) { return { success: false, error: "Missing required fields", message: - "Missing required fields: registryEntropy, namespace, verificationId, publicKey", + "Missing required fields: registryEntropy, namespace, verificationId", }; } + // Log if keyless provisioning + if (!publicKey) { + console.log(`[PROVISIONING] Keyless eVault provisioning (no publicKey provided)`); + } + // Verify the registry entropy token let payload: any; try { diff --git a/platforms/emover-api/src/controllers/AdminController.ts b/platforms/emover-api/src/controllers/AdminController.ts new file mode 100644 index 000000000..fd195cabe --- /dev/null +++ b/platforms/emover-api/src/controllers/AdminController.ts @@ -0,0 +1,224 @@ +import type { Request, Response } from "express"; +import { EventEmitter } from "node:events"; +import axios from "axios"; +import { MigrationService } from "../services/MigrationService"; + +export class AdminController { + private migrationService: MigrationService; + private registryUrl: string; + private eventEmitter: EventEmitter; + + constructor() { + this.migrationService = new MigrationService(); + this.registryUrl = process.env.PUBLIC_REGISTRY_URL || "http://localhost:4321"; + this.eventEmitter = new EventEmitter(); + + // Forward migration service events + this.migrationService.on( + "migration-update", + (migrationId: string, data: unknown) => { + this.eventEmitter.emit(migrationId, data); + }, + ); + } + + // GET /api/admin/enames - List all enames from registry + listEnames = async (req: Request, res: Response) => { + try { + const response = await axios.get(`${this.registryUrl}/list`); + const vaults = response.data; + + // Return simplified list: ename, evault, uri, provider (defensive for malformed URIs) + const enames = vaults.map((v: { ename?: string; evault?: string; uri?: string }) => { + let provider: string | null = null; + if (v.uri) { + try { + provider = new URL(v.uri).hostname; + } catch { + // Malformed URI: use empty string so one bad record does not crash the request + provider = ""; + } + } + return { + ename: v.ename, + evault: v.evault, + uri: v.uri, + provider: provider ?? "", + }; + }); + + return res.json(enames); + } catch (error) { + console.error("Error listing enames:", error); + return res.status(500).json({ error: "Failed to list enames" }); + } + }; + + // POST /api/admin/migrate - Admin initiates migration for any ename (NO SIGNING) + initiateMigration = async (req: Request, res: Response) => { + try { + const { ename, provisionerUrl } = req.body; + + if (!ename || !provisionerUrl) { + return res.status(400).json({ + error: "ename and provisionerUrl are required" + }); + } + + // Admin initiates migration for the specified ename + // Use admin user ID as the migration owner + const migration = await this.migrationService.initiateMigration( + req.user!.id, // Admin user + ename, + provisionerUrl, + ); + + // Admin migrations bypass signing - start immediately + this.processMigration(migration.id).catch(error => { + console.error(`Admin migration ${migration.id} failed:`, error); + }); + + return res.json({ + migrationId: migration.id, + message: `Migration started for ${ename}`, + }); + } catch (error) { + console.error("Error initiating admin migration:", error); + return res.status(500).json({ + error: error instanceof Error ? error.message : "Internal server error" + }); + } + }; + + // POST /api/admin/migrate/bulk - Admin initiates migrations for multiple enames + initiateBulkMigration = async (req: Request, res: Response) => { + try { + const { enames, provisionerUrl } = req.body; + + if (!enames || !Array.isArray(enames) || enames.length === 0) { + return res.status(400).json({ + error: "enames array is required and must not be empty" + }); + } + + if (!provisionerUrl) { + return res.status(400).json({ + error: "provisionerUrl is required" + }); + } + + const results = []; + + // Initiate migration for each ename + for (const ename of enames) { + try { + const migration = await this.migrationService.initiateMigration( + req.user!.id, // Admin user + ename, + provisionerUrl, + ); + + // Start migration immediately (no signing) + this.processMigration(migration.id).catch(error => { + console.error(`Admin bulk migration ${migration.id} for ${ename} failed:`, error); + }); + + results.push({ + ename, + migrationId: migration.id, + status: 'started' + }); + } catch (error) { + results.push({ + ename, + status: 'failed', + error: error instanceof Error ? error.message : 'Unknown error' + }); + } + } + + return res.json({ + results, + message: `Started ${results.filter(r => r.status === 'started').length} of ${enames.length} migrations`, + }); + } catch (error) { + console.error("Error initiating bulk admin migration:", error); + return res.status(500).json({ + error: error instanceof Error ? error.message : "Internal server error" + }); + } + }; + + // Copy processMigration from MigrationController + private async processMigration(migrationId: string): Promise { + const migration = await this.migrationService.getMigrationById(migrationId); + if (!migration) { + throw new Error("Migration not found"); + } + + try { + // Step 1: Provision new evault + if (!migration.provisionerUrl) { + throw new Error("Provisioner URL not found in migration"); + } + + const { evaultId, uri: newEvaultUri, w3id: newW3id } = + await this.migrationService.provisionNewEvault( + migrationId, + migration.provisionerUrl, + migration.eName, + ); + + // Step 2: Copy metaEnvelopes (validate oldEvaultUri so we fail fast) + const oldEvaultUri = migration.oldEvaultUri; + if (oldEvaultUri == null || oldEvaultUri.trim() === "") { + const msg = `Migration ${migrationId} (eName: ${migration.eName ?? "unknown"}) is missing oldEvaultUri; cannot copy metaEnvelopes`; + console.error(`[ADMIN MIGRATION ERROR] ${msg}`); + throw new Error(msg); + } + const count = await this.migrationService.copyMetaEnvelopes( + migrationId, + oldEvaultUri, + newEvaultUri, + migration.eName, + ); + + // Step 3: Verify copy + await this.migrationService.verifyDataCopy( + migrationId, + newEvaultUri, + migration.eName, + count, + ); + + // Step 4: Update registry mapping + await this.migrationService.updateRegistryMapping( + migrationId, + migration.eName, + evaultId, + newW3id, + ); + + // Step 5: Verify registry update + await this.migrationService.verifyRegistryUpdate( + migrationId, + migration.eName, + evaultId, + ); + + // Step 6: Mark as active + await this.migrationService.markEvaultActive( + migrationId, + migration.eName, + evaultId, + ); + } catch (error) { + console.error( + `[ADMIN MIGRATION ERROR] Migration ${migrationId} failed:`, + error, + ); + await this.migrationService.cleanupGhostEvault(migrationId); + throw error; + } + } +} diff --git a/platforms/emover-api/src/controllers/AuthController.ts b/platforms/emover-api/src/controllers/AuthController.ts index 1e8f95e1d..4f5a81032 100644 --- a/platforms/emover-api/src/controllers/AuthController.ts +++ b/platforms/emover-api/src/controllers/AuthController.ts @@ -101,6 +101,7 @@ export class AuthController { user: { id: user.id, ename: user.ename, + role: user.role, }, token, }; diff --git a/platforms/emover-api/src/controllers/MigrationController.ts b/platforms/emover-api/src/controllers/MigrationController.ts index 66b1149cf..c0d1149ff 100644 --- a/platforms/emover-api/src/controllers/MigrationController.ts +++ b/platforms/emover-api/src/controllers/MigrationController.ts @@ -312,6 +312,7 @@ export class MigrationController { `[MIGRATION ERROR] Migration ${migrationId} failed:`, error, ); + await this.migrationService.cleanupGhostEvault(migrationId); throw error; } } diff --git a/platforms/emover-api/src/controllers/UserController.ts b/platforms/emover-api/src/controllers/UserController.ts index 6ff91fed7..3c4fba848 100644 --- a/platforms/emover-api/src/controllers/UserController.ts +++ b/platforms/emover-api/src/controllers/UserController.ts @@ -12,6 +12,7 @@ export class UserController { id: req.user.id, ename: req.user.ename, name: req.user.name, + role: req.user.role, createdAt: req.user.createdAt, updatedAt: req.user.updatedAt, }); diff --git a/platforms/emover-api/src/database/entities/Migration.ts b/platforms/emover-api/src/database/entities/Migration.ts index e47fe66b2..6b4f2abdd 100644 --- a/platforms/emover-api/src/database/entities/Migration.ts +++ b/platforms/emover-api/src/database/entities/Migration.ts @@ -31,6 +31,10 @@ export class Migration { @Column({ nullable: true }) newEvaultId!: string; + /** Provisioner-created w3id registered with Registry; used for cleanup on failure. */ + @Column({ nullable: true }) + newW3id!: string; + @Column({ nullable: true }) eName!: string; diff --git a/platforms/emover-api/src/database/entities/User.ts b/platforms/emover-api/src/database/entities/User.ts index 7a77d62c6..116362261 100644 --- a/platforms/emover-api/src/database/entities/User.ts +++ b/platforms/emover-api/src/database/entities/User.ts @@ -6,6 +6,11 @@ import { UpdateDateColumn, } from "typeorm"; +export enum UserRole { + USER = "user", + ADMIN = "admin", +} + @Entity("users") export class User { @PrimaryGeneratedColumn("uuid") @@ -17,6 +22,13 @@ export class User { @Column({ nullable: true }) name!: string; + @Column({ + type: "enum", + enum: UserRole, + default: UserRole.USER, + }) + role!: UserRole; + @CreateDateColumn() createdAt!: Date; diff --git a/platforms/emover-api/src/database/migrations/1764300000000-add-user-role.ts b/platforms/emover-api/src/database/migrations/1764300000000-add-user-role.ts new file mode 100644 index 000000000..d62926519 --- /dev/null +++ b/platforms/emover-api/src/database/migrations/1764300000000-add-user-role.ts @@ -0,0 +1,24 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddUserRole1764300000000 implements MigrationInterface { + name = "AddUserRole1764300000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TYPE "user_role_enum" AS ENUM('user', 'admin') + `); + await queryRunner.query(` + ALTER TABLE "users" + ADD "role" "user_role_enum" NOT NULL DEFAULT 'user' + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "users" DROP COLUMN "role" + `); + await queryRunner.query(` + DROP TYPE "user_role_enum" + `); + } +} diff --git a/platforms/emover-api/src/database/migrations/1764310000000-add-migration-new-w3id.ts b/platforms/emover-api/src/database/migrations/1764310000000-add-migration-new-w3id.ts new file mode 100644 index 000000000..aaf99187a --- /dev/null +++ b/platforms/emover-api/src/database/migrations/1764310000000-add-migration-new-w3id.ts @@ -0,0 +1,18 @@ +import type { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddMigrationNewW3id1764310000000 implements MigrationInterface { + name = "AddMigrationNewW3id1764310000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "evault_migrations" + ADD "newW3id" character varying + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "evault_migrations" DROP COLUMN "newW3id" + `); + } +} diff --git a/platforms/emover-api/src/index.ts b/platforms/emover-api/src/index.ts index d46190bc2..11f376527 100644 --- a/platforms/emover-api/src/index.ts +++ b/platforms/emover-api/src/index.ts @@ -7,8 +7,10 @@ import { AuthController } from "./controllers/AuthController"; import { EvaultInfoController } from "./controllers/EvaultInfoController"; import { MigrationController } from "./controllers/MigrationController"; import { UserController } from "./controllers/UserController"; +import { AdminController } from "./controllers/AdminController"; import { AppDataSource } from "./database/data-source"; import { authGuard, authMiddleware } from "./middleware/auth"; +import { adminGuard } from "./middleware/admin"; config({ path: path.resolve(__dirname, "../../../.env") }); @@ -43,6 +45,7 @@ const authController = new AuthController(); const userController = new UserController(); const evaultInfoController = new EvaultInfoController(); const migrationController = new MigrationController(); +const adminController = new AdminController(); // Public routes (no auth required) app.get("/api/auth/offer", authController.getOffer); @@ -62,6 +65,11 @@ app.post("/api/migration/callback", migrationController.callback); app.get("/api/migration/status/:id", migrationController.getStatus); app.post("/api/migration/delete-old", authGuard, migrationController.deleteOld); +// Admin routes (requires admin role) +app.get("/api/admin/enames", authGuard, adminGuard, adminController.listEnames); +app.post("/api/admin/migrate", authGuard, adminGuard, adminController.initiateMigration); +app.post("/api/admin/migrate/bulk", authGuard, adminGuard, adminController.initiateBulkMigration); + // Health check app.get("/health", (req, res) => { res.json({ status: "ok" }); diff --git a/platforms/emover-api/src/middleware/admin.ts b/platforms/emover-api/src/middleware/admin.ts new file mode 100644 index 000000000..986675679 --- /dev/null +++ b/platforms/emover-api/src/middleware/admin.ts @@ -0,0 +1,14 @@ +import type { Request, Response, NextFunction } from "express"; +import { UserRole } from "../database/entities/User"; + +export const adminGuard = (req: Request, res: Response, next: NextFunction) => { + if (!req.user) { + return res.status(401).json({ error: "Authentication required" }); + } + + if (req.user.role !== UserRole.ADMIN) { + return res.status(403).json({ error: "Admin access required" }); + } + + next(); +}; diff --git a/platforms/emover-api/src/services/MigrationService.ts b/platforms/emover-api/src/services/MigrationService.ts index 75cb5d7d4..0aace42ce 100644 --- a/platforms/emover-api/src/services/MigrationService.ts +++ b/platforms/emover-api/src/services/MigrationService.ts @@ -173,19 +173,34 @@ export class MigrationService extends EventEmitter { ); // Provision new evault with preserved public key - console.log( - `[MIGRATION] Provisioning new evault with public key: ${publicKey.substring(0, 20)}...`, - ); + const provisionBody: { + registryEntropy: string; + namespace: string; + verificationId: string; + publicKey?: string; + } = { + registryEntropy, + namespace: namespace, + verificationId: + process.env.DEMO_VERIFICATION_CODE || + "d66b7138-538a-465f-a6ce-f6985854c3f4", + }; + + // Only include publicKey if it's not the default fallback + if (publicKey !== "0x0000000000000000000000000000000000000000") { + provisionBody.publicKey = publicKey; + console.log( + `[MIGRATION] Provisioning new evault with public key: ${publicKey.substring(0, 20)}...`, + ); + migration.logs += `[MIGRATION] Provisioning with public key: ${publicKey.substring(0, 20)}...\n`; + } else { + console.log(`[MIGRATION] Provisioning keyless evault (no public key)`); + migration.logs += `[MIGRATION] Provisioning keyless evault (no public key)\n`; + } + const provisionResponse = await axios.post( new URL("/provision", provisionerUrl).toString(), - { - registryEntropy, - namespace: namespace, - verificationId: - process.env.DEMO_VERIFICATION_CODE || - "d66b7138-538a-465f-a6ce-f6985854c3f4", - publicKey: publicKey, - }, + provisionBody, ); if (!provisionResponse.data.success) { @@ -194,6 +209,10 @@ export class MigrationService extends EventEmitter { const { w3id, uri } = provisionResponse.data; + // Persist newW3id immediately so cleanup can remove the Registry entry on any later failure + migration.newW3id = w3id; + await this.migrationRepository.save(migration); + // Get evault ID from registry const evaultInfo = await axios.get( new URL(`/resolve?w3id=${w3id}`, this.registryUrl).toString(), @@ -211,7 +230,13 @@ export class MigrationService extends EventEmitter { migration.newEvaultUri = uri; migration.logs += `[MIGRATION] New evault provisioned: ${evaultId}, URI: ${uri}\n`; migration.logs += `[MIGRATION] Old evault ID: ${migration.oldEvaultId || "N/A"}, New evault ID: ${evaultId}\n`; - migration.logs += `[MIGRATION] Public key preserved: ${publicKey.substring(0, 20)}...\n`; + + if (publicKey !== "0x0000000000000000000000000000000000000000") { + migration.logs += `[MIGRATION] Public key preserved: ${publicKey.substring(0, 20)}...\n`; + } else { + migration.logs += `[MIGRATION] Keyless evault provisioned successfully\n`; + } + await this.migrationRepository.save(migration); console.log( @@ -236,6 +261,41 @@ export class MigrationService extends EventEmitter { } } + /** + * Removes the ghost Registry entry (provisioner-created w3id -> new evault) when a migration fails. + * Best-effort; does not throw. + */ + async cleanupGhostEvault(migrationId: string): Promise { + const migration = await this.migrationRepository.findOneBy({ + id: migrationId, + }); + if (!migration?.newW3id) { + return; + } + try { + await axios.delete( + new URL( + `/register?ename=${encodeURIComponent(migration.newW3id)}`, + this.registryUrl, + ).toString(), + { + headers: { + Authorization: `Bearer ${process.env.REGISTRY_SHARED_SECRET}`, + }, + validateStatus: (status) => status === 200 || status === 404, + }, + ); + console.log( + `[MIGRATION] Cleaned up ghost Registry entry for w3id: ${migration.newW3id}`, + ); + } catch (error) { + console.warn( + `[MIGRATION] Failed to cleanup ghost Registry entry for w3id ${migration.newW3id}:`, + error, + ); + } + } + async copyMetaEnvelopes( migrationId: string, oldEvaultUri: string, @@ -541,6 +601,7 @@ export class MigrationService extends EventEmitter { }); migration.logs += `[MIGRATION] New evault marked as active and verified working\n`; + migration.status = MigrationStatus.COMPLETED; await this.migrationRepository.save(migration); console.log(`[MIGRATION] New evault marked as active for ${eName}`); diff --git a/platforms/emover/src/app/(app)/admin/bulk-migrate/page.tsx b/platforms/emover/src/app/(app)/admin/bulk-migrate/page.tsx new file mode 100644 index 000000000..fe7b970bb --- /dev/null +++ b/platforms/emover/src/app/(app)/admin/bulk-migrate/page.tsx @@ -0,0 +1,299 @@ +"use client"; +import { useState, useEffect, Suspense } from "react"; +import { useSearchParams, useRouter } from "next/navigation"; +import { apiClient } from "@/lib/apiClient"; + +type MigrationStatus = + | "initiated" + | "provisioning" + | "copying" + | "verifying" + | "updating_registry" + | "marking_active" + | "completed" + | "failed"; + +const STATUS_LABELS: Record = { + initiated: "Migration Initiated", + provisioning: "Provisioning New eVault", + copying: "Copying Data", + verifying: "Verifying Copy", + updating_registry: "Updating Registry", + marking_active: "Activating New eVault", + completed: "Migration Completed", + failed: "Migration Failed", +}; + +interface MigrationInfo { + migrationId: string; + ename: string; + status: MigrationStatus | null; + logs: string[]; +} + +function BulkMigrateContent() { + const searchParams = useSearchParams(); + const router = useRouter(); + const migrationIdsParam = searchParams?.get("migrations"); + const [migrations, setMigrations] = useState([]); + const [selectedMigrationId, setSelectedMigrationId] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + if (!migrationIdsParam) { + router.push("/admin"); + return; + } + + try { + // Parse migration data from query param: "id1:ename1,id2:ename2,..." + const pairs = migrationIdsParam.split(","); + const initialMigrations = pairs + .map((pair) => { + const parts = pair.split(":"); + const migrationId = parts[0]?.trim() ?? ""; + const ename = parts.slice(1).join(":").trim() ?? ""; + return { migrationId, ename }; + }) + .filter(({ migrationId, ename }) => migrationId.length > 0 && ename.length > 0) + .map(({ migrationId, ename }) => ({ + migrationId, + ename, + status: null, + logs: [], + })); + + if (initialMigrations.length === 0) { + router.push("/admin"); + return; + } + + setMigrations(initialMigrations); + const first = initialMigrations[0]; + if (first?.migrationId) { + setSelectedMigrationId(first.migrationId); + } + setIsLoading(false); + } catch (error) { + console.error("Error parsing migration IDs:", error); + router.push("/admin"); + } + }, [migrationIdsParam, router]); + + // Poll migration status for all migrations + useEffect(() => { + if (migrations.length === 0) return; + + const pollAllStatus = async () => { + const updatedMigrations = await Promise.all( + migrations.map(async (migration) => { + try { + const response = await apiClient.get(`/api/migration/status/${migration.migrationId}`); + const data = response.data; + + const logLines = data.logs + ? data.logs.split("\n").filter((line: string) => line.trim().length > 0) + : []; + + // Detect completion from logs if status is still marking_active + let finalStatus = data.status as MigrationStatus; + if (finalStatus === "marking_active" && + logLines.some((log: string) => log.includes("New evault marked as active and verified working"))) { + finalStatus = "completed"; + } + + return { + ...migration, + status: finalStatus, + logs: logLines, + }; + } catch (error) { + console.error(`Error fetching status for ${migration.migrationId}:`, error); + return migration; + } + }), + ); + setMigrations(updatedMigrations); + }; + + pollAllStatus(); + const interval = setInterval(pollAllStatus, 2000); + + return () => clearInterval(interval); + }, [migrations.length]); + + const selectedMigration = migrations.find(m => m.migrationId === selectedMigrationId); + const allCompleted = migrations.length > 0 && migrations.every(m => m.status === "completed"); + const anyFailed = migrations.some(m => m.status === "failed"); + + const getStatusColor = (status: MigrationStatus | null) => { + if (!status) return "bg-gray-100 text-gray-800"; + if (status === "completed") return "bg-green-100 text-green-800"; + if (status === "failed") return "bg-red-100 text-red-800"; + return "bg-blue-100 text-blue-800"; + }; + + const getStatusIcon = (status: MigrationStatus | null) => { + if (!status) return "⏳"; + if (status === "completed") return "✓"; + if (status === "failed") return "✗"; + return "⏳"; + }; + + if (isLoading) { + return ( +
+
+
+

Loading migrations...

+
+
+ ); + } + + return ( +
+
+

+ Bulk Migration Status +

+

+ Monitoring {migrations.length} migration{migrations.length > 1 ? "s" : ""} +

+
+ + {allCompleted && ( +
+

+ All migrations completed successfully! +

+ +
+ )} + + {anyFailed && !allCompleted && ( +
+

+ Some migrations have failed. Check the logs for details. +

+
+ )} + +
+ {/* Left: Migration list */} +
+

+ Migrations +

+
+ {migrations.map((migration) => ( + + ))} +
+
+ + {/* Right: Logs display */} +
+ {selectedMigration ? ( + <> +
+

+ {selectedMigration.ename} +

+ {selectedMigration.status && ( + + {STATUS_LABELS[selectedMigration.status]} + + )} +
+ +
+ {selectedMigration.logs.length > 0 ? ( +
+ {selectedMigration.logs.map((log, index) => ( +
+ {log} +
+ ))} +
+ ) : ( +

+ Waiting for migration logs... +

+ )} +
+ + ) : ( +
+ Select a migration to view logs +
+ )} +
+
+ +
+ +
+
+ ); +} + +export default function BulkMigratePage() { + return ( + +
+
+

Loading...

+
+
+ } + > + +
+ ); +} diff --git a/platforms/emover/src/app/(app)/admin/page.tsx b/platforms/emover/src/app/(app)/admin/page.tsx new file mode 100644 index 000000000..6022e9f10 --- /dev/null +++ b/platforms/emover/src/app/(app)/admin/page.tsx @@ -0,0 +1,386 @@ +"use client"; +import { useState, useEffect, useMemo } from "react"; +import { useAuth } from "@/lib/auth-context"; +import { apiClient } from "@/lib/apiClient"; +import { useRouter } from "next/navigation"; + +const PER_PAGE = 20; + +interface EnameInfo { + ename: string; + evault: string; + uri: string; + provider: string; +} + +interface Provisioner { + url: string; + name: string; + description: string; +} + +export default function AdminDashboard() { + const { isAdmin, isAuthenticated, isLoading } = useAuth(); + const router = useRouter(); + const [enames, setEnames] = useState([]); + const [provisioners, setProvisioners] = useState([]); + const [selectedProvisioner, setSelectedProvisioner] = useState(""); + const [isLoadingData, setIsLoadingData] = useState(true); + const [searchQuery, setSearchQuery] = useState(""); + const [page, setPage] = useState(1); + const [selectedEnames, setSelectedEnames] = useState>(new Set()); + + useEffect(() => { + if (!isLoading && (!isAuthenticated || !isAdmin)) { + router.push("/"); + return; + } + }, [isLoading, isAuthenticated, isAdmin, router]); + + useEffect(() => { + if (!isAdmin) return; + + const fetchData = async () => { + try { + const [enamesRes, provisionersRes] = await Promise.all([ + apiClient.get("/api/admin/enames"), + apiClient.get("/api/provisioners"), + ]); + setEnames(enamesRes.data); + setProvisioners(provisionersRes.data); + } catch (error) { + console.error("Error fetching admin data:", error); + } finally { + setIsLoadingData(false); + } + }; + + fetchData(); + }, [isAdmin]); + + const toggleSelectEname = (ename: string) => { + setSelectedEnames(prev => { + const next = new Set(prev); + if (next.has(ename)) { + next.delete(ename); + } else { + next.add(ename); + } + return next; + }); + }; + + const toggleSelectAll = () => { + const allOnPageSelected = + paginatedEnames.length > 0 && + paginatedEnames.every((e) => selectedEnames.has(e.ename)); + if (allOnPageSelected) { + setSelectedEnames((prev) => { + const next = new Set(prev); + for (const e of paginatedEnames) { + next.delete(e.ename); + } + return next; + }); + } else { + setSelectedEnames((prev) => { + const next = new Set(prev); + for (const e of paginatedEnames) { + next.add(e.ename); + } + return next; + }); + } + }; + + const handleStartMigration = async () => { + if (!selectedProvisioner) { + return; + } + + const enameList = Array.from(selectedEnames); + if (enameList.length === 0) { + return; + } + + try { + if (enameList.length === 1) { + // Single migration + const response = await apiClient.post("/api/admin/migrate", { + ename: enameList[0], + provisionerUrl: selectedProvisioner, + }); + setSelectedEnames(new Set()); + router.push(`/migrate?migrationId=${response.data.migrationId}`); + } else { + // Bulk migration + const response = await apiClient.post("/api/admin/migrate/bulk", { + enames: enameList, + provisionerUrl: selectedProvisioner, + }); + + const started = response.data.results.filter((r: { status: string; migrationId?: string; ename?: string }) => r.status === 'started'); + + if (started.length > 0) { + const migrationsParam = started + .map((r: { migrationId?: string; ename?: string }) => `${r.migrationId}:${r.ename}`) + .join(","); + router.push(`/admin/bulk-migrate?migrations=${encodeURIComponent(migrationsParam)}`); + } + + setSelectedEnames(new Set()); + } + } catch (error) { + console.error("Error starting migration:", error); + } + }; + + const filteredEnames = useMemo(() => { + const q = searchQuery.trim().toLowerCase(); + if (!q) return enames; + return enames.filter( + (e) => + e.ename.toLowerCase().includes(q) || + e.evault.toLowerCase().includes(q) || + e.provider.toLowerCase().includes(q), + ); + }, [enames, searchQuery]); + + const totalPages = Math.max(1, Math.ceil(filteredEnames.length / PER_PAGE)); + const currentPage = Math.min(Math.max(1, page), totalPages); + const paginatedEnames = useMemo(() => { + const start = (currentPage - 1) * PER_PAGE; + return filteredEnames.slice(start, start + PER_PAGE); + }, [filteredEnames, currentPage]); + + const allOnPageSelected = paginatedEnames.length > 0 && + paginatedEnames.every(e => selectedEnames.has(e.ename)); + + useEffect(() => { + if (page > totalPages && totalPages >= 1) setPage(totalPages); + }, [totalPages, page]); + + if (isLoading || isLoadingData) { + return ( +
+
+
+

Loading...

+
+
+ ); + } + + return ( +
+
+

+ Admin Dashboard +

+

+ Migrate eVaults for any user +

+
+ +
+ {/* Left: Ename table with search and pagination */} +
+

+ Select User (eName) +

+
+ { + setSearchQuery(e.target.value); + setPage(1); + }} + className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500" + aria-label="Search enames" + /> +
+ {enames.length > 0 ? ( + <> +
+ + + + + + + + + + + {paginatedEnames.map((row) => ( + + + + + + + ))} + +
+ + + eName + + eVault ID + + Provider +
+ toggleSelectEname(row.ename)} + onClick={(e) => e.stopPropagation()} + className="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500" + aria-label={`Select ${row.ename}`} + /> + + {row.ename} + + {row.evault} + + {row.provider} +
+
+
+

+ Showing{" "} + {(currentPage - 1) * PER_PAGE + 1}– + {Math.min( + currentPage * PER_PAGE, + filteredEnames.length, + )}{" "} + of {filteredEnames.length} +

+
+ + + Page {currentPage} of {totalPages} + + +
+
+ + ) : ( +

No enames found

+ )} +
+ + {/* Right: Provisioner selection */} +
+

+ Select New Provider +

+ {provisioners.length > 0 ? ( +
+ {provisioners.map((provisioner) => ( + + ))} + {selectedEnames.size > 0 && ( +
+

+ {selectedEnames.size} ename{selectedEnames.size > 1 ? 's' : ''} selected +

+ +
+ )} + +
+ ) : ( +

+ No provisioners available +

+ )} +
+
+
+ ); +} diff --git a/platforms/emover/src/app/(app)/layout.tsx b/platforms/emover/src/app/(app)/layout.tsx index d5fc2349b..b80d17f95 100644 --- a/platforms/emover/src/app/(app)/layout.tsx +++ b/platforms/emover/src/app/(app)/layout.tsx @@ -9,7 +9,7 @@ export default function AppLayout({ }: { children: React.ReactNode; }) { - const { isAuthenticated, isLoading, logout } = useAuth(); + const { isAuthenticated, isLoading, isAdmin, logout } = useAuth(); const router = useRouter(); function handleLogout() { @@ -51,13 +51,20 @@ export default function AppLayout({ Emover
- +
+ {isAdmin && ( + + ADMIN + + )} + +
diff --git a/platforms/emover/src/app/(app)/migrate/page.tsx b/platforms/emover/src/app/(app)/migrate/page.tsx index c76822da8..17e6f71e6 100644 --- a/platforms/emover/src/app/(app)/migrate/page.tsx +++ b/platforms/emover/src/app/(app)/migrate/page.tsx @@ -29,6 +29,7 @@ function MigrateContent() { const searchParams = useSearchParams(); const router = useRouter(); const provisionerUrl = searchParams?.get("provisioner"); + const migrationIdParam = searchParams?.get("migrationId"); // Admin provides this const [migrationId, setMigrationId] = useState(null); const [sessionId, setSessionId] = useState(null); const [qrData, setQrData] = useState(null); @@ -38,7 +39,17 @@ function MigrateContent() { const [isSigned, setIsSigned] = useState(false); const [isActivated, setIsActivated] = useState(false); + // If migrationId is provided (admin flow), skip initiation and signing + const isAdminMigration = !!migrationIdParam; + useEffect(() => { + if (isAdminMigration) { + // Admin flow: migration already started, just set ID and poll + setMigrationId(migrationIdParam); + return; + } + + // User flow: initiate migration (existing code) if (!provisionerUrl) { setError("Provisioner URL is required"); return; @@ -57,10 +68,10 @@ function MigrateContent() { }; initiateMigration(); - }, [provisionerUrl]); + }, [provisionerUrl, isAdminMigration, migrationIdParam]); useEffect(() => { - if (!migrationId) return; + if (!migrationId || isAdminMigration) return; // Skip signing for admin const createSigningSession = async () => { try { @@ -76,7 +87,7 @@ function MigrateContent() { }; createSigningSession(); - }, [migrationId]); + }, [migrationId, isAdminMigration]); // Poll migration status useEffect(() => { @@ -131,7 +142,7 @@ function MigrateContent() { // Listen for signing confirmation via SSE useEffect(() => { - if (!sessionId || isSigned) return; + if (!sessionId || isSigned || isAdminMigration) return; // Skip for admin const baseUrl = process.env.NEXT_PUBLIC_EMOVER_BASE_URL || "http://localhost:4003"; @@ -181,7 +192,7 @@ function MigrateContent() { Migration in Progress - {qrData && !isSigned && ( + {qrData && !isSigned && !isAdminMigration && (

Scan QR Code to Confirm Migration diff --git a/platforms/emover/src/app/(app)/page.tsx b/platforms/emover/src/app/(app)/page.tsx index 310c22e6d..d7c3df49e 100644 --- a/platforms/emover/src/app/(app)/page.tsx +++ b/platforms/emover/src/app/(app)/page.tsx @@ -18,7 +18,7 @@ interface Provisioner { } export default function DashboardPage() { - const { isAuthenticated, isLoading } = useAuth(); + const { isAuthenticated, isLoading, isAdmin } = useAuth(); const router = useRouter(); const [evaultInfo, setEvaultInfo] = useState(null); const [provisioners, setProvisioners] = useState([]); @@ -87,6 +87,26 @@ export default function DashboardPage() {

+ {isAdmin && ( +
+
+

+ Admin Access +

+

+ You can migrate any user's evault +

+
+ +
+ )} +

diff --git a/platforms/emover/src/lib/auth-context.tsx b/platforms/emover/src/lib/auth-context.tsx index d4b54d531..fb0995988 100644 --- a/platforms/emover/src/lib/auth-context.tsx +++ b/platforms/emover/src/lib/auth-context.tsx @@ -8,6 +8,7 @@ interface User { id: string; ename: string; name?: string; + role: "user" | "admin"; createdAt: string; updatedAt: string; } @@ -15,6 +16,7 @@ interface User { interface AuthContextType { user: User | null; isAuthenticated: boolean; + isAdmin: boolean; isLoading: boolean; login: (ename: string) => Promise; logout: () => void; @@ -27,6 +29,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const [isLoading, setIsLoading] = useState(true); const isAuthenticated = !!user; + const isAdmin = user?.role === "admin"; useEffect(() => { const initializeAuth = async () => { @@ -68,7 +71,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { }; return ( - + {children} );