Skip to content

Local Dev Setup

This guide covers local setup for all Sema Link services: the backing infrastructure (PostgreSQL, Redis, RabbitMQ) and the four application repos (API, frontend, admin, website).

Prerequisites

  • Node.js 22+ — installed via Homebrew (brew install node@22)
  • Git — with SSH access to the Sema-Link GitHub org
  • npm — comes with Node
  • Docker — required for the local backing services (Postgres, Redis, RabbitMQ)

Node.js PATH

If npm is not found after installing Node via Homebrew, add this to your ~/.zshrc:

sh
export PATH="/usr/local/opt/node@22/bin:$PATH"

Then run source ~/.zshrc.


Backing Services

Start these before the API. They run as Docker containers and persist data in named volumes.

PostgreSQL

sh
docker run -d --name semalink-pg \
  -e POSTGRES_USER=semalink \
  -e POSTGRES_PASSWORD=semalink \
  -e POSTGRES_DB=semalink \
  -p 5432:5432 \
  -v semalink-pg-data:/var/lib/postgresql/data \
  postgres:16-alpine

Available at localhost:5432. Database: semalink, user: semalink, password: semalink.

Redis

sh
docker run -d --name semalink-redis \
  -p 6379:6379 \
  -v semalink-redis-data:/data \
  redis:7-alpine

Available at localhost:6379.

RabbitMQ

sh
docker run -d --name semalink-rabbit \
  -p 5672:5672 -p 15672:15672 \
  -v semalink-rabbit-data:/var/lib/rabbitmq \
  rabbitmq:3-management

Available at localhost:5672. Management UI at http://localhost:15672 (guest / guest).

Managing containers

sh
# Stop all three
docker stop semalink-pg semalink-redis semalink-rabbit

# Start again (data persists in volumes)
docker start semalink-pg semalink-redis semalink-rabbit

# View logs
docker logs semalink-pg
docker logs semalink-rabbit

1. Clone

sh
git clone git@github.com:Sema-Link/semalink-api.git
cd semalink-api

2. Install dependencies

sh
npm install

3. Create your environment file

sh
cp .env.example .env

Fill in the values:

sh
PORT=3000
NODE_ENV=development

DATABASE_URL=postgresql://semalink:semalink@localhost:5432/semalink
REDIS_URL=redis://localhost:6379
RABBITMQ_URL=amqp://guest:guest@localhost:5672

JWT_SECRET=your-super-secret-key-at-least-32-chars
JWT_REFRESH_SECRET=another-super-secret-key-32-chars
JWT_EXPIRES_IN=15m
JWT_REFRESH_EXPIRES_IN=7d

ADMIN_JWT_SECRET=admin-super-secret-key-at-least-32-chars
ADMIN_JWT_EXPIRES_IN=8h

APP_URL=http://localhost:5173
API_URL=http://localhost:3000

MAILGUN_API_KEY=
MAILGUN_DOMAIN=
MAILGUN_FROM=noreply@semalink.africa

# OAuth (all optional — set only providers you need locally)
# GOOGLE_CLIENT_ID=
# GOOGLE_CLIENT_SECRET=
# GITHUB_CLIENT_ID=
# GITHUB_CLIENT_SECRET=

# Cloudflare R2 (optional — photo uploads won't work without it)
# R2_ACCOUNT_ID=
# R2_ACCESS_KEY_ID=
# R2_SECRET_ACCESS_KEY=
# R2_BUCKET_NAME=
# R2_PUBLIC_URL=

API_VERSION=v1

4. Run database migrations

sh
npm run db:migrate

This applies all pending Drizzle migrations against your local PostgreSQL. If you see errors about missing tables, verify the database is running and DATABASE_URL is correct.

5. Start the dev server

sh
npm run dev

API starts at http://localhost:3000. Logs are formatted with Pino in development mode.

Available Scripts

CommandDescription
npm run devStart dev server with tsx watch (hot reload)
npm run buildTypeScript compile to dist/
npm run startRun compiled output (node dist/server.js)
npm run db:generateGenerate Drizzle migration from schema changes
npm run db:migrateApply pending migrations
npm run db:studioOpen Drizzle Studio (visual DB browser)

Making schema changes

See the Database Schema page for the full migration workflow. Short version:

sh
# 1. Edit schema in src/db/schema/
# 2. Generate the migration
npm run db:generate
# 3. Review the generated SQL in src/db/migrations/
# 4. Apply it
npm run db:migrate

Optional services

OAuth providers and R2 photo upload are fully optional locally — those features return errors when their env vars are missing, but the rest of the API works normally. Mailgun is also optional — emails are logged to stdout in development if no API key is configured.


1. Clone

sh
git clone git@github.com:Sema-Link/semalink-frontend.git
cd semalink-frontend

2. Install dependencies

sh
npm install

3. Create your environment file

sh
cp .env.example .env.local

For local development pointed at staging:

sh
VITE_API_BASE_URL=https://staging-arc.semalink.africa
VITE_CF_ACCESS_CLIENT_ID=<your-client-id>
VITE_CF_ACCESS_CLIENT_SECRET=<your-client-secret>
VITE_ENV=staging

Get the Cloudflare Access credentials from 1Password or ask the team lead.

Running against local API

Point VITE_API_BASE_URL at http://localhost:3000 when running the API locally. The CF Access headers are not enforced on localhost — you can set dummy values or remove those vars.

4. Start the dev server

sh
npm run dev

Available at http://localhost:5173.

Available Scripts

CommandDescription
npm run devStart local dev server with hot reload
npm run buildType-check + production build
npm run previewPreview production build locally
npm run test:unitRun unit tests with Vitest
npm run lintLint and auto-fix with ESLint + oxlint
npm run formatFormat source files with Prettier

Type checking

sh
npx vue-tsc --noEmit

A clean exit means no type errors. Trust this over IDE warnings (Volar cache can show stale errors).


semalink-admin is the internal operations dashboard for Sema Link staff. It is a React single-page application gated behind Cloudflare Zero Trust on deployed environments — only staff members with a valid @semalink.africa identity can reach the deployed URL. Locally it runs without that network gate.

Tech Stack

LayerTechnologyVersion
UI libraryReact18.3
LanguageTypeScript5.5
Build toolVite7.3
StylingTailwind CSS v4 (via @tailwindcss/vite)4.2
State managementZustand (with persist middleware)5.0
RoutingReact Router v66.27
HTTP clientAxios (with interceptors)1.7
IconsLucide React0.446
Class utilitiesclsx + tailwind-merge
FontNunito (Google Fonts, 400–900)

Tailwind v4 requires no separate tailwind.config.* file — it is configured entirely through the Vite plugin and a single @theme block in src/index.css.


Source layout

src/
├── main.tsx                        # React DOM entry point
├── App.tsx                         # Root component — route table + RequireAuth guard
├── index.css                       # Tailwind import + Nunito font theme
├── vite-env.d.ts                   # ImportMetaEnv type declarations

├── config/
│   └── modules.ts                  # Module metadata registry (id, label, color, route, permission, status)

├── lib/
│   ├── api.ts                      # Axios instance with auth + token-refresh interceptors
│   └── utils.ts                    # cn() helper (clsx + tailwind-merge)

├── store/
│   └── auth.ts                     # Zustand auth store persisted to localStorage

├── components/
│   └── layout/
│       ├── AppLayout.tsx           # 220 px dark sidebar + top header shell
│       └── ModuleSwitcher.tsx      # Grid dropdown to jump between modules

└── pages/
    ├── auth/
    │   └── LoginPage.tsx           # Split-panel login form
    ├── launcher/
    │   └── LauncherPage.tsx        # Dashboard home — module grid + platform status
    ├── profile/
    │   └── ProfilePage.tsx         # My Account — 5 tabs
    ├── staff/
    │   ├── StaffDashboardPage.tsx  # Staff stats overview
    │   ├── StaffMembersPage.tsx    # Two-panel staff management
    │   └── StaffPage.tsx           # Modal-based alternative staff UI
    └── ComingSoon.tsx              # Placeholder for unreleased modules

Modules

The portal is divided into 11 modules. Each module has an id, a minimum required permission level, and a status of live or coming-soon. The launcher, sidebar, and module switcher all read from the same src/config/modules.ts registry.

ModuleCategoryMin permissionStatus
EngineeringInfrastructurereadLive
Sender IDsCompliancereadLive
CRMCustomersreadLive
BillingFinanceadminLive
StaffAdministrationownerLive
Audit LogCompliancereadLive
Content ModerationComplianceadminComing soon
PricingFinanceownerComing soon
ReportsAnalyticsownerComing soon
Agent ManagerPartnersadminComing soon
CampaignsOperationsreadComing soon

Each live module renders inside the AppLayout shell (sidebar + header). Coming-soon modules render a placeholder page with the module name and color. The module switcher and launcher card grid are both filtered to only show modules the signed-in staff member has access to.


Authentication

The portal uses a separate JWT namespace from the customer app — tokens issued by /admin/auth/* carry type: "admin" in their payload and are rejected on all customer routes, and vice versa. The backend uses a separate ADMIN_JWT_SECRET.

Login flow:

  1. User opens the app → RequireAuth guard checks useAuthStore().staff
  2. If not authenticated, redirected to /login
  3. User enters email + password → POST /admin/auth/login
  4. On success: staff object, accessToken, refreshToken written to the Zustand store
  5. Zustand persist middleware serialises the store to localStorage under the key semalink-admin-auth
  6. Redirected to /launcher

Auto token-refresh:
The Axios response interceptor catches any 401 Unauthorized. It immediately queues the failing request, calls POST /admin/auth/refresh with the stored refresh token, writes the new token pair to the store, then replays all queued requests. If the refresh itself returns 401, the store is cleared and the user is redirected to /login.

Logout:
Calls POST /admin/auth/logout to revoke the refresh token server-side, then calls useAuthStore().logout() which clears the store and localStorage.


Permission model

Every staff member has a role (owner | staff) and a permissions map (Record<string, string>) that stores a per-module access level.

Valid levels:

LevelWhat it means
noneNo access — module hidden
readView data, no mutations
adminRead + write
ownerFull control — only actual owners can grant this

The owner role bypasses all module-level permission checks. A staff role member sees only the modules where their level is not none. The module switcher, launcher cards, and sidebar nav are all filtered on the client using the store's staff.permissions map.


API client

File: src/lib/api.ts

Creates an Axios instance pointed at VITE_API_BASE_URL. In development (via the Vite proxy), requests to /api/* are forwarded to http://localhost:3000 so the browser never crosses origins locally.

The instance has two interceptors:

Request interceptor  → adds Authorization: Bearer <accessToken>
Response interceptor → on 401, calls /admin/auth/refresh → replays queued requests

All page components import functions from api.ts directly — there is no generated API layer or React Query; data fetching is imperative Axios calls inside event handlers and useEffect hooks.


State management

File: src/store/auth.ts

Single Zustand store. Shape:

typescript
{
  staff: Staff | null          // null = not logged in
  accessToken: string | null
  refreshToken: string | null
  setAuth(staff, at, rt)       // called after login
  setTokens(at, rt)            // called after token refresh
  patchStaff(updates)          // called after profile edit
  logout()                     // clears all fields
}

The Staff type:

typescript
{
  id: string
  name: string
  email: string
  phone?: string
  role: 'owner' | 'staff'
  permissions: Record<string, string>   // module id → level
  active: boolean
  mustChangePassword?: boolean
}

Persisted to localStorage via zustand/middleware persist. On page reload, the store is rehydrated from storage before the RequireAuth guard runs, so valid sessions survive a hard refresh.


Routing

Routes defined in App.tsx. All routes except /login are wrapped in a RequireAuth component that redirects to /login when staff is null.

PathPageNotes
/loginLoginPagePublic
/Redirects to /launcher
/launcherLauncherPageDashboard home
/profileProfilePageDefaults to Overview tab
/profile/:sectionProfilePagesecurity, notifications, messages, permissions
/staffStaffDashboardPageOwner only (enforced by API)
/staff/membersStaffMembersPageOwner only
/engineeringComingSoon
/sender-idsComingSoon
/crmComingSoon
/billingComingSoon
/auditComingSoon
/content-modComingSoon
/pricingComingSoon
/reportsComingSoon
/agentsComingSoon
/campaignsComingSoon

Pages

LauncherPage (/launcher) — Dashboard home. Left panel: time-aware greeting, module card grid (filtered to accessible live modules), a "My Account" card, faded coming-soon list, and a stats summary (Today / Week / Month tabs for Active Orgs, SMS Sent, Delivery Rate, Low Balance). Right panel: platform status (5 systems), recent activity feed, pending Sender ID alerts.

LoginPage (/login) — Split-panel. Left: dark teal gradient branding with hero text and a live stats card (54 countries, 151 prefixes). Right: email + password form. Mobile: stacked layout with logo.

ProfilePage (/profile) — Five-tab account management:

  • Overview — Avatar, name, role, status badge; module access grid
  • Security — Change password (current + new + confirm, min 8 chars); update phone number
  • Notifications — Toggle email preferences (Login Alerts, Staff Changes, Sender ID Updates, Billing Alerts, System Updates, Weekly Digest)
  • Messages — Basic inbox (placeholder)
  • Permissions — Read-only module access level grid

StaffDashboardPage (/staff) — Team overview: 4 stat cards (Total, Active, Owners, Inactive), recent logins table, role breakdown progress bars.

StaffMembersPage (/staff/members) — Two-panel staff management. Left: searchable staff list. Right: invite form (name, email, role + per-module permission grid) or detail view (Profile / Permissions / Security tabs) for the selected member. Owners can reset a member's password, toggle their active status, and update per-module permission levels.


Layout shell

AppLayout wraps every authenticated page. Structure:

┌─ 220px dark sidebar (gray-900) ─────────┬─ flex-1 main area ──────────────────┐
│  Module header block (colored)           │  Top header bar                      │
│  ──────────────────────────────────────  │    Title | Date | ModuleSwitcher     │
│  Navigation items (NavItem[])            │    Profile link                      │
│  ──────────────────────────────────────  │  ──────────────────────────────────  │
│  User card (name, role, initials)        │  Page content (overflow-y-auto)      │
│  Logout button                           │                                      │
└──────────────────────────────────────────┴──────────────────────────────────────┘

The colored header block in the sidebar uses the current module's color from modules.ts, giving each module a distinct visual identity.

ModuleSwitcher — Grid dropdown (3 columns) accessible from the top header. Shows all accessible live modules filtered by role and permissions, plus "My Account" and "Home" shortcuts.


1. Clone

sh
git clone git@github.com:Sema-Link/semalink-admin.git
cd semalink-admin

2. Install dependencies

sh
npm install

3. Create your environment file

sh
cp .env.example .env.local
sh
VITE_API_BASE_URL=https://staging-arc.semalink.africa/api/v1
VITE_ENV=development

Running against the local API

Change VITE_API_BASE_URL to /api/v1 (relative path) when pointing at localhost:3000. The Vite dev proxy in vite.config.ts forwards all /api/* requests to http://localhost:3000, so a relative base URL avoids CORS entirely.

sh
VITE_API_BASE_URL=/api/v1
VITE_ENV=development

The admin portal does not use Cloudflare Access service tokens in the build. Zero Trust is enforced at the network level on deployed environments — it does not apply when running locally.

4. Start the dev server

sh
npm run dev

Available at http://localhost:5190.

The first page after loading will be /login. Log in with an owner-role staff account. On staging, use the seed account created by the seed:admin script in semalink-api.

Available Scripts

CommandDescription
npm run devStart dev server with hot reload on port 5190
npm run buildTypeScript compile + Vite production build to dist/
npm run previewPreview the production build locally
npm run type-checkRun tsc --noEmit — type errors without building

Type checking

The project has strict TypeScript enabled. Run a full type check before pushing:

sh
npm run type-check

Path alias @/ maps to src/. Vite handles compilation at runtime — tsc is only used for type validation, not emitting output.

Seeding an owner account (local)

The admin portal has no self-registration. To log in locally, run the seed script in semalink-api:

sh
# Inside semalink-api/
npm run seed:admin

This creates an owner-role staff account. Check the script output for the email and temporary password, then log in at http://localhost:5190/login.


1. Clone

sh
git clone git@github.com:Sema-Link/semalink-website.git
cd semalink-website

2. Install dependencies

sh
npm install

3. Start the dev server

sh
npm run dev

Available at http://localhost:5173. The website has no environment variables — it is a fully static Vue 3 site built with vite-ssg.

Port conflict

If the Customer Web App is already running on port 5173, Vite will automatically pick the next available port (5174). Check your terminal output for the actual URL.

Available Scripts

CommandDescription
npm run devStart dev server with hot reload
npm run buildStatic site generation (SSG)
npm run previewPreview the built site locally

Running Everything Together

Typical local session: start the three Docker services once, then open a terminal per application.

sh
# Once — start backing services
docker start semalink-pg semalink-redis semalink-rabbit
sh
# Terminal 1 — API
cd semalink-api && npm run dev
# → http://localhost:3000

# Terminal 2 — Customer Web App
cd semalink-frontend && npm run dev
# → http://localhost:5173

# Terminal 3 — Admin Portal
cd semalink-admin && npm run dev
# → http://localhost:5190

# Terminal 4 — Marketing Website
cd semalink-website && npm run dev
# → http://localhost:5173 (or 5174 if frontend is already running)

You rarely need all four running at once. The most common pairing is semalink-api + semalink-frontend for feature development, or semalink-api + semalink-admin for admin work.

Internal use only — Sema Link Engineering