diff --git a/.env.docker.dev b/.env.docker.dev index 0126804..11ec2b1 100644 --- a/.env.docker.dev +++ b/.env.docker.dev @@ -1,4 +1,4 @@ -DATABASE_URL=postgresql://postgres:password@postgres:5432/rentaldrivego +DATABASE_URL=postgresql://placeholder:placeholder@placeholder:5432/placeholder REDIS_URL=redis://redis:6379 API_PORT=4000 API_URL=http://localhost:4000 @@ -15,27 +15,27 @@ ADMIN_INTERNAL_URL=http://host.docker.internal:3002 # correct port rather than through the marketplace proxy (which doesn't have those chunks). DASHBOARD_ASSET_PREFIX=http://localhost:3001/dashboard ADMIN_ASSET_PREFIX=http://localhost:3002/admin -JWT_SECRET=dev-secret +JWT_SECRET=placeholder JWT_EXPIRY=8h RENTER_JWT_EXPIRY=7d ADMIN_SEED_EMAIL=rentaldrivego@gmail.com -ADMIN_SEED_PASSWORD=changeme123 +ADMIN_SEED_PASSWORD=placeholder ADMIN_SEED_FIRST_NAME=Platform ADMIN_SEED_LAST_NAME=Admin NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY= -CLERK_SECRET_KEY= +CLERK_SECRET_KEY=placeholder NODE_ENV=development CORS_ORIGINS=http://localhost:3000,http://localhost:4000 # Email - disable Resend (placeholder), use SMTP instead -RESEND_API_KEY=re_... +RESEND_API_KEY=placeholder EMAIL_FROM=rentaldrivego@gmail.com EMAIL_FROM_NAME=RentalDriveGo MAIL_HOST=smtp.gmail.com MAIL_PORT=587 MAIL_SCHEME=smtp MAIL_USERNAME=rentaldrivego@gmail.com -MAIL_PASSWORD=xmhg ibqy muoc rntc +MAIL_PASSWORD=placeholder MAIL_FROM_ADDRESS=rentaldrivego@gmail.com MAIL_FROM_NAME=RentalDriveGo MAIL_REPLY_TO_ADDRESS=rentaldrivego@gmail.com diff --git a/.env.docker.production.example b/.env.docker.production.example index 6b966a2..b503737 100644 --- a/.env.docker.production.example +++ b/.env.docker.production.example @@ -14,20 +14,20 @@ REGISTRY_UPSTREAM_URL=http://10.0.0.10:5000 # APP_VERSION=latest REGISTRY_HOST=registry.rentaldrivego.ma REGISTRY_USER=rentaldrivego_registry -REGISTRY_PASSWORD=PMPS5k0D7rUeJOk0NkhI5bRtoGjkUqjK +REGISTRY_PASSWORD=placeholder REGISTRY_IMAGE=registry.rentaldrivego.ma/rentaldrivego/car_management_system -REGISTRY_HTTP_SECRET=PMPS5k0D7rUeJOk0NkhI5bRtoGjkUqjK +REGISTRY_HTTP_SECRET=placeholder # ── Database ────────────────────────────────────────────────────────────────── # Full connection URL (used when DATABASE_URL_FROM_POSTGRES=false) -DATABASE_URL=postgresql://postgres:24DY&1u5FLCkNMeiO_p@postgres:5432/rentaldrivego +DATABASE_URL=postgresql://placeholder:placeholder@placeholder:5432/placeholder # Build the URL from individual vars (set to true to use POSTGRES_* vars below) DATABASE_URL_FROM_POSTGRES=true POSTGRES_HOST=postgres POSTGRES_PORT=5432 POSTGRES_DB=rentaldrivego POSTGRES_USER=postgres -POSTGRES_PASSWORD=24DY&1u5FLCkNMeiO_p +POSTGRES_PASSWORD=placeholder # ── Cache ───────────────────────────────────────────────────────────────────── @@ -62,7 +62,7 @@ ADMIN_URL=https://rentaldrivego.ma/admin CORS_ORIGINS=https://rentaldrivego.ma,https://www.rentaldrivego.ma # ── Auth ────────────────────────────────────────────────────────────────────── -JWT_SECRET=PMPS5k0D7rUeJOk0NkhI5bRtoGjkUqjK +JWT_SECRET=placeholder JWT_EXPIRY=8h RENTER_JWT_EXPIRY=7d NODE_ENV=production @@ -78,7 +78,7 @@ MAIL_HOST=smtp.gmail.com MAIL_PORT=587 MAIL_SCHEME=smtp MAIL_USERNAME=rentaldrivego@gmail.com -MAIL_PASSWORD="xmhg ibqy muoc rntc" +MAIL_PASSWORD=placeholder MAIL_FROM_ADDRESS=rentaldrivego@gmail.com MAIL_FROM_NAME=RentalDriveGo MAIL_REPLY_TO_ADDRESS=rentaldrivego@gmail.com diff --git a/.env.docker.production.generated b/.env.docker.production.generated index dac62b6..90ae0fd 100644 --- a/.env.docker.production.generated +++ b/.env.docker.production.generated @@ -13,8 +13,8 @@ POSTGRES_HOST=postgres POSTGRES_PORT=5432 POSTGRES_DB=rentaldrivego POSTGRES_USER=postgres -POSTGRES_PASSWORD=change-me -DATABASE_URL=postgresql://postgres:change-me@postgres:5432/rentaldrivego +POSTGRES_PASSWORD=placeholder +DATABASE_URL=postgresql://placeholder:placeholder@placeholder:5432/placeholder # Cache REDIS_URL=redis://redis:6379 @@ -38,7 +38,7 @@ ADMIN_URL=https://example.com/admin CORS_ORIGINS=https://example.com,https://www.example.com # Auth -JWT_SECRET=replace-with-a-long-random-secret +JWT_SECRET=placeholder JWT_EXPIRY=8h RENTER_JWT_EXPIRY=7d NODE_ENV=production @@ -57,7 +57,7 @@ MAIL_HOST=smtp.gmail.com MAIL_PORT=587 MAIL_SCHEME=smtp MAIL_USERNAME=your-smtp-user@example.com -MAIL_PASSWORD=replace-with-an-app-password +MAIL_PASSWORD=placeholder MAIL_FROM_ADDRESS=noreply@example.com MAIL_FROM_NAME=RentalDriveGo MAIL_REPLY_TO_ADDRESS=support@example.com diff --git a/.env.docker.test b/.env.docker.test index 5a91bd4..9079815 100644 --- a/.env.docker.test +++ b/.env.docker.test @@ -1,10 +1,10 @@ -DATABASE_URL=postgresql://postgres:password@postgres:5432/rentaldrivego_test +DATABASE_URL=postgresql://placeholder:placeholder@placeholder:5432/placeholder REDIS_URL=redis://redis:6379 API_PORT=4000 API_URL=http://localhost:4000 API_INTERNAL_URL=http://localhost:4000/api/v1 NEXT_PUBLIC_API_URL=http://localhost:4000/api/v1 -JWT_SECRET=test-secret +JWT_SECRET=placeholder JWT_EXPIRY=8h RENTER_JWT_EXPIRY=7d NODE_ENV=test diff --git a/.env.example b/.env.example index e8dfeaa..fb74aa5 100644 --- a/.env.example +++ b/.env.example @@ -4,7 +4,7 @@ # ═══════════════════════════════════════════════════════════════ # ─── Database ────────────────────────────────────────────────── -DATABASE_URL="postgresql://postgres:24DY&1u5FLCkNMeiO_p@localhost:5432/rentaldrivego" +DATABASE_URL=postgresql://placeholder:placeholder@placeholder:5432/placeholder # ─── API ─────────────────────────────────────────────────────── API_PORT=4000 @@ -12,13 +12,13 @@ API_URL=http://localhost:4000 NEXT_PUBLIC_API_URL=http://localhost:4000/api/v1 # ─── JWT (Renter auth + Admin auth) ─────────────────────────── -JWT_SECRET=your-super-secret-jwt-key-change-in-production +JWT_SECRET=placeholder JWT_EXPIRY=8h RENTER_JWT_EXPIRY=7d # ─── Clerk (Company employee auth) ──────────────────────────── NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_... -CLERK_SECRET_KEY=sk_test_... +CLERK_SECRET_KEY=placeholder CLERK_WEBHOOK_SECRET=whsec_... NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up @@ -28,14 +28,14 @@ NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/onboarding # ─── AmanPay (Primary payment provider) ─────────────────────── # RentalDriveGo's own AmanPay account (for collecting subscription fees) AMANPAY_MERCHANT_ID=your-amanpay-merchant-id -AMANPAY_SECRET_KEY=your-amanpay-secret-key +AMANPAY_SECRET_KEY=placeholder AMANPAY_BASE_URL=https://api.amanpay.net -AMANPAY_WEBHOOK_SECRET=your-amanpay-webhook-secret +AMANPAY_WEBHOOK_SECRET=placeholder # ─── PayPal (Secondary payment provider) ────────────────────── # RentalDriveGo's own PayPal account (for collecting subscription fees) PAYPAL_CLIENT_ID=your-paypal-client-id -PAYPAL_CLIENT_SECRET=your-paypal-client-secret +PAYPAL_CLIENT_SECRET=placeholder PAYPAL_BASE_URL=https://api-m.paypal.com # Use https://api-m.sandbox.paypal.com for sandbox NEXT_PUBLIC_PAYPAL_CLIENT_ID=your-paypal-client-id @@ -43,23 +43,23 @@ NEXT_PUBLIC_PAYPAL_CLIENT_ID=your-paypal-client-id # ─── Cloudinary (Vehicle + brand photos) ────────────────────── CLOUDINARY_CLOUD_NAME=your-cloud-name CLOUDINARY_API_KEY=your-api-key -CLOUDINARY_API_SECRET=your-api-secret +CLOUDINARY_API_SECRET=placeholder NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME=your-cloud-name # ─── Resend (Email) ──────────────────────────────────────────── -RESEND_API_KEY=re_... +RESEND_API_KEY=placeholder EMAIL_FROM=rentaldrivego@gmail.com EMAIL_FROM_NAME=RentalDriveGo # ─── Twilio (SMS + WhatsApp) ─────────────────────────────────── TWILIO_ACCOUNT_SID=AC... -TWILIO_AUTH_TOKEN=your-twilio-auth-token +TWILIO_AUTH_TOKEN=placeholder TWILIO_PHONE_NUMBER=+1234567890 TWILIO_WHATSAPP_NUMBER=whatsapp:+14155238886 # ─── Firebase (Push notifications) ─────────────────────────── FIREBASE_PROJECT_ID=your-project-id -FIREBASE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n" +FIREBASE_PRIVATE_KEY=placeholder FIREBASE_CLIENT_EMAIL=firebase-adminsdk@your-project.iam.gserviceaccount.com NEXT_PUBLIC_FIREBASE_API_KEY=your-firebase-api-key NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=your-project.firebaseapp.com @@ -84,7 +84,7 @@ DASHBOARD_URL=http://localhost:3000/dashboard # ─── Admin seed (first SUPER_ADMIN created on db:seed) ──────── ADMIN_SEED_EMAIL=admin@rentaldrivego.com -ADMIN_SEED_PASSWORD=changeme123 +ADMIN_SEED_PASSWORD=placeholder ADMIN_SEED_FIRST_NAME=Super ADMIN_SEED_LAST_NAME=Admin diff --git a/Dockerfile.production b/Dockerfile.production index e41f833..e1de287 100644 --- a/Dockerfile.production +++ b/Dockerfile.production @@ -37,12 +37,21 @@ WORKDIR /app ENV NODE_ENV=production -RUN corepack enable && corepack prepare npm@10.5.0 --activate +RUN corepack enable && corepack prepare npm@10.5.0 --activate \ + && groupadd --system --gid 10001 app \ + && useradd --system --uid 10001 --gid app --home-dir /app --shell /usr/sbin/nologin app \ + && mkdir -p /var/lib/rentaldrivego/storage/public /var/lib/rentaldrivego/storage/private /tmp/rentaldrivego \ + && chown -R app:app /app /var/lib/rentaldrivego /tmp/rentaldrivego -COPY --from=builder /app/package.json /app/package-lock.json /app/turbo.json /app/tsconfig.base.json ./ -COPY --from=builder /app/node_modules ./node_modules -COPY --from=builder /app/apps ./apps -COPY --from=builder /app/packages ./packages +COPY --from=builder --chown=app:app /app/package.json /app/package-lock.json /app/turbo.json /app/tsconfig.base.json ./ +COPY --from=builder --chown=app:app /app/node_modules ./node_modules +COPY --from=builder --chown=app:app /app/apps ./apps +COPY --from=builder --chown=app:app /app/packages ./packages +COPY --chown=root:root docker/entrypoint.production.sh /usr/local/bin/rdg-entrypoint.sh + +RUN chmod 755 /usr/local/bin/rdg-entrypoint.sh + +ENTRYPOINT ["/usr/local/bin/rdg-entrypoint.sh"] EXPOSE 3000 3001 3002 4000 diff --git a/apps/admin/next-env.d.ts b/apps/admin/next-env.d.ts index 4f11a03..9edff1c 100644 --- a/apps/admin/next-env.d.ts +++ b/apps/admin/next-env.d.ts @@ -1,5 +1,6 @@ /// /// +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited -// see https://nextjs.org/docs/basic-features/typescript for more information. +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/admin/next.config.js b/apps/admin/next.config.js index 0d29994..6331e40 100644 --- a/apps/admin/next.config.js +++ b/apps/admin/next.config.js @@ -1,4 +1,27 @@ /** @type {import('next').NextConfig} */ + +const securityHeaders = [ + { key: 'Strict-Transport-Security', value: 'max-age=31536000; includeSubDomains' }, + { key: 'X-Content-Type-Options', value: 'nosniff' }, + { key: 'X-Frame-Options', value: 'DENY' }, + { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' }, + { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' }, + { + key: 'Content-Security-Policy', + value: [ + "default-src 'self'", + "script-src 'self' 'unsafe-inline'", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data: blob: https:", + "font-src 'self' data:", + "connect-src 'self' https: wss:", + "frame-ancestors 'none'", + "base-uri 'self'", + "form-action 'self'", + "object-src 'none'", + ].join('; '), + }, +] const apiOrigin = (process.env.API_INTERNAL_URL ?? process.env.API_URL ?? 'http://localhost:4000').replace(/\/api\/v1\/?$/, '') const apiUrl = new URL(apiOrigin) const ADMIN_BASE_PATH = '/admin' @@ -43,6 +66,14 @@ const nextConfig = { ], }, transpilePackages: ['@rentaldrivego/types'], + async headers() { + return [ + { + source: '/:path*', + headers: securityHeaders, + }, + ] + }, async rewrites() { return [ { diff --git a/apps/admin/package.json b/apps/admin/package.json index 6817a57..3707ed9 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -10,25 +10,28 @@ "prestart": "npm run build --workspace @rentaldrivego/types", "pretype-check": "npm run build --workspace @rentaldrivego/types", "start": "next start -H 0.0.0.0 -p 3002", - "type-check": "tsc --noEmit" + "type-check": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@rentaldrivego/types": "*", - "next": "14.2.3", + "autoprefixer": "^10.4.19", + "dayjs": "^1.11.11", + "lucide-react": "^0.376.0", + "next": "^16.2.7", + "postcss": "^8.4.38", "react": "^18.3.1", "react-dom": "^18.3.1", - "tailwindcss": "^3.4.3", - "autoprefixer": "^10.4.19", - "postcss": "^8.4.38", - "zod": "^3.23.0", - "dayjs": "^1.11.11", "recharts": "^2.12.7", - "lucide-react": "^0.376.0" + "tailwindcss": "^3.4.3", + "zod": "^3.23.0" }, "devDependencies": { "@types/node": "^20.12.0", "@types/react": "^18.3.1", "@types/react-dom": "^18.3.0", - "typescript": "^5.4.0" + "typescript": "^5.4.0", + "vitest": "^1.6.0" } } diff --git a/apps/admin/src/app/auth-redirect/page.tsx b/apps/admin/src/app/auth-redirect/page.tsx index b1d9e7a..b929b2c 100644 --- a/apps/admin/src/app/auth-redirect/page.tsx +++ b/apps/admin/src/app/auth-redirect/page.tsx @@ -18,14 +18,10 @@ export default function AuthRedirectPage() { useEffect(() => { const hash = window.location.hash const params = new URLSearchParams(hash.replace(/^#/, '')) - const token = params.get('token') const next = resolveAdminNextPath(params.get('next')) - if (token) { - localStorage.setItem('admin_token', token) - // Clear the token from the URL - window.history.replaceState(null, '', window.location.pathname) - } + // Clear legacy token fragments from the URL. Admin auth now uses an HttpOnly cookie. + window.history.replaceState(null, '', window.location.pathname) window.location.replace(next) }, []) diff --git a/apps/admin/src/app/dashboard/admin-users/page.tsx b/apps/admin/src/app/dashboard/admin-users/page.tsx index 862fbb7..d2283e1 100644 --- a/apps/admin/src/app/dashboard/admin-users/page.tsx +++ b/apps/admin/src/app/dashboard/admin-users/page.tsx @@ -33,13 +33,11 @@ export default function AdminUsersPage() { const [form, setForm] = useState(EMPTY_FORM) const [saving, setSaving] = useState(false) - function getToken() { return localStorage.getItem('admin_token') ?? '' } - async function fetchAdmins() { try { const res = await fetch(`${ADMIN_API_BASE}/admin/admins`, { - headers: { Authorization: `Bearer ${getToken()}` }, cache: 'no-store', + credentials: 'include', }) const json = await res.json() if (!res.ok) throw new Error(json?.message ?? 'Failed') @@ -97,7 +95,8 @@ export default function AdminUsersPage() { const res = await fetch(`${ADMIN_API_BASE}/admin/admins${editingAdminId ? `/${editingAdminId}` : ''}`, { method: isEditing ? 'PATCH' : 'POST', - headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` }, + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', body: JSON.stringify(payload), }) const json = await res.json() diff --git a/apps/admin/src/app/dashboard/audit-logs/page.tsx b/apps/admin/src/app/dashboard/audit-logs/page.tsx index e163d25..6e503a2 100644 --- a/apps/admin/src/app/dashboard/audit-logs/page.tsx +++ b/apps/admin/src/app/dashboard/audit-logs/page.tsx @@ -28,9 +28,8 @@ export default function AdminAuditLogsPage() { const [filter, setFilter] = useState('') useEffect(() => { - const token = localStorage.getItem('admin_token') ?? '' fetch(`${ADMIN_API_BASE}/admin/audit-logs`, { - headers: { Authorization: `Bearer ${token}` }, + credentials: 'include', cache: 'no-store', }) .then((r) => r.json()) diff --git a/apps/admin/src/app/dashboard/billing/page.tsx b/apps/admin/src/app/dashboard/billing/page.tsx index bee3704..87a5955 100644 --- a/apps/admin/src/app/dashboard/billing/page.tsx +++ b/apps/admin/src/app/dashboard/billing/page.tsx @@ -467,14 +467,13 @@ export default function AdminBillingPage() { ) async function api(path: string, init?: RequestInit): Promise { - const token = localStorage.getItem('admin_token') const res = await fetch(`${ADMIN_API_BASE}${path}`, { ...init, headers: { 'Content-Type': 'application/json', - ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(init?.headers ?? {}), }, + credentials: 'include', cache: 'no-store', }) if (res.headers.get('content-type')?.includes('application/pdf')) { diff --git a/apps/admin/src/app/dashboard/companies/[id]/page.tsx b/apps/admin/src/app/dashboard/companies/[id]/page.tsx index d0ee90a..fc8822e 100644 --- a/apps/admin/src/app/dashboard/companies/[id]/page.tsx +++ b/apps/admin/src/app/dashboard/companies/[id]/page.tsx @@ -207,10 +207,6 @@ const STATUS_COLORS: Record = { const INPUT_CLASS = 'mt-1 w-full rounded-xl border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 outline-none focus:border-emerald-500' const LABEL_CLASS = 'text-xs font-medium uppercase tracking-wide text-zinc-500' -function getToken() { - return localStorage.getItem('admin_token') ?? '' -} - function toDateInput(value: string | null | undefined) { return value ? new Date(value).toISOString().slice(0, 10) : '' } @@ -324,11 +320,10 @@ export default function AdminCompanyDetailPage() { const [deleteConfirm, setDeleteConfirm] = useState(false) async function fetchData() { - const headers = { Authorization: `Bearer ${getToken()}` } try { const [cRes, aRes] = await Promise.all([ - fetch(`${ADMIN_API_BASE}/admin/companies/${id}`, { headers, cache: 'no-store' }), - fetch(`${ADMIN_API_BASE}/admin/audit-logs?entityId=${id}&pageSize=10`, { headers, cache: 'no-store' }), + fetch(`${ADMIN_API_BASE}/admin/companies/${id}`, { cache: 'no-store', credentials: 'include' }), + fetch(`${ADMIN_API_BASE}/admin/audit-logs?entityId=${id}&pageSize=10`, { cache: 'no-store', credentials: 'include' }), ]) const cJson = await cRes.json() const aJson = await aRes.json() @@ -339,7 +334,7 @@ export default function AdminCompanyDetailPage() { const subId = cJson.data?.subscription?.id if (subId) { - const eRes = await fetch(`${ADMIN_API_BASE}/admin/subscriptions/${subId}/events`, { headers, cache: 'no-store' }) + const eRes = await fetch(`${ADMIN_API_BASE}/admin/subscriptions/${subId}/events`, { cache: 'no-store', credentials: 'include' }) const eJson = await eRes.json() setSubEvents(Array.isArray(eJson.data) ? eJson.data : []) } @@ -365,7 +360,8 @@ export default function AdminCompanyDetailPage() { try { const res = await fetch(`${ADMIN_API_BASE}/admin/companies/${id}`, { method: 'PATCH', - headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` }, + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', body: JSON.stringify({ company: { name: form.company.name, @@ -469,7 +465,8 @@ export default function AdminCompanyDetailPage() { try { const res = await fetch(`${ADMIN_API_BASE}/admin/subscriptions/${company.subscription.id}/${action}`, { method: 'POST', - headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` }, + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', body: JSON.stringify({ reason: subOverrideReason }), }) const json = await res.json() @@ -490,7 +487,8 @@ export default function AdminCompanyDetailPage() { try { const res = await fetch(`${ADMIN_API_BASE}/admin/companies/${id}/status`, { method: 'PATCH', - headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` }, + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', body: JSON.stringify({ status }), }) const json = await res.json() @@ -508,7 +506,7 @@ export default function AdminCompanyDetailPage() { try { const res = await fetch(`${ADMIN_API_BASE}/admin/companies/${id}`, { method: 'DELETE', - headers: { Authorization: `Bearer ${getToken()}` }, + credentials: 'include', }) const json = await res.json() if (!res.ok) throw new Error(json?.message ?? 'Delete failed') diff --git a/apps/admin/src/app/dashboard/companies/page.tsx b/apps/admin/src/app/dashboard/companies/page.tsx index 75d85b1..0f0963e 100644 --- a/apps/admin/src/app/dashboard/companies/page.tsx +++ b/apps/admin/src/app/dashboard/companies/page.tsx @@ -84,10 +84,9 @@ export default function AdminCompaniesPage() { const [actioning, setActioning] = useState(null) async function fetchCompanies() { - const token = localStorage.getItem('admin_token') try { const res = await fetch(`${ADMIN_API_BASE}/admin/companies`, { - headers: token ? { Authorization: `Bearer ${token}` } : {}, + credentials: 'include', cache: 'no-store', }) const json = await res.json() @@ -111,11 +110,11 @@ export default function AdminCompaniesPage() { async function changeStatus(id: string, status: string) { setActioning(id) - const token = localStorage.getItem('admin_token') try { const res = await fetch(`${ADMIN_API_BASE}/admin/companies/${id}/status`, { method: 'PATCH', - headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) }, + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', body: JSON.stringify({ status }), }) if (!res.ok) throw new Error('Action failed') diff --git a/apps/admin/src/app/dashboard/containers/page.tsx b/apps/admin/src/app/dashboard/containers/page.tsx index af7434e..163174b 100644 --- a/apps/admin/src/app/dashboard/containers/page.tsx +++ b/apps/admin/src/app/dashboard/containers/page.tsx @@ -25,8 +25,7 @@ interface CompanyContainer { } function authHeaders() { - const token = typeof window !== 'undefined' ? localStorage.getItem('admin_token') : '' - return { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` } + return { 'Content-Type': 'application/json' } } function StatusBadge({ status }: { status: ContainerStatus }) { @@ -57,7 +56,7 @@ function LogsModal({ companyId, companyName, onClose }: { companyId: string; com const fetchLogs = useCallback(async (lines: number) => { setLoading(true) try { - const res = await fetch(`${ADMIN_API_BASE}/admin/containers/${companyId}/logs?tail=${lines}`, { headers: authHeaders() }) + const res = await fetch(`${ADMIN_API_BASE}/admin/containers/${companyId}/logs?tail=${lines}`, { headers: authHeaders(), credentials: 'include' }) const json = await res.json() setLogs(json.data?.logs ?? '') } catch { @@ -126,7 +125,7 @@ export default function ContainersPage() { const fetchContainers = useCallback(async () => { try { - const res = await fetch(`${ADMIN_API_BASE}/admin/containers`, { headers: authHeaders() }) + const res = await fetch(`${ADMIN_API_BASE}/admin/containers`, { headers: authHeaders(), credentials: 'include' }) const json = await res.json().catch(() => null) if (!res.ok) { throw new Error(json?.message ?? 'Failed to load containers.') @@ -151,7 +150,7 @@ export default function ContainersPage() { setProvisioning(true) setProvisionResults(null) try { - const res = await fetch(`${ADMIN_API_BASE}/admin/containers/provision-all`, { method: 'POST', headers: authHeaders() }) + const res = await fetch(`${ADMIN_API_BASE}/admin/containers/provision-all`, { method: 'POST', headers: authHeaders(), credentials: 'include' }) const json = await res.json().catch(() => null) if (!res.ok) throw new Error(json?.message ?? 'Provisioning failed.') setProvisionResults(json.data?.results ?? []) @@ -172,7 +171,7 @@ export default function ContainersPage() { action === 'remove' ? `${ADMIN_API_BASE}/admin/containers/${companyId}` : `${ADMIN_API_BASE}/admin/containers/${companyId}/${action}` - const res = await fetch(url, { method, headers: authHeaders() }) + const res = await fetch(url, { method, headers: authHeaders(), credentials: 'include' }) const json = await res.json().catch(() => null) if (!res.ok) { throw new Error(json?.message ?? `Failed to ${action} container.`) diff --git a/apps/admin/src/app/dashboard/layout.tsx b/apps/admin/src/app/dashboard/layout.tsx index 258eb94..513b5e4 100644 --- a/apps/admin/src/app/dashboard/layout.tsx +++ b/apps/admin/src/app/dashboard/layout.tsx @@ -38,16 +38,30 @@ export default function AdminDashboardLayout({ children }: { children: React.Rea const [ready, setReady] = useState(false) useEffect(() => { - const token = localStorage.getItem('admin_token') - if (!token) { - window.location.replace(buildUnifiedLoginUrl(pathname)) - } else { - setReady(true) + let cancelled = false + + fetch(`${process.env.NEXT_PUBLIC_API_URL ?? '/admin/api/v1'}/admin/auth/me`, { + credentials: 'include', + }) + .then((response) => { + if (cancelled) return + if (response.ok) { + setReady(true) + } else { + window.location.replace(buildUnifiedLoginUrl(pathname)) + } + }) + .catch(() => { + if (!cancelled) window.location.replace(buildUnifiedLoginUrl(pathname)) + }) + + return () => { + cancelled = true } }, [pathname]) function handleLogout() { - localStorage.removeItem('admin_token') + void fetch('/admin/api/v1/admin/auth/logout', { method: 'POST', credentials: 'include' }) window.location.href = buildUnifiedLoginUrl('/dashboard') } diff --git a/apps/admin/src/app/dashboard/menu-management/page.tsx b/apps/admin/src/app/dashboard/menu-management/page.tsx index 65235fa..c8961fc 100644 --- a/apps/admin/src/app/dashboard/menu-management/page.tsx +++ b/apps/admin/src/app/dashboard/menu-management/page.tsx @@ -86,20 +86,15 @@ const PLANS: Plan[] = ['STARTER', 'GROWTH', 'PRO'] const ITEM_TYPES: MenuItemType[] = ['INTERNAL_PAGE', 'EXTERNAL_LINK', 'PARENT_MENU', 'SECTION_LABEL', 'DIVIDER'] const INPUT = - 'mt-1 w-full rounded-xl border border-stone-200 bg-white px-3 py-2 text-sm text-blue-950 outline-none focus:border-orange-500 dark:border-blue-800 dark:bg-[#07101e] dark:text-white' -const LABEL = 'text-xs font-semibold uppercase tracking-[0.14em] text-stone-500 dark:text-slate-400' - -function getToken() { - return typeof window === 'undefined' ? '' : localStorage.getItem('admin_token') ?? '' -} + 'mt-1 w-full rounded-xl border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 outline-none focus:ring-2 focus:ring-orange-500' +const LABEL = 'text-xs font-medium uppercase tracking-wider text-zinc-500' async function api(path: string, options?: RequestInit): Promise { - const token = getToken() const res = await fetch(`${ADMIN_API_BASE}${path}`, { ...options, + credentials: 'include', headers: { 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, ...(options?.headers ?? {}), }, }) @@ -148,13 +143,13 @@ function formFromItem(item: MenuItem): FormState { function chip(text: string, tone = 'default') { const toneClass = tone === 'success' - ? 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/60 dark:bg-emerald-900/20 dark:text-emerald-300' + ? 'bg-emerald-950/40 text-emerald-400' : tone === 'warn' - ? 'border-orange-200 bg-orange-50 text-orange-700 dark:border-orange-900/60 dark:bg-orange-900/20 dark:text-orange-300' - : 'border-stone-200 bg-stone-50 text-stone-700 dark:border-blue-800 dark:bg-[#07101e] dark:text-slate-300' + ? 'bg-orange-950/40 text-orange-400' + : 'bg-zinc-800 text-zinc-300' return ( - + {text} ) @@ -347,8 +342,8 @@ export default function MenuManagementPage() { if (loading) { return ( -
-
+
+
Loading menu management…
@@ -356,20 +351,20 @@ export default function MenuManagementPage() { } return ( -
-
+
+
-

Platform Navigation

-

Menu Management

-

+

Platform

+

Menu Management

+

Control company dashboard navigation by plan, role, and company-specific exceptions from one admin surface.

@@ -378,62 +373,62 @@ export default function MenuManagementPage() { {(error || success) && (
- {error &&
{error}
} - {success &&
{success}
} + {error &&
{error}
} + {success &&
{success}
}
)}
-
+
-

Menu items

-

{items.length} configured items

+

Menu items

+

{items.length} configured items

-
+
- - - - - - + + + + + + - + {items.map((item) => ( - - + - - + - -
ItemTypeAssignmentsStatusActions
ItemTypeAssignmentsStatusActions
+
- {item.label} + {item.label} {item.isRequired && chip('Required', 'warn')} {item.systemKey && chip(item.systemKey)}
-

+

{item.routeOrUrl || 'No route'} • order {item.displayOrder} {item.parent ? ` • child of ${item.parent.label}` : ''}

{item.itemType} + {item.itemType}
{item.subscriptionAssignments.length > 0 ? item.subscriptionAssignments.map((assignment) => ( - + {assignment.plan} )) - : No plan assignments} + : No plan assignments}
{item.companyAssignments.slice(0, 3).map((assignment) => ( - + {assignment.company.name} ))} @@ -441,25 +436,25 @@ export default function MenuManagementPage() {
{item.roleVisibilities.map((entry) => ( - + {entry.role} ))}
+ {item.isActive ? chip('Active', 'success') : chip('Inactive')} +
- - -
@@ -471,16 +466,16 @@ export default function MenuManagementPage() { -
-
+
+
-

{editingId ? 'Edit menu item' : 'New menu item'}

-

+

{editingId ? 'Edit menu item' : 'New menu item'}

+

Configure menu metadata, role visibility, plan defaults, and company-specific overrides.

{editingId && ( - )} @@ -537,18 +532,18 @@ export default function MenuManagementPage() {
-
-