Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
68cc780
add mocked dashboard app
Sharqiewicz Jun 23, 2026
15b8ba6
update the login
Sharqiewicz Jun 24, 2026
7db78b9
update recipient mock
Sharqiewicz Jun 24, 2026
88bde3c
adjust mock flow to the planned flow
Sharqiewicz Jun 25, 2026
4df3ed0
Create schema for unifying user management.
ebma Jun 25, 2026
53a4bee
add mocked wallet connection, update tranfer form
Sharqiewicz Jul 2, 2026
68e3b5b
Merge branch 'feature/dashboard-app-staging' of github.com:pendulum-c…
Sharqiewicz Jul 2, 2026
1de0a25
update recipient link flow
Sharqiewicz Jul 3, 2026
6b3f9ba
add a backend connection to dashboard plan
Sharqiewicz Jul 3, 2026
1a4c186
Merge branch 'staging' into feature/dashboard-app-staging
gianfra-t Jul 7, 2026
9a5aec2
Add full target schema: migrations 038-043 + models (migrate-once, no…
gianfra-t Jul 7, 2026
151e708
Sync bun.lock with committed package.json changes
gianfra-t Jul 7, 2026
fc40d52
Cut partner/pricing reads over to partners + partner_pricing_configs
gianfra-t Jul 7, 2026
97e57ce
Cut mykobo + avenia identity over to customer_entities/provider_custo…
gianfra-t Jul 7, 2026
631bc30
Cut alfredpay identity over to provider_customers; drop dead legacy m…
gianfra-t Jul 7, 2026
f2aed61
Add recipient, notification and onboarding-status endpoints
gianfra-t Jul 7, 2026
d71c79c
Wire dashboard to the real backend with a ported transfer machine
gianfra-t Jul 7, 2026
1aebd2f
Record S1 obsolescence and Phase-0 wiring status in the dashboard plan
gianfra-t Jul 7, 2026
0ef41d1
Reframe transfer enforcement as the recipient-context registration gap
gianfra-t Jul 7, 2026
9114aa6
Wire dashboard recipients to the real backend; self-send offramps
gianfra-t Jul 8, 2026
83a5214
Gate dashboard self-recipients on real onboarding status
gianfra-t Jul 8, 2026
08b61b2
Remove all dashboard mock data; source everything from the DB
gianfra-t Jul 8, 2026
0d7c867
Add in_review onboarding state and fix widget deep-link path
gianfra-t Jul 9, 2026
ac4b3cc
Move sender onboarding back into the dashboard; restore the mock wizard
gianfra-t Jul 9, 2026
e03f227
Enable the AR KYB deep link; reframe KYC sharing as decouple-then-ext…
gianfra-t Jul 9, 2026
75b95b0
Extract Alfredpay KYC into packages/kyc; unmock dashboard individual KYC
gianfra-t Jul 9, 2026
9932068
improve corridor card display, validation bugs
gianfra-t Jul 9, 2026
4eeb01c
Share Alfredpay KYC API adapter
gianfra-t Jul 9, 2026
d4a3cca
Extract Avenia KYC machine
gianfra-t Jul 9, 2026
53a80da
Wire dashboard Avenia KYC live
gianfra-t Jul 9, 2026
05fb742
Fix dashboard Avenia KYC flow
gianfra-t Jul 9, 2026
f3c6a9e
Document dashboard AlfredPay fiat account follow-up
gianfra-t Jul 9, 2026
8186b09
Lock Alfredpay onboarding email fields
gianfra-t Jul 10, 2026
76c27e3
Add Playwright E2E harness for the dashboard auth surface
gianfra-t Jul 10, 2026
619c0d0
Add dashboard app spec; lock invite redemption to the widget
gianfra-t Jul 10, 2026
5647dea
Allow the accepting recipient to re-enter their invite link
gianfra-t Jul 10, 2026
e451648
Add dashboard MXN offramp transfer E2E journey
gianfra-t Jul 10, 2026
fbfcbaa
Add dashboard Alfredpay MX KYC E2E journey
gianfra-t Jul 10, 2026
36df075
Wire recipient invite redemption
gianfra-t Jul 13, 2026
570ea7f
re-introduce monerium oauth kyc
gianfra-t Jul 13, 2026
9ab7d87
Fix Monerium startup and dashboard transitions
gianfra-t Jul 13, 2026
570b449
Resume Monerium onboarding in dashboard
gianfra-t Jul 13, 2026
9d7aaf4
Surface Monerium reauthentication on corridor cards
gianfra-t Jul 13, 2026
428abab
new schema modifications
gianfra-t Jul 13, 2026
de35120
Hydrate Avenia company name from KYC status endpoint
gianfra-t Jul 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
20 changes: 20 additions & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,26 @@ jobs:
path: apps/frontend/playwright-report/
retention-days: 7

# The dashboard runs its own Playwright config (own Vite server on 5174). Always run it,
# even when the frontend journeys failed — one broken app should not hide the other's status.
- name: 🌐 Install Playwright browsers (dashboard)
if: always()
working-directory: apps/dashboard
run: bunx playwright install --with-deps chromium

- name: 🧪 Dashboard E2E journeys
if: always()
working-directory: apps/dashboard
run: bun run test:e2e

- name: 📤 Upload dashboard report on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-report-dashboard
path: apps/dashboard/playwright-report/
retention-days: 7

# Non-blocking runs are only useful if somebody hears about failures.
# Uses the same webhook token the backend's Slack notifier uses
# (repo secret SLACK_WEB_HOOK_TOKEN); skips silently when unset.
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ contracts/*/.env
# Playwright E2E artifacts
apps/frontend/test-results/
apps/frontend/playwright-report/
apps/dashboard/test-results/
apps/dashboard/playwright-report/

# Local credentials and agent-tool artifacts
.api-key.json
Expand Down
6 changes: 5 additions & 1 deletion apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@ PENDULUM_FUNDING_SEED=your-pendulum-funding-seed
MOONBEAM_EXECUTOR_PRIVATE_KEY=your-moonbeam-executor-private-key
# Optional. If unset, falls back to MOONBEAM_EXECUTOR_PRIVATE_KEY for EVM funding.
EVM_FUNDING_PRIVATE_KEY=
CLIENT_DOMAIN_SECRET=your-client-domain-secret

# Optional EVM payout address used as fallback when a partner has no payout_address_evm set.
DEFAULT_VORTEX_EVM_PAYOUT_ADDRESS=
Expand Down Expand Up @@ -99,6 +98,11 @@ ALFREDPAY_BASE_URL=your-alfredpay-base-url
ALFREDPAY_API_KEY=your-alfredpay-api-key
ALFREDPAY_API_SECRET=your-alfredpay-api-secret

# Monerium OAuth (the redirect URI must exactly match the dashboard callback registered with Monerium)
MONERIUM_CLIENT_ID=your-monerium-auth-code-client-id
MONERIUM_API_URL=https://api.monerium.dev
MONERIUM_REDIRECT_URI=http://localhost:5174/dashboard/monerium/callback

# Integration test helpers (only required for phase-processor integration tests)
# BACKEND_TEST_STARTER_ACCOUNT=
# TAX_ID=
1 change: 0 additions & 1 deletion apps/api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,6 @@ yarn seed:phase-metadata
- `FUNDING_SECRET`: Secret key to sign the funding transactions on Stellar.
- `PENDULUM_FUNDING_SEED`: Seed phrase to sign the funding transactions on Pendulum.
- `MOONBEAM_EXECUTOR_PRIVATE_KEY`: Private key to sign the transactions on Moonbeam.
- `CLIENT_DOMAIN_SECRET`: Secret for client domain verification.

### Database Configuration

Expand Down
36 changes: 25 additions & 11 deletions apps/api/src/api/controllers/admin/partnerApiKeys.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,15 @@ export async function createApiKey(req: Request<{ partnerName: string }>, res: R
const partnerName = req.params.partnerName;
const { name, expiresAt, userId } = req.body;

// Verify at least one partner with this name exists and is active
const partners = await Partner.findAll({
// Resolve the (unique-name) partner; keys bind to it by FK
const partner = await Partner.findOne({
where: {
isActive: true,
name: partnerName
}
});

if (partners.length === 0) {
if (!partner) {
res.status(httpStatus.NOT_FOUND).json({
error: {
code: "PARTNER_NOT_FOUND",
Expand Down Expand Up @@ -77,7 +77,7 @@ export async function createApiKey(req: Request<{ partnerName: string }>, res: R

const expirationDate = expiresAt ? new Date(expiresAt) : null;

// Create public key record
// Create public key record (partner_name kept as informational backup; auth resolves partner_id)
const publicKeyRecord = await ApiKey.create({
expiresAt: expirationDate,
isActive: true,
Expand All @@ -86,6 +86,7 @@ export async function createApiKey(req: Request<{ partnerName: string }>, res: R
keyType: "public",
keyValue: publicKey,
name: name ? `${name} (Public)` : "Public Key",
partnerId: partner.id,
partnerName,
userId: resolvedUserId
});
Expand All @@ -99,6 +100,7 @@ export async function createApiKey(req: Request<{ partnerName: string }>, res: R
keyType: "secret",
keyValue: null,
name: name ? `${name} (Secret)` : "Secret Key",
partnerId: partner.id,
partnerName,
userId: resolvedUserId
});
Expand All @@ -108,7 +110,7 @@ export async function createApiKey(req: Request<{ partnerName: string }>, res: R
createdAt: publicKeyRecord.createdAt,
expiresAt: expirationDate,
isActive: true,
partnerCount: partners.length,
partnerId: partner.id,
partnerName,
publicKey: {
id: publicKeyRecord.id,
Expand Down Expand Up @@ -149,11 +151,11 @@ export async function listApiKeys(req: Request<{ partnerName: string }>, res: Re
const partnerName = req.params.partnerName;

// Verify partner exists
const partners = await Partner.findAll({
const partner = await Partner.findOne({
where: { name: partnerName }
});

if (partners.length === 0) {
if (!partner) {
res.status(httpStatus.NOT_FOUND).json({
error: {
code: "PARTNER_NOT_FOUND",
Expand All @@ -180,7 +182,7 @@ export async function listApiKeys(req: Request<{ partnerName: string }>, res: Re
"updatedAt"
],
order: [["createdAt", "DESC"]],
where: { partnerName }
where: { partnerId: partner.id }
});

res.status(httpStatus.OK).json({
Expand All @@ -197,7 +199,7 @@ export async function listApiKeys(req: Request<{ partnerName: string }>, res: Re
updatedAt: key.updatedAt,
userId: key.userId
})),
partnerCount: partners.length,
partnerId: partner.id,
partnerName
});
} catch (error) {
Expand All @@ -220,11 +222,23 @@ export async function revokeApiKey(req: Request<{ partnerName: string; keyId: st
try {
const { partnerName, keyId } = req.params;

const partner = await Partner.findOne({ where: { name: partnerName } });
if (!partner) {
res.status(httpStatus.NOT_FOUND).json({
error: {
code: "PARTNER_NOT_FOUND",
message: `No partners found with name: ${partnerName}`,
status: httpStatus.NOT_FOUND
}
});
return;
}

// Find the API key
const apiKey = await ApiKey.findOne({
where: {
id: keyId,
partnerName
partnerId: partner.id
}
});

Expand All @@ -240,7 +254,7 @@ export async function revokeApiKey(req: Request<{ partnerName: string; keyId: st
}

// Soft delete by setting isActive to false
await apiKey.update({ isActive: false });
await apiKey.update({ isActive: false, revokedAt: new Date() });

res.status(httpStatus.NO_CONTENT).send();
} catch (error) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import {afterEach, describe, expect, it, mock} from "bun:test";
import {RampDirection} from "@vortexfi/shared";
import {Request, Response} from "express";
import httpStatus from "http-status";
import {Op, Transaction, UniqueConstraintError} from "sequelize";
Expand Down Expand Up @@ -37,7 +36,7 @@ function createResponse() {
describe("createProfilePartnerAssignment", () => {
const originalTransaction = sequelize.transaction;
const originalUserFindByPk = User.findByPk;
const originalPartnerFindAll = Partner.findAll;
const originalPartnerFindOne = Partner.findOne;
const originalAssignmentUpdate = ProfilePartnerAssignment.update;
const originalAssignmentCreate = ProfilePartnerAssignment.create;
const originalAssignmentFindAll = ProfilePartnerAssignment.findAll;
Expand All @@ -49,7 +48,7 @@ describe("createProfilePartnerAssignment", () => {
afterEach(() => {
sequelize.transaction = originalTransaction;
User.findByPk = originalUserFindByPk;
Partner.findAll = originalPartnerFindAll;
Partner.findOne = originalPartnerFindOne;
ProfilePartnerAssignment.update = originalAssignmentUpdate;
ProfilePartnerAssignment.create = originalAssignmentCreate;
ProfilePartnerAssignment.findAll = originalAssignmentFindAll;
Expand All @@ -58,33 +57,29 @@ describe("createProfilePartnerAssignment", () => {
function mockValidAssignmentDependencies() {
const transactionMock = mock(async (callback: (tx: unknown) => Promise<unknown>) => callback(transaction));
const userFindByPkMock = mock(async () => ({ id: "user-1" }));
const partnerFindAllMock = mock(async () => [
{ id: "buy-partner-1", name: "Acme", rampType: RampDirection.BUY },
{ id: "sell-partner-1", name: "Acme", rampType: RampDirection.SELL }
]);
const partnerFindOneMock = mock(async () => ({ id: "partner-1", isActive: true, name: "Acme" }));
const assignmentUpdateMock = mock(async () => [1]);
const assignmentCreateMock = mock(async () => ({
createdAt,
expiresAt: null,
buyPartnerId: "buy-partner-1",
id: "assignment-2",
isActive: true,
partnerId: "partner-1",
partnerName: "Acme",
sellPartnerId: "sell-partner-1",
updatedAt,
userId: "user-1"
}));

sequelize.transaction = transactionMock as unknown as typeof sequelize.transaction;
User.findByPk = userFindByPkMock as unknown as typeof User.findByPk;
Partner.findAll = partnerFindAllMock as unknown as typeof Partner.findAll;
Partner.findOne = partnerFindOneMock as unknown as typeof Partner.findOne;
ProfilePartnerAssignment.update = assignmentUpdateMock as unknown as typeof ProfilePartnerAssignment.update;
ProfilePartnerAssignment.create = assignmentCreateMock as unknown as typeof ProfilePartnerAssignment.create;

return {
assignmentCreateMock,
assignmentUpdateMock,
partnerFindAllMock,
partnerFindOneMock,
transactionMock,
userFindByPkMock
};
Expand Down Expand Up @@ -122,11 +117,10 @@ describe("createProfilePartnerAssignment", () => {
);
expect(assignmentCreateMock).toHaveBeenCalledWith(
{
buyPartnerId: "buy-partner-1",
expiresAt: null,
isActive: true,
partnerId: "partner-1",
partnerName: "Acme",
sellPartnerId: "sell-partner-1",
userId: "user-1"
},
{ transaction }
Expand Down Expand Up @@ -160,12 +154,9 @@ describe("createProfilePartnerAssignment", () => {
});
});

it("rejects ambiguous active partners for the same ramp type", async () => {
it("returns 404 when no active partner has the requested name", async () => {
mockValidAssignmentDependencies();
Partner.findAll = mock(async () => [
{ id: "buy-partner-1", name: "Acme", rampType: RampDirection.BUY },
{ id: "buy-partner-2", name: "Acme", rampType: RampDirection.BUY }
]) as unknown as typeof Partner.findAll;
Partner.findOne = mock(async () => null) as unknown as typeof Partner.findOne;
const res = createResponse();

await createProfilePartnerAssignment(
Expand All @@ -178,12 +169,12 @@ describe("createProfilePartnerAssignment", () => {
res as unknown as Response
);

expect(res.statusCode).toBe(httpStatus.CONFLICT);
expect(res.statusCode).toBe(httpStatus.NOT_FOUND);
expect(res.body).toEqual({
error: {
code: "AMBIGUOUS_PARTNER_ASSIGNMENT",
message: `Multiple active ${RampDirection.BUY} partners found with this name`,
status: httpStatus.CONFLICT
code: "PARTNER_NOT_FOUND",
message: "No active partners found with name: Acme",
status: httpStatus.NOT_FOUND
}
});
});
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { RampDirection } from "@vortexfi/shared";
import { Request, Response } from "express";
import httpStatus from "http-status";
import { Op, Transaction, UniqueConstraintError, WhereOptions } from "sequelize";
Expand All @@ -10,15 +9,6 @@ import User from "../../../models/user.model";

const PROFILE_NOT_FOUND_AFTER_LOCK = "PROFILE_NOT_FOUND_AFTER_LOCK";

function getUniquePartnerIdForRamp(partners: Partner[], rampType: RampDirection): string | null {
const rampPartners = partners.filter(partner => partner.rampType === rampType);
if (rampPartners.length > 1) {
throw new Error(`Multiple active ${rampType} partners found with this name`);
}

return rampPartners[0]?.id ?? null;
}

function parseExpiration(expiresAt: unknown): Date | null {
if (!expiresAt) {
return null;
Expand All @@ -38,13 +28,12 @@ function parseExpiration(expiresAt: unknown): Date | null {

function serializeAssignment(assignment: ProfilePartnerAssignment) {
return {
buyPartnerId: assignment.buyPartnerId,
createdAt: assignment.createdAt,
expiresAt: assignment.expiresAt,
id: assignment.id,
isActive: assignment.isActive,
partnerId: assignment.partnerId,
partnerName: assignment.partnerName,
sellPartnerId: assignment.sellPartnerId,
updatedAt: assignment.updatedAt,
userId: assignment.userId
};
Expand Down Expand Up @@ -77,14 +66,14 @@ export async function createProfilePartnerAssignment(req: Request, res: Response
return;
}

const partners = await Partner.findAll({
const partner = await Partner.findOne({
where: {
isActive: true,
name: partnerName
}
});

if (partners.length === 0) {
if (!partner) {
res.status(httpStatus.NOT_FOUND).json({
error: {
code: "PARTNER_NOT_FOUND",
Expand All @@ -95,9 +84,6 @@ export async function createProfilePartnerAssignment(req: Request, res: Response
return;
}

const buyPartnerId = getUniquePartnerIdForRamp(partners, RampDirection.BUY);
const sellPartnerId = getUniquePartnerIdForRamp(partners, RampDirection.SELL);

const expirationDate = parseExpiration(expiresAt);

const assignment = await sequelize.transaction(async transaction => {
Expand All @@ -123,33 +109,20 @@ export async function createProfilePartnerAssignment(req: Request, res: Response

return ProfilePartnerAssignment.create(
{
buyPartnerId,
expiresAt: expirationDate,
isActive: true,
partnerId: partner.id,
partnerName,
sellPartnerId,
userId
},
{ transaction }
);
});

res.status(httpStatus.CREATED).json({
assignment: serializeAssignment(assignment),
partnerCount: partners.length
assignment: serializeAssignment(assignment)
});
} catch (error) {
if (error instanceof Error && error.message.startsWith("Multiple active")) {
res.status(httpStatus.CONFLICT).json({
error: {
code: "AMBIGUOUS_PARTNER_ASSIGNMENT",
message: error.message,
status: httpStatus.CONFLICT
}
});
return;
}

if (error instanceof Error && error.message.startsWith("expiresAt")) {
res.status(httpStatus.BAD_REQUEST).json({
error: {
Expand Down
Loading
Loading