diff --git a/e2e/mock-api.mjs b/e2e/mock-api.mjs
index f0d2c64..fdcb5b1 100644
--- a/e2e/mock-api.mjs
+++ b/e2e/mock-api.mjs
@@ -429,18 +429,7 @@ let preAuthKeys;
let apiKeys;
let userNodeMap;
-function resetState() {
- _nodeId = 0;
- users = structuredClone(baseUsers);
- nodes = createNodes(users);
- preAuthKeys = createPreAuthKeys(users);
- apiKeys = createApiKeys();
- userNodeMap = buildUserNodeMap(nodes, users);
-}
-
-resetState();
-
-const policy = JSON.stringify({
+const defaultPolicy = {
groups: {
'group:admin': ['alice', 'bob'],
'group:dev': ['carol', 'dave', 'eve', 'frank.garcia', 'grace'],
@@ -476,7 +465,21 @@ const policy = JSON.stringify({
{ action: 'accept', src: ['group:admin'], dst: ['*'], users: ['root', 'headscale-user'] },
{ action: 'accept', src: ['group:dev'], dst: ['tag:server'], users: ['deploy'] },
],
-});
+};
+
+let policyDocumentText = JSON.stringify(defaultPolicy);
+
+function resetState() {
+ _nodeId = 0;
+ users = structuredClone(baseUsers);
+ nodes = createNodes(users);
+ preAuthKeys = createPreAuthKeys(users);
+ apiKeys = createApiKeys();
+ userNodeMap = buildUserNodeMap(nodes, users);
+ policyDocumentText = JSON.stringify(defaultPolicy);
+}
+
+resetState();
const versionInfo = {
version: 'v0.28.0',
@@ -569,7 +572,7 @@ const server = createServer((req, res) => {
}
if (path === '/api/v1/preauthkey') return json(res, { preAuthKeys });
if (path === '/api/v1/apikey') return json(res, { apiKeys });
- if (path === '/api/v1/policy') return json(res, { policy, updatedAt: new Date().toISOString() });
+ if (path === '/api/v1/policy') return json(res, { policy: policyDocumentText, updatedAt: new Date().toISOString() });
if (path === '/api/v1/health') return json(res, healthInfo);
// Single node GET: /api/v1/node/{id}
@@ -653,7 +656,24 @@ const server = createServer((req, res) => {
// ── PUT endpoints ───────────────────────────────────────────────────────
if (method === 'PUT') {
- if (path === '/api/v1/policy') return json(res, { policy, updatedAt: new Date().toISOString() });
+ if (path === '/api/v1/policy') {
+ let body = '';
+ req.on('data', (chunk) => {
+ body += chunk;
+ });
+ req.on('end', () => {
+ try {
+ const parsed = JSON.parse(body);
+ if (typeof parsed.policy === 'string') {
+ policyDocumentText = parsed.policy;
+ }
+ } catch {
+ // Keep existing policy content when payload is invalid.
+ }
+ return json(res, { policy: policyDocumentText, updatedAt: new Date().toISOString() });
+ });
+ return;
+ }
}
// ── DELETE endpoints ────────────────────────────────────────────────────
diff --git a/e2e/policy-phase2.spec.ts b/e2e/policy-phase2.spec.ts
new file mode 100644
index 0000000..ecfb1da
--- /dev/null
+++ b/e2e/policy-phase2.spec.ts
@@ -0,0 +1,84 @@
+import { test, expect, type Page } from '@playwright/test';
+
+const MOCK_URL = 'http://localhost:8081';
+const API_KEY = 'test-api-key';
+
+async function seedAuth(page: Page) {
+ await page.goto('/');
+ await page.evaluate(
+ ([url, key]) => {
+ localStorage.setItem('apiUrl', JSON.stringify(url));
+ localStorage.setItem('apiKey', JSON.stringify(key));
+ localStorage.setItem(
+ 'apiKeyInfo',
+ JSON.stringify({
+ authorized: true,
+ expires: new Date(Date.now() + 86_400_000 * 90).toISOString(),
+ informedUnauthorized: false,
+ informedExpiringSoon: false,
+ }),
+ );
+ },
+ [MOCK_URL, API_KEY],
+ );
+ await page.reload();
+ await page.locator('[data-testid="app-shell"]').waitFor({ timeout: 10000 });
+}
+
+test.describe('policy builder phase 2', () => {
+ test.beforeEach(async ({ page }) => {
+ await seedAuth(page);
+ });
+
+ test('create and persist grants/nodeAttrs/randomizeClientPort', async ({ page }) => {
+ await page.goto('/acls/');
+
+ await page.getByRole('tab', { name: 'Grants' }).click();
+ await page.getByRole('button', { name: 'Create Grant' }).click();
+ await expect(page.getByRole('heading', { name: 'Grant #1' })).toBeVisible();
+ await page.getByRole('button', { name: 'Apply Taildrive preset' }).first().click();
+ await page.getByRole('button', { name: 'Apply capability form' }).first().click();
+
+ await page.getByRole('tab', { name: 'Node Attributes' }).click();
+ await page.getByRole('button', { name: 'Create Node Attribute Rule' }).click();
+ await expect(page.getByRole('heading', { name: 'Node Attribute #1' })).toBeVisible();
+ await page.getByRole('button', { name: 'drive:share' }).first().click();
+
+ await page.getByRole('tab', { name: 'Policy Settings' }).click();
+ await page.getByLabel('randomizeClientPort').check();
+
+ await page.getByRole('tab', { name: 'Config' }).click();
+ await page.getByRole('button', { name: 'Save Config' }).click();
+
+ await page.reload();
+ await page.getByRole('tab', { name: 'Config' }).click();
+
+ await expect(page.getByText('"randomizeClientPort": true')).toBeVisible();
+ await expect(page.getByText('"nodeAttrs"')).toBeVisible();
+ await expect(page.getByText('tailscale.com/cap/drive')).toBeVisible();
+ });
+
+ test('delete grant and persist deletion', async ({ page }) => {
+ await page.goto('/acls/');
+
+ await page.getByRole('tab', { name: 'Grants' }).click();
+ await page.getByRole('button', { name: 'Create Grant' }).click();
+ await page.getByRole('button', { name: 'Apply Taildrive preset' }).first().click();
+ await page.getByRole('button', { name: 'Apply capability form' }).first().click();
+
+ await page.getByRole('tab', { name: 'Config' }).click();
+ await page.getByRole('button', { name: 'Save Config' }).click();
+
+ await page.getByRole('tab', { name: 'Grants' }).click();
+ await page.getByTestId('delete-button').first().click();
+ await page.getByTestId('confirm-delete').first().click();
+
+ await page.getByRole('tab', { name: 'Config' }).click();
+ await page.getByRole('button', { name: 'Save Config' }).click();
+
+ await page.reload();
+ await page.getByRole('tab', { name: 'Config' }).click();
+
+ await expect(page.getByText('tailscale.com/cap/drive')).not.toBeVisible();
+ });
+});
diff --git a/src/routes/acls/+page.svelte b/src/routes/acls/+page.svelte
index 1361512..3948e7c 100644
--- a/src/routes/acls/+page.svelte
+++ b/src/routes/acls/+page.svelte
@@ -5,7 +5,10 @@
import RawMdiCodeJSON from '~icons/mdi/code-json';
import RawMdiConsole from '~icons/mdi/console';
import RawMdiDevices from '~icons/mdi/devices';
+ import RawMdiTune from '~icons/mdi/tune';
import RawMdiGroups from '~icons/mdi/account-group';
+ import RawMdiKeyChain from '~icons/mdi/key-chain-variant';
+ import RawMdiVectorPolyline from '~icons/mdi/vector-polyline';
import RawMdiSecurity from '~icons/mdi/security';
import RawMdiTag from '~icons/mdi/tag';
@@ -21,7 +24,10 @@
import Config from './Config.svelte';
import Groups from './Groups.svelte';
import Hosts from './Hosts.svelte';
+ import NodeAttrs from './NodeAttrs.svelte';
+ import PolicySettings from './PolicySettings.svelte';
import Policies from './Policies.svelte';
+ import Grants from './Grants.svelte';
import TagOwners from './TagOwners.svelte'
import SshRules from './SshRules.svelte';
@@ -37,6 +43,9 @@
{ name: 'tag-owners', title: 'Tag Owners', logo: RawMdiTag },
{ name: 'hosts', title: 'Hosts', logo: RawMdiDevices },
{ name: 'policies', title: 'Policies', logo: RawMdiSecurity },
+ { name: 'grants', title: 'Grants', logo: RawMdiKeyChain },
+ { name: 'node-attrs', title: 'Node Attributes', logo: RawMdiVectorPolyline },
+ { name: 'policy-settings', title: 'Policy Settings', logo: RawMdiTune },
{ name: 'ssh', title: 'SSH', logo: RawMdiConsole },
{ name: 'config', title: 'Config', logo: RawMdiCodeJSON },
];
@@ -82,6 +91,12 @@
Hint: via currently supports tag selectors only (for example `tag:relay`).
+Each capability key maps to a JSON array value. Use the Taildrive preset for a safe starter payload.
++ Enable random client source ports for outbound traffic. +
+