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
in dashboard.php, add a nav item in the sidebar, add a case in app.js tab switcher New admin tab → same pattern in admin.php New email template → add a new function in includes/mailer.php New setting → add the field to the Settings tab form, add the key to settings.json schema, read it in config.php New JSON field on a client → add it to the schema, update the relevant API endpoint, update setup.php seed data Each module is self-contained. No global state outside of $_SESSION and data/settings.json. Setup Script (setup.php) Runs once, then deletes itself. Must: Install PHPMailer via Composer (or check it's installed) Create all required directories with correct permissions Write data/settings.json with defaults (SMTP fields left blank) Write data/admin.json with one admin account: username admin, password admin (bcrypt hashed), email lay@shiftcode.co.uk, name Belaws Team Write seed client client_001 — James Laurent / Laurent Asia Co. Ltd. with realistic data: 3 directors, 3 shareholders, 4 requests (2 in progress, 2 completed), 6 documents, 3 compliance items Write data/ip_log.json as empty object {} Write data/pending_approvals.json as empty array [] Print a success summary, then unlink(__FILE__) Seed Demo Credentials Client: demo / demo — James Laurent, Laurent Asia Co., Ltd. Admin: admin / admin — Belaws Team, email lay@shiftcode.co.uk Tone & UX Toast notifications for all actions Loading spinner on AJAX calls Empty states when no data Mobile responsive — sidebar collapses on small screens All transitions 0.3s ease Font Awesome 6 for all icons Approval pending page auto-refreshes every 30 seconds to check if the token has been used (poll api/check_approval_status.php) — if approved elsewhere, redirect automatically