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-LinkGitHub 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:
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
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-alpineAvailable at localhost:5432. Database: semalink, user: semalink, password: semalink.
Redis
docker run -d --name semalink-redis \
-p 6379:6379 \
-v semalink-redis-data:/data \
redis:7-alpineAvailable at localhost:6379.
RabbitMQ
docker run -d --name semalink-rabbit \
-p 5672:5672 -p 15672:15672 \
-v semalink-rabbit-data:/var/lib/rabbitmq \
rabbitmq:3-managementAvailable at localhost:5672. Management UI at http://localhost:15672 (guest / guest).
Managing containers
# 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-rabbitMain API — semalink-api
1. Clone
git clone git@github.com:Sema-Link/semalink-api.git
cd semalink-api2. Install dependencies
npm install3. Create your environment file
cp .env.example .envFill in the values:
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=v14. Run database migrations
npm run db:migrateThis 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
npm run devAPI starts at http://localhost:3000. Logs are formatted with Pino in development mode.
Available Scripts
| Command | Description |
|---|---|
npm run dev | Start dev server with tsx watch (hot reload) |
npm run build | TypeScript compile to dist/ |
npm run start | Run compiled output (node dist/server.js) |
npm run db:generate | Generate Drizzle migration from schema changes |
npm run db:migrate | Apply pending migrations |
npm run db:studio | Open Drizzle Studio (visual DB browser) |
Making schema changes
See the Database Schema page for the full migration workflow. Short version:
# 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:migrateOptional 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.
Customer Web App — semalink-frontend
1. Clone
git clone git@github.com:Sema-Link/semalink-frontend.git
cd semalink-frontend2. Install dependencies
npm install3. Create your environment file
cp .env.example .env.localFor local development pointed at staging:
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=stagingGet 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
npm run devAvailable at http://localhost:5173.
Available Scripts
| Command | Description |
|---|---|
npm run dev | Start local dev server with hot reload |
npm run build | Type-check + production build |
npm run preview | Preview production build locally |
npm run test:unit | Run unit tests with Vitest |
npm run lint | Lint and auto-fix with ESLint + oxlint |
npm run format | Format source files with Prettier |
Type checking
npx vue-tsc --noEmitA clean exit means no type errors. Trust this over IDE warnings (Volar cache can show stale errors).
Admin Portal — semalink-admin
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
| Layer | Technology | Version |
|---|---|---|
| UI library | React | 18.3 |
| Language | TypeScript | 5.5 |
| Build tool | Vite | 7.3 |
| Styling | Tailwind CSS v4 (via @tailwindcss/vite) | 4.2 |
| State management | Zustand (with persist middleware) | 5.0 |
| Routing | React Router v6 | 6.27 |
| HTTP client | Axios (with interceptors) | 1.7 |
| Icons | Lucide React | 0.446 |
| Class utilities | clsx + tailwind-merge | — |
| Font | Nunito (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 modulesModules
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.
| Module | Category | Min permission | Status |
|---|---|---|---|
| Engineering | Infrastructure | read | Live |
| Sender IDs | Compliance | read | Live |
| CRM | Customers | read | Live |
| Billing | Finance | admin | Live |
| Staff | Administration | owner | Live |
| Audit Log | Compliance | read | Live |
| Content Moderation | Compliance | admin | Coming soon |
| Pricing | Finance | owner | Coming soon |
| Reports | Analytics | owner | Coming soon |
| Agent Manager | Partners | admin | Coming soon |
| Campaigns | Operations | read | Coming 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:
- User opens the app →
RequireAuthguard checksuseAuthStore().staff - If not authenticated, redirected to
/login - User enters email + password →
POST /admin/auth/login - On success:
staffobject,accessToken,refreshTokenwritten to the Zustand store - Zustand
persistmiddleware serialises the store tolocalStorageunder the keysemalink-admin-auth - 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:
| Level | What it means |
|---|---|
none | No access — module hidden |
read | View data, no mutations |
admin | Read + write |
owner | Full 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 requestsAll 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:
{
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:
{
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.
| Path | Page | Notes |
|---|---|---|
/login | LoginPage | Public |
/ | — | Redirects to /launcher |
/launcher | LauncherPage | Dashboard home |
/profile | ProfilePage | Defaults to Overview tab |
/profile/:section | ProfilePage | security, notifications, messages, permissions |
/staff | StaffDashboardPage | Owner only (enforced by API) |
/staff/members | StaffMembersPage | Owner only |
/engineering | ComingSoon | — |
/sender-ids | ComingSoon | — |
/crm | ComingSoon | — |
/billing | ComingSoon | — |
/audit | ComingSoon | — |
/content-mod | ComingSoon | — |
/pricing | ComingSoon | — |
/reports | ComingSoon | — |
/agents | ComingSoon | — |
/campaigns | ComingSoon | — |
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
git clone git@github.com:Sema-Link/semalink-admin.git
cd semalink-admin2. Install dependencies
npm install3. Create your environment file
cp .env.example .env.localVITE_API_BASE_URL=https://staging-arc.semalink.africa/api/v1
VITE_ENV=developmentRunning 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.
VITE_API_BASE_URL=/api/v1
VITE_ENV=developmentThe 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
npm run devAvailable 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
| Command | Description |
|---|---|
npm run dev | Start dev server with hot reload on port 5190 |
npm run build | TypeScript compile + Vite production build to dist/ |
npm run preview | Preview the production build locally |
npm run type-check | Run tsc --noEmit — type errors without building |
Type checking
The project has strict TypeScript enabled. Run a full type check before pushing:
npm run type-checkPath 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:
# Inside semalink-api/
npm run seed:adminThis creates an owner-role staff account. Check the script output for the email and temporary password, then log in at http://localhost:5190/login.
Marketing Website — semalink-website
1. Clone
git clone git@github.com:Sema-Link/semalink-website.git
cd semalink-website2. Install dependencies
npm install3. Start the dev server
npm run devAvailable 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
| Command | Description |
|---|---|
npm run dev | Start dev server with hot reload |
npm run build | Static site generation (SSG) |
npm run preview | Preview the built site locally |
Running Everything Together
Typical local session: start the three Docker services once, then open a terminal per application.
# Once — start backing services
docker start semalink-pg semalink-redis semalink-rabbit# 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.