@@ -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
@@ -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/`.
|
||||
- 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).
|
||||
## Architecture Layers
|
||||
|
||||
How to run & build (developer workflows)
|
||||
### Electron Architecture
|
||||
|
||||
- Uses pnpm. Always use `pnpm install` first.
|
||||
- Dev (runs Electron + Vite via Electron Forge): `pnpm run start` (invokes `electron-forge start`).
|
||||
- Build & package: `pnpm run package` (builds renderer with `vite build` then runs `electron-forge package`).
|
||||
- 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.
|
||||
- **`electron/main.ts`**: Main process—creates/manages windows, handles file system access. Loads preload script and serves the renderer.
|
||||
- **`electron/preload.ts`**: Currently empty bridge between main and renderer processes. Extend here to expose secure IPC handlers if needed.
|
||||
- **`forge.config.ts`**: Electron Forge configuration with Vite plugin for building main, preload, and renderer targets.
|
||||
|
||||
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`.
|
||||
- Preload bridge: [electron/preload.ts](electron/preload.ts) (currently empty) — add safe, whitelisted APIs here when exposing functionality to the renderer.
|
||||
- SvelteKit entry routes: `src/routes/` (examples: [src/routes/+page.svelte](src/routes/+page.svelte), [src/routes/companies/+page.svelte](src/routes/companies/+page.svelte)).
|
||||
- API client: [src/lib/axios.ts](src/lib/axios.ts) — `api` is created from `PUBLIC_API_URL` ($env/static/public).
|
||||
- Auth helper: [src/lib/authUri.ts](src/lib/authUri.ts) — example of calling backend `/v1/auth/uri`.
|
||||
- Data access: [src/lib/companies.ts](src/lib/companies.ts) — `fetchMany(accessToken)` demonstrates header usage `Authorization: Bearer <token>`.
|
||||
- Global styles: `src/app.css` (Tailwind is present in the project).
|
||||
- Patches: `patches/` contains `@sveltejs__kit.patch` and is referenced in `package.json` via `patchedDependencies` — don't remove or ignore without verifying its purpose.
|
||||
- **`src/routes/`**: SvelteKit file-based routing with standard pathname router.
|
||||
- `(auth)` group: Authentication pages (login)
|
||||
- `(secure)` group: Protected pages requiring auth
|
||||
- **`src/lib/`**: Reusable modules
|
||||
- `optima-api/`: API client abstraction with modular endpoints (auth, companies, credentials, etc.)
|
||||
- `axios.ts`: Base axios instance with `PUBLIC_API_URL` env variable
|
||||
- **`src/components/`**: Reusable Svelte components (modals, spinners, error boundaries)
|
||||
|
||||
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)).
|
||||
- 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).
|
||||
The `$lib/index.ts` exports `optima` object aggregating all API modules. Example:
|
||||
|
||||
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.
|
||||
- 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.
|
||||
Each module (e.g., `auth.ts`) exports functions that call the API using a custom axios instance.
|
||||
|
||||
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.
|
||||
- If adding new native electron APIs, update `electron/preload.ts` to expose a minimal API surface and document it.
|
||||
### Routing
|
||||
|
||||
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 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)).
|
||||
### API Module Pattern
|
||||
|
||||
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)
|
||||
|
||||
@@ -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 }}
|
||||
@@ -25,3 +25,4 @@ vite.config.ts.timestamp-*
|
||||
.vite
|
||||
|
||||
out
|
||||
tailwindcss-*.log
|
||||
|
||||
+29
@@ -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"]
|
||||
@@ -22,6 +22,7 @@
|
||||
"@electron-forge/plugin-vite": "^7.11.1",
|
||||
"@electron/fuses": "^1.8.0",
|
||||
"@playwright/test": "^1.58.0",
|
||||
"@sveltejs/adapter-node": "^5.5.3",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/kit": "^2.50.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=="],
|
||||
|
||||
"@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-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/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/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/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/wrap-ansi": ["@types/wrap-ansi@3.0.0", "", {}, "sha512-ltIpx+kM7g/MLRZfkbL7EsCEjfzCcScLpkg37eXEtx5kmrAKBkTJwd1GIAjDSL8wTpM6Hzn5YO4pSb91BEwu1g=="],
|
||||
@@ -554,6 +567,8 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
|
||||
@@ -680,7 +695,7 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -838,6 +853,8 @@
|
||||
|
||||
"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-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=="],
|
||||
|
||||
"@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/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=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
"cacache/glob/minimatch": ["minimatch@5.1.6", "", { "dependencies": { "brace-expansion": "2.0.1" } }, "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g=="],
|
||||
|
||||
+5
-10
@@ -7,29 +7,24 @@ if (started) {
|
||||
app.quit();
|
||||
}
|
||||
|
||||
const PRODUCTION_URL = "https://optima.osdci.net";
|
||||
|
||||
const createWindow = () => {
|
||||
// Create the browser window.
|
||||
const mainWindow = new BrowserWindow({
|
||||
width: 800,
|
||||
height: 600,
|
||||
width: 1200,
|
||||
height: 800,
|
||||
webPreferences: {
|
||||
preload: path.join(import.meta.dirname, "preload.js"),
|
||||
},
|
||||
});
|
||||
|
||||
// and load the index.html of the app.
|
||||
if (MAIN_WINDOW_VITE_DEV_SERVER_URL) {
|
||||
mainWindow.loadURL(`${MAIN_WINDOW_VITE_DEV_SERVER_URL}/login`);
|
||||
mainWindow.webContents.on("did-frame-finish-load", () => {
|
||||
mainWindow.webContents.openDevTools({ mode: "detach" });
|
||||
});
|
||||
} else {
|
||||
mainWindow.loadFile(
|
||||
path.join(
|
||||
import.meta.dirname,
|
||||
`../renderer/${MAIN_WINDOW_VITE_NAME}/index.html`,
|
||||
),
|
||||
);
|
||||
mainWindow.loadURL(PRODUCTION_URL);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ForgeConfig } from "@electron-forge/shared-types";
|
||||
import { MakerSquirrel } from "@electron-forge/maker-squirrel";
|
||||
import { MakerZIP } from "@electron-forge/maker-zip";
|
||||
import { MakerDMG } from "@electron-forge/maker-dmg";
|
||||
import { MakerDeb } from "@electron-forge/maker-deb";
|
||||
import { MakerRpm } from "@electron-forge/maker-rpm";
|
||||
import { VitePlugin } from "@electron-forge/plugin-vite";
|
||||
@@ -15,6 +16,7 @@ const config: ForgeConfig = {
|
||||
makers: [
|
||||
new MakerSquirrel({}),
|
||||
new MakerZIP({}, ["darwin"]),
|
||||
new MakerDMG({}),
|
||||
new MakerRpm({}),
|
||||
new MakerDeb({}),
|
||||
],
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -18,8 +18,10 @@
|
||||
"test": "npm run test:unit -- --run && npm run test:e2e",
|
||||
"test:e2e": "playwright test",
|
||||
"start": "electron-forge start",
|
||||
"package": "vite build && electron-forge package",
|
||||
"make": "vite build && electron-forge make",
|
||||
"package": "electron-forge package",
|
||||
"make": "electron-forge make",
|
||||
"make:macos": "electron-forge make --platform darwin",
|
||||
"build:server": "vite build",
|
||||
"publish": "electron-forge publish"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -34,6 +36,7 @@
|
||||
"@electron-forge/plugin-vite": "^7.11.1",
|
||||
"@electron/fuses": "^1.8.0",
|
||||
"@playwright/test": "^1.58.0",
|
||||
"@sveltejs/adapter-node": "^5.5.3",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/kit": "^2.50.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.1.1",
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
@import "tailwindcss";
|
||||
@plugin '@tailwindcss/forms';
|
||||
@plugin '@tailwindcss/typography';
|
||||
Vendored
+3
-2
@@ -5,8 +5,9 @@ declare global {
|
||||
// interface Error {}
|
||||
interface Locals {
|
||||
session?: {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
accessToken: string | null;
|
||||
refreshToken: string | null;
|
||||
set(accessToken: string, refreshToken: string): Promise<void>;
|
||||
};
|
||||
}
|
||||
// interface PageData {}
|
||||
|
||||
+4
-1
@@ -3,7 +3,10 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<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%
|
||||
</head>
|
||||
<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
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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 { 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.session = {
|
||||
accessToken: accessToken || "",
|
||||
refreshToken: refreshToken || "",
|
||||
};
|
||||
const accessToken = event.cookies.get("accessToken") || null;
|
||||
const refreshToken = event.cookies.get("refreshToken") || null;
|
||||
|
||||
if (event.url.pathname === "/logout") {
|
||||
event.cookies.delete("access_token", { path: "/" });
|
||||
event.cookies.delete("refresh_token", { path: "/" });
|
||||
event.cookies.delete("accessToken", { path: "/" });
|
||||
event.cookies.delete("refreshToken", { path: "/" });
|
||||
|
||||
redirect(303, "/login");
|
||||
|
||||
return resolve(event);
|
||||
return redirect(303, "/login");
|
||||
}
|
||||
|
||||
if (event.url.pathname.startsWith("/login") && user.isLoggedIn()) {
|
||||
if (event.url.pathname.startsWith("/login") && optima.user.isLoggedIn()) {
|
||||
return redirect(303, "/");
|
||||
}
|
||||
|
||||
@@ -29,31 +21,79 @@ export const handle: Handle = async ({ event, resolve }) => {
|
||||
return await resolve(event);
|
||||
}
|
||||
|
||||
if (!accessToken || !refreshToken) {
|
||||
user.logout(event);
|
||||
return resolve(event);
|
||||
if (!accessToken && !refreshToken) {
|
||||
optima.user.logout(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 {
|
||||
if (accessToken && refreshToken) {
|
||||
const newSession = await user.refreshSession(refreshToken);
|
||||
const [, payload] = currentAccessToken.split(".");
|
||||
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);
|
||||
|
||||
event.cookies.set("access_token", newSession.accessToken, {
|
||||
httpOnly: true,
|
||||
path: "/",
|
||||
});
|
||||
event.cookies.set("refresh_token", newSession.refreshToken, {
|
||||
httpOnly: true,
|
||||
path: "/",
|
||||
});
|
||||
if (!decoded?.exp || decoded.exp - nowSec < thresholdSec) {
|
||||
// Token is expired or about to expire — try to refresh
|
||||
if (currentRefreshToken) {
|
||||
const refreshed =
|
||||
await optima.user.refreshSession(currentRefreshToken);
|
||||
currentAccessToken = refreshed.accessToken;
|
||||
currentRefreshToken = refreshed.refreshToken ?? currentRefreshToken;
|
||||
} else {
|
||||
// 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;
|
||||
};
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -1,10 +1,26 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
|
||||
export * from "./axios";
|
||||
export * from "./user";
|
||||
export * from "./companies";
|
||||
export * from "./credentialTypes";
|
||||
import { auth } from "./optima-api/modules/auth";
|
||||
import { company } from "./optima-api/modules/companies";
|
||||
import { credential } from "./optima-api/modules/credentials";
|
||||
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
|
||||
*
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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 {
|
||||
id: string;
|
||||
name: string;
|
||||
required: boolean;
|
||||
secure: boolean;
|
||||
valueType: "plain_text" | "password" | "number" | "email" | "url";
|
||||
valueType: string;
|
||||
subFields?: CredentialTypeField[];
|
||||
}
|
||||
|
||||
export interface CredentialType {
|
||||
@@ -1,16 +1,21 @@
|
||||
import api from "./axios";
|
||||
import api from "../axios";
|
||||
|
||||
export interface CredentialField {
|
||||
id: string;
|
||||
fieldId: string;
|
||||
name: string;
|
||||
secure: boolean;
|
||||
required: boolean;
|
||||
valueType: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface Credential {
|
||||
id: string;
|
||||
name: string;
|
||||
notes?: string;
|
||||
typeId: string;
|
||||
companyId: string;
|
||||
subCredentialOfId?: string;
|
||||
fields: CredentialField[];
|
||||
type?: {
|
||||
id: string;
|
||||
@@ -45,6 +50,7 @@ export const credential = {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
return response.data;
|
||||
},
|
||||
|
||||
@@ -62,7 +68,11 @@ export const credential = {
|
||||
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, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
@@ -96,4 +106,79 @@ export const credential = {
|
||||
});
|
||||
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;
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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 { redirect, RequestEvent } from "@sveltejs/kit";
|
||||
import axios from "axios";
|
||||
import api from "../axios";
|
||||
import { io } from "socket.io-client";
|
||||
|
||||
export const user = {
|
||||
isLoggedIn(): boolean {
|
||||
const event = getRequestEvent();
|
||||
const authToken = event.cookies.get("authToken");
|
||||
const authToken = event.cookies.get("accessToken");
|
||||
return !!authToken;
|
||||
},
|
||||
|
||||
@@ -28,18 +29,39 @@ export const user = {
|
||||
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) {
|
||||
if (!event) return;
|
||||
|
||||
// Clear authentication cookies
|
||||
event.cookies.delete("authToken", { path: "/" });
|
||||
event.cookies.delete("accessToken", { path: "/" });
|
||||
event.cookies.delete("refreshToken", { path: "/" });
|
||||
|
||||
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.
|
||||
*
|
||||
@@ -57,6 +79,7 @@ export const user = {
|
||||
let settled = false;
|
||||
const socket = io(`${base}/auth_callback`, {
|
||||
transports: ["websocket"],
|
||||
rejectUnauthorized: false,
|
||||
});
|
||||
const timeout = setTimeout(
|
||||
() => {
|
||||
@@ -67,8 +90,8 @@ export const user = {
|
||||
} catch {}
|
||||
reject(new Error("Timed out waiting for auth callback"));
|
||||
},
|
||||
2 * 60 * 1000,
|
||||
); // 2 minutes
|
||||
5 * 60 * 1000,
|
||||
); // 5 minutes
|
||||
|
||||
const handlePayload = (payload: any) => {
|
||||
try {
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
@@ -0,0 +1 @@
|
||||
<slot />
|
||||
@@ -1,19 +1,19 @@
|
||||
import { user } from "$lib";
|
||||
import { Actions, redirect } from "@sveltejs/kit";
|
||||
import { optima } from "$lib";
|
||||
|
||||
export const actions: Actions = {
|
||||
login: async (event) => {
|
||||
const data = await event.request.formData();
|
||||
|
||||
const tokens = await user.awaitAuthCallback(
|
||||
const tokens = await optima.user.awaitAuthCallback(
|
||||
data.get("callbackKey") as string,
|
||||
);
|
||||
|
||||
event.cookies.set("access_token", tokens.accessToken, {
|
||||
event.cookies.set("accessToken", tokens.accessToken, {
|
||||
httpOnly: true,
|
||||
path: "/",
|
||||
});
|
||||
event.cookies.set("refresh_token", tokens.refreshToken, {
|
||||
event.cookies.set("refreshToken", tokens.refreshToken, {
|
||||
httpOnly: true,
|
||||
path: "/",
|
||||
});
|
||||
@@ -1,12 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { fetchAuthRedirectUri } from "$lib/authUri";
|
||||
import { optima } from "$lib";
|
||||
import { PUBLIC_API_URL } from "$env/static/public";
|
||||
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 { theme } from "$lib/theme";
|
||||
|
||||
const uriData = await fetchAuthRedirectUri(PUBLIC_API_URL);
|
||||
const uriData = await optima.auth.fetchAuthRedirectUri(PUBLIC_API_URL);
|
||||
let loading = writable(false);
|
||||
|
||||
function handleSubmit(e: SubmitEvent) {
|
||||
@@ -35,6 +35,42 @@
|
||||
</script>
|
||||
|
||||
<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>
|
||||
<input type="hidden" name="callbackKey" value={uriData.callbackKey} />
|
||||
<button
|
||||
@@ -63,25 +99,39 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f3f2f1;
|
||||
background: var(--bg-base);
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
.ms-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 18px;
|
||||
background: #2f2f2f;
|
||||
color: white;
|
||||
border: none;
|
||||
background: var(--bg-elevated);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
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] {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
.login-theme-toggle {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
}
|
||||
.ms-logo {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
+69
-152
@@ -1,9 +1,10 @@
|
||||
<script>
|
||||
import { page } from "$app/stores";
|
||||
import { goto } from "$app/navigation";
|
||||
import "../styles/errorpage.css";
|
||||
|
||||
function signOut() {
|
||||
goto("/logout");
|
||||
}
|
||||
$: status = $page.status || 500;
|
||||
$: message = $page.error?.message || "Something went wrong";
|
||||
|
||||
function goBack() {
|
||||
history.back();
|
||||
@@ -11,159 +12,75 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Error — App</title>
|
||||
<title>Error {status} — Project Optima</title>
|
||||
</svelte:head>
|
||||
|
||||
<header class="header container">
|
||||
<h1>Error</h1>
|
||||
<nav>
|
||||
<a href="/">Home</a>
|
||||
<button on:click={signOut}>Sign out</button>
|
||||
</nav>
|
||||
</header>
|
||||
<div class="error-page">
|
||||
<div class="error-pane">
|
||||
<!-- Pane header -->
|
||||
<div class="error-pane-header">
|
||||
<h2 class="error-pane-title">Error</h2>
|
||||
<span class="error-status-badge">{status}</span>
|
||||
</div>
|
||||
|
||||
<main class="container">
|
||||
<section class="error-section">
|
||||
<div class="error-box">
|
||||
<h2>Oops! Something went wrong</h2>
|
||||
<p class="error-message">
|
||||
We encountered an error while processing your request. Please try again
|
||||
or contact support if the problem persists.
|
||||
<!-- Pane body -->
|
||||
<div class="error-pane-body">
|
||||
<div class="error-illustration">
|
||||
<svg viewBox="0 0 120 120" width="120" height="120" aria-hidden="true">
|
||||
<circle
|
||||
cx="60"
|
||||
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>
|
||||
|
||||
<div class="error-actions">
|
||||
<button class="btn btn-primary" on:click={goBack}>Go Back</button>
|
||||
<a href="/" class="btn btn-secondary">Go Home</a>
|
||||
<button class="btn btn-primary" on:click={goBack}>
|
||||
<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>
|
||||
</section>
|
||||
</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: 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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,24 @@
|
||||
import { LayoutServerLoad } from "./$types";
|
||||
import { optima } from "$lib";
|
||||
import type { LayoutServerLoad } from "./$types";
|
||||
|
||||
export const load: LayoutServerLoad = async ({ locals }) => {
|
||||
// WARNING: returning tokens to the client exposes them to JavaScript.
|
||||
// Prefer keeping tokens httpOnly and proxying requests via server endpoints.
|
||||
return {
|
||||
session: {
|
||||
accessToken: locals.session?.accessToken ?? null,
|
||||
refreshToken: locals.session?.refreshToken ?? null,
|
||||
},
|
||||
};
|
||||
const accessToken = locals.session?.accessToken ?? null;
|
||||
|
||||
// Only check permissions if the user is authenticated
|
||||
if (!accessToken) {
|
||||
return { canViewAdmin: false };
|
||||
}
|
||||
|
||||
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 };
|
||||
};
|
||||
|
||||
@@ -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>© {new Date().getFullYear()} Total Tech Solutions, LLC</small>
|
||||
</footer>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1 +1,3 @@
|
||||
import "../app.css";
|
||||
import "../styles/app.css";
|
||||
import "../styles/layout.css";
|
||||
import "../styles/errorpage.css";
|
||||
|
||||
+4
-113
@@ -1,120 +1,11 @@
|
||||
<script>
|
||||
import { goto } from "$app/navigation";
|
||||
|
||||
function signOut() {
|
||||
goto("/logout");
|
||||
}
|
||||
<script lang="ts">
|
||||
// You can add any JavaScript logic here if needed
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Home — App</title>
|
||||
</svelte:head>
|
||||
|
||||
<header class="header container">
|
||||
<h1>App Home</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>
|
||||
<h1>Home Page</h1>
|
||||
</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>
|
||||
|
||||
@@ -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";
|
||||
|
||||
export const load: LayoutServerLoad = async ({ params, parent }) => {
|
||||
const { session } = await parent();
|
||||
export const load: LayoutServerLoad = async ({ locals }) => {
|
||||
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 {
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -1,49 +1,136 @@
|
||||
<script>
|
||||
<script lang="ts">
|
||||
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>
|
||||
|
||||
<svelte:head>
|
||||
<title>Admin Dashboard — App</title>
|
||||
</svelte:head>
|
||||
<!-- Stats overview -->
|
||||
<div class="stats-grid">
|
||||
<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">
|
||||
<h1>Admin Dashboard</h1>
|
||||
<nav>
|
||||
<a href="/">Home</a>
|
||||
<a href="/admin/credential-types">Credential Types</a>
|
||||
<button on:click={() => goto("/")}>Back</button>
|
||||
</nav>
|
||||
</header>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" 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>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<span class="stat-value">—</span>
|
||||
<span class="stat-label">Users</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main class="container">
|
||||
<section class="hero">
|
||||
<h2>Administration</h2>
|
||||
<p>Manage system settings and configurations.</p>
|
||||
</section>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke-width="2">
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
||||
<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">
|
||||
<article class="card">
|
||||
<h3>Credential Types</h3>
|
||||
<p>Create and manage credential type definitions.</p>
|
||||
<button on:click={() => goto("/admin/credential-types")}>
|
||||
Manage Credential Types
|
||||
</button>
|
||||
</article>
|
||||
</section>
|
||||
</main>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke-width="2">
|
||||
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<span class="stat-value">—</span>
|
||||
<span class="stat-label">Activity Today</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
button {
|
||||
margin-top: 1rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background-color: #0066cc;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
<!-- Quick actions -->
|
||||
<h3 class="section-heading">Quick Actions</h3>
|
||||
<div class="actions-grid">
|
||||
{#each quickActions as action}
|
||||
<a
|
||||
href={action.href}
|
||||
class="action-card"
|
||||
on:click|preventDefault={() => goto(action.href)}
|
||||
>
|
||||
<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 {
|
||||
background-color: #0052a3;
|
||||
}
|
||||
</style>
|
||||
<!-- Recent activity placeholder -->
|
||||
<h3 class="section-heading">Recent Activity</h3>
|
||||
<div class="activity-section">
|
||||
<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 { credentialType } from "$lib/credentialTypes";
|
||||
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 { 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 {
|
||||
credentialTypes: response.data || [],
|
||||
accessToken: session.accessToken,
|
||||
credentialTypes,
|
||||
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
@@ -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 });
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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}
|
||||
@@ -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 });
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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()}
|
||||
(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 “{searchQuery}”. Try a different search.</p>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -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
@@ -1,559 +1,421 @@
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import { company } from "$lib";
|
||||
import LoadingSpinner from "$lib/../components/LoadingSpinner.svelte";
|
||||
import ResultsSpinner from "$lib/../components/ResultsSpinner.svelte";
|
||||
import ErrorBoundary from "$lib/../components/ErrorBoundary.svelte";
|
||||
import { afterNavigate } from "$app/navigation";
|
||||
import type { PermissionMap } from "$lib/permissions";
|
||||
import NoResultsMonkey from "../../components/NoResultsMonkey.svelte";
|
||||
import "../../styles/companies/companylist.css";
|
||||
|
||||
export let data;
|
||||
|
||||
interface Company {
|
||||
export let data: {
|
||||
permissions: PermissionMap;
|
||||
companies: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
cw_CompanyId: number;
|
||||
cw_Identifier: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface ApiResponse {
|
||||
status: number;
|
||||
message: string;
|
||||
data: Company[];
|
||||
successful: boolean;
|
||||
meta: {
|
||||
timestamp: number;
|
||||
pagination: {
|
||||
previousPage: number | null;
|
||||
currentPage: number;
|
||||
nextPage: number | null;
|
||||
status?: string;
|
||||
type?: string;
|
||||
createdAt?: string;
|
||||
identifier?: string;
|
||||
contactEmail?: string;
|
||||
[key: string]: unknown;
|
||||
}>;
|
||||
totalPages: number;
|
||||
currentPage: 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[] = [];
|
||||
let totalPages = 0;
|
||||
let currentPage = 1;
|
||||
let totalRecords = 0;
|
||||
let isLoading = true;
|
||||
let error: string | null = null;
|
||||
let errorDetails: unknown = null;
|
||||
let isResultsLoading = false;
|
||||
let searchQuery = "";
|
||||
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
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;
|
||||
function handleSearch() {
|
||||
isSearching = true;
|
||||
searchStartedAt = Date.now();
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(() => {
|
||||
const params = new URLSearchParams();
|
||||
params.set("page", "1");
|
||||
if (searchInput) params.set("search", searchInput);
|
||||
goto(`/companies?${params.toString()}`);
|
||||
}, 300);
|
||||
}
|
||||
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 {
|
||||
if (!data.session.accessToken) {
|
||||
throw new Error("No access token available. Please log in again.");
|
||||
}
|
||||
|
||||
const response = await company.fetchMany(
|
||||
data.session.accessToken,
|
||||
page,
|
||||
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;
|
||||
return new Date(dateStr).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
$: displayedCompanies =
|
||||
searchQuery.trim().length > 0
|
||||
? companies
|
||||
: companies.filter(
|
||||
(c) =>
|
||||
c.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
c.cw_Identifier.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
c.cw_CompanyId.toString().includes(searchQuery),
|
||||
);
|
||||
|
||||
function goToPage(page: number) {
|
||||
if (page >= 1 && page <= totalPages) {
|
||||
loadCompanies(page, searchQuery.trim() || undefined);
|
||||
}
|
||||
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";
|
||||
}
|
||||
|
||||
function signOut() {
|
||||
goto("/logout");
|
||||
function companyInitials(name: string): string {
|
||||
return name
|
||||
.split(/\s+/)
|
||||
.slice(0, 2)
|
||||
.map((w) => w[0])
|
||||
.join("")
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
function retryLoad() {
|
||||
loadCompanies(currentPage, searchQuery.trim() || undefined);
|
||||
// Generate visible page numbers with ellipsis
|
||||
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
|
||||
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);
|
||||
}
|
||||
$: pageNumbers = getPageNumbers(currentPage, totalPages);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Companies — App</title>
|
||||
<title>Companies — Project Optima</title>
|
||||
</svelte:head>
|
||||
|
||||
<header class="header container">
|
||||
<h1>Companies</h1>
|
||||
<nav>
|
||||
<a href="/">Home</a>
|
||||
<a href="/settings">Settings</a>
|
||||
<a href="/profile">Profile</a>
|
||||
<button on:click={signOut}>Sign out</button>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="container">
|
||||
<section class="hero">
|
||||
<h2>Company Directory</h2>
|
||||
{#if isLoading}
|
||||
<LoadingSpinner loading={true} />
|
||||
{:else if error}
|
||||
<ErrorBoundary
|
||||
title="Failed to Load Companies"
|
||||
message={error}
|
||||
details={errorDetails}
|
||||
/>
|
||||
{:else}
|
||||
<p>Browse all companies. Total: {totalRecords} companies</p>
|
||||
{#if !hasAccess}
|
||||
<div class="access-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 view Companies. Contact your administrator to
|
||||
request access.
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="companies-page">
|
||||
<div class="companies-pane">
|
||||
<!-- Pane header -->
|
||||
<div class="pane-header">
|
||||
<div class="pane-header-left">
|
||||
<h2 class="page-title">Companies</h2>
|
||||
{#if totalRecords > 0}
|
||||
<span class="result-count"
|
||||
>{totalRecords} record{totalRecords === 1 ? "" : "s"}</span
|
||||
>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#if !isLoading && !error}
|
||||
<section class="search-section">
|
||||
</div>
|
||||
<div class="search-bar">
|
||||
<svg
|
||||
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
|
||||
type="text"
|
||||
placeholder="Search companies by name, ID, or identifier..."
|
||||
bind:value={searchQuery}
|
||||
on:input={onSearchInput}
|
||||
class="search-bar"
|
||||
placeholder="Search companies…"
|
||||
bind:this={searchInputEl}
|
||||
bind:value={searchInput}
|
||||
on:input={handleSearch}
|
||||
on:keydown={handleKeydown}
|
||||
/>
|
||||
{#if searchQuery}
|
||||
<p class="search-results">
|
||||
Found {displayedCompanies.length} company/companies matching "{searchQuery}"
|
||||
</p>
|
||||
{#if searchInput}
|
||||
<button
|
||||
class="search-clear"
|
||||
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}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if displayedCompanies.length > 0}
|
||||
<section class="companies-grid">
|
||||
{#if isResultsLoading}
|
||||
<div class="results-loader">
|
||||
<ResultsSpinner size={40} />
|
||||
<!-- Pane body -->
|
||||
<div class="pane-body">
|
||||
{#if isSearching}
|
||||
<div class="search-loading-overlay">
|
||||
<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>
|
||||
{:else}
|
||||
{#each displayedCompanies as comp (comp.id)}
|
||||
<div
|
||||
<div class="card-grid">
|
||||
{#each companies as company (company.id)}
|
||||
<button
|
||||
class="company-card"
|
||||
role="link"
|
||||
tabindex="0"
|
||||
on:click={() => goto(`/companies/${comp.id}`)}
|
||||
on:click={() => goto(`/companies/${company.id}`)}
|
||||
on:keydown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
goto(`/companies/${comp.id}`);
|
||||
}
|
||||
if (e.key === "Enter") goto(`/companies/${company.id}`);
|
||||
}}
|
||||
>
|
||||
<h3>{comp.name}</h3>
|
||||
<dl>
|
||||
<dt>CW Company ID</dt>
|
||||
<dd>{comp.cw_CompanyId}</dd>
|
||||
<dt>CW Identifier</dt>
|
||||
<dd>{comp.cw_Identifier}</dd>
|
||||
<dt>Created</dt>
|
||||
<dd>{new Date(comp.createdAt).toLocaleDateString()}</dd>
|
||||
</dl>
|
||||
<span class="view-link">View Details</span>
|
||||
<!-- Card header: avatar + status -->
|
||||
<div class="card-top">
|
||||
<div class="card-avatar">
|
||||
<span class="avatar-initials"
|
||||
>{companyInitials(company.name)}</span
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</section>
|
||||
{:else}
|
||||
<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}
|
||||
<span
|
||||
class="status-dot {statusClass(company.status)}"
|
||||
title={company.status || "Unknown"}
|
||||
></span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
on:click={() => goToPage(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
class="pagination-btn"
|
||||
>
|
||||
Next →
|
||||
</button>
|
||||
<!-- Card body -->
|
||||
<div class="card-body">
|
||||
<h3 class="card-name">{company.name}</h3>
|
||||
{#if company.contactEmail}
|
||||
<span class="card-email">{company.contactEmail}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<button
|
||||
on:click={() => goToPage(totalPages)}
|
||||
disabled={currentPage === totalPages}
|
||||
class="pagination-btn"
|
||||
<!-- Card meta -->
|
||||
<div class="card-meta">
|
||||
{#if company.type}
|
||||
<div class="meta-item">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
class="meta-icon"
|
||||
>
|
||||
Last ⟩⟩
|
||||
</button>
|
||||
<rect
|
||||
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">
|
||||
Page {currentPage} of {totalPages}
|
||||
</span>
|
||||
</section>
|
||||
{/if}
|
||||
{/if}
|
||||
</main>
|
||||
<nav class="pagination" aria-label="Pagination">
|
||||
<button
|
||||
class="page-btn"
|
||||
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">
|
||||
<small>© {new Date().getFullYear()} Your App</small>
|
||||
</footer>
|
||||
{#each pageNumbers as p}
|
||||
{#if p === "..."}
|
||||
<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>
|
||||
: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 {
|
||||
.access-denied {
|
||||
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;
|
||||
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>
|
||||
|
||||
@@ -1,72 +1,67 @@
|
||||
import { company } from "$lib/companies";
|
||||
import { credential } from "$lib/credentials";
|
||||
import { optima } from "$lib";
|
||||
import { handleApiError } from "$lib/optima-api/errorHandler";
|
||||
import { checkPermissions, type PermissionMap } from "$lib/permissions";
|
||||
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");
|
||||
export const load: PageServerLoad = async ({ locals, params }) => {
|
||||
const accessToken = locals.session?.accessToken;
|
||||
if (!accessToken) {
|
||||
return {
|
||||
company: null,
|
||||
configurations: [],
|
||||
credentials: [],
|
||||
credentialTypes: [],
|
||||
unifiSites: [],
|
||||
accessToken: null,
|
||||
permissions: {} as PermissionMap,
|
||||
};
|
||||
}
|
||||
|
||||
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) {
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
// 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,
|
||||
);
|
||||
}
|
||||
// Fetch company with or without address based on permission
|
||||
const companyResult = await optima.company.fetch(accessToken, params.id, {
|
||||
includeAddress: permissions["company.fetch.address"] === true,
|
||||
includePrimaryContact: true,
|
||||
includeAllContacts: permissions["company.fetch.contacts"] === true,
|
||||
});
|
||||
|
||||
return {
|
||||
company: companyData,
|
||||
configurations,
|
||||
configurationsError,
|
||||
credentials,
|
||||
credentialsError,
|
||||
session,
|
||||
companyId: params.id,
|
||||
company: companyResult?.data ?? null,
|
||||
configurations: configsResult?.data ?? [],
|
||||
credentials: credentialsResult?.data ?? [],
|
||||
credentialTypes: credentialTypesResult?.data ?? [],
|
||||
unifiSites: unifiSitesResult?.data ?? [],
|
||||
accessToken,
|
||||
permissions,
|
||||
};
|
||||
} 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.");
|
||||
handleApiError(err);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,215 +1,359 @@
|
||||
<script>
|
||||
import { goto } from "$app/navigation";
|
||||
import ErrorBoundary from "$lib/../components/ErrorBoundary.svelte";
|
||||
import CreateCredentialModal from "$lib/../components/CreateCredentialModal.svelte";
|
||||
<script lang="ts">
|
||||
import "../../../styles/companies/companydetail.css";
|
||||
import { onMount } from "svelte";
|
||||
import type { PageData } from "./types";
|
||||
|
||||
export let data;
|
||||
export let error;
|
||||
// Tab components
|
||||
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() {
|
||||
goto("/logout");
|
||||
$: company = data.company;
|
||||
$: 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() {
|
||||
showCreateModal = true;
|
||||
}
|
||||
|
||||
function handleCredentialCreated() {
|
||||
// Refresh the page to show the new credential
|
||||
location.reload();
|
||||
function mobileBack() {
|
||||
mobileActiveTab = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Company Detail — App</title>
|
||||
<title>{company?.name ?? "Company"} — Project Optima</title>
|
||||
</svelte:head>
|
||||
|
||||
<header class="header container">
|
||||
<h1>Company Detail</h1>
|
||||
<nav>
|
||||
<a href="/companies">Companies</a>
|
||||
<a href="/">Home</a>
|
||||
<button on:click={signOut}>Sign out</button>
|
||||
</nav>
|
||||
</header>
|
||||
<div class="company-detail-page">
|
||||
<!-- Left pane (1/4) — Company overview -->
|
||||
<CompanySidebar {company} {permissions} {isMobile} {mobileActiveTab} />
|
||||
|
||||
<main class="container">
|
||||
{#if error}
|
||||
<ErrorBoundary
|
||||
title="Failed to Load Company"
|
||||
message={error.message ||
|
||||
"We couldn't load the company details. Please try again."}
|
||||
details={error}
|
||||
/>
|
||||
{: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;"
|
||||
<!-- Mobile vertical nav menu (only visible on mobile when no tab selected) -->
|
||||
{#if isMobile && mobileActiveTab === null}
|
||||
<div class="mobile-nav-menu">
|
||||
{#each tabs as tab}
|
||||
<button
|
||||
class="mobile-nav-item"
|
||||
on:click={() => selectMobileTab(tab)}
|
||||
type="button"
|
||||
>
|
||||
<h2 style="margin: 0;">Credentials</h2>
|
||||
<button class="create-button" on:click={handleOpenCreateModal}>
|
||||
<span class="mobile-nav-icon">
|
||||
{#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
|
||||
</button>
|
||||
</div>
|
||||
{#if data.credentialsError}
|
||||
<ErrorBoundary
|
||||
title="Failed to Load Credentials"
|
||||
message={data.credentialsError}
|
||||
details={data.credentialsError}
|
||||
{/if}
|
||||
{#if activeTab === "UniFi"}
|
||||
<div class="tab-bar-spacer"></div>
|
||||
<button
|
||||
type="button"
|
||||
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}
|
||||
<details class="json-collapse" open>
|
||||
<summary>Show credentials JSON</summary>
|
||||
<pre><code>{JSON.stringify(data.credentials, null, 2)}</code></pre>
|
||||
</details>
|
||||
{:else}
|
||||
<p>No credentials available for this company.</p>
|
||||
<path
|
||||
d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"
|
||||
/>
|
||||
</svg>
|
||||
Link Site
|
||||
</button>
|
||||
{/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}
|
||||
</main>
|
||||
|
||||
<CreateCredentialModal
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
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} by
|
||||
<strong>{selectedConfig.info.enteredBy}</strong
|
||||
>{/if}{#if selectedConfig.info.dateEntered} 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} by
|
||||
<strong>{selectedConfig.info.updatedBy}</strong
|
||||
>{/if}{#if selectedConfig.info.lastUpdated} 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");
|
||||
}
|
||||
};
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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.");
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -1,4 +1,4 @@
|
||||
import adapter from "@sveltejs/adapter-static";
|
||||
import adapter from "@sveltejs/adapter-node";
|
||||
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
|
||||
|
||||
const config = {
|
||||
@@ -9,9 +9,7 @@ const config = {
|
||||
},
|
||||
},
|
||||
kit: {
|
||||
adapter: adapter({
|
||||
pages: ".vite/renderer/main_window",
|
||||
}),
|
||||
adapter: adapter(),
|
||||
router: {
|
||||
type: "pathname",
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user