diff --git a/.env.example b/.env.example index cc7c80a7..b99ec041 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,10 @@ # A long random secret used to sign sessions and tokens (min 32 characters) STACKABLE_COCKPIT_SESSION_SECRET=change-me-to-a-long-random-secret-min-32-chars -# The publicly accessible base URL of this application +# The publicly accessible base URL of this application. +# In development this is derived from the request host (so any free port works, +# e.g. when 5173 is already taken). In production builds this value is used +# directly for the OIDC redirect URI and must match the public origin. STACKABLE_COCKPIT_BASE_URL=http://localhost:5173 # OIDC discovery URL (Keycloak, Entra ID, or any compliant OIDC provider) @@ -35,4 +38,8 @@ STACKABLE_COCKPIT_OIDC_CLIENT_SECRET=your-client-secret # Feature flags # STACKABLE_COCKPIT_COMPLETION_ENABLED=false # Disable SQL editor code completion (default: true) # STACKABLE_COCKPIT_STORAGE_BROWSER_ENABLED=true # Enable S3/HDFS file browser (default: false) -# PUBLIC_STACKABLE_COCKPIT_UPLOAD_CONCURRENCY=3 # Maximum number of concurrent file uploads (default: 3) + +# OPA (Open Policy Agent) — admin rights checking +# STACKABLE_COCKPIT_OPA_ENABLED=true # Enable OPA admin checks (default: false) +# STACKABLE_COCKPIT_OPA_URL=http://localhost:8181 # OPA server base URL (required when enabled) +# STACKABLE_COCKPIT_OPA_TIMEOUT=5000 # OPA request timeout in milliseconds (default: 5000) diff --git a/.env.test b/.env.test index c1b50cfe..6375bc4e 100644 --- a/.env.test +++ b/.env.test @@ -5,6 +5,8 @@ STACKABLE_COCKPIT_SESSION_SECRET=e2e-test-session-secret-that-is-long-enough STACKABLE_COCKPIT_BASE_URL=http://localhost:4173 STACKABLE_COCKPIT_TRINO_URL=http://localhost:8080 STACKABLE_COCKPIT_STORAGE_BROWSER_ENABLED=true +STACKABLE_COCKPIT_OPA_ENABLED=true +STACKABLE_COCKPIT_OPA_URL=http://localhost:9191 ORIGIN=http://localhost:4173 STACKABLE_COCKPIT_TEXT_PREVIEW_BYTES=262144 STACKABLE_COCKPIT_IMAGE_PREVIEW_BYTES=5242880 diff --git a/.prettierignore b/.prettierignore index 6dbc7f7c..8e11e360 100644 --- a/.prettierignore +++ b/.prettierignore @@ -33,3 +33,4 @@ coverage/ # Helm templates deploy/helm/ dev/garage/ +dev/opa/ diff --git a/.vscode/settings.json b/.vscode/settings.json index ecfae0d4..ed2ef2f3 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -13,6 +13,11 @@ "editor.defaultFormatter": "esbenp.prettier-vscode", "editor.formatOnSave": true, + // Helm templates use {{ }} syntax that Prettier mangles — disable formatting. + "[yaml]": { + "editor.formatOnSave": false + }, + // Route .svelte formatting through the Svelte extension so it applies the // Svelte-aware Prettier plugin (configured in .prettierrc). "[svelte]": { diff --git a/TECH_DEBT.md b/TECH_DEBT.md index 63591e87..e72f8641 100644 --- a/TECH_DEBT.md +++ b/TECH_DEBT.md @@ -70,6 +70,14 @@ All server-side query state (progress, rows, status) is held in a module-level ` --- +### Bookmark tools embedded via unsandboxed iframe + +**File:** `src/routes/(app)/bookmark/[id]/+page.svelte` + +Bookmarks are embedded as full-page iframes without a `sandbox` attribute, so the embedded tool can run scripts, navigate the top frame, and read cookies in its own origin context. A `sandbox` attribute would break legitimate tools that need scripts/forms, and most external services will refuse framing anyway via `X-Frame-Options`/CSP. Acceptable for the current stage; long-term, consider a configurable sandbox policy per bookmark and validation of the URL scheme (http/https only). + +--- + ### Single-file download limit **File:** `src/lib/storage/download.ts`, `src/lib/components/storage/FileExplorer.svelte` diff --git a/dev/opa/Chart.yaml b/dev/opa/Chart.yaml new file mode 100644 index 00000000..354e27c6 --- /dev/null +++ b/dev/opa/Chart.yaml @@ -0,0 +1,7 @@ +--- +apiVersion: v2 +name: opa +description: Open Policy Agent for local dev and E2E testing +type: application +version: 0.1.0 +appVersion: '1.16.2' diff --git a/dev/opa/policies/admin.rego b/dev/opa/policies/admin.rego new file mode 100644 index 00000000..88d03b3c --- /dev/null +++ b/dev/opa/policies/admin.rego @@ -0,0 +1,18 @@ +package stackable + +default admin = false + +# Admin if user ID is in the hardcoded admin list +admin if { + admin_users[input.user.id] +} + +# Admin if user email ends with the admin domain +admin if { + endswith(input.user.email, "@admin.example.com") +} + +admin_users := { + "admin-user-id-1": true, + "admin-user-id-2": true, +} diff --git a/dev/opa/templates/configmap.yaml b/dev/opa/templates/configmap.yaml new file mode 100644 index 00000000..55db5883 --- /dev/null +++ b/dev/opa/templates/configmap.yaml @@ -0,0 +1,9 @@ +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: opa-policies + namespace: default +data: + admin.rego: |- + {{- .Files.Get "policies/admin.rego" | nindent 4 }} diff --git a/dev/opa/templates/deployment.yaml b/dev/opa/templates/deployment.yaml new file mode 100644 index 00000000..348edf9f --- /dev/null +++ b/dev/opa/templates/deployment.yaml @@ -0,0 +1,60 @@ +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: opa + namespace: default + labels: + app: opa +spec: + replicas: 1 + selector: + matchLabels: + app: opa + template: + metadata: + labels: + app: opa + spec: + containers: + - name: opa + image: '{{ .Values.image.repository }}:{{ .Values.image.tag }}' + imagePullPolicy: {{ .Values.image.pullPolicy }} + args: + - run + - --server + - --addr + - 0.0.0.0:8181 + - '{{ .Values.policyMountPath }}/admin.rego' + ports: + - name: http + containerPort: 8181 + protocol: TCP + readinessProbe: + httpGet: + path: /health + port: 8181 + initialDelaySeconds: 2 + periodSeconds: 3 + failureThreshold: 10 + livenessProbe: + httpGet: + path: /health + port: 8181 + initialDelaySeconds: 5 + periodSeconds: 10 + volumeMounts: + - name: policies + mountPath: '{{ .Values.policyMountPath }}' + readOnly: true + resources: + requests: + cpu: {{ .Values.resources.requests.cpu }} + memory: {{ .Values.resources.requests.memory }} + limits: + cpu: {{ .Values.resources.limits.cpu }} + memory: {{ .Values.resources.limits.memory }} + volumes: + - name: policies + configMap: + name: opa-policies diff --git a/dev/opa/templates/service.yaml b/dev/opa/templates/service.yaml new file mode 100644 index 00000000..9456c179 --- /dev/null +++ b/dev/opa/templates/service.yaml @@ -0,0 +1,15 @@ +--- +apiVersion: v1 +kind: Service +metadata: + name: opa + namespace: default +spec: + type: NodePort + selector: + app: opa + ports: + - name: http + port: 8181 + targetPort: 8181 + nodePort: {{ .Values.nodePort }} diff --git a/dev/opa/values.yaml b/dev/opa/values.yaml new file mode 100644 index 00000000..de009657 --- /dev/null +++ b/dev/opa/values.yaml @@ -0,0 +1,19 @@ +--- +image: + repository: openpolicyagent/opa + tag: 1.16.2 + pullPolicy: IfNotPresent + +# NodePort for the OPA HTTP API. +nodePort: 30181 + +# Path inside the container where policies are loaded from. +policyMountPath: /policies + +resources: + requests: + cpu: 50m + memory: 64Mi + limits: + cpu: 200m + memory: 128Mi diff --git a/dev/setup.sh b/dev/setup.sh index 96ac2a1f..6b765f00 100755 --- a/dev/setup.sh +++ b/dev/setup.sh @@ -91,6 +91,16 @@ if [[ "$SKIP_GARAGE" == false ]]; then --timeout 60s fi +# ------------------------------------------------------------------ +# 5c. Deploy OPA (via Helm) +# ------------------------------------------------------------------ +echo "" +echo "Deploying OPA..." +helm upgrade --install opa "$SCRIPT_DIR/opa" \ + --namespace default \ + --wait \ + --timeout 60s + # On some local Kubernetes distributions (e.g. Rancher Desktop k3s), the node's # InternalIP is not reachable from the host network, but NodePorts are exposed # on localhost. Probe both and use the first reachable URL. @@ -171,11 +181,12 @@ else create_user() { local username=$1 password=$2 first=$3 last=$4 + local email=${5:-"$username@example.com"} echo "Creating user '$username'..." kcadm create users \ -r stackable \ -s username="$username" \ - -s email="$username@example.com" \ + -s email="$email" \ -s firstName="$first" \ -s lastName="$last" \ -s enabled=true @@ -185,7 +196,7 @@ else --new-password "$password" } - create_user alice alicealice Alice Example + create_user alice alicealice Alice Example alice@admin.example.com create_user bob bobbob Bob Example echo "Fetching client secret..." @@ -260,6 +271,17 @@ if [[ "$SKIP_TRINO" == false ]]; then TRINO_BASE_URL="${TRINO_BASE_URL:-https://${NODE_IP}:${TRINO_PORT}}" fi +# Probe OPA reachability (same pattern as Keycloak/Trino). +OPA_NODE_PORT=30181 +OPA_BASE_URL="" +for base in "http://${NODE_IP}:${OPA_NODE_PORT}" "http://127.0.0.1:${OPA_NODE_PORT}" "http://localhost:${OPA_NODE_PORT}"; do + if curl -sf --max-time 2 "${base}/health" >/dev/null 2>&1; then + OPA_BASE_URL="$base" + break + fi +done +OPA_BASE_URL="${OPA_BASE_URL:-http://${NODE_IP}:${OPA_NODE_PORT}}" + if [[ "$SKIP_TRINO" == false ]]; then cat > "$ENV_FILE" < { const authFile = path.join(import.meta.dirname, `../.auth/user-${testInfo.project.name}.json`); + // Projects whose name contains "admin" (e.g. setup-admin) log in with an + // admin profile so the mock OPA server grants them admin rights. + const isAdmin = testInfo.project.name.includes('admin'); + await page.context().addCookies([ + { + name: PROFILE_COOKIE, + value: isAdmin ? 'admin' : 'regular', + url: ISSUER_URL + } + ]); + // Navigate to the app — auth guard redirects to /auth/login await page.goto('/'); await expect(page).toHaveURL(/\/auth\/login/); diff --git a/e2e/bookmarks.spec.ts b/e2e/bookmarks.spec.ts new file mode 100644 index 00000000..6a485cdb --- /dev/null +++ b/e2e/bookmarks.spec.ts @@ -0,0 +1,304 @@ +import { test, expect } from '@playwright/test'; +import { waitForHydration } from './support/helpers'; + +test.describe('Dashboard bookmarks', () => { + test.use({ locale: 'en-US' }); + + const addButton = 'Add Bookmark'; + + test.beforeEach(async ({ page }) => { + await page.addInitScript(() => { + localStorage.removeItem('dashboard_bookmarks'); + }); + }); + + test('shows Add Bookmark button on dashboard', async ({ page }) => { + await page.goto('/'); + await waitForHydration(page); + + await expect(page.getByRole('button', { name: addButton })).toBeVisible(); + }); + + test('opens modal when Add Bookmark is clicked', async ({ page }) => { + await page.goto('/'); + await waitForHydration(page); + + await page.getByRole('button', { name: addButton }).click(); + + await expect(page.locator('dialog[open]')).toBeVisible(); + }); + + test('modal shows product selection and form fields', async ({ page }) => { + await page.goto('/'); + await waitForHydration(page); + + await page.getByRole('button', { name: addButton }).click(); + + await expect(page.locator('dialog[open]')).toBeVisible(); + + // Product buttons are visible (names are inside buttons with logos/initials) + await expect(page.locator('button[aria-pressed]').filter({ hasText: 'Trino' })).toBeVisible(); + await expect( + page.locator('button[aria-pressed]').filter({ hasText: 'Superset' }) + ).toBeVisible(); + + // Open in options + await expect(page.getByText('Inside Cockpit')).toBeVisible(); + await expect(page.getByText('New Tab')).toBeVisible(); + + // Form fields + await expect(page.getByLabel('Name')).toBeVisible(); + await expect(page.getByLabel(/Environment/)).toBeVisible(); + await expect(page.getByLabel('URL')).toBeVisible(); + + // Pinned checkbox label + await expect(page.getByText('Pin bookmark')).toBeVisible(); + + // Preview section + await expect(page.getByText('Preview')).toBeVisible(); + }); + + test('selecting a product fills the default name', async ({ page }) => { + await page.goto('/'); + await waitForHydration(page); + + await page.getByRole('button', { name: 'Add Bookmark' }).click(); + await expect(page.locator('dialog[open]')).toBeVisible(); + + await page.locator('button[aria-pressed]').filter({ hasText: 'Superset' }).click(); + + await expect(page.getByLabel('Name')).toHaveValue('Dashboards'); + }); + + test('preview updates as user fills the form', async ({ page }) => { + await page.goto('/'); + await waitForHydration(page); + + await page.getByRole('button', { name: 'Add Bookmark' }).click(); + await expect(page.locator('dialog[open]')).toBeVisible(); + + await page.getByLabel('Name').fill('My Dashboard'); + await page.getByLabel('URL').fill('https://superset.example.com'); + + await expect(page.getByText('superset.example.com')).toBeVisible(); + }); + + test('adds a bookmark and shows it on the dashboard', async ({ page }) => { + await page.goto('/'); + await waitForHydration(page); + + await page.getByRole('button', { name: 'Add Bookmark' }).click(); + await expect(page.locator('dialog[open]')).toBeVisible(); + + await page.locator('button[aria-pressed]').filter({ hasText: 'Superset' }).click(); + await page.getByLabel('Name').fill('Dashboards'); + await page.getByLabel('URL').fill('https://superset.example.com'); + + await page.locator('dialog[open]').getByRole('button', { name: 'Add Bookmark' }).click(); + + // Bookmark section is visible + await expect(page.getByRole('heading', { name: 'Bookmarks' })).toBeVisible(); + await expect(page.getByText('Dashboards')).toBeVisible(); + await expect(page.getByText('superset.example.com')).toBeVisible(); + }); + + test('bookmark persists in localStorage', async ({ page }) => { + await page.goto('/'); + await waitForHydration(page); + + await page.getByRole('button', { name: addButton }).click(); + await expect(page.locator('dialog[open]')).toBeVisible(); + + await page.locator('button[aria-pressed]').filter({ hasText: 'Superset' }).click(); + await page.getByLabel('Name').fill('Dashboards'); + await page.getByLabel('URL').fill('https://superset.example.com'); + + await page.locator('dialog[open]').getByRole('button', { name: addButton }).click(); + + const stored = await page.evaluate(() => localStorage.getItem('dashboard_bookmarks')); + expect(stored).toBeTruthy(); + + const bookmarks = JSON.parse(stored!); + expect(bookmarks).toHaveLength(1); + expect(bookmarks[0].name).toBe('Dashboards'); + }); + + test('pins a bookmark from the dashboard with the star button', async ({ page }) => { + await page.goto('/'); + await waitForHydration(page); + + await page.getByRole('button', { name: addButton }).click(); + await expect(page.locator('dialog[open]')).toBeVisible(); + + await page.getByLabel('Name').fill('Dashboards'); + await page.getByLabel('URL').fill('https://superset.example.com'); + + await page.locator('dialog[open]').getByRole('button', { name: addButton }).click(); + + // Bookmark appears in the regular section without a pinned heading + await expect(page.getByText('Dashboards')).toBeVisible(); + await expect(page.getByText('Pinned')).not.toBeVisible(); + + await page.getByRole('button', { name: 'Pin bookmark' }).click(); + + // Pinned section appears above and contains the bookmark + await expect(page.getByText('Pinned')).toBeVisible(); + await expect(page.getByRole('button', { name: 'Unpin bookmark' })).toBeVisible(); + + const stored = await page.evaluate(() => localStorage.getItem('dashboard_bookmarks')); + const bookmarks = JSON.parse(stored!); + expect(bookmarks[0].pinned).toBe(true); + + // Unpinning moves the bookmark back to the regular section + await page.getByRole('button', { name: 'Unpin bookmark' }).click(); + await expect(page.getByText('Pinned')).not.toBeVisible(); + await expect(page.getByRole('button', { name: 'Pin bookmark' })).toBeVisible(); + + const storedAfter = await page.evaluate(() => localStorage.getItem('dashboard_bookmarks')); + const bookmarksAfter = JSON.parse(storedAfter!); + expect(bookmarksAfter[0].pinned).toBe(false); + }); + + test('pins a bookmark via the checkbox in the modal', async ({ page }) => { + await page.goto('/'); + await waitForHydration(page); + + await page.getByRole('button', { name: addButton }).click(); + await expect(page.locator('dialog[open]')).toBeVisible(); + + await page.getByLabel('Name').fill('Dashboards'); + await page.getByLabel('URL').fill('https://superset.example.com'); + await page.locator('dialog[open]').getByRole('checkbox', { name: 'Pin bookmark' }).click(); + + await page.locator('dialog[open]').getByRole('button', { name: addButton }).click(); + + await expect(page.getByText('Pinned')).toBeVisible(); + await expect(page.getByRole('button', { name: 'Unpin bookmark' })).toBeVisible(); + + const stored = await page.evaluate(() => localStorage.getItem('dashboard_bookmarks')); + const bookmarks = JSON.parse(stored!); + expect(bookmarks[0].pinned).toBe(true); + }); + + test('pinned state is preserved when editing a bookmark', async ({ page }) => { + await page.goto('/'); + await waitForHydration(page); + + await page.getByRole('button', { name: addButton }).click(); + await expect(page.locator('dialog[open]')).toBeVisible(); + + await page.getByLabel('Name').fill('Dashboards'); + await page.getByLabel('URL').fill('https://superset.example.com'); + await page.locator('dialog[open]').getByRole('checkbox', { name: 'Pin bookmark' }).click(); + + await page.locator('dialog[open]').getByRole('button', { name: addButton }).click(); + + await page.getByRole('button', { name: 'Edit bookmark' }).click(); + await expect(page.locator('dialog[open]')).toBeVisible(); + + await expect( + page.locator('dialog[open]').getByRole('checkbox', { name: 'Pin bookmark' }) + ).toBeChecked(); + }); + + test('edits a bookmark', async ({ page }) => { + await page.goto('/'); + await waitForHydration(page); + + await page.getByRole('button', { name: 'Add Bookmark' }).click(); + await expect(page.locator('dialog[open]')).toBeVisible(); + + await page.locator('button[aria-pressed]').filter({ hasText: 'Superset' }).click(); + await page.getByLabel('Name').fill('Dashboards'); + await page.getByLabel('URL').fill('https://superset.example.com'); + + await page.locator('dialog[open]').getByRole('button', { name: 'Add Bookmark' }).click(); + + await page.getByRole('button', { name: 'Edit bookmark' }).click(); + await expect(page.locator('dialog[open]')).toBeVisible(); + + // Modal opens prefilled and shows edit title + await expect( + page.locator('dialog[open]').getByRole('heading', { name: 'Edit Bookmark' }) + ).toBeVisible(); + await expect(page.getByLabel('Name')).toHaveValue('Dashboards'); + await expect(page.getByLabel('URL')).toHaveValue('https://superset.example.com'); + + await page.getByLabel('Name').fill('Renamed Dashboard'); + + await page.locator('dialog[open]').getByRole('button', { name: 'Save changes' }).click(); + + await expect(page.getByText('Renamed Dashboard')).toBeVisible(); + await expect(page.getByText('Dashboards')).not.toBeVisible(); + + const stored = await page.evaluate(() => localStorage.getItem('dashboard_bookmarks')); + const bookmarks = JSON.parse(stored!); + expect(bookmarks).toHaveLength(1); + expect(bookmarks[0].name).toBe('Renamed Dashboard'); + }); + + test('cancel in edit dialog closes without changes', async ({ page }) => { + await page.goto('/'); + await waitForHydration(page); + + await page.getByRole('button', { name: 'Add Bookmark' }).click(); + await expect(page.locator('dialog[open]')).toBeVisible(); + + await page.locator('button[aria-pressed]').filter({ hasText: 'Superset' }).click(); + await page.getByLabel('Name').fill('Dashboards'); + await page.getByLabel('URL').fill('https://superset.example.com'); + + await page.locator('dialog[open]').getByRole('button', { name: 'Add Bookmark' }).click(); + + await page.getByRole('button', { name: 'Edit bookmark' }).click(); + await expect(page.locator('dialog[open]')).toBeVisible(); + + await page.getByLabel('Name').fill('Not Saved'); + await page.locator('dialog[open]').getByRole('button', { name: 'Cancel' }).click(); + + await expect(page.locator('dialog[open]')).not.toBeVisible(); + await expect(page.getByText('Dashboards')).toBeVisible(); + await expect(page.getByText('Not Saved')).not.toBeVisible(); + + // Reopening the edit modal prefills the bookmark values again + await page.getByRole('button', { name: 'Edit bookmark' }).click(); + await expect(page.locator('dialog[open]')).toBeVisible(); + await expect(page.getByLabel('Name')).toHaveValue('Dashboards'); + await expect(page.getByLabel('URL')).toHaveValue('https://superset.example.com'); + }); + + test('deletes a bookmark from the edit modal with confirmation', async ({ page }) => { + await page.goto('/'); + await waitForHydration(page); + + await page.getByRole('button', { name: 'Add Bookmark' }).click(); + await expect(page.locator('dialog[open]')).toBeVisible(); + + await page.locator('button[aria-pressed]').filter({ hasText: 'Superset' }).click(); + await page.getByLabel('Name').fill('Dashboards'); + await page.getByLabel('URL').fill('https://superset.example.com'); + + await page.locator('dialog[open]').getByRole('button', { name: 'Add Bookmark' }).click(); + + await page.getByRole('button', { name: 'Edit bookmark' }).click(); + await expect(page.locator('dialog[open]')).toBeVisible(); + + // Delete opens a confirmation dialog + await page.locator('dialog[open]').getByRole('button', { name: 'Delete' }).click(); + await expect( + page.locator('dialog[open]').getByRole('heading', { name: 'Delete bookmark?' }) + ).toBeVisible(); + + // Cancelling the confirmation returns to the edit dialog + await page.locator('dialog[open]').getByRole('button', { name: 'Cancel' }).click(); + await expect( + page.locator('dialog[open]').getByRole('heading', { name: 'Edit Bookmark' }) + ).toBeVisible(); + + // Delete again and confirm + await page.locator('dialog[open]').getByRole('button', { name: 'Delete' }).click(); + await page.locator('dialog[open]').getByRole('button', { name: 'Delete' }).click(); + + await expect(page.getByText('Dashboards')).not.toBeVisible(); + }); +}); diff --git a/e2e/i18n.spec.ts b/e2e/i18n.spec.ts index 73b9ce29..b159662c 100644 --- a/e2e/i18n.spec.ts +++ b/e2e/i18n.spec.ts @@ -23,8 +23,8 @@ test.describe('Internationalisation', () => { await expect(html).toHaveAttribute('lang', 'en'); // Dashboard content is in English - await expect(page.getByText('Welcome back')).toBeVisible(); - await expect(page.getByText('Getting started')).toBeVisible(); + await expect(page.getByText('Stackable Unified Data Platform overview')).toBeVisible(); + await expect(page.getByText('Add Bookmark')).toBeVisible(); await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible(); }); @@ -58,8 +58,8 @@ test.describe('Internationalisation', () => { // Page reloads with German content await expect(page.locator('html')).toHaveAttribute('lang', 'de'); - await expect(page.getByText('Willkommen zurück')).toBeVisible(); - await expect(page.getByText('Erste Schritte')).toBeVisible(); + await expect(page.getByText('Ihre Plattform')).toBeVisible(); + await expect(page.getByText('Lesezeichen hinzufügen')).toBeVisible(); }); test('locale persists via cookie across navigation', async ({ page, context, baseURL }) => { @@ -78,14 +78,14 @@ test.describe('Internationalisation', () => { // Should render in German await expect(page.locator('html')).toHaveAttribute('lang', 'de'); - await expect(page.getByText('Willkommen zurück')).toBeVisible(); + await expect(page.getByText('Ihre Plattform')).toBeVisible(); // Navigate to same page (simulate navigation) await page.goto('/'); // Should still be German await expect(page.locator('html')).toHaveAttribute('lang', 'de'); - await expect(page.getByText('Willkommen zurück')).toBeVisible(); + await expect(page.getByText('Ihre Plattform')).toBeVisible(); }); test('Accept-Language header respected for first visit', async ({ browser }, testInfo) => { @@ -101,7 +101,7 @@ test.describe('Internationalisation', () => { // Should render in German based on Accept-Language await expect(page.locator('html')).toHaveAttribute('lang', 'de'); - await expect(page.getByText('Willkommen zurück')).toBeVisible(); + await expect(page.getByText('Ihre Plattform')).toBeVisible(); await context.close(); }); diff --git a/e2e/opa.spec.ts b/e2e/opa.spec.ts new file mode 100644 index 00000000..0e075d20 --- /dev/null +++ b/e2e/opa.spec.ts @@ -0,0 +1,65 @@ +import { test, expect } from '@playwright/test'; +import { waitForHydration } from './support/helpers'; + +test.describe('OPA admin gating for bookmarks', () => { + test.use({ locale: 'en-US' }); + + test.beforeEach(async ({ page }) => { + await page.addInitScript(() => { + localStorage.removeItem('dashboard_bookmarks'); + }); + }); + + test('admin can pin a bookmark for everyone', async ({ page }, testInfo) => { + // Only the `admin` project logs in with an admin OIDC profile that the + // mock OPA server grants admin rights. + test.skip(testInfo.project.name !== 'admin', 'requires an admin session'); + + await page.goto('/'); + await waitForHydration(page); + + await page.getByRole('button', { name: 'Add Bookmark' }).click(); + await expect(page.locator('dialog[open]')).toBeVisible(); + + const pinEveryone = page.getByRole('checkbox', { name: /pin bookmark for everyone/i }); + await expect(pinEveryone).toBeEnabled(); + await pinEveryone.check(); + + await page.getByLabel('Name').fill('Shared Dashboard'); + await page.getByLabel('URL').fill('https://superset.example.com'); + await page.locator('dialog[open]').getByRole('button', { name: 'Add Bookmark' }).click(); + + const stored = await page.evaluate(() => localStorage.getItem('dashboard_bookmarks')); + expect(stored).toBeTruthy(); + const bookmarks = JSON.parse(stored!); + expect(bookmarks).toHaveLength(1); + expect(bookmarks[0].pinnedForEveryone).toBe(true); + }); + + test('non-admin cannot pin a bookmark for everyone', async ({ page }, testInfo) => { + // The admin project is the only one with an admin session. + test.skip(testInfo.project.name === 'admin', 'requires a non-admin session'); + + await page.goto('/'); + await waitForHydration(page); + + await page.getByRole('button', { name: 'Add Bookmark' }).click(); + await expect(page.locator('dialog[open]')).toBeVisible(); + + // The "pin for everyone" section is not shown to regular users. + await expect(page.getByRole('checkbox', { name: /pin bookmark for everyone/i })).toHaveCount(0); + await expect(page.getByText('Only administrators can pin bookmarks for everyone')).toHaveCount( + 0 + ); + }); + + test('OPA request metrics are exposed on /metrics', async ({ page }, testInfo) => { + // Run once to avoid duplicating the check across every browser project. + test.skip(testInfo.project.name !== 'chromium', 'only check metrics on the chromium project'); + + const response = await page.request.get('/metrics'); + expect(response.ok()).toBeTruthy(); + const body = await response.text(); + expect(body).toContain('opa_request_total'); + }); +}); diff --git a/e2e/sidebar-bookmarks.spec.ts b/e2e/sidebar-bookmarks.spec.ts new file mode 100644 index 00000000..0694442e --- /dev/null +++ b/e2e/sidebar-bookmarks.spec.ts @@ -0,0 +1,203 @@ +import { test, expect } from '@playwright/test'; +import { waitForHydration } from './support/helpers'; + +test.describe('Sidebar bookmarks', () => { + test.use({ locale: 'en-US' }); + + const BOOKMARK_ID = 'b6d6c7b6-8f24-4c4a-9c64-4f3b7c19e6a1'; + const EMBED_URL = 'data:text/html,%3Ch1%3EEmbedded%20Tool%3C/h1%3E'; + + test.skip( + ({ viewport }) => (viewport?.width ?? 1280) < 1024, + 'Sidebar is off-canvas on small screens' + ); + + test.beforeEach(async ({ page }) => { + await page.addInitScript( + ({ bookmarkId, embedUrl }) => { + localStorage.removeItem('dashboard_bookmarks'); + localStorage.removeItem('sidebar_tools_open'); + localStorage.setItem( + 'dashboard_bookmarks', + JSON.stringify([ + { + id: bookmarkId, + productId: 'superset', + name: 'Dashboards', + environment: '', + url: embedUrl, + openIn: 'cockpit', + pinned: false, + createdAt: new Date().toISOString() + }, + { + id: 'e7d8c7b6-9f35-4d4b-8d75-5f4c8d2fa7b2', + productId: 'airflow', + name: 'Pipelines', + environment: '', + url: 'https://airflow.example.com', + openIn: 'cockpit', + pinned: true, + createdAt: new Date().toISOString() + } + ]) + ); + }, + { bookmarkId: BOOKMARK_ID, embedUrl: EMBED_URL } + ); + }); + + test('shows pinned bookmarks in Favourites and the rest in Tools', async ({ page }) => { + await page.goto('/'); + await waitForHydration(page); + + await expect(page.getByText('Favourites')).toBeVisible(); + await expect(page.getByRole('link', { name: 'Pipelines' })).toBeVisible(); + + await expect(page.getByRole('button', { name: 'Collapse tools section' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Dashboards' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Trino' })).toBeVisible(); + }); + + test('opens a bookmark inside the application as an iframe', async ({ page }) => { + await page.goto('/'); + await waitForHydration(page); + + await page.getByRole('link', { name: 'Dashboards' }).click(); + + await expect(page).toHaveURL(new RegExp(`/bookmark/${BOOKMARK_ID}`)); + + const frame = page.frameLocator('iframe'); + await expect(frame.getByRole('heading', { name: 'Embedded Tool' })).toBeVisible(); + }); + + test('external link button opens the bookmark URL in a new tab', async ({ page }) => { + await page.goto('/'); + await waitForHydration(page); + + const externalLinks = page.getByRole('link', { name: 'Open in new tab' }); + await expect(externalLinks).toHaveCount(2); + + const dashboardsItem = page.getByRole('link', { name: 'Dashboards' }).locator('..'); + const dashboardsExternal = dashboardsItem.getByRole('link', { name: 'Open in new tab' }); + await expect(dashboardsExternal).toHaveAttribute('href', EMBED_URL); + await expect(dashboardsExternal).toHaveAttribute('target', '_blank'); + await expect(dashboardsExternal).toHaveAttribute('rel', /noopener/); + }); + + test('inbuilt tools have no external link button', async ({ page }) => { + await page.goto('/'); + await waitForHydration(page); + + const trinoLink = page.getByRole('link', { name: 'Trino' }); + await expect(trinoLink).toBeVisible(); + const trinoItem = trinoLink.locator('..'); + await expect(trinoItem.getByRole('link', { name: 'Open in new tab' })).toHaveCount(0); + }); + + test('migrates a custom link bookmark whose id is the URL literal so it can be opened', async ({ + page + }) => { + await page.addInitScript( + ({ embedUrl }) => { + localStorage.setItem( + 'dashboard_bookmarks', + JSON.stringify([ + { + id: embedUrl, + productId: 'custom', + name: 'Custom Tool', + environment: '', + url: embedUrl, + openIn: 'cockpit', + pinned: false, + createdAt: new Date().toISOString() + } + ]) + ); + }, + { embedUrl: EMBED_URL } + ); + await page.goto('/'); + await waitForHydration(page); + + const stored = await page.evaluate(() => localStorage.getItem('dashboard_bookmarks')); + const [migrated] = JSON.parse(stored!); + expect(migrated.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i); + expect(migrated.id).not.toBe(EMBED_URL); + + await page.getByRole('link', { name: 'Custom Tool' }).click(); + await expect(page).toHaveURL(new RegExp(`/bookmark/${migrated.id}`)); + + const frame = page.frameLocator('iframe'); + await expect(frame.getByRole('heading', { name: 'Embedded Tool' })).toBeVisible(); + }); + + test('bookmark set to open in a new tab links externally and the side button opens the cockpit', async ({ + page + }) => { + await page.addInitScript( + ({ bookmarkId, embedUrl }) => { + localStorage.setItem( + 'dashboard_bookmarks', + JSON.stringify([ + { + id: bookmarkId, + productId: 'superset', + name: 'Dashboards', + environment: '', + url: embedUrl, + openIn: 'new-tab', + pinned: false, + createdAt: new Date().toISOString() + } + ]) + ); + }, + { bookmarkId: BOOKMARK_ID, embedUrl: EMBED_URL } + ); + await page.goto('/'); + await waitForHydration(page); + + const dashboardsLink = page.getByRole('link', { name: 'Dashboards' }); + await expect(dashboardsLink).toHaveAttribute('href', EMBED_URL); + await expect(dashboardsLink).toHaveAttribute('target', '_blank'); + await expect(dashboardsLink).toHaveAttribute('rel', /noopener/); + await expect(dashboardsLink).not.toHaveAttribute('aria-current', 'page'); + + const dashboardsItem = dashboardsLink.locator('..'); + const cockpitButton = dashboardsItem.getByRole('link', { name: 'Open in Cockpit' }); + await expect(cockpitButton).toHaveAttribute('href', `/bookmark/${BOOKMARK_ID}`); + await expect(cockpitButton).not.toHaveAttribute('target', '_blank'); + }); + + test('tools section is collapsible', async ({ page }) => { + await page.goto('/'); + await waitForHydration(page); + + const collapseButton = page.getByRole('button', { name: 'Collapse tools section' }); + await expect(collapseButton).toHaveAttribute('aria-expanded', 'true'); + + await collapseButton.click(); + await expect(page.getByRole('link', { name: 'Dashboards' })).not.toBeVisible(); + await expect(page.getByRole('link', { name: 'Trino' })).not.toBeVisible(); + await expect(page.getByRole('link', { name: 'Pipelines' })).toBeVisible(); + + await page.getByRole('button', { name: 'Expand tools section' }).click(); + await expect(page.getByRole('link', { name: 'Dashboards' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Trino' })).toBeVisible(); + }); + + test('active bookmark is highlighted in the sidebar', async ({ page }) => { + await page.goto('/'); + await waitForHydration(page); + + await page.getByRole('link', { name: 'Dashboards' }).click(); + await expect(page).toHaveURL(new RegExp(`/bookmark/${BOOKMARK_ID}`)); + + await expect(page.getByRole('link', { name: 'Dashboards' })).toHaveAttribute( + 'aria-current', + 'page' + ); + }); +}); diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts index 271256ef..609ba11c 100644 --- a/e2e/smoke.spec.ts +++ b/e2e/smoke.spec.ts @@ -19,7 +19,7 @@ test.describe('Smoke tests', () => { await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible(); // Dashboard content is rendered - await expect(page.getByText('Welcome back')).toBeVisible(); + await expect(page.getByText('Stackable Unified Data Platform overview')).toBeVisible(); // Trino nav item is present and navigable const trinoLink = page.getByRole('link', { name: 'Trino' }); diff --git a/e2e/support/mock-oidc-server.ts b/e2e/support/mock-oidc-server.ts index 0f4624dc..01e1994c 100644 --- a/e2e/support/mock-oidc-server.ts +++ b/e2e/support/mock-oidc-server.ts @@ -4,6 +4,16 @@ export const MOCK_OIDC_PORT = 9090; export const ISSUER_URL = `http://localhost:${MOCK_OIDC_PORT}`; export const DISCOVERY_URL = `${ISSUER_URL}/.well-known/openid-configuration`; +/** + * Cookie that a test can set (on the `localhost` domain, shared by the app and + * the mock OIDC) to choose which profile the next sign-in gets. Values: + * `admin` or `regular`. This makes the identity of each auth flow + * deterministic without relying on login order. + */ +export const PROFILE_COOKIE = 'mock-oidc-profile'; + +export type OidcProfile = 'admin' | 'regular'; + let server: OAuth2Server | null = null; function decodeJwtPayload(token: string): Record { @@ -11,32 +21,61 @@ function decodeJwtPayload(token: string): Record { return JSON.parse(Buffer.from(base64, 'base64url').toString()); } +function getCookie(header: string | undefined, name: string): string | undefined { + const needle = `${name}=`; + const part = (header ?? '') + .split(';') + .map((p) => p.trim()) + .find((p) => p.startsWith(needle)); + return part ? part.slice(needle.length) : undefined; +} + export async function startMockOidc(): Promise { server = new OAuth2Server(); await server.issuer.keys.generate('RS256'); // Each auth flow gets a unique user so that parallel Playwright projects - // (chromium, firefox, mobile) don't share the same server-side session - // and Trino connection store entry. + // (chromium, firefox, mobile, admin) don't share the same server-side + // session and Trino connection store entry. let userCounter = 0; // Map sub → profile claims so the userinfo endpoint can look them up // from the access token instead of relying on shared mutable state. const users = new Map(); + // The requested profile travels from the browser's /authorize request (which + // carries the test's cookie) to the token endpoint via the authorization + // code. `beforeTokenSigning` fires for both the access and the id token, so + // the map entry is deliberately not consumed/removed. + const profilesByCode = new Map(); + + // Capture the profile requested by the test on the /authorize request. + server.service.on('beforeAuthorizeRedirect', (redirect, req) => { + const cookie = getCookie( + (req as { headers: { cookie?: string } }).headers.cookie, + PROFILE_COOKIE + ); + const code = redirect.url.searchParams.get('code'); + if (code) profilesByCode.set(code, cookie === 'admin' ? 'admin' : 'regular'); + }); + // Add OIDC profile claims to every issued token - server.service.on('beforeTokenSigning', (token) => { + server.service.on('beforeTokenSigning', (token, req) => { userCounter++; - const sub = `mock-user-${String(userCounter).padStart(3, '0')}`; - const profile = { - name: `Test User ${userCounter}`, - email: `testuser${userCounter}@example.com`, - preferred_username: `testuser${userCounter}` + const profile = + profilesByCode.get((req as { body?: { code?: string } }).body?.code ?? '') ?? 'regular'; + const isAdmin = profile === 'admin'; + const n = userCounter; + const sub = `mock-user-${String(n).padStart(3, '0')}`; + const claims = { + name: isAdmin ? `Admin User ${n}` : `Test User ${n}`, + email: isAdmin ? `admin${n}@admin.example.com` : `testuser${n}@example.com`, + preferred_username: isAdmin ? `admin${n}` : `testuser${n}` }; - users.set(sub, profile); + users.set(sub, claims); token.payload.sub = sub; - Object.assign(token.payload, profile); + Object.assign(token.payload, claims); }); // Return claims from the userinfo endpoint, derived from the access token diff --git a/e2e/support/start-mock-opa.ts b/e2e/support/start-mock-opa.ts new file mode 100644 index 00000000..64bf1192 --- /dev/null +++ b/e2e/support/start-mock-opa.ts @@ -0,0 +1,65 @@ +import * as http from 'node:http'; + +export const MOCK_OPA_PORT = 9191; + +interface OpaInput { + user?: { + id?: string; + email?: string; + username?: string | null; + }; +} + +// Hardcoded admin user IDs — mirrors dev/opa/policies/admin.rego. +const ADMIN_USER_IDS = new Set(['admin-user-id-1', 'admin-user-id-2']); + +/** + * Mirrors the `stackable/admin` rule in `dev/opa/policies/admin.rego`: + * a user is an admin when their ID is in the hardcoded admin list or their + * email ends with `@admin.example.com`. + */ +function isAdmin(input: OpaInput): boolean { + const { id, email } = input.user ?? {}; + if (id && ADMIN_USER_IDS.has(id)) return true; + if (email?.endsWith('@admin.example.com')) return true; + return false; +} + +/** + * Minimal stand-in for a real OPA server. Implements just enough of the OPA + * Data API that the `@open-policy-agent/opa` SDK client uses: `POST /v1/data/` + * with a JSON body `{ "input": ... }` returns `{ "result": }`. + * + * GET requests also return 200 so Playwright can use the endpoint as a + * readiness probe for the webServer. + */ +http + .createServer((req, res) => { + if (!req.url?.startsWith('/v1/data/')) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'not found' })); + return; + } + + const respond = (result: unknown) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ result })); + }; + + if (req.method !== 'POST') { + respond(false); + return; + } + + let body = ''; + req.on('data', (c: Buffer) => (body += c)); + req.on('end', () => { + try { + const parsed = JSON.parse(body || '{}') as { input?: OpaInput }; + respond(isAdmin(parsed.input ?? {})); + } catch { + respond(false); + } + }); + }) + .listen(MOCK_OPA_PORT, 'localhost', () => console.log(`Mock OPA on :${MOCK_OPA_PORT}`)); diff --git a/messages/de.json b/messages/de.json index c54247fe..a4e569c4 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1,22 +1,10 @@ { "$schema": "https://inlang.com/schema/inlang-message-format", - "dashboard_welcome": "Willkommen zurück", - "dashboard_subtitle": "Stackable Unified Data Platform – Übersicht", - "dashboard_services": "Dienste", - "dashboard_services_empty": "Keine Dienste gefunden", - "dashboard_queries": "Aktive Abfragen", - "dashboard_queries_empty": "Trino verbinden, um zu beginnen", - "dashboard_health": "Zustand", - "dashboard_health_ok": "OK", - "dashboard_health_status": "Alle Systeme betriebsbereit", - "dashboard_getting_started": "Erste Schritte", - "dashboard_getting_started_description": "Die Stackable Unified Data Platform UI bietet eine zentrale Oberfläche für Ihre Dateninfrastruktur. Sobald Trino-Instanzen erkannt oder konfiguriert sind, können Sie Kataloge durchsuchen, SQL-Abfragen schreiben und Ergebnisse anzeigen – alles über diese Oberfläche.", - "dashboard_step_oidc": "OIDC-Authentifizierung konfigurieren", - "dashboard_step_trino": "Trino-Instanzen verbinden", - "dashboard_step_browse": "Kataloge durchsuchen und abfragen", + "dashboard_welcome": "Ihre Plattform", + "dashboard_subtitle": "Die Werkzeuge, die Sie sich als Lesezeichen gespeichert haben. Lesezeichen werden von Hand hinzugefügt – Cockpit erkennt noch nichts automatisch.", + "dashboard_bookmarks_empty": "Noch keine Lesezeichen. Fügen Sie Ihr erstes Lesezeichen hinzu, um zu starten.", "nav_platform": "Plattform", "nav_dashboard": "Dashboard", - "nav_data_tools": "Datenwerkzeuge", "nav_trino": "Trino", "nav_badge_soon": "Bald", "sidebar_label": "Seitenleiste", @@ -25,6 +13,12 @@ "sidebar_expand": "Seitenleiste ausklappen", "sidebar_collapse": "Seitenleiste einklappen", "sidebar_collapse_label": "Einklappen", + "sidebar_favourites": "Favoriten", + "sidebar_tools": "Werkzeuge", + "sidebar_tools_expand": "Werkzeugbereich ausklappen", + "sidebar_tools_collapse": "Werkzeugbereich einklappen", + "sidebar_bookmark_open_external": "In neuem Tab öffnen", + "sidebar_bookmark_open_in_cockpit": "Im Cockpit öffnen", "header_open_nav": "Navigation öffnen", "header_close_nav": "Navigation schließen", "header_user_menu": "Benutzermenü", @@ -34,7 +28,18 @@ "page_title_default": "Stackable", "page_title_suffix": "Stackable", "page_title_trino": "Trino", - "setup_steps_label": "Einrichtungsschritte", + "bookmark_count_links": [ + { + "declarations": ["input count", "local countPlural = count: plural"], + "selectors": ["countPlural"], + "match": { + "countPlural=one": "1 Link", + "countPlural=other": "{count} Links" + } + } + ], + "bookmark_mode_cockpit": "Cockpit", + "bookmark_mode_new_tab": "Neuer Tab", "language_label": "Sprache", "language_en": "English", "language_de": "Deutsch", @@ -369,6 +374,36 @@ "storage_upload_rename_confirm": "Mit diesem Namen hochladen", "storage_upload_retry": "Erneut versuchen", "storage_upload_close": "Schlie\u00dfen", + "bookmark_add_title": "Lesezeichen hinzufügen", + "bookmark_edit_title": "Lesezeichen bearbeiten", + "bookmark_save_changes": "Änderungen speichern", + "bookmark_product_label": "Produkt", + "bookmark_open_in_label": "Öffnen in", + "bookmark_open_in_cockpit": "Im Cockpit", + "bookmark_open_in_cockpit_desc": "Lesezeichen innerhalb der Anwendung öffnen", + "bookmark_open_in_new_tab": "Neuem Tab", + "bookmark_open_in_new_tab_desc": "Lesezeichen in einem neuen Browser-Tab öffnen", + "bookmark_name_label": "Name", + "bookmark_env_label": "Umgebung (optional)", + "bookmark_env_placeholder": "z.B. dev, prod", + "bookmark_url_label": "URL", + "bookmark_pinned_section_title": "Angeheftet", + "bookmark_pinned_label": "Lesezeichen für dich anheften", + "bookmark_pinned_label_basic": "Lesezeichen anheften", + "bookmark_pin_everyone": "Lesezeichen für alle anheften", + "bookmark_pin_everyone_hint": "Dieses Lesezeichen wird allen Benutzern angezeigt.", + "bookmark_pin_label": "Lesezeichen anheften", + "bookmark_unpin_label": "Lesezeichen lösen", + "bookmark_preview_label": "Vorschau", + "bookmark_section_title": "Lesezeichen", + "bookmark_edit_label": "Lesezeichen bearbeiten", + "bookmark_not_found_title": "Lesezeichen nicht gefunden", + "bookmark_not_found_message": "Dieses Lesezeichen existiert nicht mehr. Fügen Sie es über das Dashboard wieder hinzu, um es hier einzubetten.", + "bookmark_delete_confirm_title": "Lesezeichen löschen?", + "bookmark_delete_confirm_message": "Möchten Sie \"{name}\" wirklich löschen? Dies kann nicht rückgängig gemacht werden.", + "button_delete": "Löschen", + "button_cancel": "Abbrechen", + "button_close": "Schließen", "timestamp_just_now": "gerade eben", "timestamp_minutes_ago": [ { diff --git a/messages/en.json b/messages/en.json index 55907765..7977183b 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1,22 +1,10 @@ { "$schema": "https://inlang.com/schema/inlang-message-format", - "dashboard_welcome": "Welcome back", - "dashboard_subtitle": "Stackable Unified Data Platform overview", - "dashboard_services": "Services", - "dashboard_services_empty": "No services discovered", - "dashboard_queries": "Active Queries", - "dashboard_queries_empty": "Connect Trino to begin", - "dashboard_health": "Health", - "dashboard_health_ok": "OK", - "dashboard_health_status": "All systems operational", - "dashboard_getting_started": "Getting started", - "dashboard_getting_started_description": "The Stackable Unified Data Platform UI provides a central interface for your data infrastructure. Once Trino instances are discovered or configured, you'll be able to browse catalogs, write SQL queries, and view results, all from this interface.", - "dashboard_step_oidc": "Configure OIDC authentication", - "dashboard_step_trino": "Connect Trino instances", - "dashboard_step_browse": "Browse catalogs and query", + "dashboard_welcome": "Stackable Unified Data Platform overview", + "dashboard_subtitle": "The tools you've bookmarked. Bookmarks are added by hand — Cockpit doesn't discover anything yet.", + "dashboard_bookmarks_empty": "No bookmarks yet. Add your first bookmark to get started.", "nav_platform": "Platform", "nav_dashboard": "Dashboard", - "nav_data_tools": "Data Tools", "nav_trino": "Trino", "nav_badge_soon": "Soon", "sidebar_label": "Sidebar", @@ -25,6 +13,12 @@ "sidebar_expand": "Expand sidebar", "sidebar_collapse": "Collapse sidebar", "sidebar_collapse_label": "Collapse", + "sidebar_favourites": "Favourites", + "sidebar_tools": "Tools", + "sidebar_tools_expand": "Expand tools section", + "sidebar_tools_collapse": "Collapse tools section", + "sidebar_bookmark_open_external": "Open in new tab", + "sidebar_bookmark_open_in_cockpit": "Open in Cockpit", "header_open_nav": "Open navigation", "header_close_nav": "Close navigation", "header_user_menu": "User menu", @@ -34,7 +28,18 @@ "page_title_default": "Stackable", "page_title_suffix": "Stackable", "page_title_trino": "Trino", - "setup_steps_label": "Setup steps", + "bookmark_count_links": [ + { + "declarations": ["input count", "local countPlural = count: plural"], + "selectors": ["countPlural"], + "match": { + "countPlural=one": "1 link", + "countPlural=other": "{count} links" + } + } + ], + "bookmark_mode_cockpit": "Cockpit", + "bookmark_mode_new_tab": "New tab", "language_label": "Language", "language_en": "English", "language_de": "Deutsch", @@ -369,6 +374,36 @@ "storage_upload_rename_confirm": "Upload with this name", "storage_upload_retry": "Retry", "storage_upload_close": "Close", + "bookmark_add_title": "Add Bookmark", + "bookmark_edit_title": "Edit Bookmark", + "bookmark_save_changes": "Save changes", + "bookmark_product_label": "Product", + "bookmark_open_in_label": "Open in", + "bookmark_open_in_cockpit": "Inside Cockpit", + "bookmark_open_in_cockpit_desc": "Open the bookmark within the application interface", + "bookmark_open_in_new_tab": "New Tab", + "bookmark_open_in_new_tab_desc": "Open the bookmark in a new browser tab", + "bookmark_name_label": "Name", + "bookmark_env_label": "Environment (optional)", + "bookmark_env_placeholder": "e.g. dev, prod", + "bookmark_url_label": "URL", + "bookmark_pinned_section_title": "Pinned", + "bookmark_pinned_label": "Pin bookmark for yourself", + "bookmark_pinned_label_basic": "Pin bookmark", + "bookmark_pin_everyone": "Pin bookmark for everyone", + "bookmark_pin_everyone_hint": "This bookmark is shown to every user", + "bookmark_pin_label": "Pin bookmark", + "bookmark_unpin_label": "Unpin bookmark", + "bookmark_preview_label": "Preview", + "bookmark_section_title": "Bookmarks", + "bookmark_edit_label": "Edit bookmark", + "bookmark_not_found_title": "Bookmark not found", + "bookmark_not_found_message": "This bookmark no longer exists. Add it again from the dashboard to embed it here.", + "bookmark_delete_confirm_title": "Delete bookmark?", + "bookmark_delete_confirm_message": "Are you sure you want to delete \"{name}\"? This cannot be undone.", + "button_delete": "Delete", + "button_cancel": "Cancel", + "button_close": "Close", "timestamp_just_now": "just now", "timestamp_minutes_ago": [ { diff --git a/package-lock.json b/package-lock.json index abeb948d..784aa0e5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@aws-sdk/client-s3": "^3.1041.0", "@aws-sdk/lib-storage": "^3.1045.0", "@internationalized/date": "^3.11.0", + "@open-policy-agent/opa": "^2.0.0", "@smithy/node-http-handler": "4.9.1", "antlr4-c3": "^3.4.4", "antlr4ng": "^3.0.16", @@ -33,6 +34,7 @@ "@inlang/paraglide-js": "^2.12.0", "@playwright/test": "^1.58.2", "@sveltejs/adapter-node": "^5.5.3", + "@sveltejs/enhanced-img": "^0.11.0", "@sveltejs/kit": "^2.53.0", "@sveltejs/vite-plugin-svelte": "^6.2.4", "@tailwindcss/vite": "^4.2.1", @@ -415,16 +417,6 @@ "@aws-sdk/client-s3": "^3.1053.0" } }, - "node_modules/@aws-sdk/lib-storage/node_modules/buffer": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.6.0.tgz", - "integrity": "sha512-/gDYp/UtU0eA1ys8bOs9J6a+E/KWIY+DZ+Q2WESNUA0jFRsJOc0SNUO6xJ5SGA1xueg3NL65W6s+NY5l9cunuw==", - "license": "MIT", - "dependencies": { - "base64-js": "^1.0.2", - "ieee754": "^1.1.4" - } - }, "node_modules/@aws-sdk/middleware-bucket-endpoint": { "version": "3.972.15", "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.972.15.tgz", @@ -1682,6 +1674,544 @@ "import-meta-resolve": "^4.2.0" } }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@inlang/paraglide-js": { "version": "2.12.0", "resolved": "https://registry.npmjs.org/@inlang/paraglide-js/-/paraglide-js-2.12.0.tgz", @@ -2017,6 +2547,28 @@ "node": ">=20.0" } }, + "node_modules/@open-policy-agent/opa": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@open-policy-agent/opa/-/opa-2.0.0.tgz", + "integrity": "sha512-gtSZKm9eWU9FC7lJ7A2sbyNc+ukTddZ2t6WtJGCtHc+ze6tvhJ7xtLQmlCWLmkyIu+6TGVRn/fr2JGdKgVLdtQ==", + "license": "Apache-2.0", + "dependencies": { + "@open-policy-agent/ucast-prisma": "^0.1.6" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, + "node_modules/@open-policy-agent/ucast-prisma": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@open-policy-agent/ucast-prisma/-/ucast-prisma-0.1.6.tgz", + "integrity": "sha512-4bnz9aZzQQyfoo6OrXCWA8IH6Kp6pB34e4A2vGv2B0S/dbfFVN8TzmUn+WTJg7Eyr/NiF2EPFShiFgBwbUhhrA==", + "license": "Apache-2.0", + "dependencies": { + "@ucast/core": "^1.10.1", + "lodash.merge": "^4.6.2" + } + }, "node_modules/@opentelemetry/api": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", @@ -2917,6 +3469,25 @@ "@sveltejs/kit": "^2.4.0" } }, + "node_modules/@sveltejs/enhanced-img": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@sveltejs/enhanced-img/-/enhanced-img-0.11.0.tgz", + "integrity": "sha512-TN7VzGoqwFqvA4Faj1brUPHLtcEkyFa08nrn9bfm98yVrzr0g4bOZHjlBoPYHHX+sZNGE+cxznhTVgarECPQsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.5", + "sharp": "^0.34.1", + "svelte-parse-markup": "^0.1.5", + "vite-imagetools": "^9.0.3", + "zimmerframe": "^1.1.2" + }, + "peerDependencies": { + "@sveltejs/vite-plugin-svelte": "^6.0.0 || ^7.0.0", + "svelte": "^5.0.0", + "vite": "^6.3.0 || >=7.0.0" + } + }, "node_modules/@sveltejs/kit": { "version": "2.53.2", "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.53.2.tgz", @@ -3466,9 +4037,9 @@ } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", "dev": true, "license": "MIT", "engines": { @@ -3635,26 +4206,26 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.5" + "brace-expansion": "^5.0.8" }, "engines": { "node": "18 || 20 || >=22" @@ -3718,6 +4289,12 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@ucast/core": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/@ucast/core/-/core-1.10.2.tgz", + "integrity": "sha512-ons5CwXZ/51wrUPfoduC+cO7AS1/wRb0ybpQJ9RrssossDxVy4t49QxWoWgfBDvVKsz9VXzBk9z0wqTdZ+Cq8g==", + "license": "Apache-2.0" + }, "node_modules/@valibot/to-json-schema": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@valibot/to-json-schema/-/to-json-schema-1.5.0.tgz", @@ -4629,29 +5206,13 @@ } }, "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.6.0.tgz", + "integrity": "sha512-/gDYp/UtU0eA1ys8bOs9J6a+E/KWIY+DZ+Q2WESNUA0jFRsJOc0SNUO6xJ5SGA1xueg3NL65W6s+NY5l9cunuw==", "license": "MIT", - "optional": true, "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" + "base64-js": "^1.0.2", + "ieee754": "^1.1.4" } }, "node_modules/bytes": { @@ -5370,9 +5931,9 @@ "license": "MIT" }, "node_modules/effect": { - "version": "3.21.2", - "resolved": "https://registry.npmjs.org/effect/-/effect-3.21.2.tgz", - "integrity": "sha512-rXd2FGDM8KdjSIrc+mqEELo7ScW7xTVxEf1iInmPSpIde9/nyGuFM710cjTo7/EreGXiUX2MOonPpprbz2XHCg==", + "version": "3.22.1", + "resolved": "https://registry.npmjs.org/effect/-/effect-3.22.1.tgz", + "integrity": "sha512-TNoXushmPOBAjJlthF5d2QwnX2xBPEtcNJr5XKNKbRLbDvBcOYkXlYDfvGfSA0zriwLFuCll5MDtNMAdZL17PQ==", "dev": true, "license": "MIT", "optional": true, @@ -6385,9 +6946,9 @@ } }, "node_modules/glob/node_modules/brace-expansion": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", - "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", "dev": true, "license": "MIT", "optional": true, @@ -6535,9 +7096,9 @@ } }, "node_modules/hosted-git-info/node_modules/lru-cache": { - "version": "11.3.5", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", - "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -6650,9 +7211,9 @@ "license": "MIT" }, "node_modules/iconv-lite": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", - "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -6695,6 +7256,16 @@ "node": ">= 4" } }, + "node_modules/imagetools-core": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/imagetools-core/-/imagetools-core-9.1.0.tgz", + "integrity": "sha512-xQjs+2vrxLnAjCq+omuNkd5UQTld9/bP8+YT0LyYTlKfuSQtgUBvqhUwGugzSAh6sCdN+LnROMuLswn5hZ9Fhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -7430,7 +8001,6 @@ "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true, "license": "MIT" }, "node_modules/long": { @@ -8651,13 +9221,13 @@ } }, "node_modules/pkg-types": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", - "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz", + "integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==", "license": "MIT", "dependencies": { - "confbox": "^0.2.2", - "exsolve": "^1.0.7", + "confbox": "^0.2.4", + "exsolve": "^1.0.8", "pathe": "^2.0.3" } }, @@ -9679,6 +10249,51 @@ "dev": true, "license": "ISC" }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -10373,6 +10988,19 @@ } } }, + "node_modules/svelte-parse-markup": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/svelte-parse-markup/-/svelte-parse-markup-0.1.5.tgz", + "integrity": "sha512-T6mqZrySltPCDwfKXWQ6zehipVLk4GWfH1zCMGgRtLlOIFPuw58ZxVYxVvotMJgJaurKi1i14viB2GIRKXeJTQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://bjornlu.com/sponsor" + }, + "peerDependencies": { + "svelte": "^3.0.0 || ^4.0.0 || ^5.0.0-next.1" + } + }, "node_modules/svelte/node_modules/is-reference": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", @@ -11043,9 +11671,9 @@ "license": "MIT" }, "node_modules/uuid": { - "version": "13.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.1.tgz", - "integrity": "sha512-9ezox2roIft6ExBVTVqibSd5dc5/47Sw/uY6b4SjQUT2TzQ0tltNquWA46y4xPQmdZYqvnio22SgWd41M86+jw==", + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.2.tgz", + "integrity": "sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw==", "dev": true, "funding": [ "https://github.com/sponsors/broofa", @@ -11177,6 +11805,21 @@ } } }, + "node_modules/vite-imagetools": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/vite-imagetools/-/vite-imagetools-9.0.3.tgz", + "integrity": "sha512-FwjApRNZyN+RucPW9Z9kf0dyzyi3r3zlDfrTnzHXNaYpmT3pZ5w//d6QkApy1iypbDm+3fq+Gwfv+PYA4j4uYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.5", + "imagetools-core": "^9.1.0", + "sharp": "^0.34.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/vite/node_modules/@esbuild/linux-x64": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", diff --git a/package.json b/package.json index f9393ffe..bfe324b0 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "@inlang/paraglide-js": "^2.12.0", "@playwright/test": "^1.58.2", "@sveltejs/adapter-node": "^5.5.3", + "@sveltejs/enhanced-img": "^0.11.0", "@sveltejs/kit": "^2.53.0", "@sveltejs/vite-plugin-svelte": "^6.2.4", "@tailwindcss/vite": "^4.2.1", @@ -64,6 +65,7 @@ "@aws-sdk/client-s3": "^3.1041.0", "@aws-sdk/lib-storage": "^3.1045.0", "@internationalized/date": "^3.11.0", + "@open-policy-agent/opa": "^2.0.0", "@smithy/node-http-handler": "4.9.1", "antlr4-c3": "^3.4.4", "antlr4ng": "^3.0.16", diff --git a/playwright.config.ts b/playwright.config.ts index 568b4946..b87a7ea1 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -29,6 +29,11 @@ export default defineConfig({ url: 'http://localhost:8080', reuseExistingServer: true }, + { + command: 'npx tsx e2e/support/start-mock-opa.ts', + url: 'http://localhost:9191/v1/data/stackable/admin', + reuseExistingServer: true + }, { command: 'PORT=4173 node --env-file=.env.test build', url: baseURL, @@ -67,6 +72,17 @@ export default defineConfig({ } ] : []), + // Dedicated admin login: the setup requests an admin OIDC profile so the + // mock OPA server grants admin rights. Used by the `admin` test project + // to exercise admin-gated UI (e.g. "pin bookmark for everyone"). + { + name: 'setup-admin', + testMatch: /auth\.setup\.ts/, + use: { + browserName: 'chromium', + viewport: { width: 1280, height: 720 } + } + }, { name: 'firefox', use: { @@ -86,6 +102,16 @@ export default defineConfig({ }, dependencies: ['setup-chromium'] }, + { + name: 'admin', + use: { + browserName: 'chromium', + viewport: { width: 1280, height: 720 }, + storageState: 'e2e/.auth/user-setup-admin.json', + ...(chromiumExecutablePath && { launchOptions: { executablePath: chromiumExecutablePath } }) + }, + dependencies: ['setup-admin'] + }, ...(!process.env.CI ? [ { diff --git a/src/app.d.ts b/src/app.d.ts index f31b166a..2b116f0e 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -23,6 +23,8 @@ declare global { * Null for all other routes. */ storageConfig: import('$lib/server/storage/types.js').S3ConnectionConfig | null; + /** Whether the current user has admin rights, determined by OPA. */ + isAdmin: boolean; } // interface PageData {} // interface PageState {} diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 0cd63943..4055030c 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -8,6 +8,8 @@ import { auth, oidcEnabled } from '$lib/server/auth'; import { requestLogger, logger } from '$lib/server/logging'; import { getConnectionFromHeader } from '$lib/server/storage/connection.js'; import { storageBrowserEnabled } from '$lib/server/feature-flags.js'; +import { opaEnabled } from '$lib/server/feature-flags.js'; +import { checkAdmin } from '$lib/server/opa.js'; // Allow self-signed TLS certificates in development (e.g. local Trino with self-signed certs). if (dev) { @@ -60,6 +62,24 @@ const handleAuthGuard: Handle = async ({ event, resolve }) => { return resolve(event); }; +const handleOpaAdmin: Handle = async ({ event, resolve }) => { + const isPublic = PUBLIC_PATHS.some((p) => event.url.pathname.startsWith(p)); + if (isPublic || !event.locals.user) { + event.locals.isAdmin = false; + return resolve(event); + } + + event.locals.isAdmin = await checkAdmin({ + user: { + id: event.locals.user.id, + email: event.locals.user.email ?? '', + username: event.locals.user.username ?? null + } + }); + + return resolve(event); +}; + /** * Parse the `x-storage-connection` header (base64 JSON) for every request and * store the result in `event.locals.storageConfig`. For routes under @@ -88,6 +108,7 @@ export const handle = sequence( handleMetrics, handleParaglide, ...(oidcEnabled ? [handleAuth, handleAuthGuard] : []), + ...(opaEnabled ? [handleOpaAdmin] : []), handleStorageConnection ); diff --git a/src/lib/components/dashboard/AddBookmarkModal.svelte b/src/lib/components/dashboard/AddBookmarkModal.svelte new file mode 100644 index 00000000..c160e8ac --- /dev/null +++ b/src/lib/components/dashboard/AddBookmarkModal.svelte @@ -0,0 +1,514 @@ + + + + + + + + + diff --git a/src/lib/components/dashboard/AddBookmarkModal.svelte.spec.ts b/src/lib/components/dashboard/AddBookmarkModal.svelte.spec.ts new file mode 100644 index 00000000..f08a493d --- /dev/null +++ b/src/lib/components/dashboard/AddBookmarkModal.svelte.spec.ts @@ -0,0 +1,103 @@ +import { page } from 'vitest/browser'; +import { describe, expect, it, vi } from 'vitest'; +import { render } from 'vitest-browser-svelte'; +import AddBookmarkModal from './AddBookmarkModal.svelte'; +import type { Bookmark } from '$lib/dashboard/types'; + +const { addBookmark, updateBookmark, removeBookmark } = vi.hoisted(() => ({ + addBookmark: vi.fn(), + updateBookmark: vi.fn(), + removeBookmark: vi.fn() +})); + +vi.mock('$lib/dashboard/bookmarks.svelte.js', () => ({ + addBookmark, + updateBookmark, + removeBookmark +})); + +const renderModal = (props: Record = {}) => + render(AddBookmarkModal, { + open: true, + bookmark: null, + isAdmin: false, + ...props + }); + +const pinEveryoneCheckbox = () => + page.getByRole('checkbox', { name: /pin bookmark for everyone/i }); + +describe('AddBookmarkModal', () => { + it('does not show "pin for everyone" for non-admins', async () => { + renderModal({ isAdmin: false }); + + await expect.element(pinEveryoneCheckbox()).not.toBeInTheDocument(); + }); + + it('enables "pin for everyone" for admins', async () => { + renderModal({ isAdmin: true }); + + await expect.element(pinEveryoneCheckbox()).toBeEnabled(); + }); + + it('does not show the admin-only hint to non-admins', async () => { + renderModal({ isAdmin: false }); + + await expect + .element(page.getByText('Only administrators can pin bookmarks for everyone')) + .not.toBeInTheDocument(); + }); + + it('shows the general hint to admins', async () => { + renderModal({ isAdmin: true }); + + await expect + .element(page.getByText('This bookmark is shown to every user')) + .toBeInTheDocument(); + }); + + it('stores pinnedForEveryone: true when an admin pins for everyone', async () => { + renderModal({ isAdmin: true }); + + await page.getByLabelText('Name').fill('Shared Dashboard'); + await page.getByLabelText('URL').fill('https://superset.example.com'); + await pinEveryoneCheckbox().click(); + await page.getByRole('button', { name: 'Add Bookmark' }).click(); + + expect(addBookmark).toHaveBeenCalledWith( + expect.objectContaining({ name: 'Shared Dashboard', pinnedForEveryone: true }) + ); + }); + + it('does not set pinnedForEveryone when a non-admin adds a bookmark', async () => { + renderModal({ isAdmin: false }); + + await page.getByLabelText('Name').fill('Private Dashboard'); + await page.getByLabelText('URL').fill('https://superset.example.com'); + await page.getByRole('button', { name: 'Add Bookmark' }).click(); + + expect(addBookmark).toHaveBeenCalledWith(expect.objectContaining({ pinnedForEveryone: false })); + }); + + it('preserves pinnedForEveryone when a non-admin edits an admin-pinned bookmark', async () => { + const bookmark: Bookmark = { + id: 'b1', + productId: 'superset', + name: 'Shared Dashboard', + environment: '', + url: 'https://superset.example.com', + openIn: 'cockpit', + pinned: false, + pinnedForEveryone: true, + createdAt: '2026-01-01T00:00:00.000Z' + }; + renderModal({ isAdmin: false, bookmark }); + + await page.getByLabelText('Name').fill('Renamed Dashboard'); + await page.getByRole('button', { name: 'Save changes' }).click(); + + expect(updateBookmark).toHaveBeenCalledWith( + expect.objectContaining({ name: 'Renamed Dashboard', pinnedForEveryone: true }) + ); + }); +}); diff --git a/src/lib/components/layout/sidebar/Sidebar.svelte b/src/lib/components/layout/sidebar/Sidebar.svelte index 2ae05a16..4b12fa92 100644 --- a/src/lib/components/layout/sidebar/Sidebar.svelte +++ b/src/lib/components/layout/sidebar/Sidebar.svelte @@ -1,11 +1,18 @@ @@ -45,6 +84,79 @@