Here's the updated full prompt for Claude Code:
Build a self-hosted PHP client portal for Belaws — a Thailand-based legal services company. The app will live at app.belaws.com.
Stack & Architecture
PHP (no framework) + vanilla JavaScript + AJAX + CSS
No database — one JSON file per client stored in /data/clients/{client_id}/
PHP flock() for all file writes to prevent race conditions
File uploads stored outside web root in /storage/uploads/{client_id}/
Session-based auth via PHP $_SESSION
All AJAX calls go to /api/ endpoints (PHP files)
Modular structure — every feature lives in its own file so new features can be added without touching existing code
Folder Structure
/
├── public/ ← web root (Apache/Nginx points HERE only)
│ ├── index.php ← login page
│ ├── dashboard.php ← client portal shell
│ ├── admin.php ← admin portal shell
│ ├── approve.php ← email approval landing page
│ ├── logout.php
│ ├── download.php ← secure file proxy (auth required)
│ ├── assets/
│ │ ├── style.css
│ │ ├── app.js
│ │ └── admin.js
│ └── img/
│ └── logo.jpg ← app logo (square), placed here manually
│
├── api/ ← AJAX endpoints (outside web root)
│ ├── login.php
│ ├── approve_login.php
│ ├── get_client.php
│ ├── submit_request.php
│ ├── upload_document.php
│ ├── download_document.php
│ ├── admin_get_clients.php
│ ├── admin_get_requests.php
│ ├── admin_update_request.php
│ ├── admin_upload_document.php
│ ├── admin_create_user.php
│ └── admin_save_settings.php
│
├── includes/ ← shared helpers (outside web root)
│ ├── auth.php ← session + role check
│ ├── file_helper.php ← read/write JSON with flock()
│ ├── mailer.php ← Gmail SMTP wrapper (PHPMailer)
│ ├── ip_guard.php ← IP lockout logic
│ ├── token_helper.php ← generate/verify approval tokens
│ └── config.php ← paths, constants (no credentials)
│
├── data/ ← NEVER inside web root
│ ├── admin.json ← admin account(s)
│ ├── settings.json ← app settings including SMTP config
│ ├── ip_log.json ← failed login attempts by IP
│ ├── pending_approvals.json ← active approval tokens
│ └── clients/
│ └── client_001/
│ ├── profile.json
│ ├── company.json
│ ├── requests.json
│ └── documents.json
│
├── storage/ ← NEVER inside web root
│ └── uploads/
│ └── client_001/
│
├── logs/
│ └── access.log ← login events, lockouts
│
├── setup.php ← first-run seed script (deletes itself)
└── composer.json ← PHPMailer dependency
Design System
Use these exact values throughout — no deviations:
Logo:
— used in sidebar and login page, square format
Font headings: Soleil (@font-face local, fallback 'Open Sans', sans-serif)
Font body: Open Sans (Google Fonts)
Navy: #275c86 | Navy dark: #1e4a6e | Navy light: #3182ce
Orange CTAs: #e07b2e
Background: #f0f5fa
Cards: white, border: 1.5px solid #C8D9E9, border-radius: 20px
Text main: #2d3748 | Text soft: #64748b
Success: #38a169 | Warning: #d69e2e | Danger: #e53e3e
Hover on tiles/cards: background #275c86, all text/icons white, transition: all 0.3s ease
Buttons: border-radius: 25px
Sidebar: fixed, 260px, navy background
Authentication Flow
Login attempt
User enters username + password on index.php
api/login.php checks IP against ip_log.json — if locked, reject immediately and show lockout message
If credentials are wrong — increment fail counter for this IP in ip_log.json
If IP has 2 failed attempts from a username that does not exist in the system:
Lock the IP (set locked: true, locked_at: timestamp in ip_log.json)
Send alert email to lay@shiftcode.co.uk with: IP address, timestamp, username attempted, user agent
Return lockout message to the browser
If credentials are correct:
Generate a secure random token (32 bytes, hex) via token_helper.php
Store token in data/pending_approvals.json with: token, client_id, role, ip, created_at, expires_at (15 minutes)
Send approval email to the user's registered email address containing a single button/link: https://app.belaws.com/approve.php?token=XXXXX
Show message on login page: "An approval email has been sent to your registered address. Please check your inbox."
Do NOT create a session yet
Email approval
User clicks the link in their email — lands on approve.php
approve.php calls api/approve_login.php with the token
Token is validated: exists, not expired, IP matches original request IP
If valid: create PHP session, delete token from pending_approvals.json, redirect to dashboard.php or admin.php based on role
If invalid/expired: show error page with a "Back to login" link
IP lockout rules
Lockout triggers on 2 failed attempts from an unknown username (not an existing user with a wrong password — that is a separate counter)
Lockout duration: 30 minutes (configurable in settings.json)
After lockout expires, counter resets automatically
All login events (success, failure, lockout) written to logs/access.log
Account Management — Admin Only
No self-registration. Only an admin can create accounts.
Create user account (api/admin_create_user.php)
Admin fills a form: name, email, username, temporary password, role (client or admin)
Password hashed with bcrypt immediately
New folder created: data/clients/{client_id}/ with blank profile.json, company.json, requests.json, documents.json
Welcome email sent to the new user with their username and temporary password
New client appears in admin Clients tab immediately
Create admin account
Same form, role set to admin
Stored in data/admin.json as an array of admin objects (supports multiple admins)
Settings Panel (Admin only)
Accessible as a tab in the admin portal. Saves to data/settings.json.
Gmail SMTP section
Fields:
Gmail address (the sending account)
Gmail App Password (not the regular Gmail password — show a help note explaining this)
From name (default: "Belaws Portal")
Reply-to address
App settings section
Portal name (default: "Belaws Client Portal")
Support email shown to clients
Login approval token expiry in minutes (default: 15)
IP lockout duration in minutes (default: 30)
IP lockout threshold — number of unknown-username attempts before lock (default: 2)
Settings are saved via api/admin_save_settings.php. Passwords/app passwords are stored in settings.json which lives outside the web root. Never expose raw settings JSON to the browser.
Mailer (includes/mailer.php)
Use PHPMailer via Composer (composer require phpmailer/phpmailer).
Wrapper function:
phpfunction sendMail(string $to, string $toName, string $subject, string $htmlBody): bool
Reads SMTP config from data/settings.json at call time (so config changes take effect immediately without restarting anything).
Email templates
Login approval email:
Subject: Belaws Portal — Approve your login
Body: Clean HTML email, navy header with logo, message saying a login was requested from IP {ip} at {time}, large orange CTA button "Approve Login", note that the link expires in 15 minutes, footer saying if they did not request this to ignore the email
IP lockout alert (to lay@shiftcode.co.uk):
Subject: [SECURITY ALERT] Suspicious login attempt — Belaws Portal
Body: IP address, timestamp, username attempted, browser/user agent, number of attempts, note that the IP has been locked
Welcome email (new account):
Subject: Welcome to the Belaws Client Portal
Body: Name, username, temporary password, login URL, instructions to change password on first login
JSON Data Schema
data/settings.json
json{
"portal_name": "Belaws Client Portal",
"support_email": "support@belaws.com",
"token_expiry_minutes": 15,
"lockout_duration_minutes": 30,
"lockout_threshold": 2,
"smtp": {
"gmail_address": "",
"app_password": "",
"from_name": "Belaws Portal",
"reply_to": ""
}
}
data/pending_approvals.json
json[
{
"token": "abc123...",
"client_id": "client_001",
"role": "client",
"ip": "123.456.789.0",
"created_at": "2026-04-07T10:00:00",
"expires_at": "2026-04-07T10:15:00"
}
]
data/ip_log.json
json{
"123.456.789.0": {
"unknown_attempts": 2,
"known_attempts": 0,
"locked": true,
"locked_at": "2026-04-07T10:00:00",
"last_username_tried": "hacker123",
"user_agent": "Mozilla/5.0..."
}
}
data/clients/client_001/profile.json
json{
"client_id": "client_001",
"username": "demo",
"password_hash": "[bcrypt]",
"name": "James Laurent",
"email": "james@example.com",
"role": "client",
"created_at": "2026-01-01",
"created_by": "admin"
}
data/clients/client_001/company.json
json{
"name_en": "Laurent Asia Co., Ltd.",
"name_th": "ลอเรนท์ เอเชีย จำกัด",
"registration_number": "0105564082741",
"registration_date": "2022-03-14",
"type": "Thai Limited Company",
"capital": 2000000,
"address": "388/18 Sukhumvit Road, Klongtoey, Bangkok 10110",
"status": "active",
"directors": [],
"shareholders": []
}
data/clients/client_001/requests.json
json[
{
"id": "REQ-0028",
"type": "Add Director",
"status": "in_progress",
"submitted": "2026-03-28",
"updated": "2026-04-02",
"urgency": "normal",
"notes": "Please process ASAP",
"admin_notes": "",
"documents_required": ["Passport", "House Registration"],
"documents_received": ["Passport"],
"timeline": [
{ "event": "Request received", "date": "2026-03-28", "by": "system" }
]
}
]
data/clients/client_001/documents.json
json[
{
"id": "doc_001",
"name": "Certificate of Incorporation",
"filename_disk": "uuid-generated-name.pdf",
"original_filename": "certificate_of_incorporation.pdf",
"type": "official",
"size": "1.2 MB",
"date": "2022-03-14",
"uploaded_by": "admin",
"mime_type": "application/pdf"
}
]
Pages & Features
Client Portal — 6 tabs
Dashboard — stat cards, company snapshot, recent requests, quick action tiles, activity timeline
My Company — company details, directors table, shareholders table
Requests — full list, new request modal (type, description, urgency), AJAX submit
Documents — file list with secure download via download.php?id=, upload button (AJAX), client sees only their own files
Compliance — upcoming deadlines with days remaining and colour-coded badges
Invoices — invoice table with paid/unpaid status
Admin Portal — 6 tabs
Overview — stats, pending requests queue, compliance alerts
Clients — all clients table, click to view profile, button to create new client or admin account
Requests — all requests across all clients, drill into detail view with document checklist, timeline, action buttons (Mark Complete, Add Note, Request Documents, Upload Output)
Documents — cross-client document library, upload for specific client
Compliance — all clients' deadlines, sortable by urgency
Settings — Gmail SMTP config, app settings, all saved to settings.json
API Endpoints
Every /api/*.php file follows this pattern:
php false, 'message' => 'Unauthorised']);
exit;
}
// ... logic ...
echo json_encode(['success' => true, 'data' => $result]);
Security Requirements
data/ and storage/ are outside the web root — never directly accessible via URL
Uploaded files served only through download.php after session + ownership check
MIME type verified server-side with finfo_file(), not just extension
Uploaded files renamed to UUID on disk
Allowed upload extensions: pdf, jpg, jpeg, png, docx
All file paths sanitised — reject any input containing ../ or absolute paths
CSRF token generated per session, validated on all POST requests
session_regenerate_id(true) called after successful session creation
display_errors = Off in production — errors written to server log only
HTTP security headers set on every page:
phpheader('X-Frame-Options: DENY');
header('X-Content-Type-Options: nosniff');
header('Referrer-Policy: strict-origin-when-cross-origin');
Approval tokens are single-use — deleted immediately after use
Expired tokens cleaned up on each login attempt (purge entries older than expiry)
Extensibility — How to Add Features
The codebase is structured so new features never require editing existing files:
New API endpoint → add a new file in /api/, follow the standard pattern above
New client tab → add a new