From ea7a56fc07bebedf18b37dfbb06349e3d87d7eaa Mon Sep 17 00:00:00 2001 From: Niranjan Kumar Date: Sun, 14 Jun 2026 05:27:16 +0530 Subject: [PATCH] feat: add Contact Leads page and update workflows for deployment and CI/CD --- .github/workflows/0-ci.yml | 1 + .github/workflows/1-build-push.yml | 1 + .github/workflows/4-deploy.yml | 127 ++++++++++++-- src/app/(dashboard)/platform/layout.tsx | 8 +- src/app/(dashboard)/platform/leads/page.tsx | 176 ++++++++++++++++++++ src/components/contact-form.tsx | 52 +++++- src/lib/types.ts | 20 +++ 7 files changed, 362 insertions(+), 23 deletions(-) create mode 100644 src/app/(dashboard)/platform/leads/page.tsx diff --git a/.github/workflows/0-ci.yml b/.github/workflows/0-ci.yml index 0238891..a5239b6 100644 --- a/.github/workflows/0-ci.yml +++ b/.github/workflows/0-ci.yml @@ -1,3 +1,4 @@ +# Pipeline: merge to main → 0-ci → 1-build-push → 4-deploy (EC2) name: auth-engine-dashboard · Lint and Build on: diff --git a/.github/workflows/1-build-push.yml b/.github/workflows/1-build-push.yml index 603e1ed..689f311 100644 --- a/.github/workflows/1-build-push.yml +++ b/.github/workflows/1-build-push.yml @@ -1,3 +1,4 @@ +# Runs after 0-ci succeeds on main (workflow_run). Pushes :latest to Docker Hub. name: auth-engine-dashboard · Build and Push Docker Image on: diff --git a/.github/workflows/4-deploy.yml b/.github/workflows/4-deploy.yml index f47b0a3..a81b8da 100644 --- a/.github/workflows/4-deploy.yml +++ b/.github/workflows/4-deploy.yml @@ -1,39 +1,125 @@ -name: auth-engine-dashboard · Register Production Deployment +# Runs after 1-build-push succeeds on main. Pulls image on EC2. +name: auth-engine-dashboard · Deploy to Production on: + workflow_run: + workflows: ["auth-engine-dashboard · Build and Push Docker Image"] + types: [completed] + branches: [main] workflow_dispatch: - inputs: - git_ref: - description: Branch, tag, or SHA to record as deployed - required: false - default: main - type: string + +concurrency: + group: production-deploy-dashboard + cancel-in-progress: false + +env: + AWS_REGION: ${{ vars.AWS_REGION != '' && vars.AWS_REGION || 'ap-south-1' }} + EC2_INSTANCE_ID: ${{ vars.EC2_INSTANCE_ID }} jobs: - deployment: - name: Register GitHub deployment for app.authengine.org + deploy: + name: Deploy dashboard to EC2 runs-on: ubuntu-latest + if: > + github.event_name == 'workflow_dispatch' || + github.event.workflow_run.conclusion == 'success' + environment: production permissions: contents: read deployments: write + id-token: write steps: - - name: Checkout ref - uses: actions/checkout@v4 + - name: Require EC2 instance id + run: | + INSTANCE_ID="$(printf '%s' "${EC2_INSTANCE_ID}" | tr -d '[:space:]')" + if [ -z "${INSTANCE_ID}" ]; then + echo "Set repository variable EC2_INSTANCE_ID (e.g. i-05bd906b129c9acb2)" >&2 + exit 1 + fi + if ! printf '%s' "${INSTANCE_ID}" | grep -Eq '^i-[0-9a-f]{8,17}$'; then + echo "Invalid EC2_INSTANCE_ID: '${INSTANCE_ID}'" >&2 + echo "Repository variable must be only the instance id, with no spaces or newlines." >&2 + exit 1 + fi + if [ "${INSTANCE_ID}" != "${EC2_INSTANCE_ID}" ]; then + echo "Trimmed whitespace from EC2_INSTANCE_ID (check the repo variable in GitHub Settings)." >&2 + fi + echo "EC2_INSTANCE_ID=${INSTANCE_ID}" >> "${GITHUB_ENV}" + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 with: - ref: ${{ inputs.git_ref }} + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ env.AWS_REGION }} + + - name: Deploy via SSM + id: ssm + run: | + set -euo pipefail + REF="${{ github.event.workflow_run.head_sha || github.sha }}" + COMMENT="auth-engine-dashboard-deploy-${REF:0:7}" + + PARAMS="$(mktemp)" + cat >"${PARAMS}" <<'JSON' + { + "commands": [ + "set -euo pipefail", + "if ! docker compose version >/dev/null 2>&1; then ARCH=$(uname -m); mkdir -p /usr/local/lib/docker/cli-plugins; curl -fsSL https://github.com/docker/compose/releases/download/v2.32.4/docker-compose-linux-${ARCH} -o /usr/local/lib/docker/cli-plugins/docker-compose; chmod +x /usr/local/lib/docker/cli-plugins/docker-compose; fi", + "cd /opt/authengine/compose", + "docker compose --env-file /opt/authengine/.env -f docker-compose.prod.yml pull frontend", + "docker compose --env-file /opt/authengine/.env -f docker-compose.prod.yml up -d --force-recreate frontend", + "docker compose --env-file /opt/authengine/.env -f docker-compose.prod.yml ps" + ] + } + JSON - - name: Create deployment record + COMMAND_ID="$(aws ssm send-command \ + --instance-ids "${EC2_INSTANCE_ID}" \ + --document-name "AWS-RunShellScript" \ + --comment "${COMMENT}" \ + --parameters "file://${PARAMS}" \ + --query 'Command.CommandId' \ + --output text)" + rm -f "${PARAMS}" + echo "command_id=${COMMAND_ID}" >> "${GITHUB_OUTPUT}" + + STATUS="InProgress" + while [ "${STATUS}" = "InProgress" ] || [ "${STATUS}" = "Pending" ]; do + sleep 5 + STATUS="$(aws ssm get-command-invocation \ + --command-id "${COMMAND_ID}" \ + --instance-id "${EC2_INSTANCE_ID}" \ + --query 'Status' \ + --output text 2>/dev/null || echo InProgress)" + done + + aws ssm get-command-invocation \ + --command-id "${COMMAND_ID}" \ + --instance-id "${EC2_INSTANCE_ID}" \ + --query '[Status, StandardOutputContent, StandardErrorContent]' \ + --output text + + if [ "${STATUS}" != "Success" ]; then + echo "SSM deploy failed with status: ${STATUS}" >&2 + exit 1 + fi + + - name: Register GitHub deployment + if: success() uses: actions/github-script@v7 with: script: | + const sha = context.payload.workflow_run?.head_sha || context.sha; const deployment = await github.rest.repos.createDeployment({ owner: context.repo.owner, repo: context.repo.repo, - ref: context.sha, + ref: sha, environment: "production", auto_merge: false, - required_contexts: [] + required_contexts: [], + description: "EC2 SSM deploy (dashboard)" }); await github.rest.repos.createDeploymentStatus({ @@ -42,5 +128,14 @@ jobs: deployment_id: deployment.data.id, state: "success", environment_url: "https://app.authengine.org", - log_url: "https://app.authengine.org" + log_url: `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}` }); + + - name: Summary + if: always() + run: | + echo "## Production deploy (dashboard)" >> "${GITHUB_STEP_SUMMARY}" + echo "" >> "${GITHUB_STEP_SUMMARY}" + echo "- **Instance:** \`${EC2_INSTANCE_ID}\`" >> "${GITHUB_STEP_SUMMARY}" + echo "- **SSM command:** \`${{ steps.ssm.outputs.command_id }}\`" >> "${GITHUB_STEP_SUMMARY}" + echo "- **App:** https://app.authengine.org" >> "${GITHUB_STEP_SUMMARY}" diff --git a/src/app/(dashboard)/platform/layout.tsx b/src/app/(dashboard)/platform/layout.tsx index 79241d9..aa3ce6c 100644 --- a/src/app/(dashboard)/platform/layout.tsx +++ b/src/app/(dashboard)/platform/layout.tsx @@ -10,7 +10,8 @@ import { ShieldAlert, ScrollText, ChevronRight, - Key + Key, + Inbox, } from "lucide-react"; import { cn } from "@/lib/utils"; import { useAuthStore } from "@/stores/auth-store"; @@ -42,6 +43,11 @@ const navItems = [ href: "/platform/service-keys", icon: Key, }, + { + title: "Contact Leads", + href: "/platform/leads", + icon: Inbox, + }, { title: "Audit Explorer", href: "/platform/audit", diff --git a/src/app/(dashboard)/platform/leads/page.tsx b/src/app/(dashboard)/platform/leads/page.tsx new file mode 100644 index 0000000..6ff5197 --- /dev/null +++ b/src/app/(dashboard)/platform/leads/page.tsx @@ -0,0 +1,176 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { apiClient } from "@/lib/api-client"; +import { ContactLead } from "@/lib/types"; +import { + Search, + Filter, + Clock, + Loader2, + Mail, + Building2, + Phone, +} from "lucide-react"; + +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Card } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; + +export default function PlatformLeadsPage() { + const { data: leads, isLoading } = useQuery({ + queryKey: ["platformContactLeads"], + queryFn: async () => { + const { data } = await apiClient.get("/platform/contact-leads"); + return data; + }, + }); + + return ( +
+
+
+

Contact Leads

+

+ Inbound requests from the marketing contact form. +

+
+ + {leads?.length ?? 0} leads + +
+ +
+
+ + +
+ +
+ + + {isLoading ? ( +
+ +
+ ) : ( + + + + Contact + Company + Interest + Submitted + Message + + + + {leads?.map((lead) => ( + + +
+

+ {lead.first_name} {lead.last_name} +

+ + {lead.phone && ( +
+ + {lead.phone} +
+ )} + {lead.job_title && ( +

+ {lead.job_title} +

+ )} +
+
+ +
+
+ + {lead.company} +
+
+ {lead.company_size && ( + + {lead.company_size} + + )} + {lead.country && ( + + {lead.country} + + )} + {lead.mau && ( + + {lead.mau} MAU + + )} +
+
+
+ + {lead.interest ? ( + + {lead.interest} + + ) : ( + + )} + + +
+ + {new Date(lead.created_at).toLocaleString()} +
+ {lead.ip_address && ( +

+ {lead.ip_address} +

+ )} +
+ +

+ {lead.message || "—"} +

+
+
+ ))} + {(!leads || leads.length === 0) && ( + + + No contact leads yet. + + + )} +
+
+ )} +
+
+ ); +} diff --git a/src/components/contact-form.tsx b/src/components/contact-form.tsx index 0fd2bd9..2ae737e 100644 --- a/src/components/contact-form.tsx +++ b/src/components/contact-form.tsx @@ -51,7 +51,7 @@ export function ContactForm() { const update = (key: keyof typeof EMPTY, value: string | boolean) => setForm((prev) => ({ ...prev, [key]: value })); - const handleSubmit = (e: React.FormEvent) => { + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!form.firstName || !form.lastName || !form.email || !form.company) { @@ -63,13 +63,53 @@ export function ContactForm() { return; } + const apiBase = + process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api/v1"; + setIsSubmitting(true); - // No public lead endpoint yet — simulate submission and hand off to the team. - setTimeout(() => { - setIsSubmitting(false); + try { + const res = await fetch(`${apiBase}/public/contact`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + first_name: form.firstName, + last_name: form.lastName, + email: form.email, + phone: form.phone || null, + job_title: form.jobTitle || null, + company: form.company, + company_size: form.companySize || null, + country: form.country || null, + mau: form.mau || null, + interest: form.interest || null, + message: form.message || null, + consent: form.consent, + }), + }); + + const data = (await res.json().catch(() => ({}))) as { + detail?: string; + message?: string; + }; + + if (!res.ok) { + const detail = + typeof data.detail === "string" + ? data.detail + : "Could not submit your request. Please try again."; + toast.error(detail); + return; + } + setForm({ ...EMPTY }); - toast.success("Thanks! Our team will reach out within 1 business day."); - }, 900); + toast.success( + data.message || "Thanks! Our team will reach out within 1 business day." + ); + } catch { + toast.error("Network error. Please check your connection and try again."); + } finally { + setIsSubmitting(false); + } }; return ( diff --git a/src/lib/types.ts b/src/lib/types.ts index 7a15b43..de72b30 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -82,6 +82,26 @@ export interface AuditLogEntry { details?: Record; } +export interface ContactLead { + id: string; + first_name: string; + last_name: string; + email: string; + phone?: string; + job_title?: string; + company: string; + company_size?: string; + country?: string; + mau?: string; + interest?: string; + message?: string; + consent: boolean; + created_at: string; + source?: string; + ip_address?: string; + user_agent?: string; +} + export interface Permission { id: string; name: string;