Merge pull request #1 from Project-Optima/Proper-UI

Proper UI
This commit is contained in:
Jackson
2026-02-26 13:29:47 -06:00
committed by GitHub
86 changed files with 30621 additions and 2182 deletions
+16
View File
@@ -0,0 +1,16 @@
node_modules
.svelte-kit
build
out
.vite
.env
.env.*
*.dmg
*.zip
e2e
electron
forge.config.ts
forge.env.d.ts
vite.main.config.ts
vite.preload.config.ts
playwright.config.ts
+140 -39
View File
@@ -1,56 +1,157 @@
# Copilot Instructions — electron-svelte (SveltronKit) # Copilot Instructions for ttscm-ui
This repo is an Electron + SvelteKit app (SveltronKit). The goal of this document is to give an AI coding agent immediate, actionable knowledge about architecture, conventions, and common tasks so suggestions and changes are correct and context-aware. ## Project Overview
Big picture **ttscm-ui** is an Electron desktop application built with **SvelteKit**, TypeScript, and Vite. It connects to the Optima API for credential and company management. The app uses standard SvelteKit routing for single-page navigation and pnpm for package management with patches applied to SvelteKit.
- App = Electron main + SvelteKit renderer. See [electron/main.ts](electron/main.ts) and renderer sources under `src/`. ## Architecture Layers
- Renderer is a normal SvelteKit app but configured to use the hash router — all routed links expected in the app should begin with `#/` when constructing absolute links for the packaged Electron app (see README).
- Electron main process loads the Vite dev server when `MAIN_WINDOW_VITE_DEV_SERVER_URL` is set; otherwise loads the built renderer. See [electron/main.ts](electron/main.ts).
How to run & build (developer workflows) ### Electron Architecture
- Uses pnpm. Always use `pnpm install` first. - **`electron/main.ts`**: Main process—creates/manages windows, handles file system access. Loads preload script and serves the renderer.
- Dev (runs Electron + Vite via Electron Forge): `pnpm run start` (invokes `electron-forge start`). - **`electron/preload.ts`**: Currently empty bridge between main and renderer processes. Extend here to expose secure IPC handlers if needed.
- Build & package: `pnpm run package` (builds renderer with `vite build` then runs `electron-forge package`). - **`forge.config.ts`**: Electron Forge configuration with Vite plugin for building main, preload, and renderer targets.
- Create distributable: `pnpm run make`.
- Tests: unit tests via `vitest` and e2e via Playwright. `pnpm run test` runs both (`test:unit` and `test:e2e`). See `package.json` scripts.
Key files & patterns to reference ### Frontend (SvelteKit)
- Electron bootstrap: [electron/main.ts](electron/main.ts) — env vars used: `MAIN_WINDOW_VITE_DEV_SERVER_URL`, `MAIN_WINDOW_VITE_NAME`. - **`src/routes/`**: SvelteKit file-based routing with standard pathname router.
- Preload bridge: [electron/preload.ts](electron/preload.ts) (currently empty) — add safe, whitelisted APIs here when exposing functionality to the renderer. - `(auth)` group: Authentication pages (login)
- SvelteKit entry routes: `src/routes/` (examples: [src/routes/+page.svelte](src/routes/+page.svelte), [src/routes/companies/+page.svelte](src/routes/companies/+page.svelte)). - `(secure)` group: Protected pages requiring auth
- API client: [src/lib/axios.ts](src/lib/axios.ts) — `api` is created from `PUBLIC_API_URL` ($env/static/public). - **`src/lib/`**: Reusable modules
- Auth helper: [src/lib/authUri.ts](src/lib/authUri.ts) — example of calling backend `/v1/auth/uri`. - `optima-api/`: API client abstraction with modular endpoints (auth, companies, credentials, etc.)
- Data access: [src/lib/companies.ts](src/lib/companies.ts) — `fetchMany(accessToken)` demonstrates header usage `Authorization: Bearer <token>`. - `axios.ts`: Base axios instance with `PUBLIC_API_URL` env variable
- Global styles: `src/app.css` (Tailwind is present in the project). - **`src/components/`**: Reusable Svelte components (modals, spinners, error boundaries)
- Patches: `patches/` contains `@sveltejs__kit.patch` and is referenced in `package.json` via `patchedDependencies` — don't remove or ignore without verifying its purpose.
Project-specific conventions and examples ### API Communication
- Router: Because of the hash-router configuration, when constructing absolute links in a packaged app prefer `#/path` (README highlights this). For client navigation within components prefer `goto()` from `$app/navigation` (see [src/routes/+page.svelte](src/routes/+page.svelte)). The `$lib/index.ts` exports `optima` object aggregating all API modules. Example:
- Env & API: Use `PUBLIC_API_URL` (imported from `$env/static/public`) as the renderer-side base URL. For backend calls that require auth, pass `Authorization: Bearer <token>` headers (see `src/lib/companies.ts`).
- IPC surface: The preload file is where to expose limited, safe APIs to the renderer. The main process defines windows and startup behavior — avoid adding unsafe globals to the renderer.
- TypeScript & Svelte: The project uses TypeScript + Svelte 5 — keep code consistent with existing component patterns (script blocks, `$:` reactivity, `svelte:head`, named +page routes).
Tests & tooling ```typescript
export const optima = {
auth: (await import("./optima-api/modules/auth")).auth,
company: (await import("./optima-api/modules/companies")).company,
// etc.
};
```
- Unit tests: `vitest` (run `pnpm run test:unit`). Look at `demo.spec.ts` and `src/routes/page.svelte.test.ts` for existing examples. Each module (e.g., `auth.ts`) exports functions that call the API using a custom axios instance.
- E2E: Playwright tests live under `e2e/` and are run with `pnpm run test:e2e`.
- Sync: `prepare` script runs `svelte-kit sync`. Running `pnpm run check` runs `svelte-check` too; use it when editing routes/type-heavy code.
When making changes, be conservative ## Key Conventions
- Changing build or packaging behavior affects developers' ability to run the app locally. Prefer edits to renderer code (under `src/`) unless the user asked to adjust packaging. ### Routing
- If adding new native electron APIs, update `electron/preload.ts` to expose a minimal API surface and document it.
Useful snippets / concrete examples - **Use standard SvelteKit routing with `/` prefix** (e.g., `href="/credentials"`)
- Routes in `src/routes/` map to `/path` at runtime
- Do NOT use hash-based routing (`#/` routes)
- Navigate programmatically: `import { goto } from "$app/navigation"; goto('/logout');` (see [src/routes/+page.svelte](src/routes/+page.svelte)). ### API Module Pattern
- API client usage: `import { api } from 'src/lib/axios'; const res = await api.get('/v1/...');`
- Auth redirect fetch: `const { uri, callbackKey } = await fetchAuthRedirectUri(apiUrl);` (see [src/lib/authUri.ts](src/lib/authUri.ts)).
If anything is ambiguous or you need additional examples (tests, CI, or a missing preload implementation), ask the maintainer which behavior they want (safe IPC surface, packaging targets, or CI test matrix) before making large changes. Create API modules in `src/lib/optima-api/modules/` following this pattern:
-- End ```typescript
// Example: credentials.ts
export const credential = {
async fetchCredentials(api: AxiosInstance) {
// Implementation
},
};
```
Export as a named object, then import/aggregate in `src/lib/index.ts`.
### Environment Variables
- `PUBLIC_API_URL`: API base URL, used in `src/lib/optima-api/axios.ts`
- Prefixed with `PUBLIC_` to be accessible in client code
### File Organization
- Components go in `src/components/` (e.g., modals, spinners)
- Page-specific logic in `src/routes/[route]/+page.svelte` and `+page.server.ts`
- Styles in `src/styles/` with Tailwind CSS + TailwindCSS vite plugin
- Tests colocated: `*.spec.ts` or `*.test.ts` next to source files
## Development Workflow
### Installation & Setup
```bash
pnpm install
```
Uses pnpm with SvelteKit patches (see `patches/` directory).
### Running in Development
```bash
pnpm run start
```
Electron Forge + Vite handles dev server and hot module replacement (HMR). Dev tools open automatically. Main window loads `/login` first.
### Building & Packaging
- **Build for production**: `pnpm run package` → outputs to `out/` directory
- **Create distributable**: `pnpm run make` → creates installers (configure in `forge.config.ts`)
- **Check types & lint**: `pnpm run check` (runs svelte-kit sync + svelte-check)
### Testing
#### Unit Tests (Vitest)
```bash
pnpm run test:unit
```
- Client tests: `src/**/*.svelte.{test,spec}.{js,ts}` (jsdom environment)
- Server tests: `src/**/*.{test,spec}.{js,ts}` excluding svelte tests (node environment)
- Setup: `vitest-setup-client.ts` mocks `window.matchMedia` for Svelte 5 + jsdom compatibility
#### E2E Tests (Playwright)
```bash
pnpm run test:e2e
```
- Tests in `e2e/` directory
- Config: `playwright.config.ts` (builds and previews before testing)
#### Run All Tests
```bash
pnpm run test
```
Runs unit tests first, then e2e.
## Critical Integration Points
### IPC (Electron Main ↔ Renderer)
**Status**: Preload script is currently empty. If file system access is needed, define IPC handlers in `electron/main.ts` and expose them via `electron/preload.ts` to renderer process.
### API Authentication
- Auth flow starts in `src/lib/optima-api/modules/auth.ts` with `fetchAuthRedirectUri()`
- TODO: Enforce auth checks on every page change (see `src/lib/index.ts` comment)
### Build Artifacts
- **Dev server URL**: `MAIN_WINDOW_VITE_DEV_SERVER_URL` (injected by Electron Forge)
- **Output**: Rendered app built to `.vite/renderer/main_window/` (configured in `svelte.config.js`)
## Common Patterns & Gotchas
1. **Standard Routing**: Use standard SvelteKit routing with `/` prefix (e.g., `href="/credentials"`) — do NOT use hash-based routing
2. **SvelteKit Patches**: Project patches SvelteKit in `patches/` to work around issues. When updating SvelteKit, verify patches still apply.
3. **Async Components**: Svelte 5 has `experimental.async: true` enabled; be aware of async component patterns.
4. **Tailwind**: Uses `@tailwindcss/vite` plugin; configure utilities in tailwind config if needed.
5. **API Error Handling**: API modules throw errors with descriptive messages. Use `$lib/errorHandler.ts` for consistent error formatting.
## Useful Entry Points for Navigation
- **Frontend Layout**: [src/routes/+layout.svelte](src/routes/+layout.svelte) (main shell, sidebar, header)
- **API Abstraction**: [src/lib/optima-api/axios.ts](src/lib/optima-api/axios.ts) (base client)
- **API Modules**: [src/lib/optima-api/modules/](src/lib/optima-api/modules/) (auth, companies, credentials, etc.)
- **App Entry**: [src/app.html](src/app.html)
- **Electron Main**: [electron/main.ts](electron/main.ts)
+133
View File
@@ -0,0 +1,133 @@
name: Build and Publish
on:
release:
types: [created]
jobs:
build-server:
name: Build Server Image
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push the Docker image
uses: docker/build-push-action@v6
with:
push: true
tags: |
ghcr.io/project-optima/ttscm-ui:latest
ghcr.io/project-optima/ttscm-ui:${{ github.event.release.tag_name }}
build-desktop-macos:
name: Build Desktop (macOS)
runs-on: macos-latest
permissions:
contents: write
steps:
- name: Checkout source code
uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: latest
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build macOS distributables
run: pnpm run make:macos
- name: Upload macOS artifacts to release
uses: softprops/action-gh-release@v2
with:
files: |
out/make/**/*.dmg
out/make/**/*.zip
build-desktop-windows:
name: Build Desktop (Windows)
runs-on: windows-latest
permissions:
contents: write
steps:
- name: Checkout source code
uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: latest
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build Windows distributables
run: pnpm run make -- --platform win32
- name: Upload Windows artifacts to release
uses: softprops/action-gh-release@v2
with:
files: |
out/make/**/*.exe
out/make/**/*.nupkg
out/make/**/*.msi
deploy:
name: Deploy
needs: [build-server]
runs-on: ubuntu-latest
steps:
- name: Set the Kubernetes context
uses: azure/k8s-set-context@v2
with:
method: kubeconfig
kubeconfig: ${{ secrets.KUBECONFIG }}
- name: Checkout source code
uses: actions/checkout@v4
- name: Lint Kubernetes manifests
uses: azure/k8s-lint@v3
with:
lintType: dryrun
manifests: |
kubernetes/deployment.yaml
kubernetes/ingress.yaml
namespace: optima
- name: Deploy to the Kubernetes cluster
uses: azure/k8s-deploy@v5
with:
namespace: optima
force: true
skip-tls-verify: true
manifests: |
kubernetes/deployment.yaml
kubernetes/ingress.yaml
images: |
ghcr.io/project-optima/ttscm-ui:${{ github.event.release.tag_name }}
+1
View File
@@ -25,3 +25,4 @@ vite.config.ts.timestamp-*
.vite .vite
out out
tailwindcss-*.log
+29
View File
@@ -0,0 +1,29 @@
FROM node:22-alpine AS base
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
# Install dependencies
COPY package.json pnpm-lock.yaml ./
COPY patches ./patches
RUN pnpm install --frozen-lockfile
# Build the SvelteKit app with adapter-node
COPY . .
RUN pnpm run build:server
# Production image
FROM node:22-alpine AS production
WORKDIR /app
COPY --from=base /app/build ./build
COPY --from=base /app/package.json ./
ENV NODE_ENV=production
ENV PORT=3000
ENV ORIGIN=https://optima.osdci.net
EXPOSE 3000
CMD ["node", "build/index.js"]
+36 -1
View File
@@ -22,6 +22,7 @@
"@electron-forge/plugin-vite": "^7.11.1", "@electron-forge/plugin-vite": "^7.11.1",
"@electron/fuses": "^1.8.0", "@electron/fuses": "^1.8.0",
"@playwright/test": "^1.58.0", "@playwright/test": "^1.58.0",
"@sveltejs/adapter-node": "^5.5.3",
"@sveltejs/adapter-static": "^3.0.10", "@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.50.1", "@sveltejs/kit": "^2.50.1",
"@sveltejs/vite-plugin-svelte": "^5.1.1", "@sveltejs/vite-plugin-svelte": "^5.1.1",
@@ -238,6 +239,14 @@
"@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="], "@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="],
"@rollup/plugin-commonjs": ["@rollup/plugin-commonjs@29.0.0", "", { "dependencies": { "@rollup/pluginutils": "^5.0.1", "commondir": "^1.0.1", "estree-walker": "^2.0.2", "fdir": "^6.2.0", "is-reference": "1.2.1", "magic-string": "^0.30.3", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^2.68.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-U2YHaxR2cU/yAiwKJtJRhnyLk7cifnQw0zUpISsocBDoHDJn+HTV74ABqnwr5bEgWUwFZC9oFL6wLe21lHu5eQ=="],
"@rollup/plugin-json": ["@rollup/plugin-json@6.1.0", "", { "dependencies": { "@rollup/pluginutils": "^5.1.0" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA=="],
"@rollup/plugin-node-resolve": ["@rollup/plugin-node-resolve@16.0.3", "", { "dependencies": { "@rollup/pluginutils": "^5.0.1", "@types/resolve": "1.20.2", "deepmerge": "^4.2.2", "is-module": "^1.0.0", "resolve": "^1.22.1" }, "peerDependencies": { "rollup": "^2.78.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg=="],
"@rollup/pluginutils": ["@rollup/pluginutils@5.3.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.41.0", "", { "os": "android", "cpu": "arm" }, "sha512-KxN+zCjOYHGwCl4UCtSfZ6jrq/qi88JDUtiEFk8LELEHq2Egfc/FgW+jItZiOLRuQfb/3xJSgFuNPC9jzggX+A=="], "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.41.0", "", { "os": "android", "cpu": "arm" }, "sha512-KxN+zCjOYHGwCl4UCtSfZ6jrq/qi88JDUtiEFk8LELEHq2Egfc/FgW+jItZiOLRuQfb/3xJSgFuNPC9jzggX+A=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.41.0", "", { "os": "android", "cpu": "arm64" }, "sha512-yDvqx3lWlcugozax3DItKJI5j05B0d4Kvnjx+5mwiUpWramVvmAByYigMplaoAQ3pvdprGCTCE03eduqE/8mPQ=="], "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.41.0", "", { "os": "android", "cpu": "arm64" }, "sha512-yDvqx3lWlcugozax3DItKJI5j05B0d4Kvnjx+5mwiUpWramVvmAByYigMplaoAQ3pvdprGCTCE03eduqE/8mPQ=="],
@@ -286,6 +295,8 @@
"@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.5", "", { "peerDependencies": { "acorn": "8.14.1" } }, "sha512-IwQk4yfwLdibDlrXVE04jTZYlLnwsTT2PIOQQGNLWfjavGifnk1JD1LcZjZaBTRcxZu2FfPfNLOE04DSu9lqtQ=="], "@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.5", "", { "peerDependencies": { "acorn": "8.14.1" } }, "sha512-IwQk4yfwLdibDlrXVE04jTZYlLnwsTT2PIOQQGNLWfjavGifnk1JD1LcZjZaBTRcxZu2FfPfNLOE04DSu9lqtQ=="],
"@sveltejs/adapter-node": ["@sveltejs/adapter-node@5.5.3", "", { "dependencies": { "@rollup/plugin-commonjs": "^29.0.0", "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^16.0.0", "rollup": "^4.9.5" }, "peerDependencies": { "@sveltejs/kit": "^2.4.0" } }, "sha512-yeWbKXBL9vqDb/7R8ebvRHeuBHN4cRYYBSquNJSMQtS6rIYkXxsVSveaMTUaLvHYQsb1zNa+nH2iLTOMawBohA=="],
"@sveltejs/adapter-static": ["@sveltejs/adapter-static@3.0.10", "", { "peerDependencies": { "@sveltejs/kit": "^2.0.0" } }, "sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew=="], "@sveltejs/adapter-static": ["@sveltejs/adapter-static@3.0.10", "", { "peerDependencies": { "@sveltejs/kit": "^2.0.0" } }, "sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew=="],
"@sveltejs/kit": ["@sveltejs/kit@2.50.1", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/cookie": "^0.6.0", "acorn": "^8.14.1", "cookie": "^0.6.0", "devalue": "^5.6.2", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "sade": "^1.8.1", "set-cookie-parser": "^2.6.0", "sirv": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": "^5.3.3", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["@opentelemetry/api", "typescript"], "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-XRHD2i3zC4ukhz2iCQzO4mbsts081PAZnnMAQ7LNpWeYgeBmwMsalf0FGSwhFXBbtr2XViPKnFJBDCckWqrsLw=="], "@sveltejs/kit": ["@sveltejs/kit@2.50.1", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/cookie": "^0.6.0", "acorn": "^8.14.1", "cookie": "^0.6.0", "devalue": "^5.6.2", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "sade": "^1.8.1", "set-cookie-parser": "^2.6.0", "sirv": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": "^5.3.3", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["@opentelemetry/api", "typescript"], "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-XRHD2i3zC4ukhz2iCQzO4mbsts081PAZnnMAQ7LNpWeYgeBmwMsalf0FGSwhFXBbtr2XViPKnFJBDCckWqrsLw=="],
@@ -372,6 +383,8 @@
"@types/node": ["@types/node@22.19.7", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw=="], "@types/node": ["@types/node@22.19.7", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw=="],
"@types/resolve": ["@types/resolve@1.20.2", "", {}, "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="],
"@types/responselike": ["@types/responselike@1.0.3", "", { "dependencies": { "@types/node": "22.15.20" } }, "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw=="], "@types/responselike": ["@types/responselike@1.0.3", "", { "dependencies": { "@types/node": "22.15.20" } }, "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw=="],
"@types/wrap-ansi": ["@types/wrap-ansi@3.0.0", "", {}, "sha512-ltIpx+kM7g/MLRZfkbL7EsCEjfzCcScLpkg37eXEtx5kmrAKBkTJwd1GIAjDSL8wTpM6Hzn5YO4pSb91BEwu1g=="], "@types/wrap-ansi": ["@types/wrap-ansi@3.0.0", "", {}, "sha512-ltIpx+kM7g/MLRZfkbL7EsCEjfzCcScLpkg37eXEtx5kmrAKBkTJwd1GIAjDSL8wTpM6Hzn5YO4pSb91BEwu1g=="],
@@ -554,6 +567,8 @@
"commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], "commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="],
"commondir": ["commondir@1.0.1", "", {}, "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg=="],
"compare-version": ["compare-version@0.1.2", "", {}, "sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A=="], "compare-version": ["compare-version@0.1.2", "", {}, "sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A=="],
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
@@ -680,7 +695,7 @@
"estraverse": ["estraverse@4.3.0", "", {}, "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw=="], "estraverse": ["estraverse@4.3.0", "", {}, "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw=="],
"estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "1.0.7" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], "estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
"eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="], "eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="],
@@ -838,6 +853,8 @@
"is-lambda": ["is-lambda@1.0.1", "", {}, "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ=="], "is-lambda": ["is-lambda@1.0.1", "", {}, "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ=="],
"is-module": ["is-module@1.0.0", "", {}, "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g=="],
"is-my-ip-valid": ["is-my-ip-valid@1.0.1", "", {}, "sha512-jxc8cBcOWbNK2i2aTkCZP6i7wkHF1bqKFrwEHuN5Jtg5BSaZHUZQ/JTOJwoV41YvHnOaRyWWh72T/KvfNz9DJg=="], "is-my-ip-valid": ["is-my-ip-valid@1.0.1", "", {}, "sha512-jxc8cBcOWbNK2i2aTkCZP6i7wkHF1bqKFrwEHuN5Jtg5BSaZHUZQ/JTOJwoV41YvHnOaRyWWh72T/KvfNz9DJg=="],
"is-my-json-valid": ["is-my-json-valid@2.20.6", "", { "dependencies": { "generate-function": "2.3.1", "generate-object-property": "1.2.0", "is-my-ip-valid": "1.0.1", "jsonpointer": "5.0.1", "xtend": "4.0.2" } }, "sha512-1JQwulVNjx8UqkPE/bqDaxtH4PXCe/2VRh/y3p99heOV87HG4Id5/VfDswd+YiAfHcRTfDlWgISycnHuhZq1aw=="], "is-my-json-valid": ["is-my-json-valid@2.20.6", "", { "dependencies": { "generate-function": "2.3.1", "generate-object-property": "1.2.0", "is-my-ip-valid": "1.0.1", "jsonpointer": "5.0.1", "xtend": "4.0.2" } }, "sha512-1JQwulVNjx8UqkPE/bqDaxtH4PXCe/2VRh/y3p99heOV87HG4Id5/VfDswd+YiAfHcRTfDlWgISycnHuhZq1aw=="],
@@ -1452,6 +1469,18 @@
"@npmcli/move-file/rimraf": ["rimraf@3.0.2", "", { "dependencies": { "glob": "7.2.3" }, "bin": { "rimraf": "bin.js" } }, "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA=="], "@npmcli/move-file/rimraf": ["rimraf@3.0.2", "", { "dependencies": { "glob": "7.2.3" }, "bin": { "rimraf": "bin.js" } }, "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA=="],
"@rollup/plugin-commonjs/fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"@rollup/plugin-commonjs/is-reference": ["is-reference@1.2.1", "", { "dependencies": { "@types/estree": "*" } }, "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ=="],
"@rollup/plugin-commonjs/magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"@rollup/plugin-commonjs/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"@rollup/pluginutils/@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@rollup/pluginutils/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"@tailwindcss/node/magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "@tailwindcss/node/magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
@@ -1486,6 +1515,8 @@
"@types/yauzl/@types/node": ["@types/node@22.15.20", "", { "dependencies": { "undici-types": "6.21.0" } }, "sha512-A6BohGFRGHAscJsTslDCA9JG7qSJr/DWUvrvY8yi9IgnGtMxCyat7vvQ//MFa0DnLsyuS3wYTpLdw4Hf+Q5JXw=="], "@types/yauzl/@types/node": ["@types/node@22.15.20", "", { "dependencies": { "undici-types": "6.21.0" } }, "sha512-A6BohGFRGHAscJsTslDCA9JG7qSJr/DWUvrvY8yi9IgnGtMxCyat7vvQ//MFa0DnLsyuS3wYTpLdw4Hf+Q5JXw=="],
"@vitest/mocker/estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "1.0.7" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
"ansi-escapes/type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="], "ansi-escapes/type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="],
"cacache/glob": ["glob@8.1.0", "", { "dependencies": { "fs.realpath": "1.0.0", "inflight": "1.0.6", "inherits": "2.0.4", "minimatch": "5.1.6", "once": "1.4.0" } }, "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ=="], "cacache/glob": ["glob@8.1.0", "", { "dependencies": { "fs.realpath": "1.0.0", "inflight": "1.0.6", "inherits": "2.0.4", "minimatch": "5.1.6", "once": "1.4.0" } }, "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ=="],
@@ -1642,6 +1673,10 @@
"@inquirer/core/wrap-ansi/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "8.0.0", "is-fullwidth-code-point": "3.0.0", "strip-ansi": "6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "@inquirer/core/wrap-ansi/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "8.0.0", "is-fullwidth-code-point": "3.0.0", "strip-ansi": "6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"@rollup/plugin-commonjs/is-reference/@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@rollup/plugin-commonjs/magic-string/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@tailwindcss/node/magic-string/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], "@tailwindcss/node/magic-string/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"cacache/glob/minimatch": ["minimatch@5.1.6", "", { "dependencies": { "brace-expansion": "2.0.1" } }, "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g=="], "cacache/glob/minimatch": ["minimatch@5.1.6", "", { "dependencies": { "brace-expansion": "2.0.1" } }, "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g=="],
+5 -10
View File
@@ -7,29 +7,24 @@ if (started) {
app.quit(); app.quit();
} }
const PRODUCTION_URL = "https://optima.osdci.net";
const createWindow = () => { const createWindow = () => {
// Create the browser window.
const mainWindow = new BrowserWindow({ const mainWindow = new BrowserWindow({
width: 800, width: 1200,
height: 600, height: 800,
webPreferences: { webPreferences: {
preload: path.join(import.meta.dirname, "preload.js"), preload: path.join(import.meta.dirname, "preload.js"),
}, },
}); });
// and load the index.html of the app.
if (MAIN_WINDOW_VITE_DEV_SERVER_URL) { if (MAIN_WINDOW_VITE_DEV_SERVER_URL) {
mainWindow.loadURL(`${MAIN_WINDOW_VITE_DEV_SERVER_URL}/login`); mainWindow.loadURL(`${MAIN_WINDOW_VITE_DEV_SERVER_URL}/login`);
mainWindow.webContents.on("did-frame-finish-load", () => { mainWindow.webContents.on("did-frame-finish-load", () => {
mainWindow.webContents.openDevTools({ mode: "detach" }); mainWindow.webContents.openDevTools({ mode: "detach" });
}); });
} else { } else {
mainWindow.loadFile( mainWindow.loadURL(PRODUCTION_URL);
path.join(
import.meta.dirname,
`../renderer/${MAIN_WINDOW_VITE_NAME}/index.html`,
),
);
} }
}; };
+2
View File
@@ -1,6 +1,7 @@
import type { ForgeConfig } from "@electron-forge/shared-types"; import type { ForgeConfig } from "@electron-forge/shared-types";
import { MakerSquirrel } from "@electron-forge/maker-squirrel"; import { MakerSquirrel } from "@electron-forge/maker-squirrel";
import { MakerZIP } from "@electron-forge/maker-zip"; import { MakerZIP } from "@electron-forge/maker-zip";
import { MakerDMG } from "@electron-forge/maker-dmg";
import { MakerDeb } from "@electron-forge/maker-deb"; import { MakerDeb } from "@electron-forge/maker-deb";
import { MakerRpm } from "@electron-forge/maker-rpm"; import { MakerRpm } from "@electron-forge/maker-rpm";
import { VitePlugin } from "@electron-forge/plugin-vite"; import { VitePlugin } from "@electron-forge/plugin-vite";
@@ -15,6 +16,7 @@ const config: ForgeConfig = {
makers: [ makers: [
new MakerSquirrel({}), new MakerSquirrel({}),
new MakerZIP({}, ["darwin"]), new MakerZIP({}, ["darwin"]),
new MakerDMG({}),
new MakerRpm({}), new MakerRpm({}),
new MakerDeb({}), new MakerDeb({}),
], ],
+30
View File
@@ -0,0 +1,30 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: optima-ui
namespace: optima
spec:
selector:
matchLabels:
app: optima-ui
replicas: 1
template:
metadata:
labels:
app: optima-ui
spec:
containers:
- name: optima-ui
image: ghcr.io/project-optima/ttscm-ui:latest
imagePullPolicy: Always
env:
- name: PUBLIC_API_URL
value: "https://opt-api.osdci.net"
- name: ORIGIN
value: "https://optima.osdci.net"
- name: PORT
value: "3000"
ports:
- containerPort: 3000
imagePullSecrets:
- name: github-container-registry
+39
View File
@@ -0,0 +1,39 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: optima-ui-ingress
namespace: optima
annotations:
traefik.ingress.kubernetes.io/router.entrypoints: websecure
traefik.ingress.kubernetes.io/router.tls: "true"
spec:
tls:
- hosts:
- optima.osdci.net
secretName: osdci-net-cert
rules:
- host: optima.osdci.net
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: optima-ui
port:
number: 3000
---
apiVersion: v1
kind: Service
metadata:
name: optima-ui
namespace: optima
labels:
app: optima-ui
spec:
type: ClusterIP
ports:
- port: 3000
protocol: TCP
selector:
app: optima-ui
+5 -2
View File
@@ -18,8 +18,10 @@
"test": "npm run test:unit -- --run && npm run test:e2e", "test": "npm run test:unit -- --run && npm run test:e2e",
"test:e2e": "playwright test", "test:e2e": "playwright test",
"start": "electron-forge start", "start": "electron-forge start",
"package": "vite build && electron-forge package", "package": "electron-forge package",
"make": "vite build && electron-forge make", "make": "electron-forge make",
"make:macos": "electron-forge make --platform darwin",
"build:server": "vite build",
"publish": "electron-forge publish" "publish": "electron-forge publish"
}, },
"devDependencies": { "devDependencies": {
@@ -34,6 +36,7 @@
"@electron-forge/plugin-vite": "^7.11.1", "@electron-forge/plugin-vite": "^7.11.1",
"@electron/fuses": "^1.8.0", "@electron/fuses": "^1.8.0",
"@playwright/test": "^1.58.0", "@playwright/test": "^1.58.0",
"@sveltejs/adapter-node": "^5.5.3",
"@sveltejs/adapter-static": "^3.0.10", "@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.50.1", "@sveltejs/kit": "^2.50.1",
"@sveltejs/vite-plugin-svelte": "^5.1.1", "@sveltejs/vite-plugin-svelte": "^5.1.1",
-3
View File
@@ -1,3 +0,0 @@
@import "tailwindcss";
@plugin '@tailwindcss/forms';
@plugin '@tailwindcss/typography';
+3 -2
View File
@@ -5,8 +5,9 @@ declare global {
// interface Error {} // interface Error {}
interface Locals { interface Locals {
session?: { session?: {
accessToken: string; accessToken: string | null;
refreshToken: string; refreshToken: string | null;
set(accessToken: string, refreshToken: string): Promise<void>;
}; };
} }
// interface PageData {} // interface PageData {}
+4 -1
View File
@@ -3,7 +3,10 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" /> <link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"
/>
%sveltekit.head% %sveltekit.head%
</head> </head>
<body data-sveltekit-preload-data="hover"> <body data-sveltekit-preload-data="hover">
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+724
View File
@@ -0,0 +1,724 @@
<script lang="ts">
import { enhance } from "$app/forms";
import type { SubmitFunction } from "@sveltejs/kit";
import type { User } from "$lib/optima-api/modules/users";
import type { Role } from "$lib/optima-api/modules/roles";
import type { PermissionsCategorized } from "$lib/optima-api/modules/permissions";
type UserWithRoles = User & { roleDetails: Role[] };
export let user: UserWithRoles;
export let allRoles: Role[] = [];
export let permissionNodes: PermissionsCategorized = {};
export let canEditRoles = false;
export let canEditPermissions = false;
export let onClose: () => void = () => {};
export let onSuccess: () => void = () => {};
let editName = user.name;
let editImage = user.image ?? "";
let editError = "";
let isEditing = false;
// Role editing
let editSelectedRoles: string[] = user.roleDetails.map((r) => r.id);
// Permission editing
let editSelectedPermissions: string[] = [...(user.permissions ?? [])];
let permSearchQuery = "";
let customPermNode = "";
let customPermError = "";
// Track custom-added nodes so they appear in the list
let customNodes: string[] = [];
$: allPermissionNodes = [
...Object.values(permissionNodes ?? {}).flatMap((cat) =>
cat.permissions.map((p) => p.node),
),
...customNodes.filter(
(n) =>
!Object.values(permissionNodes ?? {})
.flatMap((cat) => cat.permissions.map((p) => p.node))
.includes(n),
),
];
$: filteredPermNodes = permSearchQuery.trim()
? allPermissionNodes.filter((n) =>
n.toLowerCase().includes(permSearchQuery.toLowerCase()),
)
: allPermissionNodes;
function toggleEditRole(id: string) {
if (editSelectedRoles.includes(id)) {
editSelectedRoles = editSelectedRoles.filter((r) => r !== id);
} else {
editSelectedRoles = [...editSelectedRoles, id];
}
}
function toggleEditPermission(node: string) {
if (editSelectedPermissions.includes(node)) {
editSelectedPermissions = editSelectedPermissions.filter(
(p) => p !== node,
);
} else {
editSelectedPermissions = [...editSelectedPermissions, node];
}
}
function addCustomPermission() {
const node = customPermNode.trim();
customPermError = "";
if (!node) return;
// Validate permission node format per PERMISSIONS.md:
// - dot-separated tokens of lowercase alphanumeric + underscores
// - special tokens: * (wildcard), ? (single-char wildcard)
// - inclusive list [a,b,c] or exclusive list <a,b,c>
// - standalone * for full access
if (
!/^(?:\*|(?:(?:[a-z0-9_]+|\*|\?|\[[a-z0-9_]+(?:,[a-z0-9_]+)*\]|<[a-z0-9_]+(?:,[a-z0-9_]+)*>)(?:\.(?:[a-z0-9_]+|\*|\?|\[[a-z0-9_]+(?:,[a-z0-9_]+)*\]|<[a-z0-9_]+(?:,[a-z0-9_]+)*>))*))$/.test(
node,
)
) {
customPermError =
"Use dot-separated tokens (e.g. resource.action). Supports: a-z, 0-9, underscores, * ? wildcards, [a,b] or <a,b> lists.";
return;
}
if (editSelectedPermissions.includes(node)) {
customPermError = "This permission is already selected.";
return;
}
if (!allPermissionNodes.includes(node)) {
customNodes = [...customNodes, node];
}
editSelectedPermissions = [...editSelectedPermissions, node];
customPermNode = "";
}
const handleEditEnhance: SubmitFunction = () => {
isEditing = true;
editError = "";
return async ({ result, update }) => {
isEditing = false;
if (result.type === "success") {
onSuccess();
} else if (result.type === "failure") {
editError =
(result.data as { message?: string })?.message ??
"Failed to update user.";
}
await update();
};
};
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="confirm-backdrop"
on:click={onClose}
on:keydown={(e) => e.key === "Escape" && onClose()}
>
<div
class="edit-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="edit-title"
tabindex="-1"
on:click|stopPropagation
on:keydown|stopPropagation
>
<h3 id="edit-title" class="edit-dialog-title">Edit User</h3>
<p class="edit-dialog-sub">
Update information for <strong>{user.name}</strong>
</p>
{#if editError}
<p class="confirm-error">{editError}</p>
{/if}
<form method="POST" action="?/updateUser" use:enhance={handleEditEnhance}>
<input type="hidden" name="id" value={user.id} />
{#if canEditRoles}
<input
type="hidden"
name="roles"
value={JSON.stringify(editSelectedRoles)}
/>
{/if}
{#if canEditPermissions}
<input
type="hidden"
name="permissions"
value={JSON.stringify(editSelectedPermissions)}
/>
{/if}
<div class="edit-field">
<label for="edit-name">Name</label>
<input
id="edit-name"
type="text"
name="name"
bind:value={editName}
required
/>
</div>
<div class="edit-field">
<label for="edit-image">Avatar URL</label>
<input
id="edit-image"
type="text"
name="image"
bind:value={editImage}
placeholder="https://..."
/>
</div>
<!-- Role assignment (gated by user.roles.other) -->
{#if canEditRoles}
<div class="edit-section">
<div class="edit-section-label">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="13"
height="13"
>
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
</svg>
Roles
<span class="edit-section-count"
>{editSelectedRoles.length} selected</span
>
</div>
<div class="edit-role-list">
{#each allRoles as role (role.id)}
<button
type="button"
class="edit-role-chip"
class:selected={editSelectedRoles.includes(role.id)}
on:click={() => toggleEditRole(role.id)}
>
<span class="edit-role-check">
{#if editSelectedRoles.includes(role.id)}
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
width="12"
height="12"
>
<polyline points="20 6 9 17 4 12" />
</svg>
{/if}
</span>
<span class="edit-role-chip-title">{role.title}</span>
<span class="edit-role-chip-moniker">{role.moniker}</span>
</button>
{/each}
</div>
</div>
{/if}
<!-- Direct permission assignment (gated by user.permissions.other) -->
{#if canEditPermissions}
<div class="edit-section">
<div class="edit-section-label">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="13"
height="13"
>
<path
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2"
/>
<rect x="9" y="3" width="6" height="4" rx="1" />
</svg>
Direct Permissions
<span class="edit-section-count"
>{editSelectedPermissions.length} selected</span
>
</div>
<div class="edit-perm-search-wrap">
<input
type="text"
class="edit-perm-search"
placeholder="Search permissions…"
bind:value={permSearchQuery}
/>
</div>
<div class="edit-custom-perm-wrap">
<input
type="text"
class="edit-custom-perm-input"
placeholder="Add custom node… (e.g. my.custom.node)"
bind:value={customPermNode}
on:keydown={(e) =>
e.key === "Enter" &&
(e.preventDefault(), addCustomPermission())}
/>
<button
type="button"
class="edit-custom-perm-btn"
on:click={addCustomPermission}
disabled={!customPermNode.trim()}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="13"
height="13"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
Add
</button>
</div>
{#if customPermError}
<p class="edit-custom-perm-error">{customPermError}</p>
{/if}
<div class="edit-perm-list">
{#each filteredPermNodes as node}
<label class="edit-perm-item">
<input
type="checkbox"
checked={editSelectedPermissions.includes(node)}
on:change={() => toggleEditPermission(node)}
/>
<span class="edit-perm-node">{node}</span>
</label>
{/each}
{#if filteredPermNodes.length === 0}
<p class="edit-perm-empty">No permissions match your search.</p>
{/if}
</div>
</div>
{/if}
<div class="confirm-actions">
<button
type="button"
class="btn-cancel"
on:click={onClose}
disabled={isEditing}
>
Cancel
</button>
<button
type="submit"
class="btn-save"
disabled={isEditing || !editName.trim()}
>
{isEditing ? "Saving…" : "Save Changes"}
</button>
</div>
</form>
</div>
</div>
<style>
/* ── Backdrop & dialog ── */
.confirm-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.edit-dialog {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: 12px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.25);
width: 90%;
max-width: 520px;
max-height: 85vh;
overflow-y: auto;
padding: 28px 24px 22px;
animation: modalIn 0.15s ease;
}
@keyframes modalIn {
from {
opacity: 0;
transform: translateY(8px) scale(0.97);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.edit-dialog-title {
margin: 0 0 4px;
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
}
.edit-dialog-sub {
margin: 0 0 18px;
font-size: 13px;
color: var(--text-secondary);
}
.confirm-error {
margin: 0 0 12px;
font-size: 13px;
color: #dc2626;
}
/* ── Form fields ── */
.edit-field {
display: flex;
flex-direction: column;
gap: 5px;
margin-bottom: 14px;
}
.edit-field label {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.edit-field input {
padding: 8px 12px;
border: 1px solid var(--border-subtle);
border-radius: 7px;
background: var(--bg-inset);
font-size: 13px;
color: var(--text-primary);
transition:
border-color 0.15s,
box-shadow 0.15s;
}
.edit-field input:focus {
outline: none;
border-color: var(--input-focus-border);
box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.1);
}
/* ── Actions ── */
.confirm-actions {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
justify-content: center;
}
.btn-cancel {
padding: 7px 16px;
border-radius: 7px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
background: var(--bg-inset);
border: 1px solid var(--border-subtle);
color: var(--text-secondary);
transition:
background 0.15s,
border-color 0.15s;
}
.btn-cancel:hover:not(:disabled) {
background: var(--card-hover-bg);
border-color: var(--border-default);
color: var(--text-primary);
}
.btn-cancel:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.btn-save {
padding: 7px 16px;
border-radius: 7px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
background: var(--accent-color, #0066cc);
border: 1px solid transparent;
color: #fff;
transition: filter 0.15s;
}
.btn-save:hover:not(:disabled) {
filter: brightness(1.1);
}
.btn-save:disabled {
opacity: 0.45;
cursor: not-allowed;
}
/* ── Edit sections (roles & permissions) ── */
.edit-section {
margin-bottom: 16px;
border-top: 1px solid var(--border-subtle);
padding-top: 14px;
}
.edit-section-label {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
margin-bottom: 10px;
}
.edit-section-label svg {
color: var(--text-muted);
flex-shrink: 0;
}
.edit-section-count {
font-size: 11px;
font-weight: 500;
color: var(--text-muted);
text-transform: none;
letter-spacing: normal;
margin-left: auto;
}
/* ── Role chips ── */
.edit-role-list {
display: flex;
flex-direction: column;
gap: 4px;
max-height: 160px;
overflow-y: auto;
padding-right: 4px;
}
.edit-role-chip {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 10px;
background: var(--bg-inset);
border: 1px solid var(--border-subtle);
border-radius: 6px;
cursor: pointer;
font-size: 13px;
color: var(--text-primary);
text-align: left;
transition:
background 0.12s,
border-color 0.12s;
}
.edit-role-chip:hover {
background: var(--card-hover-bg);
}
.edit-role-chip.selected {
background: rgba(0, 102, 204, 0.08);
border-color: var(--accent-color, #0066cc);
}
.edit-role-check {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border-radius: 4px;
border: 1.5px solid var(--border-subtle);
flex-shrink: 0;
color: var(--accent-color, #0066cc);
transition: border-color 0.12s;
}
.edit-role-chip.selected .edit-role-check {
border-color: var(--accent-color, #0066cc);
background: var(--accent-color, #0066cc);
color: #fff;
}
.edit-role-chip-title {
font-weight: 600;
font-size: 13px;
}
.edit-role-chip-moniker {
font-family: ui-monospace, "SF Mono", "Cascadia Code", Menlo, monospace;
font-size: 11px;
color: var(--text-muted);
margin-left: auto;
}
/* ── Custom permission input ── */
.edit-custom-perm-wrap {
display: flex;
gap: 6px;
margin-bottom: 8px;
}
.edit-custom-perm-input {
flex: 1;
padding: 6px 10px;
border: 1px solid var(--border-subtle);
border-radius: 6px;
background: var(--bg-inset);
font-family: ui-monospace, "SF Mono", "Cascadia Code", Menlo, monospace;
font-size: 12px;
color: var(--text-primary);
transition:
border-color 0.15s,
box-shadow 0.15s;
}
.edit-custom-perm-input::placeholder {
color: var(--text-muted);
font-family: inherit;
}
.edit-custom-perm-input:focus {
outline: none;
border-color: var(--input-focus-border);
box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.1);
}
.edit-custom-perm-btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 6px 12px;
background: var(--accent-color, #0066cc);
color: #fff;
border: none;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
transition: filter 0.15s;
}
.edit-custom-perm-btn:hover:not(:disabled) {
filter: brightness(1.1);
}
.edit-custom-perm-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.edit-custom-perm-error {
margin: 0 0 6px;
font-size: 11.5px;
color: #dc2626;
}
/* ── Permission list ── */
.edit-perm-search-wrap {
margin-bottom: 8px;
}
.edit-perm-search {
width: 100%;
padding: 6px 10px;
border: 1px solid var(--border-subtle);
border-radius: 6px;
background: var(--bg-inset);
font-size: 12.5px;
color: var(--text-primary);
transition:
border-color 0.15s,
box-shadow 0.15s;
}
.edit-perm-search::placeholder {
color: var(--text-muted);
}
.edit-perm-search:focus {
outline: none;
border-color: var(--input-focus-border);
box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.1);
}
.edit-perm-list {
display: flex;
flex-direction: column;
gap: 2px;
max-height: 180px;
overflow-y: auto;
padding-right: 4px;
}
.edit-perm-item {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 8px;
border-radius: 5px;
cursor: pointer;
transition: background 0.1s;
}
.edit-perm-item:hover {
background: var(--card-hover-bg);
}
.edit-perm-item input[type="checkbox"] {
accent-color: var(--accent-color, #0066cc);
width: 14px;
height: 14px;
flex-shrink: 0;
cursor: pointer;
}
.edit-perm-node {
font-family: ui-monospace, "SF Mono", "Cascadia Code", Menlo, monospace;
font-size: 11.5px;
color: var(--text-primary);
}
.edit-perm-empty {
margin: 0;
padding: 8px;
font-size: 12.5px;
color: var(--text-muted);
text-align: center;
}
/* Scrollbars */
.edit-role-list::-webkit-scrollbar,
.edit-perm-list::-webkit-scrollbar {
width: 5px;
}
.edit-role-list::-webkit-scrollbar-track,
.edit-perm-list::-webkit-scrollbar-track {
background: transparent;
}
.edit-role-list::-webkit-scrollbar-thumb,
.edit-perm-list::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb);
border-radius: 3px;
}
</style>
+999
View File
@@ -0,0 +1,999 @@
<script lang="ts">
import { unifi, type UnifiSite } from "$lib/optima-api/modules/unifi";
export let isOpen = false;
export let accessToken: string;
export let companyId: string;
export let linkedSiteIds: string[] = [];
export let onSuccess: () => void = () => {};
type ModalStep = "choose" | "existing" | "new";
let step: ModalStep = "choose";
// Existing site linking
let allSites: UnifiSite[] = [];
let isLoadingSites = false;
let selectedSiteId = "";
let isLinking = false;
let linkError = "";
let siteSearch = "";
// New site creation
let newSiteName = "";
let isCreating = false;
let createError = "";
function reset() {
step = "choose";
allSites = [];
isLoadingSites = false;
selectedSiteId = "";
isLinking = false;
linkError = "";
siteSearch = "";
newSiteName = "";
isCreating = false;
createError = "";
}
function close() {
isOpen = false;
reset();
}
async function goToExisting() {
step = "existing";
linkError = "";
isLoadingSites = true;
try {
const result = await unifi.fetchSites(accessToken);
// Filter out sites already linked to this company
allSites = (result?.data ?? []).filter(
(s: UnifiSite) => !linkedSiteIds.includes(s.id),
);
} catch (err) {
console.error("Failed to fetch UniFi sites:", err);
linkError = err instanceof Error ? err.message : "Failed to load sites";
allSites = [];
} finally {
isLoadingSites = false;
}
}
function goToNew() {
step = "new";
createError = "";
newSiteName = "";
}
function goBack() {
step = "choose";
linkError = "";
createError = "";
}
async function linkExistingSite() {
if (!selectedSiteId || !accessToken) return;
isLinking = true;
linkError = "";
try {
await unifi.linkSite(accessToken, selectedSiteId, companyId);
close();
onSuccess();
} catch (err) {
linkError = err instanceof Error ? err.message : "Failed to link site";
console.error("Failed to link UniFi site:", err);
} finally {
isLinking = false;
}
}
async function createAndLinkSite() {
if (!newSiteName.trim() || !accessToken) return;
isCreating = true;
createError = "";
try {
const result = await unifi.createSite(accessToken, newSiteName.trim());
const newSiteId = result?.data?.id;
if (newSiteId && companyId) {
await unifi.linkSite(accessToken, newSiteId, companyId);
}
close();
onSuccess();
} catch (err) {
createError =
err instanceof Error ? err.message : "Failed to create site";
console.error("Failed to create UniFi site:", err);
} finally {
isCreating = false;
}
}
// Filter sites that are unlinked (no company) or linked to a different company
$: availableSites = allSites.filter(
(s) => !s.companyId || s.companyId !== companyId,
);
$: unlinkedSites = availableSites.filter((s) => !s.companyId);
$: otherCompanySites = availableSites.filter(
(s) => s.companyId && s.companyId !== companyId,
);
// Search-filtered lists
$: filteredUnlinked = siteSearch.trim()
? unlinkedSites.filter((s) => {
const q = siteSearch.trim().toLowerCase();
return (
s.name.toLowerCase().includes(q) || s.siteId.toLowerCase().includes(q)
);
})
: unlinkedSites;
$: filteredOtherCompany = siteSearch.trim()
? otherCompanySites.filter((s) => {
const q = siteSearch.trim().toLowerCase();
return (
s.name.toLowerCase().includes(q) ||
s.siteId.toLowerCase().includes(q) ||
(s.company?.name ?? "").toLowerCase().includes(q)
);
})
: otherCompanySites;
$: totalFiltered = filteredUnlinked.length + filteredOtherCompany.length;
</script>
{#if isOpen}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="modal-overlay" on:click={close}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="modal-container" on:click|stopPropagation>
<div class="modal-header">
{#if step !== "choose"}
<button
class="modal-back"
on:click={goBack}
type="button"
aria-label="Back"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
>
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
</button>
{/if}
<h3 class="modal-title">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="18"
height="18"
>
<path d="M12 2L2 7l10 5 10-5-10-5z" />
<path d="M2 17l10 5 10-5" />
<path d="M2 12l10 5 10-5" />
</svg>
{#if step === "choose"}
Link UniFi Site
{:else if step === "existing"}
Link Existing Site
{:else}
Create New Site
{/if}
</h3>
<button
class="modal-close"
on:click={close}
type="button"
aria-label="Close"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
>
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
<div class="modal-body">
{#if step === "choose"}
<p class="modal-description">
How would you like to add a UniFi site?
</p>
<div class="choice-grid">
<button class="choice-card" on:click={goToNew} type="button">
<div class="choice-icon">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
width="28"
height="28"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="16" />
<line x1="8" y1="12" x2="16" y2="12" />
</svg>
</div>
<span class="choice-label">New Site</span>
<span class="choice-desc"
>Create a new site on the UniFi controller</span
>
</button>
<button class="choice-card" on:click={goToExisting} type="button">
<div class="choice-icon">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
width="28"
height="28"
>
<path
d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"
/>
<path
d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"
/>
</svg>
</div>
<span class="choice-label">Existing Site</span>
<span class="choice-desc"
>Link an existing UniFi site to this company</span
>
</button>
</div>
{:else if step === "existing"}
{#if isLoadingSites}
<div class="modal-loading">
<svg viewBox="0 0 24 24" width="20" height="20" class="spin-icon">
<circle
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="3"
fill="none"
opacity="0.25"
/>
<path
d="M12 2a10 10 0 0 1 10 10"
stroke="currentColor"
stroke-width="3"
fill="none"
stroke-linecap="round"
/>
</svg>
<span>Loading sites…</span>
</div>
{:else if availableSites.length === 0}
<div class="modal-empty">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
width="32"
height="32"
>
<path d="M12 2L2 7l10 5 10-5-10-5z" />
<path d="M2 17l10 5 10-5" />
<path d="M2 12l10 5 10-5" />
</svg>
<p>No available sites to link</p>
<span class="modal-empty-hint"
>All sites are already linked to this company, or no sites
exist.</span
>
</div>
{:else}
<!-- Search input -->
<div class="site-search-bar">
<svg
class="site-search-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
<input
class="site-search-input"
type="text"
placeholder="Search sites…"
bind:value={siteSearch}
disabled={isLinking}
/>
{#if siteSearch}
<button
class="site-search-clear"
type="button"
on:click={() => (siteSearch = "")}
aria-label="Clear search"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="12"
height="12"
>
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
{/if}
</div>
<!-- Scrollable site list -->
<div class="site-list">
{#if totalFiltered === 0}
<div class="site-list-empty">
<p>No sites matching "{siteSearch}"</p>
</div>
{:else}
{#if filteredUnlinked.length > 0}
<div class="site-list-group-label">Unlinked Sites</div>
{#each filteredUnlinked as site (site.id)}
<button
class="site-list-item"
class:selected={selectedSiteId === site.id}
type="button"
on:click={() => (selectedSiteId = site.id)}
disabled={isLinking}
>
<div class="site-list-item-icon">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
width="18"
height="18"
>
<path d="M12 2L2 7l10 5 10-5-10-5z" />
<path d="M2 17l10 5 10-5" />
<path d="M2 12l10 5 10-5" />
</svg>
</div>
<div class="site-list-item-info">
<span class="site-list-item-name">{site.name}</span>
<span class="site-list-item-id">{site.siteId}</span>
</div>
{#if selectedSiteId === site.id}
<svg
class="site-list-item-check"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
width="16"
height="16"
>
<polyline points="20 6 9 17 4 12" />
</svg>
{/if}
</button>
{/each}
{/if}
{#if filteredOtherCompany.length > 0}
<div class="site-list-group-label">
Linked to Other Companies
</div>
{#each filteredOtherCompany as site (site.id)}
<button
class="site-list-item"
class:selected={selectedSiteId === site.id}
type="button"
on:click={() => (selectedSiteId = site.id)}
disabled={isLinking}
>
<div class="site-list-item-icon">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
width="18"
height="18"
>
<path d="M12 2L2 7l10 5 10-5-10-5z" />
<path d="M2 17l10 5 10-5" />
<path d="M2 12l10 5 10-5" />
</svg>
</div>
<div class="site-list-item-info">
<span class="site-list-item-name">{site.name}</span>
<span class="site-list-item-id"
>{site.company?.name ?? "Unknown"}</span
>
</div>
{#if selectedSiteId === site.id}
<svg
class="site-list-item-check"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
width="16"
height="16"
>
<polyline points="20 6 9 17 4 12" />
</svg>
{/if}
</button>
{/each}
{/if}
{/if}
</div>
{#if linkError}
<div class="modal-error">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<circle cx="12" cy="12" r="10" /><line
x1="12"
y1="8"
x2="12"
y2="12"
/><line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
{linkError}
</div>
{/if}
{/if}
{:else if step === "new"}
<div class="modal-field">
<label class="modal-label" for="new-unifi-site-name"
>Site Name</label
>
<input
id="new-unifi-site-name"
class="modal-input"
type="text"
bind:value={newSiteName}
placeholder="e.g. Main Office"
disabled={isCreating}
/>
</div>
{#if createError}
<div class="modal-error">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<circle cx="12" cy="12" r="10" /><line
x1="12"
y1="8"
x2="12"
y2="12"
/><line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
{createError}
</div>
{/if}
{/if}
</div>
{#if step !== "choose"}
<div class="modal-footer">
<button
class="modal-btn modal-btn-cancel"
on:click={close}
type="button"
disabled={isLinking || isCreating}
>
Cancel
</button>
{#if step === "existing"}
<button
class="modal-btn modal-btn-primary"
on:click={linkExistingSite}
type="button"
disabled={isLinking || !selectedSiteId}
>
{isLinking ? "Linking…" : "Link Site"}
</button>
{:else if step === "new"}
<button
class="modal-btn modal-btn-primary"
on:click={createAndLinkSite}
type="button"
disabled={isCreating || !newSiteName.trim()}
>
{isCreating ? "Creating…" : "Create & Link"}
</button>
{/if}
</div>
{/if}
</div>
</div>
{/if}
<style>
.modal-overlay {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
}
.modal-container {
width: 480px;
max-width: 90vw;
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
overflow: hidden;
}
.modal-header {
display: flex;
align-items: center;
gap: 8px;
padding: 16px 20px;
border-bottom: 1px solid var(--border-subtle);
}
.modal-title {
display: flex;
align-items: center;
gap: 8px;
margin: 0;
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
flex: 1;
}
.modal-back {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: 1px solid var(--border-subtle);
border-radius: 6px;
background: none;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.15s;
flex-shrink: 0;
}
.modal-back:hover {
background: var(--nav-hover-bg);
color: var(--text-primary);
}
.modal-close {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: 6px;
background: none;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.15s;
flex-shrink: 0;
}
.modal-close:hover {
background: var(--nav-hover-bg);
color: var(--text-primary);
}
.modal-body {
padding: 20px;
display: flex;
flex-direction: column;
gap: 16px;
}
.modal-description {
margin: 0;
font-size: 13px;
color: var(--text-secondary);
}
/* Choice cards */
.choice-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.choice-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 24px 16px;
border: 1px solid var(--border-subtle);
border-radius: 10px;
background: var(--bg-base);
cursor: pointer;
transition: all 0.2s;
text-align: center;
}
.choice-card:hover {
border-color: var(--input-focus-border);
background: color-mix(
in srgb,
var(--input-focus-border) 5%,
var(--bg-base)
);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.choice-icon {
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border-radius: 12px;
background: color-mix(in srgb, var(--input-focus-border) 10%, transparent);
color: var(--input-focus-border);
}
.choice-label {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.choice-desc {
font-size: 11px;
color: var(--text-muted);
line-height: 1.4;
}
/* Form fields */
.modal-field {
display: flex;
flex-direction: column;
gap: 6px;
}
.modal-label {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.modal-input {
padding: 8px 12px;
font-size: 13px;
border: 1px solid var(--border-default);
border-radius: 6px;
background: var(--bg-base);
color: var(--text-primary);
outline: none;
transition: border-color 0.15s;
}
.modal-input:focus {
border-color: var(--input-focus-border);
box-shadow: 0 0 0 2px
color-mix(in srgb, var(--input-focus-border) 15%, transparent);
}
.modal-input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.modal-error {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
border-radius: 6px;
background: color-mix(in srgb, var(--status-error) 10%, transparent);
color: var(--status-error);
font-size: 12px;
font-weight: 500;
}
.modal-loading {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 32px;
color: var(--text-secondary);
font-size: 13px;
}
.modal-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 24px;
color: var(--text-secondary);
font-size: 13px;
text-align: center;
}
.modal-empty p {
margin: 0;
font-weight: 500;
}
.modal-empty-hint {
font-size: 11px;
color: var(--text-muted);
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 16px 20px;
border-top: 1px solid var(--border-subtle);
}
.modal-btn {
padding: 7px 16px;
font-size: 12px;
font-weight: 500;
border-radius: 6px;
cursor: pointer;
transition: all 0.15s;
}
.modal-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.modal-btn-cancel {
background: var(--bg-surface);
color: var(--text-secondary);
border: 1px solid var(--border-default);
}
.modal-btn-cancel:hover:not(:disabled) {
background: var(--nav-hover-bg);
}
.modal-btn-primary {
background: var(--accent-color, #0066cc);
color: #fff;
border: none;
}
.modal-btn-primary:hover:not(:disabled) {
filter: brightness(1.12);
}
/* Site search bar */
.site-search-bar {
display: flex;
align-items: center;
gap: 6px;
padding: 7px 10px;
background: var(--bg-base);
border: 1px solid var(--border-subtle);
border-radius: 6px;
transition: border-color 0.15s;
}
.site-search-bar:focus-within {
border-color: var(--input-focus-border);
box-shadow: 0 0 0 2px
color-mix(in srgb, var(--input-focus-border) 12%, transparent);
}
.site-search-icon {
color: var(--text-muted);
flex-shrink: 0;
}
.site-search-bar:focus-within .site-search-icon {
color: var(--input-focus-border);
}
.site-search-input {
flex: 1;
min-width: 0;
border: none;
background: transparent;
font-size: 13px;
color: var(--text-primary);
outline: none;
}
.site-search-input::placeholder {
color: var(--text-muted);
}
.site-search-input:disabled {
opacity: 0.6;
}
.site-search-clear {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border: none;
border-radius: 4px;
background: none;
color: var(--text-muted);
cursor: pointer;
flex-shrink: 0;
transition: all 0.15s;
}
.site-search-clear:hover {
background: var(--nav-hover-bg);
color: var(--text-primary);
}
/* Scrollable site list */
.site-list {
max-height: 280px;
overflow-y: auto;
border: 1px solid var(--border-subtle);
border-radius: 8px;
background: var(--bg-base);
}
.site-list-empty {
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
color: var(--text-muted);
font-size: 13px;
}
.site-list-empty p {
margin: 0;
}
.site-list-group-label {
padding: 8px 12px 4px;
font-size: 10px;
font-weight: 700;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
position: sticky;
top: 0;
background: var(--bg-base);
z-index: 1;
}
.site-list-item {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 8px 12px;
border: none;
border-bottom: 1px solid var(--border-subtle);
background: none;
cursor: pointer;
text-align: left;
transition: background 0.12s;
color: var(--text-primary);
}
.site-list-item:last-child {
border-bottom: none;
}
.site-list-item:hover:not(:disabled) {
background: var(--nav-hover-bg);
}
.site-list-item.selected {
background: color-mix(in srgb, var(--input-focus-border) 10%, transparent);
}
.site-list-item:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.site-list-item-icon {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 6px;
background: color-mix(in srgb, var(--input-focus-border) 8%, transparent);
color: var(--input-focus-border);
flex-shrink: 0;
}
.site-list-item.selected .site-list-item-icon {
background: color-mix(in srgb, var(--input-focus-border) 15%, transparent);
}
.site-list-item-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 1px;
}
.site-list-item-name {
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.site-list-item-id {
font-size: 11px;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.site-list-item-check {
color: var(--input-focus-border);
flex-shrink: 0;
}
/* Spinner animation */
@keyframes spin {
to {
transform: rotate(360deg);
}
}
:global(.spin-icon) {
animation: spin 1s linear infinite;
}
</style>
+303
View File
@@ -0,0 +1,303 @@
<script lang="ts">
import { unifi } from "$lib/optima-api/modules/unifi";
export let isOpen = false;
export let accessToken: string;
export let companyId: string;
export let onSuccess: () => void = () => {};
let siteName = "";
let isSubmitting = false;
let submitError = "";
function reset() {
siteName = "";
isSubmitting = false;
submitError = "";
}
function close() {
isOpen = false;
reset();
}
async function handleSubmit() {
if (!siteName.trim() || !accessToken) return;
isSubmitting = true;
submitError = "";
try {
// Create the site on the UniFi controller
const result = await unifi.createSite(accessToken, siteName.trim());
const newSiteId = result?.data?.id;
if (newSiteId && companyId) {
// Link it to this company
await unifi.linkSite(accessToken, newSiteId, companyId);
}
close();
onSuccess();
} catch (err) {
submitError =
err instanceof Error ? err.message : "Failed to create UniFi site";
console.error("Failed to create UniFi site:", err);
} finally {
isSubmitting = false;
}
}
</script>
{#if isOpen}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="modal-overlay" on:click={close}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="modal-container" on:click|stopPropagation>
<div class="modal-header">
<h3 class="modal-title">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="18"
height="18"
>
<path d="M12 2L2 7l10 5 10-5-10-5z" />
<path d="M2 17l10 5 10-5" />
<path d="M2 12l10 5 10-5" />
</svg>
New UniFi Site
</h3>
<button
class="modal-close"
on:click={close}
type="button"
aria-label="Close"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
>
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
<div class="modal-body">
<div class="modal-field">
<label class="modal-label" for="unifi-site-name">Site Name</label>
<input
id="unifi-site-name"
class="modal-input"
type="text"
bind:value={siteName}
placeholder="e.g. Main Office"
disabled={isSubmitting}
/>
</div>
{#if submitError}
<div class="modal-error">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<circle cx="12" cy="12" r="10" /><line
x1="12"
y1="8"
x2="12"
y2="12"
/><line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
{submitError}
</div>
{/if}
</div>
<div class="modal-footer">
<button
class="modal-btn modal-btn-cancel"
on:click={close}
type="button"
disabled={isSubmitting}
>
Cancel
</button>
<button
class="modal-btn modal-btn-primary"
on:click={handleSubmit}
type="button"
disabled={isSubmitting || !siteName.trim()}
>
{#if isSubmitting}
Creating…
{:else}
Create Site
{/if}
</button>
</div>
</div>
</div>
{/if}
<style>
.modal-overlay {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
}
.modal-container {
width: 420px;
max-width: 90vw;
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
overflow: hidden;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid var(--border-subtle);
}
.modal-title {
display: flex;
align-items: center;
gap: 8px;
margin: 0;
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
}
.modal-close {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: 6px;
background: none;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.15s;
}
.modal-close:hover {
background: var(--nav-hover-bg);
color: var(--text-primary);
}
.modal-body {
padding: 20px;
display: flex;
flex-direction: column;
gap: 16px;
}
.modal-field {
display: flex;
flex-direction: column;
gap: 6px;
}
.modal-label {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.modal-input {
padding: 8px 12px;
font-size: 13px;
border: 1px solid var(--border-default);
border-radius: 6px;
background: var(--bg-base);
color: var(--text-primary);
outline: none;
transition: border-color 0.15s;
}
.modal-input:focus {
border-color: var(--input-focus-border);
box-shadow: 0 0 0 2px
color-mix(in srgb, var(--input-focus-border) 15%, transparent);
}
.modal-input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.modal-error {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
border-radius: 6px;
background: color-mix(in srgb, var(--status-error) 10%, transparent);
color: var(--status-error);
font-size: 12px;
font-weight: 500;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 16px 20px;
border-top: 1px solid var(--border-subtle);
}
.modal-btn {
padding: 7px 16px;
font-size: 12px;
font-weight: 500;
border-radius: 6px;
cursor: pointer;
transition: all 0.15s;
}
.modal-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.modal-btn-cancel {
background: var(--bg-surface);
color: var(--text-secondary);
border: 1px solid var(--border-default);
}
.modal-btn-cancel:hover:not(:disabled) {
background: var(--nav-hover-bg);
}
.modal-btn-primary {
background: var(--accent-color, #0066cc);
color: #fff;
border: none;
}
.modal-btn-primary:hover:not(:disabled) {
filter: brightness(1.12);
}
</style>
+77 -37
View File
@@ -1,27 +1,19 @@
import { api, user } from "$lib"; // src/hooks.server.ts
import { optima } from "$lib";
import { redirect, type Handle } from "@sveltejs/kit"; import { redirect, type Handle } from "@sveltejs/kit";
import { access } from "fs";
import { a } from "vitest/dist/chunks/suite.d.FvehnV49.js";
export const handle: Handle = async ({ event, resolve }) => { export const handle: Handle = async ({ event, resolve }) => {
const accessToken = event.cookies.get("access_token"); const accessToken = event.cookies.get("accessToken") || null;
const refreshToken = event.cookies.get("refresh_token"); const refreshToken = event.cookies.get("refreshToken") || null;
event.locals.session = {
accessToken: accessToken || "",
refreshToken: refreshToken || "",
};
if (event.url.pathname === "/logout") { if (event.url.pathname === "/logout") {
event.cookies.delete("access_token", { path: "/" }); event.cookies.delete("accessToken", { path: "/" });
event.cookies.delete("refresh_token", { path: "/" }); event.cookies.delete("refreshToken", { path: "/" });
redirect(303, "/login"); return redirect(303, "/login");
return resolve(event);
} }
if (event.url.pathname.startsWith("/login") && user.isLoggedIn()) { if (event.url.pathname.startsWith("/login") && optima.user.isLoggedIn()) {
return redirect(303, "/"); return redirect(303, "/");
} }
@@ -29,31 +21,79 @@ export const handle: Handle = async ({ event, resolve }) => {
return await resolve(event); return await resolve(event);
} }
if (!accessToken || !refreshToken) { if (!accessToken && !refreshToken) {
user.logout(event); optima.user.logout(event);
return resolve(event); redirect(303, "/login");
} }
// Check if the access token is expired or near expiry and refresh if needed
let currentAccessToken = accessToken;
let currentRefreshToken = refreshToken;
if (currentAccessToken) {
try { try {
if (accessToken && refreshToken) { const [, payload] = currentAccessToken.split(".");
const newSession = await user.refreshSession(refreshToken); const decoded = JSON.parse(
Buffer.from(payload, "base64url").toString("utf8"),
);
const nowSec = Math.floor(Date.now() / 1000);
const thresholdSec = 60; // refresh if < 60s remaining
console.log(newSession); if (!decoded?.exp || decoded.exp - nowSec < thresholdSec) {
// Token is expired or about to expire — try to refresh
event.cookies.set("access_token", newSession.accessToken, { if (currentRefreshToken) {
httpOnly: true, const refreshed =
path: "/", await optima.user.refreshSession(currentRefreshToken);
}); currentAccessToken = refreshed.accessToken;
event.cookies.set("refresh_token", newSession.refreshToken, { currentRefreshToken = refreshed.refreshToken ?? currentRefreshToken;
httpOnly: true, } else {
path: "/", // No refresh token available, force re-login
}); optima.user.logout(event);
return redirect(303, "/login");
} }
} catch (err) {
console.trace(err);
user.logout(event);
} finally {
return await resolve(event);
} }
} catch {
// Token is malformed or refresh failed — try refresh as fallback
if (currentRefreshToken) {
try {
const refreshed =
await optima.user.refreshSession(currentRefreshToken);
currentAccessToken = refreshed.accessToken;
currentRefreshToken = refreshed.refreshToken ?? currentRefreshToken;
} catch {
// Refresh also failed, force re-login
optima.user.logout(event);
return redirect(303, "/login");
}
} else {
optima.user.logout(event);
return redirect(303, "/login");
}
}
} else if (currentRefreshToken) {
// No access token but have a refresh token — try to get a new one
try {
const refreshed = await optima.user.refreshSession(currentRefreshToken);
currentAccessToken = refreshed.accessToken;
currentRefreshToken = refreshed.refreshToken ?? currentRefreshToken;
} catch {
optima.user.logout(event);
return redirect(303, "/login");
}
}
const setTokens = async (accessToken: string, refreshToken: string) => {
event.cookies.set("accessToken", accessToken, { path: "/" });
event.cookies.set("refreshToken", refreshToken, { path: "/" });
event.locals.session = { accessToken, refreshToken, set: setTokens };
return;
};
// Persist any refreshed tokens into cookies
await setTokens(currentAccessToken!, currentRefreshToken!);
const response = await resolve(event);
return response;
}; };
-27
View File
@@ -1,27 +0,0 @@
import axios, { AxiosInstance } from "axios";
export async function fetchAuthRedirectUri(api_url: string): Promise<{
uri: string;
callbackKey: string;
}> {
const client: AxiosInstance = axios.create({
baseURL: api_url || "",
timeout: 5000,
});
try {
const res = await client.get("/v1/auth/uri");
const d = res.data ?? {};
const uri = d.data.uri;
const callbackKey = d.data.callbackKey;
if (typeof uri !== "string" || !uri)
throw new Error("redirect uri missing from response");
return {
uri,
callbackKey,
};
} catch (e) {
throw new Error(
`Failed to fetch auth redirect uri: ${(e as Error).message}`,
);
}
}
-35
View File
@@ -1,35 +0,0 @@
import api from "./axios";
export const company = {
async fetch(accessToken: string, id: string) {
const company = await api.get(`/v1/company/companies/${id}`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
return company.data;
},
async fetchMany(accessToken: string, page: number = 1, search?: string) {
const params: Record<string, unknown> = { page };
if (search && search.length > 0) params.search = search;
const companies = await api.get("/v1/company/companies", {
params,
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
return companies.data;
},
async fetchConfigurations(accessToken: string, id: string) {
const configurations = await api.get(
`/v1/company/companies/${id}/configurations`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
);
return configurations.data;
},
};
+20 -4
View File
@@ -1,10 +1,26 @@
// place files you want to import through the `$lib` alias in this folder. // place files you want to import through the `$lib` alias in this folder.
export * from "./axios"; import { auth } from "./optima-api/modules/auth";
export * from "./user"; import { company } from "./optima-api/modules/companies";
export * from "./companies"; import { credential } from "./optima-api/modules/credentials";
export * from "./credentialTypes"; import { credentialType } from "./optima-api/modules/credentialTypes";
import { role } from "./optima-api/modules/roles";
import { permission } from "./optima-api/modules/permissions";
import { user } from "./optima-api/modules/user";
import { users } from "./optima-api/modules/users";
import { unifi } from "./optima-api/modules/unifi";
export const optima = {
auth,
company,
credential,
credentialType,
role,
permission,
user,
users,
unifi,
};
/** /**
* @TODO * @TODO
* *
+29
View File
@@ -0,0 +1,29 @@
import axios, { AxiosInstance } from "axios";
export const auth = {
async fetchAuthRedirectUri(api_url: string): Promise<{
uri: string;
callbackKey: string;
}> {
const client: AxiosInstance = axios.create({
baseURL: api_url || "",
timeout: 5000,
});
try {
const res = await client.get("/v1/auth/uri");
const d = res.data ?? {};
const uri = d.data.uri;
const callbackKey = d.data.callbackKey;
if (typeof uri !== "string" || !uri)
throw new Error("redirect uri missing from response");
return {
uri,
callbackKey,
};
} catch (e) {
throw new Error(
`Failed to fetch auth redirect uri: ${(e as Error).message}`,
);
}
},
};
+63
View File
@@ -0,0 +1,63 @@
import api from "../axios";
export const company = {
async fetch(
accessToken: string,
id: string,
options?: {
includeAddress?: boolean;
includePrimaryContact?: boolean;
includeAllContacts?: boolean;
},
) {
const params: Record<string, string> = {};
if (options?.includeAddress) params.includeAddress = "true";
if (options?.includePrimaryContact) params.includePrimaryContact = "true";
if (options?.includeAllContacts) params.includeAllContacts = "true";
const company = await api.get(`/v1/company/companies/${id}`, {
params,
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
return company.data;
},
async fetchMany(
accessToken: string,
page: number = 1,
search?: string,
rpp: number = 30,
) {
const params: Record<string, unknown> = { page, rpp };
if (search && search.length > 0) params.search = search;
const companies = await api.get("/v1/company/companies", {
params,
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
return companies.data;
},
async count(accessToken: string) {
const response = await api.get("/v1/company/count", {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
return response.data.data.count;
},
async fetchConfigurations(accessToken: string, id: string) {
const configurations = await api.get(
`/v1/company/companies/${id}/configurations`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
);
return configurations.data;
},
};
@@ -1,11 +1,12 @@
import api from "./axios"; import api from "../axios";
export interface CredentialTypeField { export interface CredentialTypeField {
id: string; id: string;
name: string; name: string;
required: boolean; required: boolean;
secure: boolean; secure: boolean;
valueType: "plain_text" | "password" | "number" | "email" | "url"; valueType: string;
subFields?: CredentialTypeField[];
} }
export interface CredentialType { export interface CredentialType {
@@ -1,16 +1,21 @@
import api from "./axios"; import api from "../axios";
export interface CredentialField { export interface CredentialField {
id: string; id: string;
fieldId: string; name: string;
secure: boolean;
required: boolean;
valueType: string;
value: string; value: string;
} }
export interface Credential { export interface Credential {
id: string; id: string;
name: string; name: string;
notes?: string;
typeId: string; typeId: string;
companyId: string; companyId: string;
subCredentialOfId?: string;
fields: CredentialField[]; fields: CredentialField[];
type?: { type?: {
id: string; id: string;
@@ -45,6 +50,7 @@ export const credential = {
Authorization: `Bearer ${accessToken}`, Authorization: `Bearer ${accessToken}`,
}, },
}); });
return response.data; return response.data;
}, },
@@ -62,7 +68,11 @@ export const credential = {
return response.data; return response.data;
}, },
async update(accessToken: string, id: string, data: { name: string }) { async update(
accessToken: string,
id: string,
data: { name?: string; notes?: string },
) {
const response = await api.patch(`/v1/credential/credentials/${id}`, data, { const response = await api.patch(`/v1/credential/credentials/${id}`, data, {
headers: { headers: {
Authorization: `Bearer ${accessToken}`, Authorization: `Bearer ${accessToken}`,
@@ -96,4 +106,79 @@ export const credential = {
}); });
return response.data; return response.data;
}, },
async fetchSecureValue(
accessToken: string,
credentialId: string,
fieldId: string,
) {
const response = await api.get(
`/v1/credential/credentials/${credentialId}/secure-values/${fieldId}`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
);
return response.data;
},
async fetchValueTypes(accessToken: string) {
const response = await api.get("/v1/credential/valuetypes", {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
return response.data;
},
async fetchSubCredentials(accessToken: string, credentialId: string) {
const response = await api.get(
`/v1/credential/credentials/${credentialId}/sub-credentials`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
);
return response.data;
},
async addSubCredential(
accessToken: string,
credentialId: string,
data: {
fieldId: string;
name: string;
fields: Array<{ fieldId: string; value: string }>;
},
) {
const response = await api.post(
`/v1/credential/credentials/${credentialId}/sub-credentials`,
data,
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
);
return response.data;
},
async removeSubCredential(
accessToken: string,
credentialId: string,
subId: string,
) {
const response = await api.delete(
`/v1/credential/credentials/${credentialId}/sub-credentials/${subId}`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
);
return response.data;
},
}; };
+47
View File
@@ -0,0 +1,47 @@
import api from "../axios";
export interface PermissionNode {
node: string;
description: string;
usedIn: string[];
dependencies?: string[];
}
export interface PermissionCategory {
name: string;
description: string;
permissions: PermissionNode[];
}
export interface PermissionsCategorized {
[category: string]: PermissionCategory;
}
export const permission = {
async fetchCategorized(accessToken: string) {
const response = await api.get("/v1/permissions", {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
return response.data;
},
async fetchFlat(accessToken: string) {
const response = await api.get("/v1/permissions/nodes", {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
return response.data;
},
async fetchByCategory(accessToken: string, category: string) {
const response = await api.get(`/v1/permissions/${category}`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
return response.data;
},
};
+104
View File
@@ -0,0 +1,104 @@
import api from "../axios";
export interface Role {
id: string;
title: string;
moniker: string;
permissions: string[];
createdAt: string;
updatedAt: string;
}
export const role = {
async fetchMany(accessToken: string) {
const response = await api.get("/v1/role", {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
return response.data;
},
async fetch(accessToken: string, identifier: string) {
const response = await api.get(`/v1/role/${identifier}`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
return response.data;
},
async create(
accessToken: string,
data: Omit<Role, "id" | "createdAt" | "updatedAt">,
) {
const response = await api.post("/v1/role", data, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
return response.data;
},
async update(
accessToken: string,
identifier: string,
updates: Partial<Omit<Role, "id" | "createdAt" | "updatedAt">>,
) {
const response = await api.patch(`/v1/role/${identifier}`, updates, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
return response.data;
},
async delete(accessToken: string, identifier: string) {
const response = await api.delete(`/v1/role/${identifier}`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
return response.data;
},
async addPermissions(
accessToken: string,
identifier: string,
permissions: string[],
) {
const response = await api.post(
`/v1/role/${identifier}/permissions`,
{ permissions },
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
);
return response.data;
},
async removePermissions(
accessToken: string,
identifier: string,
permissions: string[],
) {
const response = await api.delete(`/v1/role/${identifier}/permissions`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
data: { permissions },
});
return response.data;
},
async fetchUsers(accessToken: string, identifier: string) {
const response = await api.get(`/v1/role/${identifier}/users`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
return response.data;
},
};
+383
View File
@@ -0,0 +1,383 @@
import api from "../axios";
export interface UnifiSite {
id: string;
name: string;
siteId: string;
companyId: string | null;
company?: {
id: string;
name: string;
};
createdAt: string;
updatedAt: string;
}
export interface UnifiSiteOverview {
health: Array<{
subsystem: string;
status: string;
numAdopted?: number;
numGateway?: number;
[key: string]: unknown;
}>;
sysInfo: {
timezone?: string;
hostname?: string;
version?: string;
[key: string]: unknown;
};
siteInfo: {
description?: string;
name?: string;
[key: string]: unknown;
};
}
export interface UnifiDevice {
id: string;
mac: string;
model: string;
name: string;
type: string;
state: string | number;
ip: string;
version: string;
uptime: number;
radios?: unknown[];
uplink?: unknown;
[key: string]: unknown;
}
export interface UnifiWifiNetwork {
id: string;
name?: string;
siteId?: string;
enabled?: boolean;
security?: string;
wpaMode?: string;
wpaEnc?: string;
wpa3Support?: boolean;
wpa3Transition?: boolean;
wpa3FastRoaming?: boolean;
wpa3Enhanced192?: boolean;
passphrase?: string;
passphraseAutogenerated?: boolean;
hideSSID?: boolean;
isGuest?: boolean;
band?: string;
bands?: string[];
networkconfId?: string;
usergroupId?: string;
apGroupIds?: string[];
apGroupMode?: string;
pmfMode?: string;
groupRekey?: number;
dtimMode?: string;
dtimNg?: number;
dtimNa?: number;
dtim6e?: number;
l2Isolation?: boolean;
fastRoamingEnabled?: boolean;
bssTransition?: boolean;
uapsdEnabled?: boolean;
iappEnabled?: boolean;
proxyArp?: boolean;
mcastenhanceEnabled?: boolean;
macFilterEnabled?: boolean;
macFilterPolicy?: string;
macFilterList?: string[];
radiusDasEnabled?: boolean;
radiusMacAuthEnabled?: boolean;
radiusMacaclFormat?: string;
minrateSettingPreference?: string;
minrateNgEnabled?: boolean;
minrateNgDataRateKbps?: number;
minrateNgAdvertisingRates?: boolean;
minrateNaEnabled?: boolean;
minrateNaDataRateKbps?: number;
minrateNaAdvertisingRates?: boolean;
settingPreference?: string;
no2ghzOui?: boolean;
privatePreSharedKeysEnabled?: boolean;
privatePreSharedKeys?: unknown[];
saeGroups?: unknown[];
saePsk?: unknown[];
schedule?: unknown[];
scheduleWithDuration?: unknown[];
bcFilterList?: unknown[];
externalId?: string;
[key: string]: unknown;
}
export interface UnifiNetwork {
id: string;
name: string;
purpose: string;
subnet: string;
vlanId: number | null;
dhcpEnabled: boolean;
dhcpStart: string;
dhcpStop: string;
domainName: string;
isNat: boolean;
enabled: boolean;
}
export interface UnifiWlanGroup {
id: string;
name: string;
siteId: string;
noDelete: boolean;
noEdit: boolean;
hidden: boolean;
}
export interface UnifiApGroup {
id: string;
name: string;
deviceMacs: string[];
noDelete: boolean;
}
export interface UnifiAccessPoint {
id: string;
mac: string;
model: string;
type: string;
name: string;
state: number;
adopted: boolean;
ip: string;
version: string;
}
export interface UnifiSpeedProfile {
id: string;
name: string;
siteId: string;
noDelete: boolean;
downloadLimitKbps: number;
uploadLimitKbps: number;
}
export interface UnifiPPSK {
key: string;
name: string;
mac: string | null;
vlanId: number | null;
}
export const unifi = {
/** Fetch all UniFi sites */
async fetchSites(accessToken: string) {
const response = await api.get("/v1/unifi/sites", {
headers: { Authorization: `Bearer ${accessToken}` },
});
return response.data;
},
/** Sync sites from UniFi controller */
async syncSites(accessToken: string) {
const response = await api.post(
"/v1/unifi/sites/sync",
{},
{
headers: { Authorization: `Bearer ${accessToken}` },
},
);
return response.data;
},
/** Create a new UniFi site */
async createSite(accessToken: string, description: string) {
const response = await api.post(
"/v1/unifi/sites/create",
{ description },
{
headers: { Authorization: `Bearer ${accessToken}` },
},
);
return response.data;
},
/** Fetch a single UniFi site */
async fetchSite(accessToken: string, id: string) {
const response = await api.get(`/v1/unifi/site/${id}`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
return response.data;
},
/** Fetch UniFi sites linked to a company */
async fetchCompanySites(accessToken: string, companyId: string) {
const response = await api.get(
`/v1/company/companies/${companyId}/unifi/sites`,
{
headers: { Authorization: `Bearer ${accessToken}` },
},
);
return response.data;
},
/** Link a site to a company */
async linkSite(accessToken: string, siteId: string, companyId: string) {
const response = await api.post(
`/v1/unifi/site/${siteId}/link`,
{ companyId },
{
headers: { Authorization: `Bearer ${accessToken}` },
},
);
return response.data;
},
/** Unlink a site from its company */
async unlinkSite(accessToken: string, siteId: string) {
const response = await api.post(
`/v1/unifi/site/${siteId}/unlink`,
{},
{
headers: { Authorization: `Bearer ${accessToken}` },
},
);
return response.data;
},
/** Get site overview (health, sysInfo, siteInfo) */
async fetchSiteOverview(accessToken: string, siteId: string) {
const response = await api.get(`/v1/unifi/site/${siteId}/overview`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
return response.data;
},
/** Get site devices */
async fetchSiteDevices(accessToken: string, siteId: string) {
const response = await api.get(`/v1/unifi/site/${siteId}/devices`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
return response.data;
},
/** Get site WiFi networks */
async fetchSiteWifi(accessToken: string, siteId: string) {
const response = await api.get(`/v1/unifi/site/${siteId}/wifi`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
return response.data;
},
/** Update a WiFi network */
async updateWifi(
accessToken: string,
siteId: string,
wlanId: string,
data: Record<string, unknown>,
) {
const response = await api.patch(
`/v1/unifi/site/${siteId}/wifi/${wlanId}`,
data,
{
headers: { Authorization: `Bearer ${accessToken}` },
},
);
return response.data;
},
/** Get site networks */
async fetchSiteNetworks(accessToken: string, siteId: string) {
const response = await api.get(`/v1/unifi/site/${siteId}/networks`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
return response.data;
},
/** Get WLAN groups */
async fetchWlanGroups(accessToken: string, siteId: string) {
const response = await api.get(`/v1/unifi/site/${siteId}/wlan-groups`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
return response.data;
},
/** Get AP groups (collections of access points for broadcasting) */
async fetchApGroups(accessToken: string, siteId: string) {
const response = await api.get(`/v1/unifi/site/${siteId}/ap-groups`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
return response.data;
},
/** Get access points */
async fetchAccessPoints(accessToken: string, siteId: string) {
const response = await api.get(`/v1/unifi/site/${siteId}/access-points`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
return response.data;
},
/** Get speed profiles (user groups) */
async fetchSpeedProfiles(accessToken: string, siteId: string) {
const response = await api.get(`/v1/unifi/site/${siteId}/speed-profiles`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
return response.data;
},
/** Create a speed profile */
async createSpeedProfile(
accessToken: string,
siteId: string,
data: {
name: string;
downloadLimitKbps?: number;
uploadLimitKbps?: number;
},
) {
const response = await api.post(
`/v1/unifi/site/${siteId}/speed-profiles`,
data,
{
headers: { Authorization: `Bearer ${accessToken}` },
},
);
return response.data;
},
/** Get private PSKs for a WLAN */
async fetchPPSKs(accessToken: string, siteId: string, wlanId: string) {
const response = await api.get(
`/v1/unifi/site/${siteId}/wifi/${wlanId}/ppsk`,
{
headers: { Authorization: `Bearer ${accessToken}` },
},
);
return response.data;
},
/** Create a private PSK on a WLAN */
async createPPSK(
accessToken: string,
siteId: string,
wlanId: string,
data: { key: string; name: string; mac?: string; vlanId?: number },
) {
const response = await api.post(
`/v1/unifi/site/${siteId}/wifi/${wlanId}/ppsk`,
data,
{
headers: { Authorization: `Bearer ${accessToken}` },
},
);
return response.data;
},
/** Get WiFi limits per AP per radio */
async fetchWifiLimits(accessToken: string, siteId: string) {
const response = await api.get(`/v1/unifi/site/${siteId}/wifi-limits`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
return response.data;
},
};
@@ -2,12 +2,13 @@ import { getRequestEvent } from "$app/server";
import { PUBLIC_API_URL } from "$env/static/public"; import { PUBLIC_API_URL } from "$env/static/public";
import { redirect, RequestEvent } from "@sveltejs/kit"; import { redirect, RequestEvent } from "@sveltejs/kit";
import axios from "axios"; import axios from "axios";
import api from "../axios";
import { io } from "socket.io-client"; import { io } from "socket.io-client";
export const user = { export const user = {
isLoggedIn(): boolean { isLoggedIn(): boolean {
const event = getRequestEvent(); const event = getRequestEvent();
const authToken = event.cookies.get("authToken"); const authToken = event.cookies.get("accessToken");
return !!authToken; return !!authToken;
}, },
@@ -28,18 +29,39 @@ export const user = {
return refreshedTokens; return refreshedTokens;
}, },
fetchInfo() {}, async fetchInfo(accessToken: string) {
const response = await api.get("/v1/user/@me", {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
return response.data;
},
logout(event: RequestEvent) { logout(event: RequestEvent) {
if (!event) return; if (!event) return;
// Clear authentication cookies // Clear authentication cookies
event.cookies.delete("authToken", { path: "/" }); event.cookies.delete("accessToken", { path: "/" });
event.cookies.delete("refreshToken", { path: "/" }); event.cookies.delete("refreshToken", { path: "/" });
return redirect(303, "/login"); return redirect(303, "/login");
}, },
async checkPermissions(accessToken: string, permissions: string[]) {
const response = await api.post(
"/v1/user/@me/check-permission",
{ permissions },
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
);
return response.data;
},
/** /**
* @todo Get communication with server working and setup a key system so that the frontend can listen for a specific key from the backend so that nobody can poach off of login events. * @todo Get communication with server working and setup a key system so that the frontend can listen for a specific key from the backend so that nobody can poach off of login events.
* *
@@ -57,6 +79,7 @@ export const user = {
let settled = false; let settled = false;
const socket = io(`${base}/auth_callback`, { const socket = io(`${base}/auth_callback`, {
transports: ["websocket"], transports: ["websocket"],
rejectUnauthorized: false,
}); });
const timeout = setTimeout( const timeout = setTimeout(
() => { () => {
@@ -67,8 +90,8 @@ export const user = {
} catch {} } catch {}
reject(new Error("Timed out waiting for auth callback")); reject(new Error("Timed out waiting for auth callback"));
}, },
2 * 60 * 1000, 5 * 60 * 1000,
); // 2 minutes ); // 5 minutes
const handlePayload = (payload: any) => { const handlePayload = (payload: any) => {
try { try {
+126
View File
@@ -0,0 +1,126 @@
import api from "../axios";
import type { Role } from "./roles";
export interface User {
id: string;
name: string;
email: string;
login: string;
image?: string;
roles: string[];
permissions?: string[];
createdAt: string;
updatedAt: string;
}
export interface PermissionCheckResult {
permission: string;
hasPermission: boolean;
}
export const users = {
/**
* Fetch all users.
* Requires: user.read.other, user.list.other
*/
async fetchAll(accessToken: string): Promise<{ data: User[] }> {
const response = await api.get("/v1/user/users", {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
return response.data;
},
/**
* Fetch a specific user by their ID.
* Requires: user.read.other
*/
async fetch(
accessToken: string,
identifier: string,
): Promise<{ data: User }> {
const response = await api.get(`/v1/user/users/${identifier}`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
return response.data;
},
/**
* Update a specific user's information.
* Requires: user.write.other
* Conditional: user.roles.other (if roles included), user.permissions.other (if permissions included)
*/
async update(
accessToken: string,
identifier: string,
updates: {
name?: string;
image?: string;
roles?: string[];
permissions?: string[];
},
): Promise<{ data: User }> {
const response = await api.patch(`/v1/user/users/${identifier}`, updates, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
return response.data;
},
/**
* Delete a specific user.
* Requires: user.delete.other
*/
async delete(
accessToken: string,
identifier: string,
): Promise<{ data: User }> {
const response = await api.delete(`/v1/user/users/${identifier}`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
return response.data;
},
/**
* Fetch all roles assigned to a specific user.
* Requires: user.read.other, role.read
*/
async fetchRoles(
accessToken: string,
identifier: string,
): Promise<{ data: Role[] }> {
const response = await api.get(`/v1/user/users/${identifier}/roles`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
return response.data;
},
/**
* Check if a specific user has certain permissions.
* Requires: user.read.other
*/
async checkPermissions(
accessToken: string,
identifier: string,
permissions: string[],
): Promise<{ data: { results: PermissionCheckResult[] } }> {
const response = await api.post(
`/v1/user/users/${identifier}/check-permission`,
{ permissions },
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
);
return response.data;
},
};
+51
View File
@@ -0,0 +1,51 @@
import { optima } from "$lib";
export type PermissionMap = Record<string, boolean>;
/**
* Check multiple permissions for the current user and return a map of
* permission boolean. Designed to be called from any +page.server.ts
* or +layout.server.ts load function.
*
* @example
* ```ts
* const perms = await checkPermissions(accessToken, [
* "company.fetch.address",
* "credential.create",
* ]);
* // perms => { "company.fetch.address": true, "credential.create": false }
* ```
*/
export async function checkPermissions(
accessToken: string,
permissions: string[],
): Promise<PermissionMap> {
if (!permissions.length) return {};
try {
const result = await optima.user.checkPermissions(accessToken, permissions);
const results: Array<{ permission: string; hasPermission: boolean }> =
result?.data?.results ?? [];
return results.reduce<PermissionMap>((map, entry) => {
map[entry.permission] = entry.hasPermission === true;
return map;
}, {});
} catch (err) {
console.error("Permission check failed:", err);
// Default every requested permission to false on failure
return permissions.reduce<PermissionMap>((map, p) => {
map[p] = false;
return map;
}, {});
}
}
/**
* Convenience helper returns true when a specific permission is
* granted inside a PermissionMap.
*/
export function hasPermission(map: PermissionMap, permission: string): boolean {
return map[permission] === true;
}
+39
View File
@@ -0,0 +1,39 @@
import { writable } from "svelte/store";
import { browser } from "$app/environment";
type Theme = "light" | "dark";
function createThemeStore() {
const initial: Theme = browser
? ((localStorage.getItem("theme") as Theme) ?? "dark")
: "dark";
const { subscribe, set, update } = writable<Theme>(initial);
function applyTheme(theme: Theme) {
if (browser) {
document.documentElement.setAttribute("data-theme", theme);
localStorage.setItem("theme", theme);
}
}
// Apply on init
if (browser) applyTheme(initial);
return {
subscribe,
toggle() {
update((current) => {
const next = current === "dark" ? "light" : "dark";
applyTheme(next);
return next;
});
},
set(theme: Theme) {
applyTheme(theme);
set(theme);
},
};
}
export const theme = createThemeStore();
View File
+1
View File
@@ -0,0 +1 @@
<slot />
@@ -1,19 +1,19 @@
import { user } from "$lib";
import { Actions, redirect } from "@sveltejs/kit"; import { Actions, redirect } from "@sveltejs/kit";
import { optima } from "$lib";
export const actions: Actions = { export const actions: Actions = {
login: async (event) => { login: async (event) => {
const data = await event.request.formData(); const data = await event.request.formData();
const tokens = await user.awaitAuthCallback( const tokens = await optima.user.awaitAuthCallback(
data.get("callbackKey") as string, data.get("callbackKey") as string,
); );
event.cookies.set("access_token", tokens.accessToken, { event.cookies.set("accessToken", tokens.accessToken, {
httpOnly: true, httpOnly: true,
path: "/", path: "/",
}); });
event.cookies.set("refresh_token", tokens.refreshToken, { event.cookies.set("refreshToken", tokens.refreshToken, {
httpOnly: true, httpOnly: true,
path: "/", path: "/",
}); });
@@ -1,12 +1,12 @@
<script lang="ts"> <script lang="ts">
import { fetchAuthRedirectUri } from "$lib/authUri"; import { optima } from "$lib";
import { PUBLIC_API_URL } from "$env/static/public"; import { PUBLIC_API_URL } from "$env/static/public";
import { enhance } from "$app/forms"; import { enhance } from "$app/forms";
import { goto } from "$app/navigation"; import LoadingSpinner from "../../../components/LoadingSpinner.svelte";
import LoadingSpinner from "../../components/LoadingSpinner.svelte";
import { writable } from "svelte/store"; import { writable } from "svelte/store";
import { theme } from "$lib/theme";
const uriData = await fetchAuthRedirectUri(PUBLIC_API_URL); const uriData = await optima.auth.fetchAuthRedirectUri(PUBLIC_API_URL);
let loading = writable(false); let loading = writable(false);
function handleSubmit(e: SubmitEvent) { function handleSubmit(e: SubmitEvent) {
@@ -35,6 +35,42 @@
</script> </script>
<div class="container"> <div class="container">
<button
class="theme-toggle login-theme-toggle"
onclick={() => theme.toggle()}
aria-label="Toggle {$theme === 'dark' ? 'light' : 'dark'} mode"
title="Switch to {$theme === 'dark' ? 'light' : 'dark'} mode"
>
<svg
class="theme-icon sun-icon"
class:visible={$theme === "dark"}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="5" />
<line x1="12" y1="1" x2="12" y2="3" />
<line x1="12" y1="21" x2="12" y2="23" />
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
<line x1="1" y1="12" x2="3" y2="12" />
<line x1="21" y1="12" x2="23" y2="12" />
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
</svg>
<svg
class="theme-icon moon-icon"
class:visible={$theme === "light"}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
</button>
<form action="?/login" method="POST" onsubmit={handleSubmit} use:enhance> <form action="?/login" method="POST" onsubmit={handleSubmit} use:enhance>
<input type="hidden" name="callbackKey" value={uriData.callbackKey} /> <input type="hidden" name="callbackKey" value={uriData.callbackKey} />
<button <button
@@ -63,25 +99,39 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: #f3f2f1; background: var(--bg-base);
transition: background 0.2s ease;
} }
.ms-button { .ms-button {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
padding: 12px 18px; padding: 12px 18px;
background: #2f2f2f; background: var(--bg-elevated);
color: white; color: var(--text-primary);
border: none; border: 1px solid var(--border-default);
border-radius: 6px; border-radius: 6px;
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
transition:
background 0.2s ease,
color 0.2s ease,
border-color 0.2s ease;
}
.ms-button:hover:not([disabled]) {
background: var(--card-hover-bg);
border-color: var(--border-strong);
} }
.ms-button[disabled] { .ms-button[disabled] {
opacity: 0.6; opacity: 0.6;
cursor: default; cursor: default;
} }
.login-theme-toggle {
position: absolute;
top: 16px;
right: 16px;
}
.ms-logo { .ms-logo {
width: 20px; width: 20px;
height: 20px; height: 20px;
+69 -152
View File
@@ -1,9 +1,10 @@
<script> <script>
import { page } from "$app/stores";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import "../styles/errorpage.css";
function signOut() { $: status = $page.status || 500;
goto("/logout"); $: message = $page.error?.message || "Something went wrong";
}
function goBack() { function goBack() {
history.back(); history.back();
@@ -11,159 +12,75 @@
</script> </script>
<svelte:head> <svelte:head>
<title>Error — App</title> <title>Error {status} — Project Optima</title>
</svelte:head> </svelte:head>
<header class="header container"> <div class="error-page">
<h1>Error</h1> <div class="error-pane">
<nav> <!-- Pane header -->
<a href="/">Home</a> <div class="error-pane-header">
<button on:click={signOut}>Sign out</button> <h2 class="error-pane-title">Error</h2>
</nav> <span class="error-status-badge">{status}</span>
</header> </div>
<main class="container"> <!-- Pane body -->
<section class="error-section"> <div class="error-pane-body">
<div class="error-box"> <div class="error-illustration">
<h2>Oops! Something went wrong</h2> <svg viewBox="0 0 120 120" width="120" height="120" aria-hidden="true">
<p class="error-message"> <circle
We encountered an error while processing your request. Please try again cx="60"
or contact support if the problem persists. cy="60"
r="56"
fill="var(--error-circle-outer)"
stroke="var(--error-circle-stroke)"
stroke-width="2"
/>
<circle cx="60" cy="60" r="40" fill="var(--error-circle-inner)" />
<path
d="M60 35v30"
stroke="#dc2626"
stroke-width="5"
stroke-linecap="round"
/>
<circle cx="60" cy="78" r="4" fill="#dc2626" />
</svg>
</div>
<h3 class="error-heading">Oops! Something went wrong</h3>
<p class="error-message">{message}</p>
<p class="error-hint">
Please try again or contact support if the problem persists.
</p> </p>
<div class="error-actions"> <div class="error-actions">
<button class="btn btn-primary" on:click={goBack}>Go Back</button> <button class="btn btn-primary" on:click={goBack}>
<a href="/" class="btn btn-secondary">Go Home</a> <svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
>
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
Go Back
</button>
<a href="/" class="btn btn-secondary">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
>
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<polyline points="9 22 9 12 15 12 15 22" />
</svg>
Go Home
</a>
</div> </div>
</div> </div>
</section> </div>
</main> </div>
<footer class="container">
<small>© {new Date().getFullYear()} Your App</small>
</footer>
<style>
:global(body) {
margin: 0;
font-family:
system-ui,
-apple-system,
"Segoe UI",
Roboto,
"Helvetica Neue",
Arial;
background: #f7f7f8;
color: #111;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 1rem;
}
header {
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #e5e7eb;
background: #fff;
}
nav {
display: flex;
gap: 0.5rem;
align-items: center;
}
nav a,
nav button {
padding: 0.5rem 1rem;
text-decoration: none;
color: #0066cc;
border: none;
background: none;
cursor: pointer;
font-size: 1rem;
}
nav a:hover,
nav button:hover {
text-decoration: underline;
}
main {
padding: 2rem 1rem;
}
.error-section {
display: flex;
justify-content: center;
align-items: center;
min-height: 400px;
}
.error-box {
background: white;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 2rem;
max-width: 500px;
text-align: center;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.error-box h2 {
margin: 0 0 1rem;
color: #d32f2f;
font-size: 1.5rem;
}
.error-message {
color: #666;
margin: 1rem 0 2rem;
line-height: 1.6;
}
.error-actions {
display: flex;
gap: 1rem;
justify-content: center;
}
.btn {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
text-decoration: none;
display: inline-block;
transition: all 0.3s ease;
}
.btn-primary {
background: #0066cc;
color: white;
}
.btn-primary:hover {
background: #0052a3;
}
.btn-secondary {
background: #e5e7eb;
color: #111;
}
.btn-secondary:hover {
background: #d1d5db;
}
footer {
text-align: center;
padding: 2rem 1rem;
border-top: 1px solid #e5e7eb;
color: #666;
}
</style>
+21 -9
View File
@@ -1,12 +1,24 @@
import { LayoutServerLoad } from "./$types"; import { optima } from "$lib";
import type { LayoutServerLoad } from "./$types";
export const load: LayoutServerLoad = async ({ locals }) => { export const load: LayoutServerLoad = async ({ locals }) => {
// WARNING: returning tokens to the client exposes them to JavaScript. const accessToken = locals.session?.accessToken ?? null;
// Prefer keeping tokens httpOnly and proxying requests via server endpoints.
return { // Only check permissions if the user is authenticated
session: { if (!accessToken) {
accessToken: locals.session?.accessToken ?? null, return { canViewAdmin: false };
refreshToken: locals.session?.refreshToken ?? null, }
},
}; let canViewAdmin = false;
try {
const permResult = await optima.user.checkPermissions(accessToken, [
"ui.navigation.admin.view",
]);
canViewAdmin = permResult?.data?.results?.[0]?.hasPermission === true;
} catch (err) {
console.error("Admin permission check failed:", err);
canViewAdmin = false;
}
return { canViewAdmin };
}; };
+138
View File
@@ -0,0 +1,138 @@
<script lang="ts">
import { optima } from "$lib";
import { page } from "$app/stores";
import { theme } from "$lib/theme";
const navItems = [
{
href: "/",
label: "Home",
icon: '<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path><polyline points="9 22 9 12 15 12 15 22"></polyline>',
exact: true,
},
{
href: "/companies",
label: "Companies",
icon: '<path d="M3 21h18"></path><path d="M5 21V7l8-4v18"></path><path d="M19 21V11l-6-4"></path><path d="M9 9h1"></path><path d="M9 13h1"></path><path d="M9 17h1"></path>',
},
];
const adminNavItem = {
href: "/admin",
label: "Admin",
icon: '<path d="M12 15v2m-6 4h12a2 2 0 0 0 2-2v-6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2zm10-10V7a4 4 0 0 0-8 0v4h8z"></path>',
};
$: canViewAdmin = $page.data?.canViewAdmin === true;
function isActive(pathname: string, item: (typeof navItems)[0]) {
return item.exact ? pathname === item.href : pathname.startsWith(item.href);
}
</script>
{#if $page.route.id?.startsWith("/(auth)")}
<slot />
{:else}
<div class="layout-container">
<header class="header">
<div class="header-content">
<h1>Project Optima</h1>
<button
class="theme-toggle"
on:click={() => theme.toggle()}
aria-label="Toggle {$theme === 'dark' ? 'light' : 'dark'} mode"
title="Switch to {$theme === 'dark' ? 'light' : 'dark'} mode"
>
<svg
class="theme-icon sun-icon"
class:visible={$theme === "dark"}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="5" />
<line x1="12" y1="1" x2="12" y2="3" />
<line x1="12" y1="21" x2="12" y2="23" />
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
<line x1="1" y1="12" x2="3" y2="12" />
<line x1="21" y1="12" x2="23" y2="12" />
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
</svg>
<svg
class="theme-icon moon-icon"
class:visible={$theme === "light"}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
</button>
</div>
</header>
<div class="layout-wrapper">
<aside class="sidebar">
<nav class="sidebar-nav">
{#each navItems as item}
<a
href={item.href}
class="nav-item {isActive($page.url.pathname, item)
? 'active'
: ''}"
title={item.label}
>
<svg
class="nav-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
{@html item.icon}
</svg>
<span class="nav-label">{item.label}</span>
</a>
{/each}
{#if canViewAdmin}
<hr class="nav-divider" />
<a
href={adminNavItem.href}
class="nav-item {$page.url.pathname.startsWith('/admin')
? 'active'
: ''}"
title={adminNavItem.label}
>
<svg
class="nav-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
{@html adminNavItem.icon}
</svg>
<span class="nav-label">{adminNavItem.label}</span>
</a>
{/if}
</nav>
</aside>
<div class="content-area">
<div class="accent-bar"></div>
<main class="main-content">
<slot />
</main>
</div>
</div>
<footer class="footer">
<small>&copy; {new Date().getFullYear()} Total Tech Solutions, LLC</small>
</footer>
</div>
{/if}
+3 -1
View File
@@ -1 +1,3 @@
import "../app.css"; import "../styles/app.css";
import "../styles/layout.css";
import "../styles/errorpage.css";
+4 -113
View File
@@ -1,120 +1,11 @@
<script> <script lang="ts">
import { goto } from "$app/navigation"; // You can add any JavaScript logic here if needed
function signOut() {
goto("/logout");
}
</script> </script>
<svelte:head> <svelte:head>
<title>Home — App</title> <title>Home — App</title>
</svelte:head> </svelte:head>
<header class="header container"> <main>
<h1>App Home</h1> <h1>Home Page</h1>
<nav>
<a href="/companies">Companies</a>
<a href="/projects">Projects</a>
<a href="/settings">Settings</a>
<a href="/profile">Profile</a>
<a href="/admin">Admin</a>
<button on:click={signOut}>Sign out</button>
</nav>
</header>
<main class="container">
<section class="hero">
<h2>Welcome back</h2>
<p>
This is your protected home page. Quick links and recent activity appear
below.
</p>
</section>
<section class="grid">
<article class="card">
<h3>Quick actions</h3>
<ul>
<li><a href="/projects/new">Create project</a></li>
<li><a href="/profile/edit">Edit profile</a></li>
</ul>
</article>
<article class="card">
<h3>Recent activity</h3>
<p>No recent activity.</p>
</article>
</section>
</main> </main>
<footer class="container">
<small>© {new Date().getFullYear()} Your App</small>
</footer>
<style>
:global(body) {
margin: 0;
font-family:
system-ui,
-apple-system,
"Segoe UI",
Roboto,
"Helvetica Neue",
Arial;
background: #f7f7f8;
color: #111;
}
.container {
max-width: 960px;
margin: 0 auto;
padding: 1rem;
}
header {
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #e5e7eb;
background: #fff;
}
nav {
display: flex;
gap: 0.5rem;
align-items: center;
}
nav a {
color: #0366d6;
text-decoration: none;
padding: 0.25rem 0.5rem;
}
nav button {
background: #ef4444;
color: #fff;
border: 0;
padding: 0.4rem 0.6rem;
border-radius: 6px;
cursor: pointer;
}
main {
padding: 1.5rem 0;
}
.hero h2 {
margin: 0 0 0.5rem 0;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1rem;
margin-top: 1rem;
}
.card {
background: #fff;
border: 1px solid #e5e7eb;
padding: 1rem;
border-radius: 8px;
}
footer {
text-align: center;
padding: 1rem 0;
color: #6b7280;
}
</style>
+35 -3
View File
@@ -1,8 +1,40 @@
import { optima } from "$lib";
import { handleApiError } from "$lib/optima-api/errorHandler";
import { checkPermissions, type PermissionMap } from "$lib/permissions";
import { redirect } from "@sveltejs/kit";
import type { LayoutServerLoad } from "./$types"; import type { LayoutServerLoad } from "./$types";
export const load: LayoutServerLoad = async ({ params, parent }) => { export const load: LayoutServerLoad = async ({ locals }) => {
const { session } = await parent(); const accessToken = locals.session?.accessToken;
if (!accessToken) {
throw redirect(303, "/login");
}
try {
// Check the top-level admin gate + all per-tab permissions in one call
const permissions = await checkPermissions(accessToken, [
"ui.navigation.admin.view",
"admin.users.view",
"admin.roles.view",
"admin.credential-types.view",
]);
if (!permissions["ui.navigation.admin.view"]) {
throw redirect(303, "/");
}
// Fetch current user info for the dashboard greeting
const userInfo = await optima.user.fetchInfo(accessToken);
return { return {
accessToken: session.accessToken, user: userInfo?.data ?? null,
permissions,
}; };
} catch (err) {
// Re-throw redirects so SvelteKit handles them
if (err && typeof err === "object" && "status" in err) {
throw err;
}
handleApiError(err);
}
}; };
+105
View File
@@ -0,0 +1,105 @@
<script lang="ts">
import { page } from "$app/stores";
import "../../styles/admin.css";
import type { PermissionMap } from "$lib/permissions";
export let data: {
user: {
id: string;
name?: string;
email?: string;
[key: string]: unknown;
} | null;
permissions: PermissionMap;
};
$: permissions = data.permissions;
$: userName = data.user?.name || "Admin";
// Tab definitions — each gated by a permission
const allTabs = [
{ label: "Overview", href: "/admin", exact: true, permission: null },
{
label: "Users",
href: "/admin/users",
exact: false,
permission: "admin.users.view",
},
{
label: "Roles",
href: "/admin/roles",
exact: false,
permission: "admin.roles.view",
},
{
label: "Credential Types",
href: "/admin/credential-types",
exact: false,
permission: "admin.credential-types.view",
},
] as const;
// Only show tabs the user has permission for
$: visibleTabs = allTabs.filter(
(t) => t.permission === null || permissions[t.permission] === true,
);
function isActive(
tab: { href: string; exact?: boolean },
pathname: string,
): boolean {
if (tab.exact) return pathname === tab.href;
return pathname.startsWith(tab.href);
}
</script>
<svelte:head>
<title>Admin — Project Optima</title>
</svelte:head>
<div class="admin-page">
<div class="admin-pane">
<!-- Pane header -->
<div class="admin-header">
<div class="admin-header-left">
<svg
class="admin-header-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="3" />
<path
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"
/>
</svg>
<h2 class="admin-title">Administration</h2>
<span class="admin-subtitle">Welcome back, {userName}</span>
</div>
</div>
<!-- Tab bar -->
<div class="tab-bar" role="tablist">
{#each visibleTabs as tab}
<a
href={tab.href}
class="tab-btn"
class:active={isActive(tab, $page.url.pathname)}
role="tab"
aria-selected={isActive(tab, $page.url.pathname)}
>
{tab.label}
</a>
{/each}
</div>
<!-- Tab content -->
<div class="admin-body">
<slot />
</div>
</div>
</div>
+20
View File
@@ -0,0 +1,20 @@
import { optima } from "$lib";
import { handleApiError } from "$lib/optima-api/errorHandler";
import type { PageServerLoad } from "./$types";
export const load: PageServerLoad = async ({ locals }) => {
const accessToken = locals.session?.accessToken;
if (!accessToken) {
return { companyCount: null };
}
try {
const companyCount = await optima.company.count(accessToken);
return {
companyCount: companyCount ?? null,
};
} catch (err) {
handleApiError(err);
}
};
+128 -41
View File
@@ -1,49 +1,136 @@
<script> <script lang="ts">
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
export let data: {
companyCount: number | null;
};
$: companyCount = data.companyCount;
const quickActions = [
{
href: "/companies",
name: "Companies",
desc: "View and manage companies",
icon: '<path d="M3 21h18"></path><path d="M5 21V7l8-4v18"></path><path d="M19 21V11l-6-4"></path><path d="M9 9h1"></path><path d="M9 13h1"></path><path d="M9 17h1"></path>',
},
{
href: "/admin/users",
name: "Manage Users",
desc: "View, edit, and assign user roles",
icon: '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path>',
},
{
href: "/admin/roles",
name: "Manage Roles",
desc: "Configure roles and permissions",
icon: '<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path>',
},
{
href: "/admin/credential-types",
name: "Credential Types",
desc: "Configure credential type definitions",
icon: '<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect><path d="M7 11V7a5 5 0 0 1 10 0v4"></path>',
},
];
</script> </script>
<svelte:head> <!-- Stats overview -->
<title>Admin Dashboard — App</title> <div class="stats-grid">
</svelte:head> <div class="stat-card">
<div class="stat-icon">
<svg viewBox="0 0 24 24" fill="none" stroke-width="2">
<path d="M3 21h18" />
<path d="M5 21V7l8-4v18" />
<path d="M19 21V11l-6-4" />
</svg>
</div>
<div class="stat-info">
<span class="stat-value">{companyCount ?? "—"}</span>
<span class="stat-label">Companies</span>
</div>
</div>
<header class="header container"> <div class="stat-card">
<h1>Admin Dashboard</h1> <div class="stat-icon">
<nav> <svg viewBox="0 0 24 24" fill="none" stroke-width="2">
<a href="/">Home</a> <path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<a href="/admin/credential-types">Credential Types</a> <circle cx="9" cy="7" r="4" />
<button on:click={() => goto("/")}>Back</button> <path d="M23 21v-2a4 4 0 0 0-3-3.87" />
</nav> <path d="M16 3.13a4 4 0 0 1 0 7.75" />
</header> </svg>
</div>
<div class="stat-info">
<span class="stat-value"></span>
<span class="stat-label">Users</span>
</div>
</div>
<main class="container"> <div class="stat-card">
<section class="hero"> <div class="stat-icon">
<h2>Administration</h2> <svg viewBox="0 0 24 24" fill="none" stroke-width="2">
<p>Manage system settings and configurations.</p> <rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
</section> <path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
</div>
<div class="stat-info">
<span class="stat-value"></span>
<span class="stat-label">Credentials</span>
</div>
</div>
<section class="grid"> <div class="stat-card">
<article class="card"> <div class="stat-icon">
<h3>Credential Types</h3> <svg viewBox="0 0 24 24" fill="none" stroke-width="2">
<p>Create and manage credential type definitions.</p> <polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />
<button on:click={() => goto("/admin/credential-types")}> </svg>
Manage Credential Types </div>
</button> <div class="stat-info">
</article> <span class="stat-value"></span>
</section> <span class="stat-label">Activity Today</span>
</main> </div>
</div>
</div>
<style> <!-- Quick actions -->
button { <h3 class="section-heading">Quick Actions</h3>
margin-top: 1rem; <div class="actions-grid">
padding: 0.5rem 1rem; {#each quickActions as action}
background-color: #0066cc; <a
color: white; href={action.href}
border: none; class="action-card"
border-radius: 4px; on:click|preventDefault={() => goto(action.href)}
cursor: pointer; >
} <div class="action-icon">
<svg viewBox="0 0 24 24" fill="none" stroke-width="2">
{@html action.icon}
</svg>
</div>
<div class="action-text">
<span class="action-name">{action.name}</span>
<span class="action-desc">{action.desc}</span>
</div>
<svg
class="action-arrow"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M5 12h14M12 5l7 7-7 7" />
</svg>
</a>
{/each}
</div>
button:hover { <!-- Recent activity placeholder -->
background-color: #0052a3; <h3 class="section-heading">Recent Activity</h3>
} <div class="activity-section">
</style> <div class="activity-empty">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
<span>Activity feed coming soon</span>
</div>
</div>
@@ -1,11 +1,184 @@
import type { PageServerLoad } from "./$types"; import { optima } from "$lib";
import { credentialType } from "$lib/credentialTypes"; import { handleApiError } from "$lib/optima-api/errorHandler";
import { checkPermissions } from "$lib/permissions";
import { fail } from "@sveltejs/kit";
import type { Actions, PageServerLoad } from "./$types";
import { AxiosError } from "axios";
export const load: PageServerLoad = async ({ locals }) => {
const accessToken = locals.session?.accessToken;
if (!accessToken) {
return { credentialTypes: [], permissions: {}, valueTypes: [] };
}
try {
const [typesResult, permissions, valueTypesResult] = await Promise.all([
optima.credentialType.fetchMany(accessToken),
checkPermissions(accessToken, [
"admin.credential-types.view",
"admin.credential-types.create",
"admin.credential-types.edit",
"admin.credential-types.delete",
]),
optima.credential.fetchValueTypes(accessToken).catch((err) => {
console.error(
"Failed to fetch value types:",
err?.response?.data ?? err?.message ?? err,
);
return { data: [] };
}),
]);
const credentialTypes = typesResult?.data ?? [];
const valueTypes: string[] = valueTypesResult?.data ?? [];
export const load: PageServerLoad = async ({ params, parent }) => {
const { session } = await parent();
const response = await credentialType.fetchMany(session.accessToken ?? "");
return { return {
credentialTypes: response.data || [], credentialTypes,
accessToken: session.accessToken, permissions,
valueTypes,
}; };
} catch (err) {
handleApiError(err);
}
};
export const actions: Actions = {
createCredentialType: async ({ locals, request }) => {
const accessToken = locals.session?.accessToken;
if (!accessToken) {
return fail(401, { message: "Not authenticated." });
}
const formData = await request.formData();
const name = (formData.get("name") as string)?.trim();
const permissionScope = (formData.get("permissionScope") as string)?.trim();
const icon = (formData.get("icon") as string)?.trim() || undefined;
const fieldsJson = (formData.get("fields") as string)?.trim();
if (!name || !permissionScope) {
return fail(400, { message: "Name and permission scope are required." });
}
let fields: Array<
import("$lib/optima-api/modules/credentialTypes").CredentialTypeField
> = [];
if (fieldsJson) {
try {
fields = JSON.parse(fieldsJson);
} catch {
return fail(400, { message: "Invalid fields data." });
}
}
try {
await optima.credentialType.create(accessToken, {
name,
permissionScope,
icon,
fields,
});
return {};
} catch (err: unknown) {
console.log(
"Error creating credential type:",
(err as AxiosError<{ error?: string }>)?.response?.data?.error,
);
const data = (err as AxiosError)?.response?.data as
| Record<string, unknown>
| undefined;
const message =
(data?.message as string) ??
(err instanceof Error
? err.message
: "Failed to create credential type.");
const status = (data?.status as number) ?? 500;
return fail(status, { message });
}
},
updateCredentialType: async ({ locals, request }) => {
const accessToken = locals.session?.accessToken;
if (!accessToken) {
return fail(401, { message: "Not authenticated." });
}
const formData = await request.formData();
const id = (formData.get("id") as string)?.trim();
const name = (formData.get("name") as string)?.trim();
const permissionScope = (formData.get("permissionScope") as string)?.trim();
const icon = (formData.get("icon") as string)?.trim() || undefined;
const fieldsJson = (formData.get("fields") as string)?.trim();
if (!id || !name || !permissionScope) {
return fail(400, { message: "Required fields are missing." });
}
let fields:
| Array<
Omit<
import("$lib/optima-api/modules/credentialTypes").CredentialTypeField,
"id"
>
>
| undefined;
if (fieldsJson) {
try {
fields = JSON.parse(fieldsJson);
} catch {
return fail(400, { message: "Invalid fields data." });
}
}
try {
await optima.credentialType.update(accessToken, id, {
name,
permissionScope,
icon,
fields: fields as any,
});
return {};
} catch (err: unknown) {
const data = (err as AxiosError)?.response?.data as
| Record<string, unknown>
| undefined;
const message =
(data?.message as string) ??
(err instanceof Error
? err.message
: "Failed to update credential type.");
const status = (data?.status as number) ?? 500;
return fail(status, { message });
}
},
deleteCredentialType: async ({ locals, request }) => {
const accessToken = locals.session?.accessToken;
if (!accessToken) {
return fail(401, { message: "Not authenticated." });
}
const formData = await request.formData();
const id = (formData.get("id") as string)?.trim();
if (!id) {
return fail(400, { message: "Credential type ID is required." });
}
try {
await optima.credentialType.delete(accessToken, id);
return {};
} catch (err: unknown) {
const data = (err as AxiosError)?.response?.data as
| Record<string, unknown>
| undefined;
const message =
(data?.message as string) ??
(err instanceof Error
? err.message
: "Failed to delete credential type.");
const status = (data?.status as number) ?? 500;
return fail(status, { message });
}
},
}; };
File diff suppressed because it is too large Load Diff
+148
View File
@@ -0,0 +1,148 @@
import { optima } from "$lib";
import { handleApiError } from "$lib/optima-api/errorHandler";
import { checkPermissions } from "$lib/permissions";
import { fail } from "@sveltejs/kit";
import type { Actions, PageServerLoad } from "./$types";
import { AxiosError } from "axios";
export const load: PageServerLoad = async ({ locals }) => {
const accessToken = locals.session?.accessToken;
if (!accessToken) {
return { roles: [], permissions: {}, permissionNodes: {} };
}
try {
const [rolesResult, permissions, permNodesResult] = await Promise.all([
optima.role.fetchMany(accessToken),
checkPermissions(accessToken, [
"admin.roles.view",
"admin.roles.create",
"admin.roles.edit",
"admin.roles.delete",
]),
optima.permission
.fetchCategorized(accessToken)
.catch(() => ({ data: {} })),
]);
const roles = rolesResult?.data ?? [];
// Fetch users for each role in parallel
const rolesWithUsers = await Promise.all(
roles.map(async (role: Record<string, unknown>) => {
try {
const usersResult = await optima.role.fetchUsers(
accessToken,
role.id as string,
);
return { ...role, users: usersResult?.data ?? [] };
} catch {
return { ...role, users: [] };
}
}),
);
return {
roles: rolesWithUsers,
permissions,
permissionNodes: permNodesResult?.data ?? {},
};
} catch (err) {
handleApiError(err);
}
};
export const actions: Actions = {
createRole: async ({ locals, request }) => {
const accessToken = locals.session?.accessToken;
if (!accessToken) {
return fail(401, { message: "Not authenticated." });
}
const formData = await request.formData();
const title = (formData.get("title") as string)?.trim();
const moniker = (formData.get("moniker") as string)?.trim();
const permissions = formData.getAll("permissions") as string[];
if (!title || !moniker) {
return fail(400, { message: "Title and moniker are required." });
}
try {
await optima.role.create(accessToken, { title, moniker, permissions });
return {};
} catch (err: unknown) {
const data = (err as AxiosError)?.response?.data as
| Record<string, unknown>
| undefined;
const message =
(data?.message as string) ??
(err instanceof Error ? err.message : "Failed to create role.");
const status = (data?.status as number) ?? 500;
return fail(status, { message });
}
},
updateRole: async ({ locals, request }) => {
const accessToken = locals.session?.accessToken;
if (!accessToken) {
return fail(401, { message: "Not authenticated." });
}
const formData = await request.formData();
const id = (formData.get("id") as string)?.trim();
const title = (formData.get("title") as string)?.trim();
const moniker = (formData.get("moniker") as string)?.trim();
const permissions = formData.getAll("permissions") as string[];
if (!id || !title || !moniker) {
return fail(400, { message: "Required fields are missing." });
}
try {
await optima.role.update(accessToken, id, {
title,
moniker,
permissions,
});
return {};
} catch (err: unknown) {
const data = (err as AxiosError)?.response?.data as
| Record<string, unknown>
| undefined;
const message =
(data?.message as string) ??
(err instanceof Error ? err.message : "Failed to update role.");
const status = (data?.status as number) ?? 500;
return fail(status, { message });
}
},
deleteRole: async ({ locals, request }) => {
const accessToken = locals.session?.accessToken;
if (!accessToken) {
return fail(401, { message: "Not authenticated." });
}
const formData = await request.formData();
const id = (formData.get("id") as string)?.trim();
if (!id) {
return fail(400, { message: "Role ID is required." });
}
try {
await optima.role.delete(accessToken, id);
return {};
} catch (err: unknown) {
const data = (err as AxiosError)?.response?.data as
| Record<string, unknown>
| undefined;
const message =
(data?.message as string) ??
(err instanceof Error ? err.message : "Failed to delete role.");
const status = (data?.status as number) ?? 500;
return fail(status, { message });
}
},
};
+558
View File
@@ -0,0 +1,558 @@
<script lang="ts">
import { enhance } from "$app/forms";
import type { SubmitFunction } from "@sveltejs/kit";
import type { PermissionMap } from "$lib/permissions";
import type { Role } from "$lib/optima-api/modules/roles";
import type { PermissionsCategorized } from "$lib/optima-api/modules/permissions";
import CreateRoleModal from "../../../components/CreateRoleModal.svelte";
import "../../../styles/admin/roles.css";
interface RoleUser {
id: string;
name: string;
login: string;
roles: string[];
createdAt: string;
updatedAt: string;
}
type RoleWithUsers = Role & { users: RoleUser[] };
export let data: {
permissions: PermissionMap;
roles: RoleWithUsers[];
permissionNodes: PermissionsCategorized;
};
$: hasAccess = data.permissions["admin.roles.view"] === true;
$: canCreate = data.permissions["admin.roles.create"] === true;
$: canEdit = data.permissions["admin.roles.edit"] === true;
$: canDelete = data.permissions["admin.roles.delete"] === true;
$: roles = data.roles;
// Create/edit modal state
let isCreateModalOpen = false;
let roleToEdit: Role | null = null;
function openEdit(r: Role) {
roleToEdit = r;
isCreateModalOpen = true;
openMenuId = null;
}
// Three-dot menu
let openMenuId: string | null = null;
function toggleMenu(id: string) {
openMenuId = openMenuId === id ? null : id;
}
/** Position the dropdown using fixed coordinates so it escapes overflow:hidden parents */
function positionMenu(node: HTMLElement) {
const btn = node.parentElement?.querySelector(
".menu-btn",
) as HTMLElement | null;
if (!btn) return;
function update() {
const rect = btn!.getBoundingClientRect();
node.style.top = `${rect.bottom + 4}px`;
node.style.left = `${rect.right - node.offsetWidth}px`;
}
update();
window.addEventListener("scroll", update, true);
window.addEventListener("resize", update);
return {
destroy() {
window.removeEventListener("scroll", update, true);
window.removeEventListener("resize", update);
},
};
}
// Delete confirmation
let roleToDelete: Role | null = null;
let isDeleting = false;
let deleteError = "";
function openDeleteConfirm(r: Role) {
roleToDelete = r;
openMenuId = null;
}
function cancelDelete() {
roleToDelete = null;
deleteError = "";
}
const handleDeleteEnhance: SubmitFunction = () => {
isDeleting = true;
deleteError = "";
return async ({ result, update }) => {
isDeleting = false;
if (result.type === "success") {
roleToDelete = null;
} else if (result.type === "failure") {
deleteError =
(result.data as { message?: string })?.message ??
"Failed to delete role.";
}
await update();
};
};
// Expanded row state
let expandedRoleId: string | null = null;
function toggleRole(id: string) {
expandedRoleId = expandedRoleId === id ? null : id;
}
function formatDate(dateStr?: string): string {
if (!dateStr) return "";
try {
return new Date(dateStr).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
} catch {
return "";
}
}
</script>
<svelte:window on:click={() => (openMenuId = null)} />
{#if !hasAccess}
<div class="admin-denied">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10" />
<line x1="4.93" y1="4.93" x2="19.07" y2="19.07" />
</svg>
<h3>Access Denied</h3>
<p>
You don't have permission to manage roles. Contact your administrator to
request access.
</p>
</div>
{:else if roles.length === 0}
<CreateRoleModal
isOpen={isCreateModalOpen}
permissionNodes={data.permissionNodes}
{roleToEdit}
onClose={() => {
isCreateModalOpen = false;
roleToEdit = null;
}}
onSuccess={() => {
isCreateModalOpen = false;
roleToEdit = null;
}}
/>
<div class="admin-tab-empty">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
</svg>
<h3>No Roles Found</h3>
<p>
There are no roles configured yet. Create your first role to get started.
</p>
{#if canCreate}
<button
type="button"
class="create-role-btn"
on:click={() => (isCreateModalOpen = true)}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
Create Role
</button>
{/if}
</div>
{:else}
<CreateRoleModal
isOpen={isCreateModalOpen}
permissionNodes={data.permissionNodes}
{roleToEdit}
onClose={() => {
isCreateModalOpen = false;
roleToEdit = null;
}}
onSuccess={() => {
isCreateModalOpen = false;
roleToEdit = null;
}}
/>
{#if roleToDelete}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="confirm-backdrop"
on:click={cancelDelete}
on:keydown={(e) => e.key === "Escape" && cancelDelete()}
>
<div
class="confirm-dialog"
role="alertdialog"
aria-modal="true"
aria-labelledby="confirm-title"
tabindex="-1"
on:click|stopPropagation
on:keydown|stopPropagation
>
<div class="confirm-icon-wrap">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
width="22"
height="22"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6" />
<path d="M10 11v6M14 11v6" />
<path d="M9 6V4a1 1 0 011-1h4a1 1 0 011 1v2" />
</svg>
</div>
<h3 id="confirm-title" class="confirm-title">Delete Role</h3>
<p class="confirm-body">
Are you sure you want to delete
<strong>{roleToDelete.title}</strong>? This action cannot be undone.
</p>
{#if deleteError}
<p class="confirm-error">{deleteError}</p>
{/if}
<div class="confirm-actions">
<button
type="button"
class="btn-cancel"
on:click={cancelDelete}
disabled={isDeleting}
>
Cancel
</button>
<form
method="POST"
action="?/deleteRole"
use:enhance={handleDeleteEnhance}
>
<input type="hidden" name="id" value={roleToDelete.id} />
<button
type="submit"
class="btn-delete-confirm"
disabled={isDeleting}
>
{isDeleting ? "Deleting…" : "Delete Role"}
</button>
</form>
</div>
</div>
</div>
{/if}
<div class="admin-table-header">
<h3>
Roles
<span class="result-count"
>{roles.length} role{roles.length === 1 ? "" : "s"}</span
>
</h3>
{#if canCreate}
<button
type="button"
class="create-role-btn"
on:click={() => (isCreateModalOpen = true)}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
Create Role
</button>
{/if}
</div>
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th>Title</th>
<th>Moniker</th>
<th>Permissions</th>
<th>Users</th>
<th>Created</th>
<th>Updated</th>
<th></th>
</tr>
</thead>
<tbody>
{#each roles as role (role.id)}
<tr
class="role-row"
class:expanded={expandedRoleId === role.id}
on:click={() => toggleRole(role.id)}
>
<td>
<div class="role-title-cell">
<svg
class="role-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
</svg>
<span class="role-title">{role.title}</span>
</div>
</td>
<td>
<span class="role-moniker">{role.moniker}</span>
</td>
<td>
<span class="role-perm-count">
{role.permissions.length} permission{role.permissions.length ===
1
? ""
: "s"}
</span>
</td>
<td>
<span class="role-user-count">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="13"
height="13"
>
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 00-3-3.87" />
<path d="M16 3.13a4 4 0 010 7.75" />
</svg>
{role.users.length} user{role.users.length === 1 ? "" : "s"}
</span>
</td>
<td>{formatDate(role.createdAt)}</td>
<td>{formatDate(role.updatedAt)}</td>
<td class="row-end-cell">
<div class="row-end-content">
{#if role.moniker === "administrator"}
<span class="system-badge">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="11"
height="11"
>
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0110 0v4" />
</svg>
System
</span>
{:else if canEdit || canDelete}
<div class="menu-wrap">
<button
type="button"
class="menu-btn"
aria-label="Role actions"
on:click|stopPropagation={() => toggleMenu(role.id)}
>
<svg
viewBox="0 0 16 16"
fill="currentColor"
width="14"
height="14"
>
<circle cx="8" cy="2.5" r="1.5" />
<circle cx="8" cy="8" r="1.5" />
<circle cx="8" cy="13.5" r="1.5" />
</svg>
</button>
{#if openMenuId === role.id}
<div class="role-menu" use:positionMenu>
{#if canEdit}
<button
type="button"
class="role-menu-item"
on:click|stopPropagation={() => openEdit(role)}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="13"
height="13"
>
<path
d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"
/>
<path
d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"
/>
</svg>
Edit
</button>
{/if}
{#if canEdit && canDelete}
<div class="role-menu-sep"></div>
{/if}
{#if canDelete}
<button
type="button"
class="role-menu-item role-menu-item-danger"
on:click|stopPropagation={() =>
openDeleteConfirm(role)}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="13"
height="13"
>
<polyline points="3 6 5 6 21 6" />
<path
d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6"
/>
<path d="M10 11v6M14 11v6" />
<path d="M9 6V4a1 1 0 011-1h4a1 1 0 011 1v2" />
</svg>
Delete
</button>
{/if}
</div>
{/if}
</div>
{/if}
<svg
class="row-chevron"
class:open={expandedRoleId === role.id}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
>
<path d="M6 9l6 6 6-6" />
</svg>
</div>
</td>
</tr>
{#if expandedRoleId === role.id}
<tr class="role-detail-row">
<td colspan="7">
<div class="role-detail-content">
<div class="role-detail-grid">
<div class="role-detail-section">
<h4 class="role-detail-heading">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<path
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2"
/>
<rect x="9" y="3" width="6" height="4" rx="1" />
</svg>
Permissions
<span class="detail-count"
>{role.permissions.length}</span
>
</h4>
{#if role.permissions.length === 0}
<p class="role-detail-empty">No permissions assigned</p>
{:else}
<div class="permission-tags">
{#each role.permissions as perm}
<span class="permission-tag">{perm}</span>
{/each}
</div>
{/if}
</div>
<div class="role-detail-section">
<h4 class="role-detail-heading">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 00-3-3.87" />
<path d="M16 3.13a4 4 0 010 7.75" />
</svg>
Users
<span class="detail-count">{role.users.length}</span>
</h4>
{#if role.users.length === 0}
<p class="role-detail-empty">
No users assigned to this role
</p>
{:else}
<div class="user-list">
{#each role.users as user (user.id)}
<div class="user-card">
<div class="user-avatar">
{user.name
.split(" ")
.map((n) => n[0])
.join("")
.slice(0, 2)
.toUpperCase()}
</div>
<div class="user-info">
<span class="user-name">{user.name}</span>
<span class="user-login">{user.login}</span>
</div>
</div>
{/each}
</div>
{/if}
</div>
</div>
</div>
</td>
</tr>
{/if}
{/each}
</tbody>
</table>
</div>
{/if}
+143
View File
@@ -0,0 +1,143 @@
import { optima } from "$lib";
import { handleApiError } from "$lib/optima-api/errorHandler";
import { checkPermissions } from "$lib/permissions";
import { fail } from "@sveltejs/kit";
import type { Actions, PageServerLoad } from "./$types";
import { AxiosError } from "axios";
export const load: PageServerLoad = async ({ locals }) => {
const accessToken = locals.session?.accessToken;
if (!accessToken) {
return { users: [], roles: [], permissions: {} };
}
try {
const [usersResult, rolesResult, permissions, permNodesResult] =
await Promise.all([
optima.users.fetchAll(accessToken),
optima.role.fetchMany(accessToken),
checkPermissions(accessToken, [
"admin.users.view",
"admin.users.edit",
"admin.users.delete",
"user.roles.other",
"user.permissions.other",
]),
optima.permission
.fetchCategorized(accessToken)
.catch(() => ({ data: {} })),
]);
const allUsers = usersResult?.data ?? [];
const allRoles = rolesResult?.data ?? [];
// Fetch roles for each user in parallel
const usersWithRoles = await Promise.all(
allUsers.map(async (user) => {
try {
const rolesResult = await optima.users.fetchRoles(
accessToken,
user.id,
);
return { ...user, roleDetails: rolesResult?.data ?? [] };
} catch {
return { ...user, roleDetails: [] };
}
}),
);
return {
users: usersWithRoles,
roles: allRoles,
permissions,
permissionNodes: permNodesResult?.data ?? {},
};
} catch (err) {
handleApiError(err);
}
};
export const actions: Actions = {
updateUser: async ({ locals, request }) => {
const accessToken = locals.session?.accessToken;
if (!accessToken) {
return fail(401, { message: "Not authenticated." });
}
const formData = await request.formData();
const id = (formData.get("id") as string)?.trim();
const name = (formData.get("name") as string)?.trim();
const image = (formData.get("image") as string)?.trim() || undefined;
const rolesJson = (formData.get("roles") as string)?.trim();
const permissionsJson = (formData.get("permissions") as string)?.trim();
if (!id || !name) {
return fail(400, { message: "User ID and name are required." });
}
const updates: {
name: string;
image?: string;
roles?: string[];
permissions?: string[];
} = { name, image };
if (rolesJson) {
try {
updates.roles = JSON.parse(rolesJson);
} catch {
return fail(400, { message: "Invalid roles data." });
}
}
if (permissionsJson) {
try {
updates.permissions = JSON.parse(permissionsJson);
} catch {
return fail(400, { message: "Invalid permissions data." });
}
}
try {
await optima.users.update(accessToken, id, updates);
return {};
} catch (err: unknown) {
const data = (err as AxiosError)?.response?.data as
| Record<string, unknown>
| undefined;
const message =
(data?.message as string) ??
(err instanceof Error ? err.message : "Failed to update user.");
const status = (data?.status as number) ?? 500;
return fail(status, { message });
}
},
deleteUser: async ({ locals, request }) => {
const accessToken = locals.session?.accessToken;
if (!accessToken) {
return fail(401, { message: "Not authenticated." });
}
const formData = await request.formData();
const id = (formData.get("id") as string)?.trim();
if (!id) {
return fail(400, { message: "User ID is required." });
}
try {
await optima.users.delete(accessToken, id);
return {};
} catch (err: unknown) {
const data = (err as AxiosError)?.response?.data as
| Record<string, unknown>
| undefined;
const message =
(data?.message as string) ??
(err instanceof Error ? err.message : "Failed to delete user.");
const status = (data?.status as number) ?? 500;
return fail(status, { message });
}
},
};
+603
View File
@@ -0,0 +1,603 @@
<script lang="ts">
import { enhance } from "$app/forms";
import type { SubmitFunction } from "@sveltejs/kit";
import type { PermissionMap } from "$lib/permissions";
import type { User } from "$lib/optima-api/modules/users";
import type { Role } from "$lib/optima-api/modules/roles";
import type { PermissionsCategorized } from "$lib/optima-api/modules/permissions";
import EditUserModal from "../../../components/EditUserModal.svelte";
import "../../../styles/admin/users.css";
type UserWithRoles = User & { roleDetails: Role[] };
export let data: {
permissions: PermissionMap;
users: UserWithRoles[];
roles: Role[];
permissionNodes: PermissionsCategorized;
};
$: hasAccess = data.permissions["admin.users.view"] === true;
$: canEdit = data.permissions["admin.users.edit"] === true;
$: canDelete = data.permissions["admin.users.delete"] === true;
$: canEditRoles = data.permissions["user.roles.other"] === true;
$: canEditPermissions = data.permissions["user.permissions.other"] === true;
$: users = data.users;
$: allRoles = data.roles;
// Search / filter
let searchQuery = "";
$: filteredUsers = users.filter((u) => {
if (!searchQuery.trim()) return true;
const q = searchQuery.toLowerCase();
return (
u.name.toLowerCase().includes(q) ||
u.email.toLowerCase().includes(q) ||
u.login.toLowerCase().includes(q)
);
});
// Expanded row state
let expandedUserId: string | null = null;
function toggleUser(id: string) {
expandedUserId = expandedUserId === id ? null : id;
}
// Three-dot menu
let openMenuId: string | null = null;
function toggleMenu(id: string) {
openMenuId = openMenuId === id ? null : id;
}
function positionMenu(node: HTMLElement) {
const btn = node.parentElement?.querySelector(
".menu-btn",
) as HTMLElement | null;
if (!btn) return;
function update() {
const rect = btn!.getBoundingClientRect();
node.style.top = `${rect.bottom + 4}px`;
node.style.left = `${rect.right - node.offsetWidth}px`;
}
update();
window.addEventListener("scroll", update, true);
window.addEventListener("resize", update);
return {
destroy() {
window.removeEventListener("scroll", update, true);
window.removeEventListener("resize", update);
},
};
}
// Edit modal state
let editingUser: UserWithRoles | null = null;
function openEdit(u: UserWithRoles) {
editingUser = u;
openMenuId = null;
}
function cancelEdit() {
editingUser = null;
}
// Delete confirmation
let userToDelete: UserWithRoles | null = null;
let isDeleting = false;
let deleteError = "";
function openDeleteConfirm(u: UserWithRoles) {
userToDelete = u;
openMenuId = null;
}
function cancelDelete() {
userToDelete = null;
deleteError = "";
}
const handleDeleteEnhance: SubmitFunction = () => {
isDeleting = true;
deleteError = "";
return async ({ result, update }) => {
isDeleting = false;
if (result.type === "success") {
userToDelete = null;
} else if (result.type === "failure") {
deleteError =
(result.data as { message?: string })?.message ??
"Failed to delete user.";
}
await update();
};
};
function initials(name: string): string {
return name
.split(" ")
.map((n) => n[0])
.join("")
.slice(0, 2)
.toUpperCase();
}
function formatDate(dateStr?: string): string {
if (!dateStr) return "";
try {
return new Date(dateStr).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
} catch {
return "";
}
}
</script>
<svelte:window on:click={() => (openMenuId = null)} />
{#if !hasAccess}
<div class="admin-denied">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10" />
<line x1="4.93" y1="4.93" x2="19.07" y2="19.07" />
</svg>
<h3>Access Denied</h3>
<p>
You don't have permission to manage users. Contact your administrator to
request access.
</p>
</div>
{:else if users.length === 0}
<div class="admin-tab-empty">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
<h3>No Users Found</h3>
<p>There are no users in the system yet.</p>
</div>
{:else}
<!-- Edit user modal -->
{#if editingUser}
<EditUserModal
user={editingUser}
{allRoles}
permissionNodes={data.permissionNodes}
{canEditRoles}
{canEditPermissions}
onClose={cancelEdit}
onSuccess={cancelEdit}
/>
{/if}
<!-- Delete confirmation modal -->
{#if userToDelete}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="confirm-backdrop"
on:click={cancelDelete}
on:keydown={(e) => e.key === "Escape" && cancelDelete()}
>
<div
class="confirm-dialog"
role="alertdialog"
aria-modal="true"
aria-labelledby="confirm-title"
tabindex="-1"
on:click|stopPropagation
on:keydown|stopPropagation
>
<div class="confirm-icon-wrap">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
width="22"
height="22"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6" />
<path d="M10 11v6M14 11v6" />
<path d="M9 6V4a1 1 0 011-1h4a1 1 0 011 1v2" />
</svg>
</div>
<h3 id="confirm-title" class="confirm-title">Delete User</h3>
<p class="confirm-body">
Are you sure you want to delete
<strong>{userToDelete.name}</strong>? This action cannot be undone.
</p>
{#if deleteError}
<p class="confirm-error">{deleteError}</p>
{/if}
<div class="confirm-actions">
<button
type="button"
class="btn-cancel"
on:click={cancelDelete}
disabled={isDeleting}
>
Cancel
</button>
<form
method="POST"
action="?/deleteUser"
use:enhance={handleDeleteEnhance}
>
<input type="hidden" name="id" value={userToDelete.id} />
<button
type="submit"
class="btn-delete-confirm"
disabled={isDeleting}
>
{isDeleting ? "Deleting…" : "Delete User"}
</button>
</form>
</div>
</div>
</div>
{/if}
<div class="admin-table-header">
<h3>
Users
<span class="result-count"
>{filteredUsers.length} user{filteredUsers.length === 1
? ""
: "s"}{#if searchQuery.trim()}
&nbsp;(filtered){/if}</span
>
</h3>
<div class="user-search-wrap">
<svg
class="user-search-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
<input
type="text"
class="user-search-input"
placeholder="Search users…"
bind:value={searchQuery}
/>
</div>
</div>
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th>User</th>
<th>Email</th>
<th>Login</th>
<th>Roles</th>
<th>Created</th>
<th></th>
</tr>
</thead>
<tbody>
{#each filteredUsers as user (user.id)}
<tr
class="user-row"
class:expanded={expandedUserId === user.id}
on:click={() => toggleUser(user.id)}
>
<td>
<div class="user-name-cell">
{#if user.image}
<img
src={user.image}
alt={user.name}
class="user-table-avatar"
/>
{:else}
<div class="user-table-avatar user-table-avatar-initials">
{initials(user.name)}
</div>
{/if}
<span class="user-table-name">{user.name}</span>
</div>
</td>
<td>
<span class="user-email">{user.email}</span>
</td>
<td>
<span class="user-login-mono">{user.login}</span>
</td>
<td>
<span class="user-role-count">
{user.roleDetails.length} role{user.roleDetails.length === 1
? ""
: "s"}
</span>
</td>
<td>{formatDate(user.createdAt)}</td>
<td class="row-end-cell">
<div class="row-end-content">
{#if canEdit || canDelete}
<div class="menu-wrap">
<button
type="button"
class="menu-btn"
aria-label="User actions"
on:click|stopPropagation={() => toggleMenu(user.id)}
>
<svg
viewBox="0 0 16 16"
fill="currentColor"
width="14"
height="14"
>
<circle cx="8" cy="2.5" r="1.5" />
<circle cx="8" cy="8" r="1.5" />
<circle cx="8" cy="13.5" r="1.5" />
</svg>
</button>
{#if openMenuId === user.id}
<div class="user-menu" use:positionMenu>
{#if canEdit}
<button
type="button"
class="user-menu-item"
on:click|stopPropagation={() => openEdit(user)}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="13"
height="13"
>
<path
d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"
/>
<path
d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"
/>
</svg>
Edit
</button>
{/if}
{#if canEdit && canDelete}
<div class="user-menu-sep"></div>
{/if}
{#if canDelete}
<button
type="button"
class="user-menu-item user-menu-item-danger"
on:click|stopPropagation={() =>
openDeleteConfirm(user)}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="13"
height="13"
>
<polyline points="3 6 5 6 21 6" />
<path
d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6"
/>
<path d="M10 11v6M14 11v6" />
<path d="M9 6V4a1 1 0 011-1h4a1 1 0 011 1v2" />
</svg>
Delete
</button>
{/if}
</div>
{/if}
</div>
{/if}
<svg
class="row-chevron"
class:open={expandedUserId === user.id}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
>
<path d="M6 9l6 6 6-6" />
</svg>
</div>
</td>
</tr>
{#if expandedUserId === user.id}
<tr class="user-detail-row">
<td colspan="6">
<div class="user-detail-content">
<div class="user-detail-grid">
<!-- User info section -->
<div class="user-detail-section">
<h4 class="user-detail-heading">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
Details
</h4>
<div class="user-detail-fields">
<div class="user-detail-field">
<span class="detail-label">ID</span>
<span class="detail-value detail-mono">{user.id}</span
>
</div>
<div class="user-detail-field">
<span class="detail-label">Email</span>
<span class="detail-value">{user.email}</span>
</div>
<div class="user-detail-field">
<span class="detail-label">Login</span>
<span class="detail-value detail-mono"
>{user.login}</span
>
</div>
<div class="user-detail-field">
<span class="detail-label">Created</span>
<span class="detail-value"
>{formatDate(user.createdAt)}</span
>
</div>
<div class="user-detail-field">
<span class="detail-label">Updated</span>
<span class="detail-value"
>{formatDate(user.updatedAt)}</span
>
</div>
</div>
</div>
<!-- Roles section -->
<div class="user-detail-section">
<h4 class="user-detail-heading">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<path
d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"
/>
</svg>
Roles
<span class="detail-count"
>{user.roleDetails.length}</span
>
</h4>
{#if user.roleDetails.length === 0}
<p class="user-detail-empty">No roles assigned</p>
{:else}
<div class="user-role-list">
{#each user.roleDetails as role (role.id)}
<div class="user-role-card">
<div class="user-role-card-header">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<path
d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"
/>
</svg>
<span class="user-role-title">{role.title}</span
>
<span class="user-role-moniker"
>{role.moniker}</span
>
</div>
{#if role.permissions.length > 0}
<div class="permission-tags">
{#each role.permissions.slice(0, 8) as perm}
<span class="permission-tag">{perm}</span>
{/each}
{#if role.permissions.length > 8}
<span
class="permission-tag permission-tag-more"
>+{role.permissions.length - 8} more</span
>
{/if}
</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>
<!-- Additional Permissions section -->
<div class="user-detail-section">
<h4 class="user-detail-heading">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<path d="M9 12l2 2 4-4" />
<path
d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z"
/>
</svg>
Additional Permissions
{#if user.permissions?.length}
<span class="detail-count"
>{user.permissions.length}</span
>
{/if}
</h4>
{#if !user.permissions?.length}
<p class="user-detail-empty">
No additional permissions assigned
</p>
{:else}
<div class="permission-tags">
{#each user.permissions as perm}
<span class="permission-tag">{perm}</span>
{/each}
</div>
{/if}
</div>
</div>
</div>
</td>
</tr>
{/if}
{/each}
</tbody>
</table>
</div>
{#if filteredUsers.length === 0 && searchQuery.trim()}
<div class="admin-tab-empty">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
<h3>No Results</h3>
<p>No users match &ldquo;{searchQuery}&rdquo;. Try a different search.</p>
</div>
{/if}
{/if}
+51
View File
@@ -0,0 +1,51 @@
import { optima } from "$lib";
import { handleApiError } from "$lib/optima-api/errorHandler";
import { checkPermissions } from "$lib/permissions";
import type { PageServerLoad } from "./$types";
export const load: PageServerLoad = async ({ locals, url }) => {
const accessToken = locals.session?.accessToken;
if (!accessToken) {
return {
companies: [],
totalPages: 1,
currentPage: 1,
totalRecords: 0,
search: "",
permissions: {},
};
}
const page = Math.max(1, Number(url.searchParams.get("page")) || 1);
const search = url.searchParams.get("search") || "";
try {
const [result, permissions] = await Promise.all([
optima.company.fetchMany(accessToken, page, search).catch((err) => {
console.error(
"Failed to fetch companies:",
err?.response?.data ?? err?.message ?? err,
);
return {
data: [],
meta: {
pagination: { totalPages: 1, currentPage: 1, totalRecords: 0 },
},
};
}),
checkPermissions(accessToken, ["companies.view"]),
]);
return {
companies: result?.data ?? [],
totalPages: result?.meta?.pagination?.totalPages ?? 1,
currentPage: result?.meta?.pagination?.currentPage ?? page,
totalRecords:
result?.meta?.pagination?.totalRecords ?? result?.data?.length ?? 0,
search,
permissions,
};
} catch (err) {
handleApiError(err);
}
};
+366 -504
View File
@@ -1,559 +1,421 @@
<script lang="ts"> <script lang="ts">
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { company } from "$lib"; import { afterNavigate } from "$app/navigation";
import LoadingSpinner from "$lib/../components/LoadingSpinner.svelte"; import type { PermissionMap } from "$lib/permissions";
import ResultsSpinner from "$lib/../components/ResultsSpinner.svelte"; import NoResultsMonkey from "../../components/NoResultsMonkey.svelte";
import ErrorBoundary from "$lib/../components/ErrorBoundary.svelte"; import "../../styles/companies/companylist.css";
export let data; export let data: {
permissions: PermissionMap;
interface Company { companies: Array<{
id: string; id: string;
name: string; name: string;
cw_CompanyId: number; status?: string;
cw_Identifier: string; type?: string;
createdAt: string; createdAt?: string;
updatedAt: string; identifier?: string;
} contactEmail?: string;
[key: string]: unknown;
interface ApiResponse { }>;
status: number;
message: string;
data: Company[];
successful: boolean;
meta: {
timestamp: number;
pagination: {
previousPage: number | null;
currentPage: number;
nextPage: number | null;
totalPages: number; totalPages: number;
currentPage: number;
totalRecords: number; totalRecords: number;
listedRecords: number; search: string;
};
}; };
$: hasAccess = data.permissions["companies.view"] === true;
let searchInput = data.search;
let debounceTimer: ReturnType<typeof setTimeout>;
let isSearching = false;
let searchInputEl: HTMLInputElement;
let searchStartedAt = 0;
// When navigation completes (results loaded), clear loading & refocus
// Ensure spinner stays visible for at least 500ms
afterNavigate(() => {
const elapsed = Date.now() - searchStartedAt;
const remaining = Math.max(0, 500 - elapsed);
setTimeout(() => {
isSearching = false;
if (searchInputEl && document.activeElement !== searchInputEl) {
requestAnimationFrame(() => searchInputEl?.focus());
}
}, remaining);
});
$: currentPage = data.currentPage;
$: totalPages = data.totalPages;
$: totalRecords = data.totalRecords;
$: companies = data.companies;
function navigateToPage(p: number) {
const params = new URLSearchParams();
params.set("page", String(p));
if (searchInput) params.set("search", searchInput);
goto(`/companies?${params.toString()}`);
} }
let companies: Company[] = []; function handleSearch() {
let totalPages = 0; isSearching = true;
let currentPage = 1; searchStartedAt = Date.now();
let totalRecords = 0; clearTimeout(debounceTimer);
let isLoading = true; debounceTimer = setTimeout(() => {
let error: string | null = null; const params = new URLSearchParams();
let errorDetails: unknown = null; params.set("page", "1");
let isResultsLoading = false; if (searchInput) params.set("search", searchInput);
let searchQuery = ""; goto(`/companies?${params.toString()}`);
let searchTimeout: ReturnType<typeof setTimeout> | null = null; }, 300);
const itemsPerPage = 30;
async function loadCompanies(page: number = 1, search?: string) {
// If caller provided a `search` argument (even empty string), treat this
// as a results-only refresh and show the inline results loader. Only
// show the full-page loader when `search` is not provided (initial load).
if (search !== undefined) {
isResultsLoading = true;
} else {
isLoading = true;
} }
error = null;
errorDetails = null; function handleKeydown(e: KeyboardEvent) {
if (e.key === "Enter") {
isSearching = true;
searchStartedAt = Date.now();
clearTimeout(debounceTimer);
const params = new URLSearchParams();
params.set("page", "1");
if (searchInput) params.set("search", searchInput);
goto(`/companies?${params.toString()}`);
}
}
function formatDate(dateStr?: string): string {
if (!dateStr) return "";
try { try {
if (!data.session.accessToken) { return new Date(dateStr).toLocaleDateString("en-US", {
throw new Error("No access token available. Please log in again."); month: "short",
} day: "numeric",
year: "numeric",
const response = await company.fetchMany( });
data.session.accessToken, } catch {
page, return "";
search,
);
if (response && response.data && Array.isArray(response.data)) {
companies = response.data;
totalRecords =
response.meta?.pagination?.totalRecords || response.data.length;
totalPages =
response.meta?.pagination?.totalPages ||
Math.ceil(companies.length / itemsPerPage);
currentPage = page;
} else {
throw new Error(
response?.message ||
"Failed to load companies: Invalid response format",
);
}
} catch (err) {
console.error("Failed to fetch companies:", err);
errorDetails = err;
if (err instanceof Error) {
if (
err.message.includes("401") ||
err.message.includes("Unauthorized")
) {
error = "Your session has expired. Please log in again.";
} else if (
err.message.includes("403") ||
err.message.includes("Forbidden")
) {
error = "You don't have permission to view companies.";
} else if (err.message.includes("Network")) {
error = "Network error. Please check your connection and try again.";
} else {
error = err.message || "Error loading companies. Please try again.";
}
} else {
error = "An unexpected error occurred. Please try again.";
}
} finally {
isLoading = false;
isResultsLoading = false;
} }
} }
$: displayedCompanies = function statusClass(status?: string): string {
searchQuery.trim().length > 0 if (!status) return "neutral";
? companies const s = status.toLowerCase();
: companies.filter( if (s === "active") return "active";
(c) => if (s === "inactive" || s === "disabled") return "inactive";
c.name.toLowerCase().includes(searchQuery.toLowerCase()) || if (s === "pending") return "pending";
c.cw_Identifier.toLowerCase().includes(searchQuery.toLowerCase()) || return "neutral";
c.cw_CompanyId.toString().includes(searchQuery),
);
function goToPage(page: number) {
if (page >= 1 && page <= totalPages) {
loadCompanies(page, searchQuery.trim() || undefined);
}
} }
function signOut() { function companyInitials(name: string): string {
goto("/logout"); return name
.split(/\s+/)
.slice(0, 2)
.map((w) => w[0])
.join("")
.toUpperCase();
} }
function retryLoad() { // Generate visible page numbers with ellipsis
loadCompanies(currentPage, searchQuery.trim() || undefined); function getPageNumbers(current: number, total: number): (number | "...")[] {
if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1);
const pages: (number | "...")[] = [];
pages.push(1);
if (current > 3) pages.push("...");
const start = Math.max(2, current - 1);
const end = Math.min(total - 1, current + 1);
for (let i = start; i <= end; i++) pages.push(i);
if (current < total - 2) pages.push("...");
pages.push(total);
return pages;
} }
// Load companies on component mount $: pageNumbers = getPageNumbers(currentPage, totalPages);
loadCompanies();
function onSearchInput() {
// Show inline spinner immediately while the user is typing.
isResultsLoading = true;
if (searchTimeout) clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
// Pass an explicit `search` arg (may be empty string) so loadCompanies
// treats this as a results-only refresh instead of a full-page reload.
loadCompanies(1, searchQuery.trim());
}, 500);
}
</script> </script>
<svelte:head> <svelte:head>
<title>Companies — App</title> <title>Companies — Project Optima</title>
</svelte:head> </svelte:head>
<header class="header container"> {#if !hasAccess}
<h1>Companies</h1> <div class="access-denied">
<nav> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<a href="/">Home</a> <circle cx="12" cy="12" r="10" />
<a href="/settings">Settings</a> <line x1="4.93" y1="4.93" x2="19.07" y2="19.07" />
<a href="/profile">Profile</a> </svg>
<button on:click={signOut}>Sign out</button> <h3>Access Denied</h3>
</nav> <p>
</header> You don't have permission to view Companies. Contact your administrator to
request access.
<main class="container"> </p>
<section class="hero"> </div>
<h2>Company Directory</h2> {:else}
{#if isLoading} <div class="companies-page">
<LoadingSpinner loading={true} /> <div class="companies-pane">
{:else if error} <!-- Pane header -->
<ErrorBoundary <div class="pane-header">
title="Failed to Load Companies" <div class="pane-header-left">
message={error} <h2 class="page-title">Companies</h2>
details={errorDetails} {#if totalRecords > 0}
/> <span class="result-count"
{:else} >{totalRecords} record{totalRecords === 1 ? "" : "s"}</span
<p>Browse all companies. Total: {totalRecords} companies</p> >
{/if} {/if}
</section> </div>
<div class="search-bar">
{#if !isLoading && !error} <svg
<section class="search-section"> class="search-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="11" cy="11" r="8" />
<path d="M21 21l-4.35-4.35" />
</svg>
<input <input
type="text" type="text"
placeholder="Search companies by name, ID, or identifier..." placeholder="Search companies"
bind:value={searchQuery} bind:this={searchInputEl}
on:input={onSearchInput} bind:value={searchInput}
class="search-bar" on:input={handleSearch}
on:keydown={handleKeydown}
/> />
{#if searchQuery} {#if searchInput}
<p class="search-results"> <button
Found {displayedCompanies.length} company/companies matching "{searchQuery}" class="search-clear"
</p> on:click={() => {
searchInput = "";
handleSearch();
}}
aria-label="Clear search"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
{/if} {/if}
</section> </div>
</div>
{#if displayedCompanies.length > 0} <!-- Pane body -->
<section class="companies-grid"> <div class="pane-body">
{#if isResultsLoading} {#if isSearching}
<div class="results-loader"> <div class="search-loading-overlay">
<ResultsSpinner size={40} /> <div class="search-spinner"></div>
</div>
{/if}
{#if companies.length === 0}
<div class="empty-state">
<NoResultsMonkey
message={searchInput
? "No companies match your search"
: "No companies found"}
/>
</div> </div>
{:else} {:else}
{#each displayedCompanies as comp (comp.id)} <div class="card-grid">
<div {#each companies as company (company.id)}
<button
class="company-card" class="company-card"
role="link" on:click={() => goto(`/companies/${company.id}`)}
tabindex="0"
on:click={() => goto(`/companies/${comp.id}`)}
on:keydown={(e) => { on:keydown={(e) => {
if (e.key === "Enter" || e.key === " ") { if (e.key === "Enter") goto(`/companies/${company.id}`);
e.preventDefault();
goto(`/companies/${comp.id}`);
}
}} }}
> >
<h3>{comp.name}</h3> <!-- Card header: avatar + status -->
<dl> <div class="card-top">
<dt>CW Company ID</dt> <div class="card-avatar">
<dd>{comp.cw_CompanyId}</dd> <span class="avatar-initials"
<dt>CW Identifier</dt> >{companyInitials(company.name)}</span
<dd>{comp.cw_Identifier}</dd> >
<dt>Created</dt>
<dd>{new Date(comp.createdAt).toLocaleDateString()}</dd>
</dl>
<span class="view-link">View Details</span>
</div> </div>
{/each} <span
{/if} class="status-dot {statusClass(company.status)}"
</section> title={company.status || "Unknown"}
{:else} ></span>
<section class="companies-grid">
<p class="no-results">No companies found</p>
</section>
{/if}
{#if totalPages > 1 && !isResultsLoading}
<section class="pagination">
<button
on:click={() => goToPage(1)}
disabled={currentPage === 1}
class="pagination-btn"
>
⟨⟨ First
</button>
<button
on:click={() => goToPage(currentPage - 1)}
disabled={currentPage === 1}
class="pagination-btn"
>
← Previous
</button>
<div class="page-numbers">
{#each Array.from({ length: Math.min(totalPages, 5) }, (_, i) => {
const startPage = Math.max(1, currentPage - 2);
return startPage + i;
}) as page}
{#if page <= totalPages}
<button
on:click={() => goToPage(page)}
class="page-number {page === currentPage ? 'active' : ''}"
>
{page}
</button>
{/if}
{/each}
</div> </div>
<button <!-- Card body -->
on:click={() => goToPage(currentPage + 1)} <div class="card-body">
disabled={currentPage === totalPages} <h3 class="card-name">{company.name}</h3>
class="pagination-btn" {#if company.contactEmail}
> <span class="card-email">{company.contactEmail}</span>
Next → {/if}
</button> </div>
<button <!-- Card meta -->
on:click={() => goToPage(totalPages)} <div class="card-meta">
disabled={currentPage === totalPages} {#if company.type}
class="pagination-btn" <div class="meta-item">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class="meta-icon"
> >
Last ⟩⟩ <rect
</button> x="2"
y="7"
width="20"
height="14"
rx="2"
ry="2"
/>
<path d="M16 3h-8l-2 4h12z" />
</svg>
<span>{company.type}</span>
</div>
{/if}
{#if company.identifier || company.id}
<div class="meta-item">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class="meta-icon"
>
<path d="M4 9h16M4 15h16M10 3L8 21M16 3l-2 18" />
</svg>
<span class="mono"
>{company.identifier || company.id?.slice(0, 8)}</span
>
</div>
{/if}
</div>
<!-- Card footer -->
<div class="card-footer">
{#if company.status}
<span class="status-label {statusClass(company.status)}"
>{company.status}</span
>
{/if}
{#if formatDate(company.createdAt)}
<span class="card-date"
>{formatDate(company.createdAt)}</span
>
{/if}
</div>
<!-- Hover arrow -->
<svg
class="card-arrow"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M5 12h14M12 5l7 7-7 7" />
</svg>
</button>
{/each}
</div>
{/if}
</div>
<!-- Pane footer / Pagination -->
{#if totalPages > 1}
<div class="pane-footer">
<span class="page-info"> <span class="page-info">
Page {currentPage} of {totalPages} Page {currentPage} of {totalPages}
</span> </span>
</section> <nav class="pagination" aria-label="Pagination">
{/if} <button
{/if} class="page-btn"
</main> disabled={currentPage <= 1}
on:click={() => navigateToPage(currentPage - 1)}
aria-label="Previous page"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
>
<path d="M15 18l-6-6 6-6" />
</svg>
</button>
<footer class="container"> {#each pageNumbers as p}
<small>© {new Date().getFullYear()} Your App</small> {#if p === "..."}
</footer> <span class="page-ellipsis"></span>
{:else}
<button
class="page-btn"
class:active={p === currentPage}
on:click={() => navigateToPage(p)}
aria-current={p === currentPage ? "page" : undefined}
>
{p}
</button>
{/if}
{/each}
<button
class="page-btn"
disabled={currentPage >= totalPages}
on:click={() => navigateToPage(currentPage + 1)}
aria-label="Next page"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
>
<path d="M9 18l6-6-6-6" />
</svg>
</button>
</nav>
</div>
{/if}
</div>
</div>
{/if}
<style> <style>
:global(body) { .access-denied {
margin: 0;
font-family:
system-ui,
-apple-system,
"Segoe UI",
Roboto,
"Helvetica Neue",
Arial;
background: #f7f7f8;
color: #111;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 1rem;
}
header {
display: flex; display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #e5e7eb;
background: #fff;
}
nav {
display: flex;
gap: 0.5rem;
align-items: center;
}
nav a {
color: #0366d6;
text-decoration: none;
padding: 0.25rem 0.5rem;
}
nav button {
background: #ef4444;
color: #fff;
border: 0;
padding: 0.4rem 0.6rem;
border-radius: 6px;
cursor: pointer;
}
main {
padding: 1.5rem 0;
}
.hero h2 {
margin: 0 0 0.5rem 0;
}
.hero p {
margin: 0;
color: #6b7280;
}
.search-results {
color: #6b7280;
margin-top: 0.5rem;
font-size: 0.9rem;
}
.no-results {
color: #9ca3af;
text-align: center;
padding: 2rem;
grid-column: 1 / -1;
}
.search-section {
margin: 1.5rem 0;
}
.search-bar {
width: 100%;
padding: 0.75rem 1rem;
font-size: 1rem;
border: 1px solid #e5e7eb;
border-radius: 6px;
background: #fff;
color: #111;
transition:
border-color 0.2s,
box-shadow 0.2s;
}
.search-bar:focus {
outline: none;
border-color: #0366d6;
box-shadow: 0 0 0 3px rgba(3, 102, 214, 0.1);
}
.search-bar::placeholder {
color: #9ca3af;
}
.results-loader {
grid-column: 1 / -1;
display: flex;
justify-content: center;
padding: 2rem 0;
}
.companies-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1.5rem;
margin: 2rem 0;
}
.company-card {
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 1.5rem;
transition:
box-shadow 0.2s,
border-color 0.2s;
cursor: pointer;
}
.company-card:hover {
border-color: #0366d6;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.company-card:focus {
outline: none;
border-color: #0366d6;
box-shadow: 0 0 0 4px rgba(3, 102, 214, 0.08);
}
.company-card h3 {
margin: 0 0 1rem 0;
color: #111;
font-size: 1.1rem;
}
.company-card dl {
margin: 0 0 1rem 0;
font-size: 0.9rem;
}
.company-card dt {
font-weight: 600;
color: #6b7280;
margin-top: 0.5rem;
}
.company-card dd {
margin: 0.25rem 0 0 0;
color: #111;
font-family: monospace;
}
.view-link {
display: inline-block;
color: #0366d6;
text-decoration: none;
padding: 0.5rem 0;
font-weight: 500;
border-bottom: 1px solid transparent;
transition: border-color 0.2s;
}
.view-link:hover {
border-bottom-color: #0366d6;
}
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
margin: 2rem 0;
padding: 1rem;
background: #fff;
border-radius: 8px;
border: 1px solid #e5e7eb;
flex-wrap: wrap;
}
.pagination-btn {
background: #fff;
border: 1px solid #e5e7eb;
padding: 0.5rem 1rem;
border-radius: 6px;
cursor: pointer;
color: #0366d6;
font-weight: 500;
transition: all 0.2s;
}
.pagination-btn:hover:not(:disabled) {
background: #0366d6;
color: #fff;
}
.pagination-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.page-numbers {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.page-number {
background: #fff;
border: 1px solid #e5e7eb;
padding: 0.5rem 0.75rem;
border-radius: 4px;
cursor: pointer;
color: #0366d6;
font-weight: 500;
transition: all 0.2s;
min-width: 2.5rem;
}
.page-number:hover {
background: #f3f4f6;
}
.page-number.active {
background: #0366d6;
color: #fff;
border-color: #0366d6;
}
.page-info {
color: #6b7280;
font-size: 0.9rem;
white-space: nowrap;
}
footer {
text-align: center;
padding: 1rem 0;
color: #6b7280;
}
@media (max-width: 768px) {
.companies-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 480px) {
.companies-grid {
grid-template-columns: 1fr;
}
.pagination {
flex-direction: column; flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
gap: 12px;
} }
.access-denied svg {
width: 40px;
height: 40px;
color: var(--status-inactive-color);
}
.access-denied h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
}
.access-denied p {
margin: 0;
color: var(--text-secondary);
font-size: 13px;
text-align: center;
max-width: 360px;
} }
</style> </style>
+55 -60
View File
@@ -1,72 +1,67 @@
import { company } from "$lib/companies"; import { optima } from "$lib";
import { credential } from "$lib/credentials"; import { handleApiError } from "$lib/optima-api/errorHandler";
import { checkPermissions, type PermissionMap } from "$lib/permissions";
import type { PageServerLoad } from "./$types"; import type { PageServerLoad } from "./$types";
import { error } from "@sveltejs/kit";
export const load: PageServerLoad = async ({ params, parent }) => { export const load: PageServerLoad = async ({ locals, params }) => {
const { session } = await parent(); const accessToken = locals.session?.accessToken;
if (!accessToken) {
if (!session.accessToken) { return {
throw error(401, "Unauthorized: Access token required"); company: null,
configurations: [],
credentials: [],
credentialTypes: [],
unifiSites: [],
accessToken: null,
permissions: {} as PermissionMap,
};
} }
try { try {
const companyData = await company.fetch(session.accessToken, params.id); // Run permission checks in parallel with other data fetches.
// Add any new permissions the company page needs to this array.
const [
permissions,
configsResult,
credentialsResult,
credentialTypesResult,
unifiSitesResult,
] = await Promise.all([
checkPermissions(accessToken, [
"company.fetch.address",
"company.fetch.contacts",
"credential.secure_values.read",
"unifi.site.wifi",
"unifi.site.wifi.read.name",
"unifi.site.wifi.update",
]),
optima.company.fetchConfigurations(accessToken, params.id),
optima.credential
.fetchByCompany(accessToken, params.id)
.catch(() => ({ data: [] })),
optima.credentialType.fetchMany(accessToken).catch(() => ({ data: [] })),
optima.unifi
.fetchCompanySites(accessToken, params.id)
.catch(() => ({ data: [] })),
]);
if (!companyData) { // Fetch company with or without address based on permission
throw error(404, `Company with ID ${params.id} not found`); const companyResult = await optima.company.fetch(accessToken, params.id, {
} includeAddress: permissions["company.fetch.address"] === true,
includePrimaryContact: true,
// attempt to load configurations but don't fail the whole page if it errors includeAllContacts: permissions["company.fetch.contacts"] === true,
let configurations = null; });
let configurationsError = null;
try {
configurations = await company.fetchConfigurations(
session.accessToken,
params.id,
);
} catch (cfgErr) {
console.error("Failed to fetch configurations:", cfgErr);
configurationsError = String(
cfgErr instanceof Error ? cfgErr.message : cfgErr,
);
}
// attempt to load credentials but don't fail the whole page if it errors
let credentials = null;
let credentialsError = null;
try {
credentials = await credential.fetchByCompany(
session.accessToken,
params.id,
);
} catch (credErr) {
console.error("Failed to fetch credentials:", credErr);
credentialsError = String(
credErr instanceof Error ? credErr.message : credErr,
);
}
return { return {
company: companyData, company: companyResult?.data ?? null,
configurations, configurations: configsResult?.data ?? [],
configurationsError, credentials: credentialsResult?.data ?? [],
credentials, credentialTypes: credentialTypesResult?.data ?? [],
credentialsError, unifiSites: unifiSitesResult?.data ?? [],
session, accessToken,
companyId: params.id, permissions,
}; };
} catch (err) { } catch (err) {
console.error("Failed to fetch company:", err); handleApiError(err);
if (err instanceof Error && err.message.includes("404")) {
throw error(404, `Company with ID ${params.id} not found`);
}
if (err instanceof Error && err.message.includes("401")) {
throw error(401, "Your session has expired. Please log in again.");
}
throw error(500, "Failed to load company details. Please try again.");
} }
}; };
+341 -197
View File
@@ -1,215 +1,359 @@
<script> <script lang="ts">
import { goto } from "$app/navigation"; import "../../../styles/companies/companydetail.css";
import ErrorBoundary from "$lib/../components/ErrorBoundary.svelte"; import { onMount } from "svelte";
import CreateCredentialModal from "$lib/../components/CreateCredentialModal.svelte"; import type { PageData } from "./types";
export let data; // Tab components
export let error; import CompanySidebar from "./components/CompanySidebar.svelte";
import OverviewTab from "./components/OverviewTab.svelte";
import CredentialsTab from "./components/CredentialsTab.svelte";
import ConfigurationsTab from "./components/ConfigurationsTab.svelte";
import ContactsTab from "./components/ContactsTab.svelte";
import UniFiTab from "./components/UniFiTab.svelte";
import ActivityTab from "./components/ActivityTab.svelte";
let showCreateModal = false; export let data: PageData;
function signOut() { $: company = data.company;
goto("/logout"); $: configurations = data.configurations;
$: credentials = data.credentials;
$: credentialTypes = data.credentialTypes;
$: unifiSites = data.unifiSites;
$: accessToken = data.accessToken;
$: permissions = data.permissions;
// Mobile detection
let isMobile = false;
function checkMobile() {
isMobile = typeof window !== "undefined" && window.innerWidth <= 768;
}
onMount(() => {
checkMobile();
window.addEventListener("resize", checkMobile);
return () => window.removeEventListener("resize", checkMobile);
});
// Create credential modal state (bound to CredentialsTab)
let isCreateCredentialOpen = false;
// Link UniFi site modal state (bound to UniFiTab)
let isLinkUnifiOpen = false;
// Tab navigation
const tabs = [
"Overview",
"Credentials",
"Configurations",
"UniFi",
"Contacts",
"Activity",
] as const;
type Tab = (typeof tabs)[number];
let activeTab: Tab = "Overview";
// Mobile nav state: null = show vertical nav menu; set = show tab content
let mobileActiveTab: Tab | null = null;
function selectMobileTab(tab: Tab) {
activeTab = tab;
mobileActiveTab = tab;
} }
function handleOpenCreateModal() { function mobileBack() {
showCreateModal = true; mobileActiveTab = null;
}
function handleCredentialCreated() {
// Refresh the page to show the new credential
location.reload();
} }
</script> </script>
<svelte:head> <svelte:head>
<title>Company Detail — App</title> <title>{company?.name ?? "Company"} — Project Optima</title>
</svelte:head> </svelte:head>
<header class="header container"> <div class="company-detail-page">
<h1>Company Detail</h1> <!-- Left pane (1/4) — Company overview -->
<nav> <CompanySidebar {company} {permissions} {isMobile} {mobileActiveTab} />
<a href="/companies">Companies</a>
<a href="/">Home</a>
<button on:click={signOut}>Sign out</button>
</nav>
</header>
<main class="container"> <!-- Mobile vertical nav menu (only visible on mobile when no tab selected) -->
{#if error} {#if isMobile && mobileActiveTab === null}
<ErrorBoundary <div class="mobile-nav-menu">
title="Failed to Load Company" {#each tabs as tab}
message={error.message || <button
"We couldn't load the company details. Please try again."} class="mobile-nav-item"
details={error} on:click={() => selectMobileTab(tab)}
/> type="button"
{:else}
<section>
<h2>API Response</h2>
<details class="json-collapse" open>
<summary>Show company JSON</summary>
<pre><code>{JSON.stringify(data.company, null, 2)}</code></pre>
</details>
</section>
<section style="margin-top:1.5rem;">
<h2>Configurations</h2>
{#if data.configurationsError}
<ErrorBoundary
title="Failed to Load Configurations"
message={data.configurationsError}
details={data.configurationsError}
/>
{:else if data.configurations}
<details class="json-collapse" open>
<summary>Show configurations JSON</summary>
<pre><code>{JSON.stringify(data.configurations, null, 2)}</code></pre>
</details>
{:else}
<p>No configurations available for this company.</p>
{/if}
</section>
<section style="margin-top:1.5rem;">
<div
style="display: flex; justify-content: space-between; align-items: center;"
> >
<h2 style="margin: 0;">Credentials</h2> <span class="mobile-nav-icon">
<button class="create-button" on:click={handleOpenCreateModal}> {#if tab === "Credentials"}
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="20"
height="20"
>
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" /><path
d="M7 11V7a5 5 0 0 1 10 0v4"
/>
</svg>
{:else if tab === "Configurations"}
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="20"
height="20"
>
<circle cx="12" cy="12" r="3" /><path
d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"
/>
</svg>
{:else if tab === "UniFi"}
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="20"
height="20"
>
<path d="M12 2L2 7l10 5 10-5-10-5z" /><path
d="M2 17l10 5 10-5"
/><path d="M2 12l10 5 10-5" />
</svg>
{:else if tab === "Contacts"}
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="20"
height="20"
>
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2" /><circle
cx="9"
cy="7"
r="4"
/><path d="M23 21v-2a4 4 0 00-3-3.87" /><path
d="M16 3.13a4 4 0 010 7.75"
/>
</svg>
{:else}
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="20"
height="20"
>
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />
</svg>
{/if}
</span>
<span class="mobile-nav-label">{tab}</span>
{#if tab === "Credentials" && credentials.length > 0}
<span class="mobile-nav-badge">{credentials.length}</span>
{/if}
{#if tab === "Configurations" && configurations.length > 0}
<span class="mobile-nav-badge">{configurations.length}</span>
{/if}
{#if tab === "UniFi" && unifiSites.length > 0}
<span class="mobile-nav-badge">{unifiSites.length}</span>
{/if}
{#if tab === "Contacts" && (company?.cw_Data?.allContacts?.length ?? 0) > 0}
<span class="mobile-nav-badge"
>{company?.cw_Data?.allContacts?.length}</span
>
{/if}
<svg
class="mobile-nav-chevron"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
>
<polyline points="9 18 15 12 9 6" />
</svg>
</button>
{/each}
</div>
{/if}
<!-- Right pane (3/4) -->
<div
class="company-detail-right"
class:mobile-hidden={isMobile && mobileActiveTab === null}
>
<!-- Mobile content header with back button -->
{#if isMobile && mobileActiveTab !== null}
<div class="mobile-content-header">
<button
class="mobile-back-btn"
on:click={mobileBack}
type="button"
aria-label="Back to menu"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="18"
height="18"
>
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
</button>
<h3 class="mobile-content-title">{mobileActiveTab}</h3>
{#if mobileActiveTab === "Credentials"}
<button
type="button"
class="create-credential-btn mobile-create-btn"
on:click={() => (isCreateCredentialOpen = true)}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<line x1="12" y1="5" x2="12" y2="19" /><line
x1="5"
y1="12"
x2="19"
y2="12"
/>
</svg>
New
</button>
{/if}
{#if mobileActiveTab === "UniFi"}
<button
type="button"
class="create-credential-btn mobile-create-btn"
on:click={() => (isLinkUnifiOpen = true)}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<path
d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"
/>
<path
d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"
/>
</svg>
Link
</button>
{/if}
</div>
{/if}
<div class="tab-bar" role="tablist">
{#each tabs as tab}
<button
class="tab-btn"
class:active={activeTab === tab}
role="tab"
aria-selected={activeTab === tab}
on:click={() => (activeTab = tab)}
>
{tab}
{#if tab === "Credentials" && credentials.length > 0}
<span class="tab-count-badge">{credentials.length}</span>
{/if}
{#if tab === "Configurations" && configurations.length > 0}
<span class="tab-count-badge">{configurations.length}</span>
{/if}
{#if tab === "UniFi" && unifiSites.length > 0}
<span class="tab-count-badge">{unifiSites.length}</span>
{/if}
</button>
{/each}
{#if activeTab === "Credentials"}
<div class="tab-bar-spacer"></div>
<button
type="button"
class="create-credential-btn"
on:click={() => (isCreateCredentialOpen = true)}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
Create Credential Create Credential
</button> </button>
</div> {/if}
{#if data.credentialsError} {#if activeTab === "UniFi"}
<ErrorBoundary <div class="tab-bar-spacer"></div>
title="Failed to Load Credentials" <button
message={data.credentialsError} type="button"
details={data.credentialsError} class="create-credential-btn"
on:click={() => (isLinkUnifiOpen = true)}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<path
d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"
/> />
{:else if data.credentials && data.credentials.data && data.credentials.data.length > 0} <path
<details class="json-collapse" open> d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"
<summary>Show credentials JSON</summary> />
<pre><code>{JSON.stringify(data.credentials, null, 2)}</code></pre> </svg>
</details> Link Site
{:else} </button>
<p>No credentials available for this company.</p>
{/if} {/if}
</section> </div>
<div class="detail-pane-body">
{#if activeTab === "Overview"}
<OverviewTab {company} {credentials} {configurations} {unifiSites} />
{:else if activeTab === "Credentials"}
<CredentialsTab
companyId={company?.id ?? ""}
{credentials}
{credentialTypes}
{accessToken}
{permissions}
{isMobile}
bind:isCreateCredentialOpen
/>
{:else if activeTab === "Configurations"}
<ConfigurationsTab {configurations} {isMobile} />
{:else if activeTab === "UniFi"}
<UniFiTab
companyId={company?.id ?? ""}
{unifiSites}
{accessToken}
{permissions}
bind:isLinkUnifiOpen
/>
{:else if activeTab === "Contacts"}
<ContactsTab {company} />
{:else if activeTab === "Activity"}
<ActivityTab />
{/if} {/if}
</main> </div>
</div>
<CreateCredentialModal </div>
isOpen={showCreateModal}
companyId={data.companyId}
accessToken={data.session?.accessToken || ""}
onSuccess={handleCredentialCreated}
/>
<style>
:global(body) {
margin: 0;
font-family:
system-ui,
-apple-system,
"Segoe UI",
Roboto,
"Helvetica Neue",
Arial;
background: #f7f7f8;
color: #111;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 1rem;
}
header {
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #e5e7eb;
background: #fff;
}
nav {
display: flex;
gap: 0.5rem;
align-items: center;
}
nav a,
nav button {
padding: 0.5rem 1rem;
text-decoration: none;
color: #0066cc;
border: none;
background: none;
cursor: pointer;
font-size: 1rem;
}
nav a:hover,
nav button:hover {
text-decoration: underline;
}
main {
padding: 2rem 1rem;
}
section {
background: white;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 2rem;
}
h2 {
margin-top: 0;
}
pre {
background-color: #f5f5f5;
padding: 1rem;
border-radius: 4px;
overflow-x: auto;
margin: 0;
}
code {
font-family: "Courier New", monospace;
font-size: 0.875rem;
}
.json-collapse summary {
cursor: pointer;
padding: 0.5rem 0;
font-weight: 600;
color: #0066cc;
outline: none;
}
.json-collapse pre {
margin-top: 0.5rem;
}
.create-button {
padding: 0.5rem 1rem;
background-color: #0066cc;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.95rem;
font-weight: 500;
transition: background-color 0.2s;
}
.create-button:hover:not(:disabled) {
background-color: #0052a3;
}
.create-button:disabled {
background-color: #9ca3af;
cursor: not-allowed;
}
</style>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,5 @@
<script lang="ts">
// Activity tab — placeholder for future implementation
</script>
<p class="tab-placeholder">Activity content</p>
@@ -0,0 +1,260 @@
<script lang="ts">
import { goto } from "$app/navigation";
import {
type CompanyData,
companyInitials,
statusClass,
formatDate,
formatPhone,
formatAddress,
} from "../types";
import type { PermissionMap } from "$lib/permissions";
export let company: CompanyData | null;
export let permissions: PermissionMap;
export let isMobile: boolean;
export let mobileActiveTab: string | null;
</script>
<div
class="company-detail-left"
class:mobile-collapsed={isMobile && mobileActiveTab !== null}
>
<div class="detail-pane-body">
<button
class="back-btn"
on:click={() => goto("/companies")}
aria-label="Back to companies"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
>
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
</button>
{#if company}
<!-- Avatar + name + status -->
<div class="profile-header">
<div class="profile-avatar">
<span class="profile-initials">{companyInitials(company.name)}</span>
</div>
<h3 class="profile-name">{company.name}</h3>
{#if company.status}
<span class="profile-status {statusClass(company.status)}"
>{company.status}</span
>
{/if}
</div>
<!-- Info rows -->
<div class="profile-info">
{#if company.cw_Data?.primaryContact}
{@const contact = company.cw_Data.primaryContact}
<div class="primary-contact-section">
<div class="primary-contact-header">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
<span class="primary-contact-label">Primary Contact</span>
</div>
<div class="primary-contact-card">
<div class="primary-contact-name">
{[contact.firstName, contact.lastName]
.filter(Boolean)
.join(" ")}
{#if contact.inactive}
<span class="primary-contact-inactive">Inactive</span>
{/if}
</div>
{#if contact.title}
<div class="primary-contact-title">{contact.title}</div>
{/if}
{#if contact.email}
<div class="primary-contact-detail">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="12"
height="12"
>
<rect x="2" y="4" width="20" height="16" rx="2" />
<path d="M22 7l-10 7L2 7" />
</svg>
<a
href="mailto:{contact.email}"
class="primary-contact-link"
on:click|stopPropagation>{contact.email}</a
>
</div>
{/if}
{#if contact.phone}
<div class="primary-contact-detail">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="12"
height="12"
>
<path
d="M22 16.92v3a2 2 0 01-2.18 2 19.79 19.79 0 01-8.63-3.07 19.5 19.5 0 01-6-6A19.79 19.79 0 012.12 4.18 2 2 0 014.11 2h3a2 2 0 012 1.72c.127.96.361 1.903.7 2.81a2 2 0 01-.45 2.11L8.09 9.91a16 16 0 006 6l1.27-1.27a2 2 0 012.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0122 16.92z"
/>
</svg>
<span>{formatPhone(contact.phone)}</span>
</div>
{/if}
</div>
</div>
{/if}
{#if company.type}
<div class="info-row">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class="info-icon"
>
<rect x="2" y="7" width="20" height="14" rx="2" ry="2" />
<path d="M16 3h-8l-2 4h12z" />
</svg>
<div class="info-content">
<span class="info-label">Type</span>
<span class="info-value">{company.type}</span>
</div>
</div>
{/if}
{#if company.contactEmail}
<div class="info-row">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class="info-icon"
>
<rect x="2" y="4" width="20" height="16" rx="2" />
<path d="M22 7l-10 7L2 7" />
</svg>
<div class="info-content">
<span class="info-label">Email</span>
<span class="info-value">{company.contactEmail}</span>
</div>
</div>
{/if}
{#if company.contactPhone}
<div class="info-row">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class="info-icon"
>
<path
d="M22 16.92v3a2 2 0 01-2.18 2 19.79 19.79 0 01-8.63-3.07 19.5 19.5 0 01-6-6A19.79 19.79 0 012.12 4.18 2 2 0 014.11 2h3a2 2 0 012 1.72c.127.96.361 1.903.7 2.81a2 2 0 01-.45 2.11L8.09 9.91a16 16 0 006 6l1.27-1.27a2 2 0 012.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0122 16.92z"
/>
</svg>
<div class="info-content">
<span class="info-label">Phone</span>
<span class="info-value">{formatPhone(company.contactPhone)}</span
>
</div>
</div>
{/if}
{#if permissions["company.fetch.address"] && formatAddress(company).length > 0}
<div class="info-row">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class="info-icon"
>
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0118 0z" />
<circle cx="12" cy="10" r="3" />
</svg>
<div class="info-content">
<span class="info-label">Address</span>
<span class="info-value address-multiline">
{#each formatAddress(company) as line}
{line}<br />
{/each}
</span>
</div>
</div>
{/if}
{#if formatDate(company.createdAt)}
<div class="info-row">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class="info-icon"
>
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
<div class="info-content">
<span class="info-label">Created</span>
<span class="info-value">{formatDate(company.createdAt)}</span>
</div>
</div>
{/if}
{#if formatDate(company.updatedAt)}
<div class="info-row">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class="info-icon"
>
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
<div class="info-content">
<span class="info-label">Updated</span>
<span class="info-value">{formatDate(company.updatedAt)}</span>
</div>
</div>
{/if}
</div>
{#if company.identifier || company.id}
<div class="side-pane-identifier">
{company.identifier || company.id}
</div>
{/if}
{:else}
<div class="profile-empty">
<p>Company not found.</p>
</div>
{/if}
</div>
</div>
@@ -0,0 +1,376 @@
<script lang="ts">
import type { ConfigurationData } from "../types";
import { formatDate, configStatusClass } from "../types";
export let configurations: ConfigurationData[];
export let isMobile: boolean;
// Configurations split-view state
let selectedConfig: ConfigurationData | null = null;
let configFadeKey = 0;
// Track which password fields are revealed (by question id)
let revealedPasswords: Record<number, boolean> = {};
function togglePassword(questionId: number) {
revealedPasswords[questionId] = !revealedPasswords[questionId];
revealedPasswords = revealedPasswords;
}
function selectConfig(config: ConfigurationData) {
if (selectedConfig?.id === config.id) {
selectedConfig = null;
} else {
selectedConfig = config;
configFadeKey++;
revealedPasswords = {};
}
}
</script>
{#if configurations.length === 0}
<div class="tab-empty">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
class="tab-empty-icon"
>
<path
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
/>
</svg>
<p>No configurations found</p>
</div>
{:else}
<div
class="config-split"
class:expanded={selectedConfig !== null && !isMobile}
>
<!-- Left side: config buttons -->
<div
class="config-list"
class:collapsed={selectedConfig !== null && !isMobile}
>
{#each configurations as config (config.id)}
<button
class="config-item"
class:selected={selectedConfig?.id === config.id}
class:config-inactive={config.status?.name === "Inactive" ||
config.status?.name === "Automate Inactive"}
on:click={() => selectConfig(config)}
type="button"
>
<div class="config-item-header">
<div class="config-name-group">
<span
class="config-status-dot dot-{configStatusClass(
config.status?.name,
)}"
title={config.status?.name ?? "Unknown"}
></span>
<span class="config-name">{config.name}</span>
</div>
<div class="config-header-badges">
{#if config.status?.name && (!selectedConfig || isMobile)}
<span
class="config-status-badge status-{configStatusClass(
config.status.name,
)}">{config.status.name}</span
>
{/if}
{#if config.type?.name && (!selectedConfig || isMobile)}
<span class="config-type-badge">{config.type.name}</span>
{/if}
</div>
</div>
{#if !selectedConfig || isMobile}
{#if config.description}
<p class="config-description">{config.description}</p>
{/if}
{#if config.key}
<div class="config-kv">
<span class="config-key">{config.key}</span>
{#if config.value}
<span class="config-value">{config.value}</span>
{/if}
</div>
{/if}
{#if formatDate(config.updatedAt) || formatDate(config.createdAt) || formatDate(config.info?.lastUpdated) || formatDate(config.info?.dateEntered)}
<span class="config-date">
{#if formatDate(config.updatedAt)}
Updated {formatDate(config.updatedAt)}
{:else if formatDate(config.info?.lastUpdated)}
Updated {formatDate(config.info?.lastUpdated)}
{:else if formatDate(config.createdAt)}
Created {formatDate(config.createdAt)}
{:else if formatDate(config.info?.dateEntered)}
Created {formatDate(config.info?.dateEntered)}
{/if}
</span>
{/if}
{/if}
</button>
{/each}
</div>
<!-- Right side: config detail panel -->
{#if selectedConfig}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="bottom-sheet-overlay"
class:active={selectedConfig !== null}
on:click={() => {
selectedConfig = null;
}}
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="bottom-sheet-panel" on:click|stopPropagation>
<div class="bottom-sheet-handle"></div>
<div class="bottom-sheet-body">
<div class="config-detail-panel">
{#key configFadeKey}
<div class="config-detail-content">
<div class="config-detail-header">
<div class="config-detail-header-left">
<h3 class="config-detail-title">
{selectedConfig.name}
</h3>
<div class="config-detail-meta-badges">
{#if selectedConfig.type?.name}
<span class="config-badge type"
>{selectedConfig.type.name}</span
>
{/if}
{#if selectedConfig.status?.name}
<span
class="config-badge status-{configStatusClass(
selectedConfig.status.name,
)}"
>
{selectedConfig.status.name}
</span>
{/if}
</div>
</div>
<button
class="config-detail-close"
on:click={() => (selectedConfig = null)}
aria-label="Close detail view"
type="button"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
>
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
{#if selectedConfig.serialNumber}
<div class="config-serial">
<span class="config-serial-label">Serial #</span>
<span class="config-serial-value"
>{selectedConfig.serialNumber}</span
>
</div>
{/if}
<!-- Questions / Fields -->
{#if (selectedConfig.questions && selectedConfig.questions.length > 0) || selectedConfig.notes}
<div class="config-questions">
{#if selectedConfig.notes}
<div class="config-notes">
<h4 class="config-section-title">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
>
<path
d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"
/>
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
<polyline points="10 9 9 9 8 9" />
</svg>
Notes
</h4>
<p class="config-notes-text">
{selectedConfig.notes}
</p>
</div>
{/if}
<h4 class="config-section-title">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
>
<path
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2"
/>
<rect x="9" y="3" width="6" height="4" rx="1" />
</svg>
Configuration Details
</h4>
<div class="questions-grid">
{#each selectedConfig.questions as q (q.id)}
<div
class="question-row"
class:has-answer={!!q.answer}
>
<span class="question-label">{q.question}</span>
<div class="question-value-wrap">
{#if q.fieldType === "Password"}
<span class="question-value password-value">
{#if revealedPasswords[q.id]}
{q.answer || "—"}
{:else}
{q.answer ? "••••••••" : "—"}
{/if}
</span>
{#if q.answer}
<button
class="password-toggle"
on:click={() => togglePassword(q.id)}
type="button"
aria-label={revealedPasswords[q.id]
? "Hide password"
: "Show password"}
>
{#if revealedPasswords[q.id]}
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<path
d="M17.94 17.94A10.07 10.07 0 0112 20c-7 0-11-8-11-8a18.45 18.45 0 015.06-5.94"
/>
<path
d="M9.9 4.24A9.12 9.12 0 0112 4c7 0 11 8 11 8a18.5 18.5 0 01-2.16 3.19"
/>
<path
d="M14.12 14.12a3 3 0 11-4.24-4.24"
/>
<line x1="1" y1="1" x2="23" y2="23" />
</svg>
{:else}
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<path
d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"
/>
<circle cx="12" cy="12" r="3" />
</svg>
{/if}
</button>
{/if}
{:else if q.fieldType === "TextArea"}
<span class="question-value textarea-value"
>{q.answer || "—"}</span
>
{:else}
<span class="question-value"
>{q.answer || "—"}</span
>
{/if}
</div>
</div>
{/each}
</div>
</div>
{:else if !selectedConfig.notes}
<div class="config-no-questions">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
width="32"
height="32"
>
<circle cx="12" cy="12" r="10" />
<path d="M9.09 9a3 3 0 015.83 1c0 2-3 3-3 3" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
<p>No configuration fields available</p>
</div>
{/if}
<!-- Footer metadata -->
{#if selectedConfig.info}
<div class="config-info-footer">
{#if selectedConfig.info.enteredBy || selectedConfig.info.dateEntered}
<div class="config-info-item">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="13"
height="13"
>
<path d="M12 5v14M5 12h14" />
</svg>
Created{#if selectedConfig.info.enteredBy}&nbsp;by
<strong>{selectedConfig.info.enteredBy}</strong
>{/if}{#if selectedConfig.info.dateEntered}&nbsp;on
{formatDate(selectedConfig.info.dateEntered)}{/if}
</div>
{/if}
{#if selectedConfig.info.updatedBy || selectedConfig.info.lastUpdated}
<div class="config-info-item">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="13"
height="13"
>
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
Updated{#if selectedConfig.info.updatedBy}&nbsp;by
<strong>{selectedConfig.info.updatedBy}</strong
>{/if}{#if selectedConfig.info.lastUpdated}&nbsp;on
{formatDate(selectedConfig.info.lastUpdated)}{/if}
</div>
{/if}
</div>
{/if}
</div>
{/key}
</div>
</div>
</div>
</div>
{/if}
</div>
{/if}
@@ -0,0 +1,211 @@
<script lang="ts">
import type { CompanyData } from "../types";
import { formatPhone } from "../types";
export let company: CompanyData | null;
</script>
{#if !company?.cw_Data?.allContacts || company.cw_Data.allContacts.length === 0}
<div class="tab-empty">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
class="tab-empty-icon"
>
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 00-3-3.87" />
<path d="M16 3.13a4 4 0 010 7.75" />
</svg>
<p>No contacts found</p>
</div>
{:else}
{@const activeContacts = company.cw_Data.allContacts.filter(
(c) => !c.inactive,
)}
{@const inactiveContacts = company.cw_Data.allContacts.filter(
(c) => c.inactive,
)}
<!-- Active contacts -->
{#if activeContacts.length > 0}
<div class="contacts-section">
<div class="contacts-section-header">
<h3 class="contacts-section-title">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="var(--text-muted)"
stroke-width="2"
width="16"
height="16"
>
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 00-3-3.87" />
<path d="M16 3.13a4 4 0 010 7.75" />
</svg>
Active
</h3>
<span class="contacts-section-count">{activeContacts.length}</span>
</div>
<div class="contacts-grid">
{#each activeContacts as contact (contact.cwId ?? `${contact.firstName}-${contact.lastName}`)}
<div class="contact-card">
<div class="contact-card-header">
<div class="contact-avatar">
<span class="contact-initials">
{contact.firstName?.[0] ?? ""}{contact.lastName?.[0] ?? ""}
</span>
</div>
<div class="contact-card-info">
<div class="contact-name">
{[contact.firstName, contact.lastName]
.filter(Boolean)
.join(" ")}
</div>
{#if contact.title}
<div class="contact-title">{contact.title}</div>
{/if}
</div>
</div>
<div class="contact-card-details">
{#if contact.email}
<div class="contact-detail-row">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="var(--text-secondary)"
stroke-width="2"
width="13"
height="13"
style="min-width:13px;min-height:13px;"
>
<rect x="2" y="4" width="20" height="16" rx="2" />
<path d="M22 7l-10 7L2 7" />
</svg>
<a
href="mailto:{contact.email}"
class="contact-link"
on:click|stopPropagation>{contact.email}</a
>
</div>
{/if}
{#if contact.phone}
<div class="contact-detail-row">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="var(--text-secondary)"
stroke-width="2"
width="13"
height="13"
style="min-width:13px;min-height:13px;"
>
<path
d="M22 16.92v3a2 2 0 01-2.18 2 19.79 19.79 0 01-8.63-3.07 19.5 19.5 0 01-6-6A19.79 19.79 0 012.12 4.18 2 2 0 014.11 2h3a2 2 0 012 1.72c.127.96.361 1.903.7 2.81a2 2 0 01-.45 2.11L8.09 9.91a16 16 0 006 6l1.27-1.27a2 2 0 012.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0122 16.92z"
/>
</svg>
<span>{formatPhone(contact.phone)}</span>
</div>
{/if}
</div>
</div>
{/each}
</div>
</div>
{/if}
<!-- Inactive contacts -->
{#if inactiveContacts.length > 0}
<div class="contacts-section contacts-section-inactive">
<div class="contacts-section-header">
<h3 class="contacts-section-title">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="var(--text-muted)"
stroke-width="2"
width="16"
height="16"
>
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2" />
<circle cx="9" cy="7" r="4" />
<line x1="23" y1="13" x2="17" y2="13" />
</svg>
Inactive
</h3>
<span class="contacts-section-count inactive"
>{inactiveContacts.length}</span
>
</div>
<div class="contacts-grid">
{#each inactiveContacts as contact (contact.cwId ?? `${contact.firstName}-${contact.lastName}`)}
<div class="contact-card contact-inactive">
<div class="contact-card-header">
<div class="contact-avatar contact-avatar-inactive">
<span class="contact-initials">
{contact.firstName?.[0] ?? ""}{contact.lastName?.[0] ?? ""}
</span>
</div>
<div class="contact-card-info">
<div class="contact-name">
{[contact.firstName, contact.lastName]
.filter(Boolean)
.join(" ")}
<span class="contact-inactive-badge">Inactive</span>
</div>
{#if contact.title}
<div class="contact-title">{contact.title}</div>
{/if}
</div>
</div>
<div class="contact-card-details">
{#if contact.email}
<div class="contact-detail-row">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="var(--text-secondary)"
stroke-width="2"
width="13"
height="13"
style="min-width:13px;min-height:13px;"
>
<rect x="2" y="4" width="20" height="16" rx="2" />
<path d="M22 7l-10 7L2 7" />
</svg>
<a
href="mailto:{contact.email}"
class="contact-link"
on:click|stopPropagation>{contact.email}</a
>
</div>
{/if}
{#if contact.phone}
<div class="contact-detail-row">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="var(--text-secondary)"
stroke-width="2"
width="13"
height="13"
style="min-width:13px;min-height:13px;"
>
<path
d="M22 16.92v3a2 2 0 01-2.18 2 19.79 19.79 0 01-8.63-3.07 19.5 19.5 0 01-6-6A19.79 19.79 0 012.12 4.18 2 2 0 014.11 2h3a2 2 0 012 1.72c.127.96.361 1.903.7 2.81a2 2 0 01-.45 2.11L8.09 9.91a16 16 0 006 6l1.27-1.27a2 2 0 012.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0122 16.92z"
/>
</svg>
<span>{formatPhone(contact.phone)}</span>
</div>
{/if}
</div>
</div>
{/each}
</div>
</div>
{/if}
{/if}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,87 @@
<script lang="ts">
import type { CompanyData, ConfigurationData } from "../types";
import type { Credential } from "$lib/optima-api/modules/credentials";
import type { UnifiSite } from "$lib/optima-api/modules/unifi";
export let company: CompanyData | null;
export let credentials: Credential[];
export let configurations: ConfigurationData[];
export let unifiSites: UnifiSite[];
</script>
<div class="overview-tab">
<div class="overview-section">
<h3 class="overview-section-title">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
>
<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
Company Details
</h3>
<div class="overview-details-grid">
<div class="overview-detail-item">
<span class="overview-detail-label">Name</span>
<span class="overview-detail-value">{company?.name ?? "—"}</span>
</div>
<div class="overview-detail-item">
<span class="overview-detail-label">ID</span>
<span class="overview-detail-value mono">{company?.id ?? "—"}</span>
</div>
</div>
</div>
<div class="overview-section">
<h3 class="overview-section-title">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
>
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
</svg>
At a Glance
</h3>
<div class="overview-stats-grid">
<div class="overview-stat-card">
<span class="overview-stat-value">{credentials.length}</span>
<span class="overview-stat-label">Credentials</span>
</div>
<div class="overview-stat-card">
<span class="overview-stat-value">{configurations.length}</span>
<span class="overview-stat-label">Configurations</span>
</div>
<div class="overview-stat-card">
<span class="overview-stat-value">{unifiSites.length}</span>
<span class="overview-stat-label">UniFi Sites</span>
</div>
</div>
</div>
<div class="overview-section">
<h3 class="overview-section-title">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
>
<circle cx="12" cy="12" r="10" />
<path d="M12 6v6l4 2" />
</svg>
Recent Activity
</h3>
<p class="overview-placeholder">Activity feed coming soon.</p>
</div>
</div>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,32 @@
import { optima } from "$lib";
import { json, error } from "@sveltejs/kit";
import type { RequestHandler } from "./$types";
export const GET: RequestHandler = async ({ url, locals }) => {
const accessToken = locals.session?.accessToken;
if (!accessToken) {
throw error(401, "Unauthorized");
}
const credentialId = url.searchParams.get("credentialId");
const fieldId = url.searchParams.get("fieldId");
if (!credentialId || !fieldId) {
throw error(400, "Missing credentialId or fieldId");
}
try {
const result = await optima.credential.fetchSecureValue(
accessToken,
credentialId,
fieldId,
);
return json(result);
} catch (err: unknown) {
console.error("Failed to fetch secure value:", err);
const status =
err && typeof err === "object" && "status" in err
? (err as { status: number }).status
: 500;
throw error(status, "Failed to fetch secure value");
}
};
+187
View File
@@ -0,0 +1,187 @@
import type { Credential } from "$lib/optima-api/modules/credentials";
import type { CredentialType } from "$lib/optima-api/modules/credentialTypes";
import type { UnifiSite } from "$lib/optima-api/modules/unifi";
import type { PermissionMap } from "$lib/permissions";
export interface CompanyData {
id: string;
name: string;
status?: string;
type?: string;
createdAt?: string;
updatedAt?: string;
identifier?: string;
contactEmail?: string;
contactPhone?: string;
address?: string;
city?: string;
state?: string;
zip?: string;
country?: string;
cw_Data?: {
address?: {
line1?: string;
line2?: string | null;
city?: string;
state?: string;
zip?: string;
country?: string;
};
primaryContact?: {
firstName?: string;
lastName?: string;
cwId?: number;
inactive?: boolean;
title?: string;
phone?: string;
email?: string;
};
allContacts?: Array<{
firstName?: string;
lastName?: string;
cwId?: number;
inactive?: boolean;
title?: string;
phone?: string;
email?: string;
}>;
[key: string]: unknown;
};
[key: string]: unknown;
}
export interface ConfigurationData {
id: string | number;
name: string;
active?: boolean;
serialNumber?: string;
status?: {
id: number;
name: string;
};
type?: {
id: number;
name: string;
_info?: { type_href?: string };
};
notes?: string;
questions?: Array<{
id: number;
question: string;
answer?: string;
fieldType: string;
}> | null;
info?: {
lastUpdated?: string;
updatedBy?: string;
dateEntered?: string;
enteredBy?: string;
};
key?: string;
value?: string;
description?: string;
createdAt?: string;
updatedAt?: string;
[key: string]: unknown;
}
export interface PageData {
company: CompanyData | null;
configurations: ConfigurationData[];
credentials: Credential[];
credentialTypes: CredentialType[];
unifiSites: UnifiSite[];
accessToken: string | null;
permissions: PermissionMap;
}
// Shared utility functions
export function companyInitials(name: string): string {
return name
.split(/\s+/)
.slice(0, 2)
.map((w) => w[0])
.join("")
.toUpperCase();
}
export function statusClass(status?: string): string {
if (!status) return "neutral";
const s = status.toLowerCase();
if (s === "active") return "active";
if (s === "inactive" || s === "disabled") return "inactive";
if (s === "pending") return "pending";
return "neutral";
}
export function configStatusClass(statusName?: string): string {
if (!statusName) return "neutral";
const s = statusName.toLowerCase();
if (s === "active") return "active";
if (s === "inactive" || s === "automate inactive") return "inactive";
if (s === "reserved") return "reserved";
if (s === "provisioning" || s === "pending approval") return "pending";
return "neutral";
}
export function formatDate(dateStr?: string): string {
if (!dateStr) return "";
try {
return new Date(dateStr).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
} catch {
return "";
}
}
export function formatPhone(phone?: string): string {
if (!phone) return "";
const digits = phone.replace(/\D/g, "");
if (digits.length === 10) {
return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`;
}
if (digits.length === 11 && digits.startsWith("1")) {
return `+1 (${digits.slice(1, 4)}) ${digits.slice(4, 7)}-${digits.slice(7)}`;
}
return phone;
}
export function formatValueTypeLabel(vt: string): string {
return vt
.split("_")
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" ");
}
export function formatAddress(c: CompanyData): string[] {
const addr = c.cw_Data?.address;
if (addr) {
const lines: string[] = [];
if (addr.line1) lines.push(addr.line1);
if (addr.line2) lines.push(addr.line2);
const cityStateZip = [addr.city, addr.state, addr.zip]
.filter(Boolean)
.join(", ");
if (cityStateZip) lines.push(cityStateZip);
if (addr.country) lines.push(addr.country);
return lines;
}
const lines: string[] = [];
if (c.address) lines.push(c.address);
const cityStateZip = [c.city, c.state, c.zip].filter(Boolean).join(", ");
if (cityStateZip) lines.push(cityStateZip);
if (c.country) lines.push(c.country);
return lines;
}
export function formatUptime(seconds: number): string {
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const mins = Math.floor((seconds % 3600) / 60);
if (days > 0) return `${days}d ${hours}h`;
if (hours > 0) return `${hours}h ${mins}m`;
return `${mins}m`;
}
-12
View File
@@ -1,12 +0,0 @@
<script>
import { goto } from "$app/navigation";
// Redirect legacy /company to /companies
goto("/companies");
</script>
<svelte:head>
<meta http-equiv="refresh" content="0;url=/companies" />
<title>Redirecting...</title>
</svelte:head>
<p>Redirecting to <a href="/companies">/companies</a></p>
-52
View File
@@ -1,52 +0,0 @@
import { company } from "$lib/companies";
import type { PageServerLoad } from "./$types";
import { error } from "@sveltejs/kit";
export const load: PageServerLoad = async ({ params, parent }) => {
const { session } = await parent();
if (!session.accessToken) {
throw error(401, "Unauthorized: Access token required");
}
try {
const companyData = await company.fetch(session.accessToken, params.id);
if (!companyData) {
throw error(404, `Company with ID ${params.id} not found`);
}
// attempt to load configurations but don't fail the whole page if it errors
let configurations = null;
let configurationsError = null;
try {
configurations = await company.fetchConfigurations(
session.accessToken,
params.id,
);
} catch (cfgErr) {
console.error("Failed to fetch configurations:", cfgErr);
configurationsError = String(
cfgErr instanceof Error ? cfgErr.message : cfgErr,
);
}
return {
company: companyData,
configurations,
configurationsError,
};
} catch (err) {
console.error("Failed to fetch company:", err);
if (err instanceof Error && err.message.includes("404")) {
throw error(404, `Company with ID ${params.id} not found`);
}
if (err instanceof Error && err.message.includes("401")) {
throw error(401, "Your session has expired. Please log in again.");
}
throw error(500, "Failed to load company details. Please try again.");
}
};
-25
View File
@@ -1,25 +0,0 @@
<script>
import { onMount } from "svelte";
import { page } from "$app/stores";
import { goto } from "$app/navigation";
// client-side redirect from legacy /company/:id to /companies/:id
onMount(() => {
const unsubscribe = page.subscribe(($page) => {
const id = $page.params?.id;
if (id) {
goto(`/companies/${id}`, { replaceState: true });
} else {
goto("/companies", { replaceState: true });
}
});
unsubscribe();
});
</script>
<svelte:head>
<meta http-equiv="refresh" content="0;url=/companies" />
<title>Redirecting...</title>
</svelte:head>
<p>Redirecting to <a href="/companies">/companies</a></p>
-11
View File
@@ -1,11 +0,0 @@
import { describe, test, expect } from 'vitest';
import '@testing-library/jest-dom/vitest';
import { render, screen } from '@testing-library/svelte';
import Page from './+page.svelte';
describe('/+page.svelte', () => {
test('should render h1', () => {
render(Page);
expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument();
});
});
+480
View File
@@ -0,0 +1,480 @@
/*
Admin Pane + Tab Bar Layout
*/
/* Page container */
.admin-page {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
width: 100%;
}
/* ── Pane container ── */
.admin-pane {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
background: var(--bg-surface);
border-radius: 12px;
box-shadow: var(--header-shadow);
overflow: hidden;
}
/* ── Pane header ── */
.admin-header {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
padding: 20px 24px 16px;
border-bottom: none;
flex-shrink: 0;
}
.admin-header-left {
display: flex;
align-items: center;
gap: 12px;
}
.admin-header-icon {
width: 22px;
height: 22px;
color: var(--text-secondary);
flex-shrink: 0;
}
.admin-title {
margin: 0;
font-size: 20px;
font-weight: 600;
color: var(--text-primary);
}
.admin-subtitle {
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
margin-left: 4px;
}
/* ── Tab bar (mirrors companydetail.css) ── */
.admin-pane .tab-bar {
display: flex;
align-items: stretch;
gap: 0;
padding: 0 24px;
border-bottom: 1px solid var(--border-subtle);
flex-shrink: 0;
background: var(--bg-surface);
}
.admin-pane .tab-btn {
position: relative;
display: inline-flex;
align-items: center;
padding: 14px 16px 12px;
border: none;
background: none;
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
cursor: pointer;
transition: color 0.15s;
white-space: nowrap;
text-decoration: none;
}
.admin-pane .tab-btn::after {
content: "";
position: absolute;
bottom: -1px;
left: 0;
right: 0;
height: 2px;
border-radius: 2px 2px 0 0;
background: transparent;
transition: background 0.15s;
}
.admin-pane .tab-btn:hover {
color: var(--text-primary);
}
.admin-pane .tab-btn.active {
color: var(--text-primary);
font-weight: 600;
}
.admin-pane .tab-btn.active::after {
background: var(--input-focus-border);
}
/* ── Pane body (tab content area) ── */
.admin-body {
position: relative;
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 24px;
}
/* ── Stats grid ── */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 16px;
margin-bottom: 32px;
}
.stat-card {
display: flex;
align-items: flex-start;
gap: 14px;
padding: 20px;
background: var(--card-bg);
border: 1px solid var(--card-border);
border-radius: 10px;
transition:
background 0.2s,
border-color 0.2s,
box-shadow 0.2s;
}
.stat-card:hover {
background: var(--card-hover-bg);
border-color: var(--card-hover-border);
box-shadow: var(--card-hover-shadow);
}
.stat-icon {
display: flex;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
border-radius: 10px;
background: linear-gradient(
135deg,
var(--avatar-gradient-from),
var(--avatar-gradient-to)
);
flex-shrink: 0;
}
.stat-icon svg {
width: 20px;
height: 20px;
color: var(--text-inverse);
stroke: currentColor;
}
.stat-info {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.stat-value {
font-size: 24px;
font-weight: 700;
color: var(--text-primary);
line-height: 1.2;
}
.stat-label {
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
}
/* ── Section headings ── */
.section-heading {
margin: 0 0 16px;
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
}
/* ── Quick-action cards ── */
.actions-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 16px;
margin-bottom: 32px;
}
.action-card {
display: flex;
align-items: center;
gap: 16px;
padding: 18px 20px;
background: var(--card-bg);
border: 1px solid var(--card-border);
border-radius: 10px;
cursor: pointer;
text-decoration: none;
color: inherit;
transition:
background 0.2s,
border-color 0.2s,
box-shadow 0.2s;
position: relative;
}
.action-card:hover {
background: var(--card-hover-bg);
border-color: var(--card-hover-border);
box-shadow: var(--card-hover-shadow);
}
.action-card:active {
box-shadow: var(--card-active-shadow);
}
.action-icon {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 10px;
background: var(--nav-active-bg);
flex-shrink: 0;
}
.action-icon svg {
width: 20px;
height: 20px;
color: var(--nav-active-color);
stroke: currentColor;
}
.action-text {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.action-name {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.action-desc {
font-size: 12px;
color: var(--text-secondary);
}
.action-arrow {
position: absolute;
right: 16px;
width: 16px;
height: 16px;
color: var(--card-arrow-color);
opacity: 0;
transform: translateX(-4px);
transition:
opacity 0.2s,
transform 0.2s;
}
.action-card:hover .action-arrow {
opacity: 1;
transform: translateX(0);
}
/* ── Activity placeholder ── */
.activity-section {
margin-bottom: 32px;
}
.activity-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 20px;
background: var(--bg-inset);
border: 1px dashed var(--border-default);
border-radius: 10px;
color: var(--text-muted);
font-size: 14px;
gap: 8px;
}
.activity-empty svg {
width: 32px;
height: 32px;
color: var(--text-faint);
}
/* ── Scrollbar ── */
.admin-body::-webkit-scrollbar {
width: 6px;
}
.admin-body::-webkit-scrollbar-track {
background: transparent;
}
.admin-body::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb);
border-radius: 3px;
}
.admin-body::-webkit-scrollbar-thumb:hover {
background: var(--scrollbar-thumb-hover);
}
/*
Admin Tab Empty State
*/
.admin-tab-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
color: var(--text-muted);
font-size: 14px;
gap: 12px;
}
.admin-tab-empty svg {
width: 40px;
height: 40px;
color: var(--text-faint);
}
.admin-tab-empty h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
}
.admin-tab-empty p {
margin: 0;
color: var(--text-secondary);
font-size: 13px;
text-align: center;
max-width: 360px;
}
/*
Admin Permission Denied State
*/
.admin-denied {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
gap: 12px;
}
.admin-denied svg {
width: 40px;
height: 40px;
color: var(--status-inactive-color);
}
.admin-denied h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
}
.admin-denied p {
margin: 0;
color: var(--text-secondary);
font-size: 13px;
text-align: center;
max-width: 360px;
}
/*
Admin Data Tables (Users, Roles, Cred Types)
*/
.admin-table-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
flex-wrap: wrap;
gap: 12px;
}
.admin-table-header h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
}
.admin-table-header .result-count {
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
margin-left: 8px;
}
.admin-table-wrap {
border: 1px solid var(--card-border);
border-radius: 10px;
overflow: hidden;
}
.admin-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.admin-table thead {
background: var(--bg-inset);
}
.admin-table th {
padding: 10px 16px;
text-align: left;
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
border-bottom: 1px solid var(--card-border);
white-space: nowrap;
}
.admin-table td {
padding: 12px 16px;
color: var(--text-primary);
border-bottom: 1px solid var(--border-subtle);
vertical-align: middle;
}
.admin-table tbody tr {
transition: background 0.15s;
}
.admin-table tbody tr:hover {
background: var(--card-hover-bg);
}
.admin-table tbody tr:last-child td {
border-bottom: none;
}
+591
View File
@@ -0,0 +1,591 @@
/* ── Credential Types Admin Page ── */
/* ── Table header layout ── */
:global(.admin-table-header) {
display: flex;
align-items: center;
justify-content: space-between;
}
.create-ct-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 14px;
background: var(--accent-color, #0066cc);
color: #fff;
border: none;
border-radius: 7px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: filter 0.15s;
flex-shrink: 0;
}
.create-ct-btn:hover {
filter: brightness(1.1);
}
/* ── Search bar ── */
.ct-search-wrap {
position: relative;
display: flex;
align-items: center;
}
.ct-search-icon {
position: absolute;
left: 10px;
color: var(--text-muted);
pointer-events: none;
}
.ct-search-input {
padding: 7px 12px 7px 30px;
border: 1px solid var(--border-subtle);
border-radius: 7px;
background: var(--bg-inset);
font-size: 13px;
color: var(--text-primary);
width: 220px;
transition:
border-color 0.15s,
box-shadow 0.15s;
}
.ct-search-input::placeholder {
color: var(--text-muted);
}
.ct-search-input:focus {
outline: none;
border-color: var(--input-focus-border);
box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.1);
}
/* ── Credential Type row ── */
.ct-row {
cursor: pointer;
}
.ct-row.expanded {
background: var(--card-hover-bg);
}
.ct-name-cell {
display: flex;
align-items: center;
gap: 10px;
}
.ct-icon {
width: 18px;
height: 18px;
color: var(--text-muted);
flex-shrink: 0;
}
.ct-name {
font-weight: 600;
color: var(--text-primary);
}
.ct-scope {
font-family: ui-monospace, "SF Mono", "Cascadia Code", Menlo, monospace;
font-size: 12px;
color: var(--text-secondary);
background: var(--bg-inset);
padding: 2px 8px;
border-radius: 4px;
}
.ct-field-count {
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
}
.ct-cred-count {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
}
.ct-cred-count svg {
color: var(--text-muted);
flex-shrink: 0;
}
/* ── Expanded detail row ── */
.ct-detail-row td {
padding: 0 !important;
border-bottom: 1px solid var(--border-subtle);
}
.ct-detail-content {
padding: 16px 24px 20px;
background: var(--bg-inset);
animation: ctDetailFadeIn 0.15s ease;
}
@keyframes ctDetailFadeIn {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.ct-detail-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
.ct-detail-section {
margin-bottom: 0;
}
.ct-detail-heading {
display: flex;
align-items: center;
gap: 6px;
margin: 0 0 10px;
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
.ct-detail-empty {
margin: 0;
font-size: 13px;
color: var(--text-muted);
}
/* ── Field cards in detail view ── */
.ct-field-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.ct-field-card {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: 7px;
}
.ct-field-icon {
width: 28px;
height: 28px;
border-radius: 6px;
background: var(--status-neutral-bg);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: var(--text-muted);
}
.ct-field-info {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
flex: 1;
}
.ct-field-name {
font-size: 12.5px;
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ct-field-meta {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: var(--text-muted);
}
.ct-field-badge {
display: inline-flex;
align-items: center;
gap: 3px;
font-size: 10px;
font-weight: 500;
padding: 1px 6px;
border-radius: 4px;
background: var(--status-neutral-bg);
color: var(--text-secondary);
border: 1px solid var(--border-subtle);
}
.ct-field-badge.required {
background: rgba(0, 102, 204, 0.08);
color: var(--accent-color, #0066cc);
border-color: rgba(0, 102, 204, 0.2);
}
.ct-field-badge.secure {
background: rgba(220, 38, 38, 0.06);
color: #dc2626;
border-color: rgba(220, 38, 38, 0.15);
}
/* ── Sub-field display in detail view ── */
.ct-subfield-group {
display: flex;
gap: 0;
padding-left: 14px;
position: relative;
}
.ct-subfield-connector {
width: 16px;
flex-shrink: 0;
position: relative;
}
.ct-subfield-connector::before {
content: "";
position: absolute;
left: 0;
top: -3px;
bottom: 50%;
width: 1px;
background: var(--border-subtle);
}
.ct-subfield-connector::after {
content: "";
position: absolute;
left: 0;
top: 50%;
width: 12px;
height: 1px;
background: var(--border-subtle);
display: none;
}
.ct-subfield-list {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
}
.ct-subfield-card {
padding: 6px 10px;
border-style: dashed;
background: var(--bg-inset);
}
.ct-subfield-icon {
width: 24px;
height: 24px;
border-radius: 5px;
}
/* ── Detail info fields ── */
.ct-detail-fields {
display: flex;
flex-direction: column;
gap: 8px;
}
.ct-detail-field {
display: flex;
flex-direction: column;
gap: 2px;
}
.detail-label {
font-size: 11px;
font-weight: 500;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.detail-value {
font-size: 13px;
color: var(--text-primary);
}
.detail-mono {
font-family: ui-monospace, "SF Mono", "Cascadia Code", Menlo, monospace;
font-size: 12px;
}
/* ── Three-dot menu ── */
.ct-menu {
position: fixed;
z-index: 200;
min-width: 130px;
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18);
padding: 4px;
animation: menuIn 0.1s ease;
}
.ct-menu-item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 6px 10px;
background: none;
border: none;
border-radius: 5px;
font-size: 13px;
font-weight: 400;
color: var(--text-primary);
cursor: pointer;
text-align: left;
transition: background 0.1s;
}
.ct-menu-item:hover {
background: var(--card-hover-bg);
}
.ct-menu-item svg {
color: var(--text-muted);
flex-shrink: 0;
}
.ct-menu-item-danger {
color: #dc2626;
}
.ct-menu-item-danger svg {
color: #dc2626;
}
.ct-menu-item-danger:hover {
background: rgba(220, 38, 38, 0.08);
}
.ct-menu-sep {
height: 1px;
background: var(--border-subtle);
margin: 3px 4px;
}
/* ── Delete confirmation overlay ── */
.confirm-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.confirm-dialog {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: 12px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.25);
width: 90%;
max-width: 380px;
padding: 28px 24px 22px;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
animation: modalIn 0.15s ease;
}
.confirm-icon-wrap {
width: 44px;
height: 44px;
border-radius: 50%;
background: rgba(220, 38, 38, 0.1);
border: 1px solid rgba(220, 38, 38, 0.2);
display: flex;
align-items: center;
justify-content: center;
color: #dc2626;
margin-bottom: 14px;
}
.confirm-title {
margin: 0 0 8px;
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
}
.confirm-body {
margin: 0 0 16px;
font-size: 13.5px;
color: var(--text-secondary);
line-height: 1.5;
}
.confirm-error {
margin: 0 0 12px;
font-size: 13px;
color: #dc2626;
}
.confirm-actions {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
justify-content: center;
}
.confirm-actions form {
display: contents;
}
.btn-cancel {
padding: 7px 16px;
border-radius: 7px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
background: var(--bg-inset);
border: 1px solid var(--border-subtle);
color: var(--text-secondary);
transition:
background 0.15s,
border-color 0.15s;
}
.btn-cancel:hover:not(:disabled) {
background: var(--card-hover-bg);
border-color: var(--border-default);
color: var(--text-primary);
}
.btn-cancel:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.btn-delete-confirm {
padding: 7px 16px;
border-radius: 7px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
background: #dc2626;
border: 1px solid transparent;
color: #fff;
transition: filter 0.15s;
}
.btn-delete-confirm:hover:not(:disabled) {
filter: brightness(1.1);
}
.btn-delete-confirm:disabled {
opacity: 0.45;
cursor: not-allowed;
}
/* ── Shared ── */
.detail-count {
font-size: 11px;
font-weight: 500;
color: var(--text-muted);
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
padding: 1px 6px;
border-radius: 8px;
margin-left: 2px;
}
.row-end-cell {
width: 1%;
white-space: nowrap;
}
.row-end-content {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 6px;
}
.row-chevron {
transition: transform 0.2s ease;
color: var(--text-muted);
}
.row-chevron.open {
transform: rotate(180deg);
}
.menu-wrap {
position: relative;
}
.menu-btn {
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
padding: 0;
background: none;
border: 1px solid transparent;
border-radius: 6px;
cursor: pointer;
color: var(--text-muted);
transition:
background 0.12s,
border-color 0.12s,
color 0.12s;
}
.menu-btn:hover {
background: var(--card-hover-bg);
border-color: var(--border-subtle);
color: var(--text-primary);
}
@keyframes menuIn {
from {
opacity: 0;
transform: translateY(-4px) scale(0.97);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes modalIn {
from {
opacity: 0;
transform: translateY(8px) scale(0.97);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
+481
View File
@@ -0,0 +1,481 @@
/* ── Roles Admin Page ── */
/* ── Table header layout ── */
:global(.admin-table-header) {
display: flex;
align-items: center;
justify-content: space-between;
}
.create-role-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 14px;
background: var(--accent-color, #0066cc);
color: #fff;
border: none;
border-radius: 7px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: filter 0.15s;
flex-shrink: 0;
}
.create-role-btn:hover {
filter: brightness(1.1);
}
/* ── Role-specific styles ── */
.role-row {
cursor: pointer;
}
.role-row.expanded {
background: var(--card-hover-bg);
}
.role-title-cell {
display: flex;
align-items: center;
gap: 10px;
}
.role-icon {
width: 18px;
height: 18px;
color: var(--text-muted);
flex-shrink: 0;
}
.role-title {
font-weight: 600;
color: var(--text-primary);
}
.role-moniker {
font-family: ui-monospace, "SF Mono", "Cascadia Code", Menlo, monospace;
font-size: 12px;
color: var(--text-secondary);
background: var(--bg-inset);
padding: 2px 8px;
border-radius: 4px;
}
.role-perm-count {
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
}
.role-user-count {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
}
.role-user-count svg {
color: var(--text-muted);
flex-shrink: 0;
}
/* Expanded detail row */
.role-detail-row td {
padding: 0 !important;
border-bottom: 1px solid var(--border-subtle);
}
.role-detail-content {
padding: 16px 24px 20px;
background: var(--bg-inset);
animation: roleDetailFadeIn 0.15s ease;
}
@keyframes roleDetailFadeIn {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.role-detail-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
.role-detail-section {
margin-bottom: 0;
}
.role-detail-heading {
display: flex;
align-items: center;
gap: 6px;
margin: 0 0 10px;
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
.role-detail-empty {
margin: 0;
font-size: 13px;
color: var(--text-muted);
}
/* Permission tags */
.permission-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.permission-tag {
display: inline-block;
font-family: ui-monospace, "SF Mono", "Cascadia Code", Menlo, monospace;
font-size: 11px;
font-weight: 500;
padding: 3px 8px;
border-radius: 5px;
background: var(--status-neutral-bg);
color: var(--text-secondary);
border: 1px solid var(--border-subtle);
}
/* User list in role detail */
.user-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.user-card {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 10px;
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: 7px;
}
.user-avatar {
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--accent-color, #0066cc);
color: #fff;
font-size: 10.5px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
letter-spacing: 0.02em;
}
.user-info {
display: flex;
flex-direction: column;
gap: 1px;
min-width: 0;
}
.user-name {
font-size: 12.5px;
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.user-login {
font-size: 11px;
color: var(--text-muted);
font-family: ui-monospace, "SF Mono", "Cascadia Code", Menlo, monospace;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ── Three-dot menu ── */
.role-menu {
position: fixed;
z-index: 200;
min-width: 130px;
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18);
padding: 4px;
animation: menuIn 0.1s ease;
}
.role-menu-item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 6px 10px;
background: none;
border: none;
border-radius: 5px;
font-size: 13px;
font-weight: 400;
color: var(--text-primary);
cursor: pointer;
text-align: left;
transition: background 0.1s;
}
.role-menu-item:hover {
background: var(--card-hover-bg);
}
.role-menu-item svg {
color: var(--text-muted);
flex-shrink: 0;
}
.role-menu-item-danger {
color: #dc2626;
}
.role-menu-item-danger svg {
color: #dc2626;
}
.role-menu-item-danger:hover {
background: rgba(220, 38, 38, 0.08);
}
.role-menu-sep {
height: 1px;
background: var(--border-subtle);
margin: 3px 4px;
}
/* ── System badge ── */
.system-badge {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
font-weight: 500;
padding: 2px 8px;
border-radius: 5px;
background: var(--bg-inset);
color: var(--text-muted);
border: 1px solid var(--border-subtle);
white-space: nowrap;
}
/* ── Delete confirmation overlay ── */
.confirm-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.confirm-dialog {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: 12px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.25);
width: 90%;
max-width: 380px;
padding: 28px 24px 22px;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
animation: modalIn 0.15s ease;
}
.confirm-icon-wrap {
width: 44px;
height: 44px;
border-radius: 50%;
background: rgba(220, 38, 38, 0.1);
border: 1px solid rgba(220, 38, 38, 0.2);
display: flex;
align-items: center;
justify-content: center;
color: #dc2626;
margin-bottom: 14px;
}
.confirm-title {
margin: 0 0 8px;
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
}
.confirm-body {
margin: 0 0 16px;
font-size: 13.5px;
color: var(--text-secondary);
line-height: 1.5;
}
.confirm-error {
margin: 0 0 12px;
font-size: 13px;
color: #dc2626;
}
.confirm-actions {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
justify-content: center;
}
.confirm-actions form {
display: contents;
}
.btn-cancel {
padding: 7px 16px;
border-radius: 7px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
background: var(--bg-inset);
border: 1px solid var(--border-subtle);
color: var(--text-secondary);
transition:
background 0.15s,
border-color 0.15s;
}
.btn-cancel:hover:not(:disabled) {
background: var(--card-hover-bg);
border-color: var(--border-default);
color: var(--text-primary);
}
.btn-cancel:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.btn-delete-confirm {
padding: 7px 16px;
border-radius: 7px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
background: #dc2626;
border: 1px solid transparent;
color: #fff;
transition: filter 0.15s;
}
.btn-delete-confirm:hover:not(:disabled) {
filter: brightness(1.1);
}
.btn-delete-confirm:disabled {
opacity: 0.45;
cursor: not-allowed;
}
/* ── Shared ── */
.detail-count {
font-size: 11px;
font-weight: 500;
color: var(--text-muted);
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
padding: 1px 6px;
border-radius: 8px;
margin-left: 2px;
}
.row-end-cell {
width: 1%;
white-space: nowrap;
}
.row-end-content {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 6px;
}
.row-chevron {
transition: transform 0.2s ease;
color: var(--text-muted);
}
.row-chevron.open {
transform: rotate(180deg);
}
.menu-wrap {
position: relative;
}
.menu-btn {
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
padding: 0;
background: none;
border: 1px solid transparent;
border-radius: 6px;
cursor: pointer;
color: var(--text-muted);
transition:
background 0.12s,
border-color 0.12s,
color 0.12s;
}
.menu-btn:hover {
background: var(--card-hover-bg);
border-color: var(--border-subtle);
color: var(--text-primary);
}
@keyframes menuIn {
from {
opacity: 0;
transform: translateY(-4px) scale(0.97);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes modalIn {
from {
opacity: 0;
transform: translateY(8px) scale(0.97);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
+575
View File
@@ -0,0 +1,575 @@
/* ── Users Admin Page ── */
/* ── Search bar ── */
.user-search-wrap {
position: relative;
display: flex;
align-items: center;
}
.user-search-icon {
position: absolute;
left: 10px;
color: var(--text-muted);
pointer-events: none;
}
.user-search-input {
padding: 7px 12px 7px 30px;
border: 1px solid var(--border-subtle);
border-radius: 7px;
background: var(--bg-inset);
font-size: 13px;
color: var(--text-primary);
width: 220px;
transition:
border-color 0.15s,
box-shadow 0.15s;
}
.user-search-input::placeholder {
color: var(--text-muted);
}
.user-search-input:focus {
outline: none;
border-color: var(--input-focus-border);
box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.1);
}
/* ── User row ── */
.user-row {
cursor: pointer;
}
.user-row.expanded {
background: var(--card-hover-bg);
}
.user-name-cell {
display: flex;
align-items: center;
gap: 10px;
}
.user-table-avatar {
width: 30px;
height: 30px;
border-radius: 50%;
flex-shrink: 0;
object-fit: cover;
}
.user-table-avatar-initials {
background: var(--accent-color, #0066cc);
color: #fff;
font-size: 11px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
letter-spacing: 0.02em;
}
.user-table-name {
font-weight: 600;
color: var(--text-primary);
}
.user-email {
font-size: 12.5px;
color: var(--text-secondary);
}
.user-login-mono {
font-family: ui-monospace, "SF Mono", "Cascadia Code", Menlo, monospace;
font-size: 12px;
color: var(--text-secondary);
background: var(--bg-inset);
padding: 2px 8px;
border-radius: 4px;
}
.user-role-count {
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
}
/* ── Expanded detail row ── */
.user-detail-row td {
padding: 0 !important;
border-bottom: 1px solid var(--border-subtle);
}
.user-detail-content {
padding: 16px 24px 20px;
background: var(--bg-inset);
animation: userDetailFadeIn 0.15s ease;
}
@keyframes userDetailFadeIn {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.user-detail-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
.user-detail-section {
margin-bottom: 0;
}
.user-detail-heading {
display: flex;
align-items: center;
gap: 6px;
margin: 0 0 10px;
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
.user-detail-empty {
margin: 0;
font-size: 13px;
color: var(--text-muted);
}
/* Detail fields */
.user-detail-fields {
display: flex;
flex-direction: column;
gap: 6px;
}
.user-detail-field {
display: flex;
align-items: baseline;
gap: 10px;
}
.detail-label {
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
min-width: 60px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.detail-value {
font-size: 13px;
color: var(--text-primary);
word-break: break-all;
}
.detail-mono {
font-family: ui-monospace, "SF Mono", "Cascadia Code", Menlo, monospace;
font-size: 12px;
}
/* ── Role cards in expanded row ── */
.user-role-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.user-role-card {
padding: 10px 12px;
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: 7px;
}
.user-role-card-header {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 6px;
}
.user-role-card-header svg {
color: var(--text-muted);
flex-shrink: 0;
}
.user-role-title {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
.user-role-moniker {
font-family: ui-monospace, "SF Mono", "Cascadia Code", Menlo, monospace;
font-size: 11px;
color: var(--text-muted);
background: var(--bg-inset);
padding: 1px 6px;
border-radius: 4px;
margin-left: auto;
}
/* Permission tags */
.permission-tags {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.permission-tag {
display: inline-block;
font-family: ui-monospace, "SF Mono", "Cascadia Code", Menlo, monospace;
font-size: 10.5px;
font-weight: 500;
padding: 2px 7px;
border-radius: 5px;
background: var(--status-neutral-bg);
color: var(--text-secondary);
border: 1px solid var(--border-subtle);
}
.permission-tag-more {
background: var(--bg-inset);
color: var(--text-muted);
font-style: italic;
font-family: inherit;
}
/* ── Three-dot menu ── */
.user-menu {
position: fixed;
z-index: 200;
min-width: 130px;
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18);
padding: 4px;
animation: menuIn 0.1s ease;
}
.user-menu-item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 6px 10px;
background: none;
border: none;
border-radius: 5px;
font-size: 13px;
font-weight: 400;
color: var(--text-primary);
cursor: pointer;
text-align: left;
transition: background 0.1s;
}
.user-menu-item:hover {
background: var(--card-hover-bg);
}
.user-menu-item svg {
color: var(--text-muted);
flex-shrink: 0;
}
.user-menu-item-danger {
color: #dc2626;
}
.user-menu-item-danger svg {
color: #dc2626;
}
.user-menu-item-danger:hover {
background: rgba(220, 38, 38, 0.08);
}
.user-menu-sep {
height: 1px;
background: var(--border-subtle);
margin: 3px 4px;
}
/* ── Edit dialog ── */
.edit-dialog {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: 12px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.25);
width: 90%;
max-width: 520px;
max-height: 85vh;
overflow-y: auto;
padding: 28px 24px 22px;
animation: modalIn 0.15s ease;
}
.edit-dialog-title {
margin: 0 0 4px;
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
}
.edit-dialog-sub {
margin: 0 0 18px;
font-size: 13px;
color: var(--text-secondary);
}
.edit-field {
display: flex;
flex-direction: column;
gap: 5px;
margin-bottom: 14px;
}
.edit-field label {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.edit-field input {
padding: 8px 12px;
border: 1px solid var(--border-subtle);
border-radius: 7px;
background: var(--bg-inset);
font-size: 13px;
color: var(--text-primary);
transition:
border-color 0.15s,
box-shadow 0.15s;
}
.edit-field input:focus {
outline: none;
border-color: var(--input-focus-border);
box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.1);
}
.btn-save {
padding: 7px 16px;
border-radius: 7px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
background: var(--accent-color, #0066cc);
border: 1px solid transparent;
color: #fff;
transition: filter 0.15s;
}
.btn-save:hover:not(:disabled) {
filter: brightness(1.1);
}
.btn-save:disabled {
opacity: 0.45;
cursor: not-allowed;
}
/* ── Edit modal sections (roles & permissions) ── */
.edit-section {
margin-bottom: 16px;
border-top: 1px solid var(--border-subtle);
padding-top: 14px;
}
.edit-section-label {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
margin-bottom: 10px;
}
.edit-section-label svg {
color: var(--text-muted);
flex-shrink: 0;
}
.edit-section-count {
font-size: 11px;
font-weight: 500;
color: var(--text-muted);
text-transform: none;
letter-spacing: normal;
margin-left: auto;
}
/* ── Role chips ── */
.edit-role-list {
display: flex;
flex-direction: column;
gap: 4px;
max-height: 160px;
overflow-y: auto;
padding-right: 4px;
}
.edit-role-chip {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 10px;
background: var(--bg-inset);
border: 1px solid var(--border-subtle);
border-radius: 6px;
cursor: pointer;
font-size: 13px;
color: var(--text-primary);
text-align: left;
transition:
background 0.12s,
border-color 0.12s;
}
.edit-role-chip:hover {
background: var(--card-hover-bg);
}
.edit-role-chip.selected {
background: rgba(0, 102, 204, 0.08);
border-color: var(--accent-color, #0066cc);
}
.edit-role-check {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border-radius: 4px;
border: 1.5px solid var(--border-subtle);
flex-shrink: 0;
color: var(--accent-color, #0066cc);
transition: border-color 0.12s;
}
.edit-role-chip.selected .edit-role-check {
border-color: var(--accent-color, #0066cc);
background: var(--accent-color, #0066cc);
color: #fff;
}
.edit-role-chip-title {
font-weight: 600;
font-size: 13px;
}
.edit-role-chip-moniker {
font-family: ui-monospace, "SF Mono", "Cascadia Code", Menlo, monospace;
font-size: 11px;
color: var(--text-muted);
margin-left: auto;
}
/* ── Permission list ── */
.edit-perm-search-wrap {
margin-bottom: 8px;
}
.edit-perm-search {
width: 100%;
padding: 6px 10px;
border: 1px solid var(--border-subtle);
border-radius: 6px;
background: var(--bg-inset);
font-size: 12.5px;
color: var(--text-primary);
transition:
border-color 0.15s,
box-shadow 0.15s;
}
.edit-perm-search::placeholder {
color: var(--text-muted);
}
.edit-perm-search:focus {
outline: none;
border-color: var(--input-focus-border);
box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.1);
}
.edit-perm-list {
display: flex;
flex-direction: column;
gap: 2px;
max-height: 180px;
overflow-y: auto;
padding-right: 4px;
}
.edit-perm-item {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 8px;
border-radius: 5px;
cursor: pointer;
transition: background 0.1s;
}
.edit-perm-item:hover {
background: var(--card-hover-bg);
}
.edit-perm-item input[type="checkbox"] {
accent-color: var(--accent-color, #0066cc);
width: 14px;
height: 14px;
flex-shrink: 0;
cursor: pointer;
}
.edit-perm-node {
font-family: ui-monospace, "SF Mono", "Cascadia Code", Menlo, monospace;
font-size: 11.5px;
color: var(--text-primary);
}
.edit-perm-empty {
margin: 0;
padding: 8px;
font-size: 12.5px;
color: var(--text-muted);
text-align: center;
}
/* Scrollbars for edit lists */
.edit-role-list::-webkit-scrollbar,
.edit-perm-list::-webkit-scrollbar {
width: 5px;
}
.edit-role-list::-webkit-scrollbar-track,
.edit-perm-list::-webkit-scrollbar-track {
background: transparent;
}
.edit-role-list::-webkit-scrollbar-thumb,
.edit-perm-list::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb);
border-radius: 3px;
}
+279
View File
@@ -0,0 +1,279 @@
@import "tailwindcss";
@plugin '@tailwindcss/forms';
@plugin '@tailwindcss/typography';
/*
Theme Variables
*/
:root,
[data-theme="light"] {
/* Surfaces */
--bg-base: #f0f0f0;
--bg-surface: #ffffff;
--bg-surface-alt: #f8f9fb;
--bg-elevated: #ffffff;
--bg-inset: #f9fafb;
--bg-gradient-from: #3498db;
--bg-gradient-to: #2980b9;
/* Borders */
--border-default: #e0e0e0;
--border-subtle: #eef0f3;
--border-strong: #d1d5db;
/* Text */
--text-primary: #2c3e50;
--text-secondary: #666666;
--text-muted: #8492a6;
--text-faint: #6b7280;
--text-inverse: #ffffff;
/* Accent */
--accent: #3498db;
--nav-hover-bg: rgba(0, 0, 0, 0.04);
--nav-active-bg: rgba(52, 152, 219, 0.08);
--nav-active-color: #3498db;
--nav-active-border: #3498db;
/* Header */
--header-bg: #ffffff;
--header-text: #2c3e50;
--header-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
--accent-bar-from: #2980b9;
--accent-bar-to: #3498db;
--accent-bar-height: 0;
/* Cards */
--card-bg: #f8f9fb;
--card-hover-bg: #ffffff;
--card-border: #eef0f3;
--card-hover-border: #d0d9e8;
--card-hover-shadow:
0 8px 24px rgba(0, 0, 0, 0.08), 0 2px 8px rgba(0, 0, 0, 0.04);
--card-active-shadow: 0 4px 12px rgba(0, 0, 0, 0.06);
--card-focus-ring: #3498db;
--card-arrow-color: #cbd5e1;
/* Avatar */
--avatar-gradient-from: #3498db;
--avatar-gradient-to: #2980b9;
/* Inputs */
--input-bg: #f9fafb;
--input-focus-bg: #ffffff;
--input-border: #d1d5db;
--input-focus-border: #3498db;
--input-focus-ring: rgba(52, 152, 219, 0.12);
--input-text: #374151;
--input-placeholder: #9ca3af;
/* Pagination */
--page-btn-bg: #ffffff;
--page-btn-border: #d1d5db;
--page-btn-hover-bg: #f3f4f6;
--page-btn-hover-border: #9ca3af;
--page-btn-active-bg: #2c3e50;
--page-btn-active-border: #2c3e50;
--page-btn-active-color: #ffffff;
--page-btn-color: #374151;
/* Overlay */
--overlay-bg: rgba(255, 255, 255, 0.8);
--spinner-track: #eef0f3;
--spinner-accent: #3498db;
/* Scrollbar */
--scrollbar-thumb: rgba(0, 0, 0, 0.15);
--scrollbar-thumb-hover: rgba(0, 0, 0, 0.25);
/* Status labels */
--status-active-bg: #dcfce7;
--status-active-color: #15803d;
--status-inactive-bg: #fee2e2;
--status-inactive-color: #b91c1c;
--status-pending-bg: #fef3c7;
--status-pending-color: #a16207;
--status-reserved-bg: #dbeafe;
--status-reserved-color: #1d4ed8;
--status-provisioning-bg: #fef3c7;
--status-provisioning-color: #a16207;
--status-neutral-bg: #f1f5f9;
--status-neutral-color: #64748b;
--status-neutral-dot: #d1d5db;
--status-neutral-dot-ring: rgba(209, 213, 219, 0.3);
/* Toggle */
--toggle-bg: rgba(0, 0, 0, 0.08);
--toggle-hover-bg: rgba(0, 0, 0, 0.12);
--toggle-color: #666666;
/* Error illustration */
--error-circle-outer: #fef2f2;
--error-circle-stroke: #fecaca;
--error-circle-inner: #fee2e2;
color-scheme: light;
}
[data-theme="dark"] {
/* Surfaces */
--bg-base: #0e0e0e;
--bg-surface: #1a1a1a;
--bg-surface-alt: #141414;
--bg-elevated: #1c1c1c;
--bg-inset: #111111;
--bg-gradient-from: #12161e;
--bg-gradient-to: #181d28;
/* Borders */
--border-default: #2a2a2a;
--border-subtle: #262626;
--border-strong: #333333;
/* Text */
--text-primary: #e0e0e0;
--text-secondary: #737373;
--text-muted: #737373;
--text-faint: #525252;
--text-inverse: #0e0e0e;
/* Accent */
--accent: #333333;
/* Nav */
--nav-hover-bg: rgba(255, 255, 255, 0.04);
--nav-active-bg: rgba(255, 255, 255, 0.06);
--nav-active-color: #ffffff;
--nav-active-border: #ffffff;
/* Header */
--header-bg: #1a1a1a;
--header-text: #e0e0e0;
--header-shadow: none;
--accent-bar-from: #1a2a44;
--accent-bar-to: #0e1b33;
--accent-bar-height: 0;
/* Cards */
--card-bg: #141414;
--card-hover-bg: #1c1c1c;
--card-border: #262626;
--card-hover-border: #3d3d3d;
--card-hover-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
--card-active-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
--card-focus-ring: #a3a3a3;
--card-arrow-color: #a3a3a3;
/* Avatar */
--avatar-gradient-from: #333333;
--avatar-gradient-to: #262626;
/* Inputs */
--input-bg: #111111;
--input-focus-bg: #161616;
--input-border: #333333;
--input-focus-border: #525252;
--input-focus-ring: rgba(255, 255, 255, 0.04);
--input-text: #d4d4d4;
--input-placeholder: #525252;
/* Pagination */
--page-btn-bg: #1a1a1a;
--page-btn-border: #333333;
--page-btn-hover-bg: #262626;
--page-btn-hover-border: #404040;
--page-btn-active-bg: #ffffff;
--page-btn-active-border: #ffffff;
--page-btn-active-color: #0e0e0e;
--page-btn-color: #a3a3a3;
/* Overlay */
--overlay-bg: rgba(14, 14, 14, 0.85);
--spinner-track: #262626;
--spinner-accent: #a3a3a3;
/* Scrollbar */
--scrollbar-thumb: rgba(255, 255, 255, 0.15);
--scrollbar-thumb-hover: rgba(255, 255, 255, 0.3);
/* Status labels */
--status-active-bg: rgba(34, 197, 94, 0.12);
--status-active-color: #4ade80;
--status-inactive-bg: rgba(239, 68, 68, 0.12);
--status-inactive-color: #f87171;
--status-pending-bg: rgba(245, 158, 11, 0.12);
--status-pending-color: #fbbf24;
--status-reserved-bg: rgba(59, 130, 246, 0.12);
--status-reserved-color: #60a5fa;
--status-provisioning-bg: rgba(245, 158, 11, 0.12);
--status-provisioning-color: #fbbf24;
--status-neutral-bg: rgba(255, 255, 255, 0.06);
--status-neutral-color: #737373;
--status-neutral-dot: #525252;
--status-neutral-dot-ring: rgba(82, 82, 82, 0.3);
/* Toggle */
--toggle-bg: rgba(255, 255, 255, 0.08);
--toggle-hover-bg: rgba(255, 255, 255, 0.12);
--toggle-color: #a3a3a3;
/* Error illustration */
--error-circle-outer: rgba(239, 68, 68, 0.08);
--error-circle-stroke: rgba(239, 68, 68, 0.2);
--error-circle-inner: rgba(239, 68, 68, 0.12);
color-scheme: dark;
}
body {
margin: 0;
padding: 0;
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: transparent;
overflow: hidden;
}
/*
Theme Toggle Button
*/
.theme-toggle {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border: none;
border-radius: 8px;
background: var(--toggle-bg);
color: var(--toggle-color);
cursor: pointer;
transition:
background 0.2s,
color 0.2s;
margin-left: auto;
flex-shrink: 0;
}
.theme-toggle:hover {
background: var(--toggle-hover-bg);
color: var(--text-primary);
}
.theme-icon {
width: 18px;
height: 18px;
position: absolute;
opacity: 0;
transform: scale(0.6) rotate(-90deg);
transition:
opacity 0.25s ease,
transform 0.25s ease;
}
.theme-icon.visible {
opacity: 1;
transform: scale(1) rotate(0deg);
}
File diff suppressed because it is too large Load Diff
+616
View File
@@ -0,0 +1,616 @@
/*
Companies Page Pane + Card Grid Layout
*/
/* Page container */
.companies-page {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
width: 100%;
}
/* ── Pane container ── */
.companies-pane {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
background: var(--bg-surface);
border-radius: 12px;
box-shadow: var(--header-shadow);
overflow: hidden;
}
/* ── Pane header ── */
.pane-header {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
padding: 20px 24px 16px;
border-bottom: 1px solid var(--border-subtle);
flex-shrink: 0;
}
.pane-header-left {
display: flex;
align-items: baseline;
gap: 12px;
}
.page-title {
margin: 0;
font-size: 20px;
font-weight: 600;
color: var(--text-primary);
}
.result-count {
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
}
.search-bar {
position: relative;
width: 280px;
}
.search-bar input {
width: 100%;
padding: 9px 34px 9px 38px;
border: 1px solid var(--input-border);
border-radius: 8px;
font-size: 14px;
outline: none;
background: var(--input-bg);
color: var(--input-text);
transition:
border-color 0.2s,
box-shadow 0.2s;
}
.search-bar input::placeholder {
color: var(--input-placeholder);
}
.search-bar input:focus {
border-color: var(--input-focus-border);
box-shadow: 0 0 0 3px var(--input-focus-ring);
background: var(--input-focus-bg);
}
.search-icon {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
width: 16px;
height: 16px;
color: var(--text-faint);
pointer-events: none;
}
.search-clear {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: var(--text-faint);
cursor: pointer;
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition:
color 0.15s,
background 0.15s;
}
.search-clear:hover {
color: var(--input-text);
background: var(--nav-hover-bg);
}
/* ── Pane body ── */
.pane-body {
position: relative;
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 20px 24px;
}
/* Search loading overlay */
.search-loading-overlay {
position: absolute;
inset: 0;
background: var(--overlay-bg);
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
backdrop-filter: blur(2px);
}
.search-spinner {
width: 36px;
height: 36px;
border-radius: 50%;
border: 4px solid var(--spinner-track);
border-top-color: var(--spinner-accent);
animation: search-spin 0.7s linear infinite;
}
@keyframes search-spin {
to {
transform: rotate(360deg);
}
}
/* ── Empty state ── */
.empty-state {
display: flex;
justify-content: center;
align-items: center;
flex: 1;
padding: 48px 16px;
min-height: 200px;
}
/*
Card Grid
*/
.card-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
/* ── Individual Card ── */
.company-card {
position: relative;
display: flex;
flex-direction: column;
gap: 14px;
padding: 18px;
background: var(--card-bg);
border-radius: 12px;
border: 1px solid var(--card-border);
box-shadow: none;
cursor: pointer;
transition:
transform 0.18s,
box-shadow 0.18s,
border-color 0.18s,
background 0.18s;
text-align: left;
font: inherit;
color: var(--input-text);
overflow: hidden;
width: 100%;
}
.company-card:hover {
transform: translateY(-3px);
box-shadow: var(--card-hover-shadow);
border-color: var(--card-hover-border);
background: var(--card-hover-bg);
}
.company-card:active {
transform: translateY(-1px);
box-shadow: var(--card-active-shadow);
}
.company-card:focus-visible {
outline: 2px solid var(--card-focus-ring);
outline-offset: 2px;
}
/* Card top: avatar + status dot */
.card-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
}
.card-avatar {
width: 44px;
height: 44px;
border-radius: 12px;
background: linear-gradient(
135deg,
var(--avatar-gradient-from),
var(--avatar-gradient-to)
);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.avatar-initials {
font-size: 16px;
font-weight: 700;
color: #fff;
letter-spacing: 0.5px;
line-height: 1;
}
/* Status dot */
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
margin-top: 4px;
}
.status-dot.active {
background: #22c55e;
box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.2);
}
.status-dot.inactive {
background: #ef4444;
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.15);
}
.status-dot.pending {
background: #f59e0b;
box-shadow: 0 0 0 3px rgba(245, 158, 11, 0.15);
}
.status-dot.neutral {
background: var(--status-neutral-dot);
box-shadow: 0 0 0 3px var(--status-neutral-dot-ring);
}
/* Card body */
.card-body {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.card-name {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.3;
}
.card-email {
font-size: 13px;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Card meta */
.card-meta {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.meta-item {
display: flex;
align-items: center;
gap: 5px;
font-size: 12px;
color: var(--text-secondary);
}
.meta-icon {
width: 14px;
height: 14px;
flex-shrink: 0;
color: var(--text-faint);
}
.meta-item .mono {
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
font-size: 11px;
letter-spacing: -0.02em;
}
/* Card footer */
.card-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding-top: 10px;
border-top: 1px solid var(--border-subtle);
margin-top: auto;
}
.status-label {
font-size: 11px;
font-weight: 600;
padding: 3px 10px;
border-radius: 20px;
text-transform: capitalize;
letter-spacing: 0.02em;
}
.status-label.active {
background: var(--status-active-bg);
color: var(--status-active-color);
}
.status-label.inactive {
background: var(--status-inactive-bg);
color: var(--status-inactive-color);
}
.status-label.pending {
background: var(--status-pending-bg);
color: var(--status-pending-color);
}
.status-label.neutral {
background: var(--status-neutral-bg);
color: var(--status-neutral-color);
}
.card-date {
font-size: 12px;
color: var(--text-faint);
margin-left: auto;
}
/* Hover arrow */
.card-arrow {
position: absolute;
top: 20px;
right: 16px;
width: 18px;
height: 18px;
color: var(--card-arrow-color);
opacity: 0;
transform: translateX(-4px);
transition:
opacity 0.18s,
transform 0.18s;
}
.company-card:hover .card-arrow {
opacity: 1;
transform: translateX(0);
}
/*
Pagination Footer
*/
.pane-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px 24px;
border-top: 1px solid var(--border-subtle);
background: var(--bg-surface-alt);
flex-shrink: 0;
}
.page-info {
font-size: 13px;
color: var(--text-secondary);
}
.pagination {
display: flex;
align-items: center;
gap: 4px;
}
.page-btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 32px;
height: 32px;
padding: 0 6px;
border: 1px solid var(--page-btn-border);
border-radius: 6px;
background: var(--page-btn-bg);
color: var(--page-btn-color);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
}
.page-btn:hover:not(:disabled):not(.active) {
background: var(--page-btn-hover-bg);
border-color: var(--page-btn-hover-border);
}
.page-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.page-btn.active {
background: var(--page-btn-active-bg);
border-color: var(--page-btn-active-border);
color: var(--page-btn-active-color);
font-weight: 600;
}
.page-ellipsis {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
color: var(--text-faint);
font-size: 13px;
user-select: none;
}
/*
Responsive
*/
/* Tablets — 2 columns */
@media (max-width: 1024px) {
.card-grid {
grid-template-columns: repeat(2, 1fr);
gap: 14px;
}
}
/* Mobile — single column */
@media (max-width: 768px) {
.companies-pane {
border-radius: 10px;
}
.pane-header {
flex-direction: column;
align-items: stretch;
gap: 10px;
padding: 16px 16px 12px;
}
.pane-header-left {
justify-content: space-between;
width: 100%;
}
.page-title {
font-size: 18px;
}
.search-bar {
width: 100%;
}
.search-bar input {
font-size: 16px; /* prevents iOS zoom */
padding: 11px 34px 11px 40px;
}
.pane-body {
padding: 12px;
}
.card-grid {
grid-template-columns: 1fr;
gap: 10px;
}
.company-card {
padding: 14px;
border-radius: 10px;
gap: 12px;
}
.card-avatar {
width: 40px;
height: 40px;
border-radius: 10px;
}
.avatar-initials {
font-size: 15px;
}
.card-name {
font-size: 15px;
}
.card-arrow {
display: none;
}
.pane-footer {
flex-direction: column;
gap: 8px;
padding: 12px 16px;
}
.page-btn {
min-width: 36px;
height: 36px;
}
}
/* Small phones */
@media (max-width: 480px) {
.companies-pane {
border-radius: 8px;
}
.pane-header {
padding: 12px 12px 10px;
}
.page-title {
font-size: 16px;
}
.pane-body {
padding: 8px;
}
.company-card {
padding: 12px;
gap: 10px;
}
.card-avatar {
width: 36px;
height: 36px;
}
.avatar-initials {
font-size: 14px;
}
.card-name {
font-size: 14px;
}
.card-email {
font-size: 12px;
}
.status-label {
font-size: 10px;
padding: 2px 8px;
}
.card-date {
font-size: 11px;
}
.pane-footer {
padding: 10px 12px;
}
.page-btn {
min-width: 32px;
height: 32px;
font-size: 13px;
}
}
+233
View File
@@ -0,0 +1,233 @@
/*
Error Page Pane Layout
*/
/* Page container */
.error-page {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
width: 100%;
}
/* ── Pane container ── */
.error-pane {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
background: var(--bg-surface);
border-radius: 12px;
box-shadow:
0 4px 24px rgba(0, 0, 0, 0.08),
0 1px 4px rgba(0, 0, 0, 0.04);
overflow: hidden;
}
/* ── Pane header ── */
.error-pane-header {
display: flex;
align-items: center;
gap: 12px;
padding: 20px 24px 16px;
border-bottom: 1px solid var(--border-subtle);
flex-shrink: 0;
}
.error-pane-title {
margin: 0;
font-size: 20px;
font-weight: 600;
color: var(--text-primary);
}
.error-status-badge {
font-size: 12px;
font-weight: 600;
padding: 3px 10px;
border-radius: 20px;
background: var(--status-inactive-bg);
color: var(--status-inactive-color);
letter-spacing: 0.02em;
}
/* ── Pane body ── */
.error-pane-body {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 24px;
gap: 16px;
text-align: center;
overflow-y: auto;
}
/* Illustration */
.error-illustration {
margin-bottom: 8px;
}
/* Heading */
.error-heading {
margin: 0;
font-size: 22px;
font-weight: 700;
color: var(--text-primary);
line-height: 1.3;
}
/* Message */
.error-message {
margin: 0;
font-size: 15px;
color: var(--text-secondary);
line-height: 1.6;
max-width: 420px;
}
.error-hint {
margin: 0;
font-size: 13px;
color: var(--text-muted);
line-height: 1.5;
max-width: 420px;
}
/* Actions */
.error-actions {
display: flex;
gap: 12px;
justify-content: center;
flex-wrap: wrap;
margin-top: 8px;
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 20px;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
text-decoration: none;
transition: all 0.18s;
line-height: 1;
}
.btn-primary {
background: #3498db;
color: #fff;
}
.btn-primary:hover {
background: #2980b9;
box-shadow: 0 4px 12px rgba(52, 152, 219, 0.25);
transform: translateY(-1px);
}
.btn-primary:active {
transform: translateY(0);
box-shadow: 0 2px 6px rgba(52, 152, 219, 0.2);
}
.btn-secondary {
background: var(--nav-hover-bg);
color: var(--text-secondary);
border: 1px solid var(--border-default);
}
.btn-secondary:hover {
background: var(--nav-active-bg);
border-color: var(--border-strong);
transform: translateY(-1px);
}
.btn-secondary:active {
transform: translateY(0);
}
/*
Responsive
*/
@media (max-width: 768px) {
.error-pane {
border-radius: 10px;
}
.error-pane-header {
padding: 16px 16px 12px;
}
.error-pane-title {
font-size: 18px;
}
.error-pane-body {
padding: 32px 16px;
gap: 14px;
}
.error-heading {
font-size: 20px;
}
.error-message {
font-size: 14px;
}
.btn {
padding: 10px 18px;
font-size: 14px;
}
}
@media (max-width: 480px) {
.error-pane {
border-radius: 8px;
}
.error-pane-header {
padding: 12px 12px 10px;
}
.error-pane-title {
font-size: 16px;
}
.error-pane-body {
padding: 24px 12px;
gap: 12px;
}
.error-illustration svg {
width: 96px;
height: 96px;
}
.error-heading {
font-size: 18px;
}
.error-message {
font-size: 13px;
}
.error-actions {
flex-direction: column;
width: 100%;
}
.btn {
justify-content: center;
width: 100%;
}
}
View File
+334
View File
@@ -0,0 +1,334 @@
/* Layout Container */
.layout-container {
display: flex;
flex-direction: column;
height: 100vh;
width: 100%;
}
/* Header */
.header {
height: 60px;
background: var(--header-bg);
color: var(--header-text);
display: flex;
align-items: center;
padding: 0 20px;
box-shadow: var(--header-shadow);
flex-shrink: 0;
z-index: 100;
}
.header-content {
display: flex;
align-items: center;
width: 100%;
}
.header-content h1 {
margin: 0;
font-size: 24px;
font-weight: 600;
letter-spacing: 1px;
color: var(--header-text);
}
/* Layout Wrapper */
.layout-wrapper {
display: flex;
flex: 1;
overflow: hidden;
}
/* Sidebar */
.sidebar {
width: 72px;
background-color: var(--bg-surface-alt);
border-right: 1px solid var(--border-default);
box-shadow: none;
flex-shrink: 0;
overflow-y: auto;
overflow-x: hidden;
z-index: 99;
padding: 0;
display: flex;
flex-direction: column;
justify-content: center;
}
.sidebar-nav {
display: flex;
flex-direction: column;
gap: 0;
align-items: center;
width: 100%;
}
/* Navigation Items */
.nav-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
padding: 14px 0;
color: var(--text-secondary);
text-decoration: none;
transition: all 0.2s ease;
position: relative;
cursor: pointer;
gap: 4px;
box-sizing: border-box;
border-left: 3px solid transparent;
}
.nav-item:hover {
background-color: var(--nav-hover-bg);
color: var(--text-primary);
}
.nav-item:active {
background-color: var(--nav-active-bg);
color: var(--nav-active-color);
}
/* Active page indicator */
.nav-item.active {
color: var(--nav-active-color);
background-color: var(--nav-active-bg);
border-left: 3px solid var(--nav-active-border);
}
.nav-item.active .nav-icon {
stroke: var(--nav-active-color);
}
.nav-item.active .nav-label {
color: var(--nav-active-color);
font-weight: 600;
}
/* Navigation Icon */
.nav-icon {
width: 24px;
height: 24px;
stroke: currentColor;
flex-shrink: 0;
}
/* Navigation Label */
.nav-label {
font-size: 11px;
font-weight: 500;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 60px;
}
/* Main Content */
.content-area {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
background:
linear-gradient(180deg, var(--bg-gradient-from), var(--bg-gradient-to))
top / 100% 220px no-repeat,
var(--bg-base);
}
.accent-bar {
height: var(--accent-bar-height);
background: linear-gradient(
90deg,
var(--accent-bar-from),
var(--accent-bar-to)
);
flex-shrink: 0;
}
.main-content {
flex: 1;
overflow: hidden;
background-color: transparent;
padding: 20px;
padding-top: 80px;
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
/* Scrollbar Styling */
.sidebar::-webkit-scrollbar,
.main-content::-webkit-scrollbar {
width: 6px;
}
.sidebar::-webkit-scrollbar-track,
.main-content::-webkit-scrollbar-track {
background: transparent;
}
.sidebar::-webkit-scrollbar-thumb,
.main-content::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb);
border-radius: 3px;
}
.sidebar::-webkit-scrollbar-thumb:hover,
.main-content::-webkit-scrollbar-thumb:hover {
background: var(--scrollbar-thumb-hover);
}
/* Footer */
.footer {
display: flex;
align-items: center;
justify-content: center;
padding: 4px 20px;
background-color: var(--bg-surface);
border-top: 1px solid var(--border-default);
font-size: 12px;
color: var(--text-faint);
flex-shrink: 0;
}
/* Nav Divider */
.nav-divider {
width: calc(100% - 1.5rem);
border: none;
border-top: 1px solid var(--border-default);
margin: 0.5rem auto;
padding: 0;
box-sizing: border-box;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.header {
height: 48px;
padding: 0 12px;
}
.header-content h1 {
font-size: 18px;
}
.layout-wrapper {
flex-direction: column;
padding-bottom: 56px;
}
.sidebar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
width: 100%;
height: 56px;
border-right: none;
border-top: 1px solid var(--border-default);
box-shadow: var(--header-shadow);
padding: 0;
z-index: 100;
}
.sidebar-nav {
flex-direction: row;
justify-content: space-around;
height: 100%;
}
.nav-item {
padding: 6px 0;
min-width: 64px;
border-left: none;
border-bottom: 3px solid transparent;
}
.nav-item.active {
border-left: none;
border-bottom: 3px solid var(--nav-active-border);
}
.nav-icon {
width: 22px;
height: 22px;
}
.nav-label {
font-size: 10px;
}
.main-content {
padding: 12px;
padding-top: 60px;
}
.content-area {
background:
linear-gradient(180deg, var(--bg-gradient-from), var(--bg-gradient-to))
top / 100% 190px no-repeat,
var(--bg-base);
}
.footer {
display: none;
}
.nav-divider {
width: 1px;
height: calc(100% - 1rem);
border-top: none;
border-left: 1px solid var(--border-default);
margin: auto 0;
align-self: center;
}
}
/* Small phones */
@media (max-width: 480px) {
.header {
height: 44px;
padding: 0 8px;
}
.header-content h1 {
font-size: 16px;
}
.main-content {
padding: 8px;
padding-top: 40px;
}
.content-area {
background:
linear-gradient(180deg, var(--bg-gradient-from), var(--bg-gradient-to))
top / 100% 160px no-repeat,
var(--bg-base);
}
.nav-item {
min-width: 48px;
}
}
/* Safe area insets for notched devices */
@supports (padding-bottom: env(safe-area-inset-bottom)) {
@media (max-width: 768px) {
.sidebar {
padding-bottom: env(safe-area-inset-bottom);
height: calc(56px + env(safe-area-inset-bottom));
}
.layout-wrapper {
padding-bottom: calc(56px + env(safe-area-inset-bottom));
}
}
}
+59
View File
@@ -0,0 +1,59 @@
import { optima } from "$lib";
import { redirect, type Handle } from "@sveltejs/kit";
import { access } from "fs";
import { a } from "vitest/dist/chunks/suite.d.FvehnV49.js";
export const handle: Handle = async ({ event, resolve }) => {
const accessToken = event.cookies.get("access_token");
const refreshToken = event.cookies.get("refresh_token");
event.locals.ession = {
accessToken: accessToken || "",
refreshToken: refreshToken || "",
};
if (event.url.pathname === "/logout") {
event.cookies.delete("access_token", { path: "/" });
event.cookies.delete("refresh_token", { path: "/" });
redirect(303, "/login");
return resolve(event);
}
if (event.url.pathname.startsWith("/login") && optima.user.isLoggedIn()) {
return redirect(303, "/");
}
if (event.url.pathname.startsWith("/login")) {
return await resolve(event);
}
if (!accessToken || !refreshToken) {
optima.user.logout(event);
return resolve(event);
}
try {
if (accessToken && refreshToken) {
const newSession = await optima.user.refreshSession(refreshToken);
console.log(newSession);
event.cookies.set("access_token", newSession.accessToken, {
httpOnly: true,
path: "/",
});
event.cookies.set("refresh_token", newSession.refreshToken, {
httpOnly: true,
path: "/",
});
}
} catch (err) {
console.trace(err);
optima.user.logout(event);
} finally {
return await resolve(event);
}
};
+2 -4
View File
@@ -1,4 +1,4 @@
import adapter from "@sveltejs/adapter-static"; import adapter from "@sveltejs/adapter-node";
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
const config = { const config = {
@@ -9,9 +9,7 @@ const config = {
}, },
}, },
kit: { kit: {
adapter: adapter({ adapter: adapter(),
pages: ".vite/renderer/main_window",
}),
router: { router: {
type: "pathname", type: "pathname",
}, },