diff --git a/.github/workflows/deploy-staging.yml b/.github/workflows/deploy-staging.yml
index a4f41042..027a6042 100644
--- a/.github/workflows/deploy-staging.yml
+++ b/.github/workflows/deploy-staging.yml
@@ -64,6 +64,16 @@ jobs:
ghcr.io/${{ env.OWNER_LC }}/remitlend-frontend:staging-latest
target: runner
+ build-args: |
+ NEXT_PUBLIC_API_URL=${{ vars.NEXT_PUBLIC_API_URL }}
+ NEXT_PUBLIC_RPC_URL=${{ vars.NEXT_PUBLIC_RPC_URL }}
+ NEXT_PUBLIC_NETWORK_PASSPHRASE=${{ vars.NEXT_PUBLIC_NETWORK_PASSPHRASE }}
+ NEXT_PUBLIC_EXPLORER_URL=${{ vars.NEXT_PUBLIC_EXPLORER_URL }}
+ NEXT_PUBLIC_MANAGER_CONTRACT_ID=${{ vars.NEXT_PUBLIC_MANAGER_CONTRACT_ID }}
+ NEXT_PUBLIC_POOL_CONTRACT_ID=${{ vars.NEXT_PUBLIC_POOL_CONTRACT_ID }}
+ NEXT_PUBLIC_GOVERNANCE_CONTRACT_ID=${{ vars.NEXT_PUBLIC_GOVERNANCE_CONTRACT_ID }}
+ NEXT_PUBLIC_NFT_CONTRACT_ID=${{ vars.NEXT_PUBLIC_NFT_CONTRACT_ID }}
+
- name: Run Trivy vulnerability scanner (HIGH - warn)
uses: aquasecurity/trivy-action@master
with:
@@ -95,6 +105,32 @@ jobs:
with:
sarif_file: 'trivy-results.sarif'
+ - name: Run Trivy vulnerability scanner for frontend (HIGH - warn)
+ uses: aquasecurity/trivy-action@master
+ with:
+ image-ref: 'ghcr.io/${{ env.OWNER_LC }}/remitlend-frontend:staging-${{ github.sha }}'
+ format: 'table'
+ severity: 'HIGH'
+ exit-code: '0'
+ trivyignores: '.trivyignore'
+
+ - name: Run Trivy vulnerability scanner for frontend (CRITICAL - fail)
+ uses: aquasecurity/trivy-action@master
+ with:
+ image-ref: 'ghcr.io/${{ env.OWNER_LC }}/remitlend-frontend:staging-${{ github.sha }}'
+ format: 'sarif'
+ output: 'frontend-trivy-results.sarif'
+ severity: 'CRITICAL'
+ exit-code: '1'
+ limit-severities-for-sarif: 'true'
+ trivyignores: '.trivyignore'
+
+ - name: Upload frontend Trivy scan results to GitHub Security tab
+ uses: github/codeql-action/upload-sarif@v3
+ if: always()
+ with:
+ sarif_file: 'frontend-trivy-results.sarif'
+
- name: Smoke-test the backend staging image
run: |
set -e
diff --git a/docker-compose.staging.yml b/docker-compose.staging.yml
index a8c6963e..9ecd1438 100644
--- a/docker-compose.staging.yml
+++ b/docker-compose.staging.yml
@@ -34,7 +34,7 @@ services:
ports:
- "3000:3000"
environment:
- - NEXT_PUBLIC_API_URL=http://localhost:3001
+
- API_URL=http://backend:3001
depends_on:
- backend
diff --git a/frontend/Dockerfile b/frontend/Dockerfile
index 46cb1db0..8a25a13a 100644
--- a/frontend/Dockerfile
+++ b/frontend/Dockerfile
@@ -14,8 +14,26 @@ ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["npm", "run", "dev"]
-# Build Stage
FROM base AS builder
+
+ARG NEXT_PUBLIC_API_URL
+ARG NEXT_PUBLIC_RPC_URL
+ARG NEXT_PUBLIC_NETWORK_PASSPHRASE
+ARG NEXT_PUBLIC_EXPLORER_URL
+ARG NEXT_PUBLIC_MANAGER_CONTRACT_ID
+ARG NEXT_PUBLIC_POOL_CONTRACT_ID
+ARG NEXT_PUBLIC_GOVERNANCE_CONTRACT_ID
+ARG NEXT_PUBLIC_NFT_CONTRACT_ID
+
+ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
+ENV NEXT_PUBLIC_RPC_URL=$NEXT_PUBLIC_RPC_URL
+ENV NEXT_PUBLIC_NETWORK_PASSPHRASE=$NEXT_PUBLIC_NETWORK_PASSPHRASE
+ENV NEXT_PUBLIC_EXPLORER_URL=$NEXT_PUBLIC_EXPLORER_URL
+ENV NEXT_PUBLIC_MANAGER_CONTRACT_ID=$NEXT_PUBLIC_MANAGER_CONTRACT_ID
+ENV NEXT_PUBLIC_POOL_CONTRACT_ID=$NEXT_PUBLIC_POOL_CONTRACT_ID
+ENV NEXT_PUBLIC_GOVERNANCE_CONTRACT_ID=$NEXT_PUBLIC_GOVERNANCE_CONTRACT_ID
+ENV NEXT_PUBLIC_NFT_CONTRACT_ID=$NEXT_PUBLIC_NFT_CONTRACT_ID
+
RUN npm run build
# Production Stage
diff --git a/frontend/src/app/[locale]/settings/page.tsx b/frontend/src/app/[locale]/settings/page.tsx
index f5db32b1..b4926b38 100644
--- a/frontend/src/app/[locale]/settings/page.tsx
+++ b/frontend/src/app/[locale]/settings/page.tsx
@@ -102,17 +102,15 @@ function Toggle({
@@ -222,11 +220,10 @@ function WalletSection() {
{network?.isSupported ? "Supported" : "Unsupported"}
@@ -281,6 +278,7 @@ function NotificationsSection() {
});
const [saved, setSaved] = useState(false);
const [saveError, setSaveError] = useState(null);
+ const [phoneError, setPhoneError] = useState(null);
useEffect(() => {
if (!data) return;
@@ -314,11 +312,30 @@ function NotificationsSection() {
const handleSave = () => {
setSaveError(null);
+ setPhoneError(null);
+
+ const phone = prefs.phone.trim();
+
+ if (prefs.sms) {
+ if (!phone) {
+ setPhoneError("A phone number is required for SMS notifications.");
+ return;
+ }
+
+ // Basic international phone validation
+ const phoneRegex = /^\+?[1-9]\d{7,14}$/;
+
+ if (!phoneRegex.test(phone)) {
+ setPhoneError("Please enter a valid phone number.");
+ return;
+ }
+ }
+
updateNotificationPreferences.mutate(
{
emailEnabled: prefs.email,
smsEnabled: prefs.sms,
- phone: prefs.phone.trim() || null,
+ phone: phone || null,
perTypeOverrides,
},
{
@@ -361,7 +378,10 @@ function NotificationsSection() {
/>
toggle("sms")}
+ onChange={() => {
+ setPhoneError(null);
+ toggle("sms");
+ }}
label="SMS Notifications"
description="Requires a verified phone number"
/>
@@ -370,13 +390,21 @@ function NotificationsSection() {
label="Phone number"
placeholder="+14155552671"
value={prefs.phone}
- onChange={(e) => setPrefs((p) => ({ ...p, phone: e.target.value }))}
+ onChange={(e) => {
+ setPhoneError(null);
+ setPrefs((p) => ({ ...p, phone: e.target.value }));
+ }}
helperText={
prefs.sms
? "A phone number is required for SMS notifications."
: "Optional unless SMS notifications are enabled."
}
/>
+ {phoneError && (
+
+ {phoneError}
+
+ )}
@@ -471,11 +499,10 @@ function SecuritySection() {
KYC Status
{user?.kycVerified ? "Verified" : "Not Verified"}
@@ -565,11 +592,10 @@ function DisplaySection() {
@@ -653,11 +679,10 @@ export default function SettingsPage() {