diff --git a/.gitignore b/.gitignore
index fd3dbb5..8fda23c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -34,3 +34,8 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
+
+# Playwright
+playwright-report/
+test-results/
+.auth/
diff --git a/app/globals.css b/app/globals.css
index 1ebba8f..218c15d 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -2,63 +2,101 @@
@tailwind components;
@tailwind utilities;
+/* FiberOps Design System — Pixel/Conan approved */
+/* Primary: #0891B2 | CTA: #059669 | BG: #F8FAFC | Text: #0F172A */
+/* Fonts: Fira Sans (body) + Fira Code (data/numbers) */
+
@layer base {
:root {
- --background: 0 0% 100%;
- --foreground: 222.2 84% 4.9%;
- --card: 0 0% 100%;
- --card-foreground: 222.2 84% 4.9%;
- --popover: 0 0% 100%;
- --popover-foreground: 222.2 84% 4.9%;
- --primary: 222.2 47.4% 11.2%;
- --primary-foreground: 210 40% 98%;
- --secondary: 210 40% 96.1%;
- --secondary-foreground: 222.2 47.4% 11.2%;
- --muted: 210 40% 96.1%;
- --muted-foreground: 215.4 16.3% 46.9%;
- --accent: 210 40% 96.1%;
- --accent-foreground: 222.2 47.4% 11.2%;
- --destructive: 0 84.2% 60.2%;
- --destructive-foreground: 210 40% 98%;
- --border: 214.3 31.8% 91.4%;
- --input: 214.3 31.8% 91.4%;
- --ring: 222.2 84% 4.9%;
- --radius: 0.5rem;
- }
+ /* FiberOps tokens */
+ --color-primary: #0891B2;
+ --color-primary-dark: #0E7490;
+ --color-secondary: #22D3EE;
+ --color-cta: #059669;
+ --color-cta-hover: #047857;
+ --color-bg: #F8FAFC;
+ --color-surface: #FFFFFF;
+ --color-text: #0F172A;
+ --color-muted: #64748B;
+ --color-border: #E2E8F0;
+ --color-danger: #EF4444;
+ --color-warning: #F59E0B;
+ --color-success: #059669;
- .dark {
- --background: 222.2 84% 4.9%;
- --foreground: 210 40% 98%;
- --card: 222.2 84% 4.9%;
- --card-foreground: 210 40% 98%;
- --popover: 222.2 84% 4.9%;
- --popover-foreground: 210 40% 98%;
- --primary: 210 40% 98%;
- --primary-foreground: 222.2 47.4% 11.2%;
- --secondary: 217.2 32.6% 17.5%;
- --secondary-foreground: 210 40% 98%;
- --muted: 217.2 32.6% 17.5%;
- --muted-foreground: 215 20.2% 65.1%;
- --accent: 217.2 32.6% 17.5%;
- --accent-foreground: 210 40% 98%;
- --destructive: 0 62.8% 30.6%;
- --destructive-foreground: 210 40% 98%;
- --border: 217.2 32.6% 17.5%;
- --input: 217.2 32.6% 17.5%;
- --ring: 212.7 26.8% 83.9%;
+ /* Shadows */
+ --shadow-sm: 0 1px 2px rgba(0,0,0,0.05);
+ --shadow-md: 0 4px 6px rgba(0,0,0,0.07);
+ --shadow-lg: 0 10px 15px rgba(0,0,0,0.08);
+ --shadow-xl: 0 20px 25px rgba(0,0,0,0.12);
+
+ /* Radius */
+ --radius-sm: 6px;
+ --radius-md: 10px;
+ --radius-lg: 14px;
+ --radius-xl: 20px;
}
* {
- @apply border-border;
+ box-sizing: border-box;
}
body {
- @apply bg-background text-foreground;
+ background-color: var(--color-bg);
+ color: var(--color-text);
+ font-family: 'Fira Sans', 'Inter', system-ui, sans-serif;
+ font-size: 15px;
+ line-height: 1.6;
+ -webkit-font-smoothing: antialiased;
+ }
+
+ /* Data values, numbers, IDs */
+ code, .font-mono, .tabular-nums {
+ font-family: 'Fira Code', 'JetBrains Mono', monospace;
}
}
@layer utilities {
- .text-balance {
- text-wrap: balance;
+ /* Skeleton loading animation */
+ .skeleton {
+ @apply animate-pulse bg-slate-200 rounded;
}
+
+ /* Row hover for tables */
+ .table-row-hover:hover {
+ background-color: #F0FDFF;
+ cursor: pointer;
+ }
+
+ /* Transition helper */
+ .transition-fast {
+ transition: all 150ms ease;
+ }
+
+ .transition-smooth {
+ transition: all 200ms ease;
+ }
+
+ /* Card base */
+ .fiberops-card {
+ background: white;
+ border-radius: var(--radius-lg);
+ border: 1px solid var(--color-border);
+ box-shadow: var(--shadow-sm);
+ }
+
+ /* Status badge helpers */
+ .badge-active { background: #DCFCE7; color: #166534; }
+ .badge-inactive { background: #F1F5F9; color: #475569; }
+ .badge-suspended { background: #FEF3C7; color: #92400E; }
+ .badge-pending { background: #DBEAFE; color: #1E40AF; }
+ .badge-overdue { background: #FEE2E2; color: #991B1B; }
+ .badge-paid { background: #DCFCE7; color: #166534; }
+ .badge-partial { background: #FEF3C7; color: #92400E; }
+ .badge-void { background: #F1F5F9; color: #94A3B8; }
+ .badge-open { background: #DBEAFE; color: #1E40AF; }
+ .badge-in-progress { background: #FEF3C7; color: #92400E; }
+ .badge-resolved { background: #DCFCE7; color: #166534; }
+ .badge-closed { background: #F1F5F9; color: #475569; }
+
+ .text-balance { text-wrap: balance; }
}
diff --git a/app/layout.tsx b/app/layout.tsx
index 80963ac..bea5b62 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -1,23 +1,29 @@
import type { Metadata } from 'next';
-import { Inter } from 'next/font/google';
+import { Fira_Sans, Fira_Code } from 'next/font/google';
import './globals.css';
import Providers from '@/components/providers';
-const inter = Inter({ subsets: ['latin'] });
+const firaSans = Fira_Sans({
+ subsets: ['latin'],
+ weight: ['300', '400', '500', '600', '700'],
+ variable: '--font-fira-sans',
+});
+
+const firaCode = Fira_Code({
+ subsets: ['latin'],
+ weight: ['400', '500', '600', '700'],
+ variable: '--font-fira-code',
+});
export const metadata: Metadata = {
title: 'FiberOps Admin',
description: 'ISP Management Platform',
};
-export default function RootLayout({
- children,
-}: {
- children: React.ReactNode;
-}) {
+export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
-
+
{children}
diff --git a/e2e/auth.spec.ts b/e2e/auth.spec.ts
new file mode 100644
index 0000000..ccdce46
--- /dev/null
+++ b/e2e/auth.spec.ts
@@ -0,0 +1,39 @@
+import { test, expect } from '@playwright/test';
+import { login, DEMO } from './helpers/auth';
+
+test.describe('Authentication', () => {
+ test('login page renders correctly', async ({ page }) => {
+ await page.goto('/login');
+ await expect(page.locator('text=FiberOps')).toBeVisible();
+ await expect(page.locator('text=Sign in to your account')).toBeVisible();
+ await expect(page.locator('input[placeholder="e.g. demo-isp"]')).toBeVisible();
+ await expect(page.locator('input[type="email"]')).toBeVisible();
+ await expect(page.locator('input[type="password"]')).toBeVisible();
+ });
+
+ test('login with valid credentials redirects to dashboard', async ({ page }) => {
+ await login(page);
+ await expect(page).toHaveURL(/\/dashboard/);
+ });
+
+ test('login with wrong credentials shows error', async ({ page }) => {
+ await page.goto('/login');
+ await page.fill('input[placeholder="e.g. demo-isp"]', DEMO.tenant);
+ await page.fill('input[type="email"]', DEMO.email);
+ await page.fill('input[type="password"]', 'wrongpassword');
+ await page.click('button[type="submit"]');
+ // Should show error toast or stay on login
+ await expect(page).toHaveURL(/\/login/);
+ });
+
+ test('unauthenticated redirect to login', async ({ page }) => {
+ await page.goto('/dashboard');
+ await expect(page).toHaveURL(/\/login/);
+ });
+
+ test('logout returns to login', async ({ page }) => {
+ await login(page);
+ await page.click('button:has-text("Logout"), a:has-text("Logout")');
+ await expect(page).toHaveURL(/\/login/);
+ });
+});
diff --git a/e2e/helpers/auth.ts b/e2e/helpers/auth.ts
new file mode 100644
index 0000000..c71e222
--- /dev/null
+++ b/e2e/helpers/auth.ts
@@ -0,0 +1,16 @@
+import { Page } from '@playwright/test';
+
+export const DEMO = {
+ tenant: 'demo-isp',
+ email: 'admin@demo-isp.com',
+ password: 'Admin123!',
+};
+
+export async function login(page: Page, creds = DEMO) {
+ await page.goto('/login');
+ await page.fill('input[placeholder="e.g. demo-isp"]', creds.tenant);
+ await page.fill('input[type="email"]', creds.email);
+ await page.fill('input[type="password"]', creds.password);
+ await page.click('button[type="submit"]');
+ await page.waitForURL('**/dashboard', { timeout: 10000 });
+}
diff --git a/package-lock.json b/package-lock.json
index 8668e7c..959d9e5 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -38,6 +38,7 @@
"zustand": "^5.0.12"
},
"devDependencies": {
+ "@playwright/test": "^1.58.2",
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
@@ -1547,6 +1548,22 @@
"node": ">=14"
}
},
+ "node_modules/@playwright/test": {
+ "version": "1.58.2",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
+ "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
+ "devOptional": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright": "1.58.2"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@radix-ui/number": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
@@ -8376,6 +8393,53 @@
"node": ">=16.20.0"
}
},
+ "node_modules/playwright": {
+ "version": "1.58.2",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
+ "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
+ "devOptional": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.58.2"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.58.2",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
+ "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
+ "devOptional": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/playwright/node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
"node_modules/possible-typed-array-names": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
diff --git a/package.json b/package.json
index 3901452..2dae793 100644
--- a/package.json
+++ b/package.json
@@ -39,6 +39,7 @@
"zustand": "^5.0.12"
},
"devDependencies": {
+ "@playwright/test": "^1.58.2",
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
diff --git a/playwright.config.ts b/playwright.config.ts
new file mode 100644
index 0000000..bf5a2aa
--- /dev/null
+++ b/playwright.config.ts
@@ -0,0 +1,21 @@
+import { defineConfig, devices } from '@playwright/test';
+
+export default defineConfig({
+ testDir: './e2e',
+ fullyParallel: false, // run serially — shared demo tenant
+ forbidOnly: !!process.env.CI,
+ retries: 1,
+ workers: 1,
+ reporter: [['html', { outputFolder: 'playwright-report' }], ['line']],
+ use: {
+ baseURL: process.env.E2E_BASE_URL || 'http://192.168.1.167:3002',
+ trace: 'on-first-retry',
+ screenshot: 'only-on-failure',
+ },
+ projects: [
+ {
+ name: 'chromium',
+ use: { ...devices['Desktop Chrome'], headless: true },
+ },
+ ],
+});