diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 359cde3..9ded03f 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -177,6 +177,17 @@ jobs: E2E_BASE_URL=http://localhost:5173 \ pnpm --filter @dorfteich/web exec playwright test e2e/members.spec.ts + # Three contexts per test (owner + editor + viewer) → reset first. + - name: Reset login rate limit before access-rules pack + run: | + echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \ + pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL" + + - name: Run access-rules pack + run: | + E2E_BASE_URL=http://localhost:5173 \ + pnpm --filter @dorfteich/web exec playwright test e2e/access-rules.spec.ts + - name: Reset login rate limit before offline pack run: | echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \ diff --git a/apps/api/prisma/seed.ts b/apps/api/prisma/seed.ts index ecb1d1e..f7b01b6 100644 --- a/apps/api/prisma/seed.ts +++ b/apps/api/prisma/seed.ts @@ -7,6 +7,8 @@ * fixture-user active, regular account * fixture-editor active, regular account (a second non-admin for the * collab permission packs: reader/editor of another's pond) + * fixture-viewer active, regular account, never a member (for the + * `authenticated`/`public` access-rule cases, issue #55) * fixture-pending registered but e-mail not verified * * All fixture accounts share the password below — they exist only on @@ -66,6 +68,14 @@ const FIXTURES: FixtureUser[] = [ status: 'ACTIVE', isSiteAdmin: false, }, + { + // A signed-in, non-admin, non-member account: for `authenticated`/`public` + // access-rule cases (issue #55) where the viewer must be no one's member. + username: 'fixture-viewer', + displayName: 'Fixture Viewer', + status: 'ACTIVE', + isSiteAdmin: false, + }, { username: 'fixture-pending', displayName: 'Fixture Pending', diff --git a/apps/api/src/grants/grants.controller.ts b/apps/api/src/grants/grants.controller.ts index aab7282..e8c688a 100644 --- a/apps/api/src/grants/grants.controller.ts +++ b/apps/api/src/grants/grants.controller.ts @@ -1,5 +1,6 @@ import { Body, Controller, Delete, Get, HttpCode, Param, Post, Req } from '@nestjs/common'; import { + AccessRuleView, CreateGrantInput, GrantView, createGrantInputSchema, @@ -26,6 +27,13 @@ export class GrantsController { return this.grants.listGrants(pondId); } + /** Grants enriched with subject/scope names for the access-rules UI (#55). */ + @Get('access-rules') + @RequiresPondRole('pond_admin', { idParam: 'pondId' }) + async accessRules(@Param('pondId') pondId: string): Promise { + return this.grants.listAccessRules(pondId); + } + @Post() @RequiresPondRole('pond_admin', { idParam: 'pondId' }) async create( diff --git a/apps/api/src/grants/grants.service.db.test.ts b/apps/api/src/grants/grants.service.db.test.ts index 9499c37..927b007 100644 --- a/apps/api/src/grants/grants.service.db.test.ts +++ b/apps/api/src/grants/grants.service.db.test.ts @@ -63,6 +63,8 @@ describe.skipIf(!hasTestDb)('GrantsService (db, issue #51)', () => { afterAll(async () => { await prisma.roleGrant.deleteMany({ where: { pondId: { in: [shared, personal] } } }); + await prisma.page.deleteMany({ where: { pondId: { in: [shared, personal] } } }); + await prisma.label.deleteMany({ where: { pondId: { in: [shared, personal] } } }); await prisma.pond.deleteMany({ where: { id: { in: [shared, personal] } } }); await prisma.user.deleteMany({ where: { id: { in: [owner.id, target.id] } } }); await prisma.$disconnect(); @@ -101,6 +103,47 @@ describe.skipIf(!hasTestDb)('GrantsService (db, issue #51)', () => { ); }); + it('lists access rules enriched with subject and scope names (#55)', async () => { + const label = await prisma.label.create({ + data: { pondId: shared, name: 'Confidential', color: '#64748b' }, + }); + const page = await prisma.page.create({ + data: { + pondId: shared, + slug: `secret-${suffix}`, + title: 'Salaries', + createdBy: owner.id, + sortKey: 'a0', + ydocState: new Uint8Array(), + }, + }); + await grants.createGrant(owner, shared, { + ...editorGrant, + scopeType: 'label', + scopeId: label.id, + effect: 'deny', + }); + await grants.createGrant(owner, shared, { + ...editorGrant, + role: 'reader', + scopeType: 'page', + scopeId: page.id, + effect: 'allow', + }); + + const rules = await grants.listAccessRules(shared); + const labelRule = rules.find((r) => r.scopeType === 'label'); + const pageRule = rules.find((r) => r.scopeType === 'page'); + expect(labelRule).toMatchObject({ + subjectName: 'Grant Target', + scopeName: 'Confidential', + effect: 'deny', + }); + expect(pageRule).toMatchObject({ subjectName: 'Grant Target', scopeName: 'Salaries' }); + // Pond-scope (the editor grant from the first test) carries no scope name. + expect(rules.find((r) => r.scopeType === 'pond')).toMatchObject({ scopeName: null }); + }); + // The pond_admin-scope rule also has a DB CHECK backstop (in the migration); // it is not exercised here because the test database is built with `db push`, // which syncs tables/columns but not the raw CHECK constraint. diff --git a/apps/api/src/grants/grants.service.ts b/apps/api/src/grants/grants.service.ts index aaee463..7b1cfc9 100644 --- a/apps/api/src/grants/grants.service.ts +++ b/apps/api/src/grants/grants.service.ts @@ -4,7 +4,7 @@ import { Injectable, NotFoundException, } from '@nestjs/common'; -import { Grant, GrantView, grantValidationError } from '@dorfteich/shared'; +import { AccessRuleView, Grant, GrantView, grantValidationError } from '@dorfteich/shared'; import { Pond, RoleGrant, User } from '@prisma/client'; import { PinoLogger } from 'nestjs-pino'; @@ -66,6 +66,59 @@ export class GrantsService { return rows.map((row) => GrantsService.viewOf(row)); } + /** + * A pond's grants enriched with the display names the access-rules UI renders + * as sentences (issue #55): each user subject's display name and each + * label/page scope's name, resolved in one batched query per kind so the + * client needs no id lookups. + */ + async listAccessRules(pondId: string): Promise { + await this.requireLivePond(pondId); + const rows = await this.prisma.roleGrant.findMany({ + where: { pondId }, + orderBy: { createdAt: 'asc' }, + }); + + const userIds = rows.filter((r) => r.subjectId).map((r) => r.subjectId!); + const labelIds = rows + .filter((r) => r.scopeType === 'LABEL' && r.scopeId) + .map((r) => r.scopeId!); + const pageIds = rows.filter((r) => r.scopeType === 'PAGE' && r.scopeId).map((r) => r.scopeId!); + + const [users, labels, pages] = await Promise.all([ + this.prisma.user.findMany({ + where: { id: { in: userIds } }, + select: { id: true, displayName: true }, + }), + this.prisma.label.findMany({ + where: { id: { in: labelIds } }, + select: { id: true, name: true }, + }), + this.prisma.page.findMany({ + where: { id: { in: pageIds } }, + select: { id: true, title: true }, + }), + ]); + const userName = new Map(users.map((u) => [u.id, u.displayName])); + const labelName = new Map(labels.map((l) => [l.id, l.name])); + const pageTitle = new Map(pages.map((p) => [p.id, p.title])); + + return rows.map((row) => { + const base = GrantsService.viewOf(row); + const scopeName = + row.scopeType === 'LABEL' + ? (labelName.get(row.scopeId ?? '') ?? null) + : row.scopeType === 'PAGE' + ? (pageTitle.get(row.scopeId ?? '') ?? null) + : null; + return { + ...base, + subjectName: row.subjectId ? (userName.get(row.subjectId) ?? null) : null, + scopeName, + }; + }); + } + /** The grant must point at things that exist in this pond — a label/page * from elsewhere would silently never match during resolution. */ private async assertScopeAndSubjectExist(pondId: string, grant: Grant): Promise { diff --git a/apps/web/e2e/README.md b/apps/web/e2e/README.md index 5ca2464..d17418f 100644 --- a/apps/web/e2e/README.md +++ b/apps/web/e2e/README.md @@ -47,6 +47,7 @@ only on dev machines and disposable CI/Test databases. | `fixture-admin` | active, Site Admin | admin UI/permissions cases | | `fixture-user` | active | regular journeys, settings, sessions | | `fixture-editor` | active | second regular account for the collab-permissions pack (reader/editor of another's pond) | +| `fixture-viewer` | active | signed-in non-member for `authenticated`/`public` access-rule cases (issue #55) | | `fixture-pending` | e-mail not verified | unverified-login cases | ## Content fixtures diff --git a/apps/web/e2e/access-rules.spec.ts b/apps/web/e2e/access-rules.spec.ts new file mode 100644 index 0000000..9fad2ad --- /dev/null +++ b/apps/web/e2e/access-rules.spec.ts @@ -0,0 +1,169 @@ +import { expect, test } from '@playwright/test'; +import type { APIRequestContext, BrowserContext } from '@playwright/test'; + +import { contextForUser } from './helpers'; + +const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173'; + +/** + * Fine-grained access rules (issue #55): both vision patterns are configured + * through the pond-settings UI and their effect is verified end to end — + * "deny label X" (an editor loses a labelled page) and "only label Y" (a + * signed-in non-member reads only the labelled pages). Also covers the + * shadowed-rule hint and the public confirmation. + */ + +async function json( + ctx: APIRequestContext, + method: 'post', + path: string, + data: unknown, +): Promise { + const res = await ctx[method](path, { data }); + if (!res.ok()) throw new Error(`${method} ${path} → ${res.status()} ${await res.text()}`); + return (await res.json()) as T; +} + +const meId = async (ctx: BrowserContext): Promise => + ((await (await ctx.request.get('/api/v1/auth/me')).json()) as { id: string }).id; + +const tokenStatus = async (ctx: BrowserContext, pageId: string): Promise => + (await ctx.request.get(`/api/v1/pages/${pageId}/collab-token`)).status(); + +const tokenMode = async (ctx: BrowserContext, pageId: string): Promise => { + const res = await ctx.request.get(`/api/v1/pages/${pageId}/collab-token`); + return res.ok() ? ((await res.json()) as { mode: string }).mode : res.status(); +}; + +test('both vision patterns are configurable in the UI and take effect', async ({ browser }) => { + const owner = await contextForUser(browser, BASE_URL, 'fixture-user'); + const editor = await contextForUser(browser, BASE_URL, 'fixture-editor'); + const viewer = await contextForUser(browser, BASE_URL, 'fixture-viewer'); + const editorId = await meId(editor); + + const pond = await json<{ id: string; slug: string }>(owner.request, 'post', '/api/v1/ponds', { + name: `Access ${Date.now()}`, + }); + await json(owner.request, 'post', `/api/v1/ponds/${pond.id}/members`, { + usernameOrEmail: 'fixture-editor', + role: 'editor', + }); + const labelX = await json<{ id: string }>( + owner.request, + 'post', + `/api/v1/ponds/${pond.id}/labels`, + { + name: 'Confidential', + }, + ); + const labelY = await json<{ id: string }>( + owner.request, + 'post', + `/api/v1/ponds/${pond.id}/labels`, + { + name: 'Handbook', + }, + ); + const px = await json<{ id: string }>(owner.request, 'post', `/api/v1/ponds/${pond.id}/pages`, { + title: 'Secret', + }); + const py = await json<{ id: string }>(owner.request, 'post', `/api/v1/ponds/${pond.id}/pages`, { + title: 'Guide', + }); + const pz = await json<{ id: string }>(owner.request, 'post', `/api/v1/ponds/${pond.id}/pages`, { + title: 'Misc', + }); + await json(owner.request, 'post', `/api/v1/pages/${px.id}/labels`, { labelId: labelX.id }); + await json(owner.request, 'post', `/api/v1/pages/${py.id}/labels`, { labelId: labelY.id }); + + // Baseline: the editor may write every page. + expect(await tokenMode(editor, px.id)).toBe('rw'); + + const page = await owner.newPage(); + await page.goto(`/p/${pond.slug}/settings`); + + // Pattern 1 — "deny label X": the editor may not touch Confidential pages. + await page.locator('.rule-add__subject').selectOption(`user:${editorId}`); + await page.locator('.rule-add__scope-type').selectOption('label'); + await page.locator('.rule-add__scope-target').selectOption(labelX.id); + await page.locator('.rule-add__role').selectOption('editor'); + await page.locator('.rule-add__effect').selectOption('deny'); + await page.locator('.rule-add__submit').click(); + await expect(page.locator('.rule-sentence', { hasText: 'Confidential' })).toBeVisible(); + + await expect.poll(() => tokenStatus(editor, px.id)).toBe(404); // labelled page now hidden + expect(await tokenMode(editor, pz.id)).toBe('rw'); // unlabelled page still editable + + // Pattern 2 — "only label Y": signed-in users read only Handbook pages. + await page.locator('.rule-add__subject').selectOption('authenticated'); + await page.locator('.rule-add__scope-type').selectOption('label'); + await page.locator('.rule-add__scope-target').selectOption(labelY.id); + await page.locator('.rule-add__role').selectOption('reader'); + await page.locator('.rule-add__effect').selectOption('allow'); + await page.locator('.rule-add__submit').click(); + await expect(page.locator('.rule-sentence', { hasText: 'Handbook' })).toBeVisible(); + + // A signed-in non-member reads the labelled page, nothing else. + await expect.poll(() => tokenMode(viewer, py.id)).toBe('ro'); + expect(await tokenStatus(viewer, px.id)).toBe(404); + expect(await tokenStatus(viewer, pz.id)).toBe(404); + + await owner.close(); + await editor.close(); + await viewer.close(); +}); + +test('hints at shadowed rules and confirms before going public', async ({ browser }) => { + const owner = await contextForUser(browser, BASE_URL, 'fixture-user'); + const editor = await contextForUser(browser, BASE_URL, 'fixture-editor'); + const editorId = await meId(editor); + + const pond = await json<{ id: string; slug: string }>(owner.request, 'post', '/api/v1/ponds', { + name: `Access2 ${Date.now()}`, + }); + await json(owner.request, 'post', `/api/v1/ponds/${pond.id}/members`, { + usernameOrEmail: 'fixture-editor', + role: 'editor', + }); + const label = await json<{ id: string }>( + owner.request, + 'post', + `/api/v1/ponds/${pond.id}/labels`, + { + name: 'Docs', + }, + ); + const pg = await json<{ id: string }>(owner.request, 'post', `/api/v1/ponds/${pond.id}/pages`, { + title: 'Page', + }); + // A more specific existing rule (page-scope deny) will shadow a label-scope allow. + await json(owner.request, 'post', `/api/v1/ponds/${pond.id}/grants`, { + subjectType: 'user', + subjectId: editorId, + role: 'editor', + scopeType: 'page', + scopeId: pg.id, + effect: 'deny', + }); + + const page = await owner.newPage(); + await page.goto(`/p/${pond.slug}/settings`); + + // Shadowed: a label-scope allow for the same editor, opposite to the page deny. + await page.locator('.rule-add__subject').selectOption(`user:${editorId}`); + await page.locator('.rule-add__scope-type').selectOption('label'); + await page.locator('.rule-add__scope-target').selectOption(label.id); + await page.locator('.rule-add__role').selectOption('editor'); + await page.locator('.rule-add__effect').selectOption('allow'); + await expect(page.locator('.rule-add__shadow')).toBeVisible(); + + // Public + allow: a confirmation gate blocks submission until acknowledged. + await page.locator('.rule-add__subject').selectOption('public'); + await expect(page.locator('.rule-add__public-warning')).toBeVisible(); + await expect(page.locator('.rule-add__submit')).toBeDisabled(); + await page.locator('.rule-add__public-confirm').check(); + await expect(page.locator('.rule-add__submit')).toBeEnabled(); + + await owner.close(); + await editor.close(); +}); diff --git a/apps/web/src/access/AccessRulesManager.tsx b/apps/web/src/access/AccessRulesManager.tsx new file mode 100644 index 0000000..3953e28 --- /dev/null +++ b/apps/web/src/access/AccessRulesManager.tsx @@ -0,0 +1,295 @@ +import type { + AccessRuleView, + CreateGrantInput, + Grant, + GrantSubjectType, + PageListItemView, +} from '@dorfteich/shared'; +import { isRuleShadowed } from '@dorfteich/shared'; +import { useQuery } from '@tanstack/react-query'; +import type { TFunction } from 'i18next'; +import { useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { ApiError, apiGet } from '../lib/api'; +import { usePondLabels } from '../labels/use-pond-labels'; +import { usePondMembers } from '../members/use-pond-members'; +import { useAccessRules, useAccessRuleMutations } from './use-access-rules'; + +type ScopedType = 'label' | 'page'; + +/** Renders one grant as a readable de/en sentence (issue #55, ADR 0012). */ +export function ruleSentence(rule: AccessRuleView, t: TFunction): string { + const subject = t(`subject.${rule.subjectType}`, { name: rule.subjectName ?? '' }); + const ability = t(`ability.${rule.role}`); + const scope = t(`scope.${rule.scopeType}`, { name: rule.scopeName ?? '' }); + return t(`sentence.${rule.effect}`, { subject, ability, scope }); +} + +const subjectValue = (type: GrantSubjectType, id: string | null): string => + type === 'user' ? `user:${id}` : type; +const parseSubject = (value: string): { type: GrantSubjectType; id: string | null } => + value.startsWith('user:') + ? { type: 'user', id: value.slice(5) } + : { type: value as GrantSubjectType, id: null }; + +/** + * Pond-settings access-rules management (issue #55): fine-grained label/page + * rules (allow/deny) for members and the `signed-in`/`public` subjects, on top + * of the base roles from #54. Rules render as sentences, not raw tuples; the + * add form warns when a rule would be shadowed by a more specific one (shared + * algorithm) and confirms before opening anything to the public. The api + * enforces the semantics — this UI only reflects permissions.md. + */ +export function AccessRulesManager({ pondId }: { pondId: string }): React.JSX.Element | null { + const { t } = useTranslation('access'); + const { t: tErrors } = useTranslation('errors'); + const rulesQuery = useAccessRules(pondId); + const mutations = useAccessRuleMutations(pondId); + const members = usePondMembers(pondId); + const { flat: labels } = usePondLabels(pondId); + const pages = useQuery({ + queryKey: ['pages', pondId], + queryFn: () => apiGet(`/ponds/${pondId}/pages`), + }); + + const [subject, setSubject] = useState(''); + const [scopeType, setScopeType] = useState('label'); + const [scopeId, setScopeId] = useState(''); + const [role, setRole] = useState<'reader' | 'editor'>('editor'); + const [effect, setEffect] = useState<'allow' | 'deny'>('allow'); + const [publicConfirmed, setPublicConfirmed] = useState(false); + const [error, setError] = useState(null); + + const rules = rulesQuery.data ?? []; + const grouped = useMemo(() => groupBySubject(rules), [rules]); + + // A non-admin gets 403 here — render nothing (the member list stays visible). + if (rulesQuery.error instanceof ApiError && rulesQuery.error.status === 403) return null; + + const parsed = subject ? parseSubject(subject) : null; + const candidate: Grant | null = + parsed && scopeId + ? { + subjectType: parsed.type, + subjectId: parsed.id, + role, + scopeType, + scopeId, + effect, + } + : null; + const shadowed = candidate ? isRuleShadowed(candidate, rules) : false; + const isPublic = parsed?.type === 'public'; + const needsPublicConfirm = isPublic && effect === 'allow' && !publicConfirmed; + const canSubmit = Boolean(candidate) && !needsPublicConfirm; + + const submit = async (event: React.FormEvent): Promise => { + event.preventDefault(); + if (!candidate || !parsed) return; + setError(null); + const input: CreateGrantInput = { + subjectType: parsed.type, + subjectId: parsed.id ?? undefined, + role, + scopeType, + scopeId, + effect, + }; + try { + await mutations.add(input); + setScopeId(''); + setPublicConfirmed(false); + } catch (err) { + setError( + err instanceof ApiError + ? tErrors(err.body.code, { defaultValue: err.body.message, ...(err.body.details ?? {}) }) + : tErrors('internal_error'), + ); + } + }; + + const scopeOptions = + scopeType === 'label' + ? labels.map((l) => ({ id: l.id, name: l.name })) + : (pages.data ?? []).map((p) => ({ id: p.id, name: p.title })); + + if (rulesQuery.isLoading) return null; + + return ( +
+

{t('title')}

+

{t('description')}

+ +
+

{t('add.title')}

+
+ + + + + + + + + +
+ + {shadowed && ( +

+ {t('add.shadowedWarning')} +

+ )} + {isPublic && effect === 'allow' && ( + + )} + {error && ( +

+ {error} +

+ )} + +
+ + {rules.length === 0 ? ( +

{t('noRules')}

+ ) : ( +
    + {grouped.map((group) => ( +
  • +

    {group.subjectLabel(t)}

    +
      + {group.rules.map((rule) => ( +
    • + {ruleSentence(rule, t)} + +
    • + ))} +
    +
  • + ))} +
+ )} +
+ ); +} + +interface RuleGroup { + key: string; + subjectLabel: (t: TFunction) => string; + rules: AccessRuleView[]; +} + +/** Groups rules by subject, ordering users first, then signed-in, then public. */ +function groupBySubject(rules: AccessRuleView[]): RuleGroup[] { + const order: Record = { user: 0, authenticated: 1, public: 2 }; + const map = new Map(); + for (const rule of rules) { + const key = `${rule.subjectType}:${rule.subjectId ?? ''}`; + let group = map.get(key); + if (!group) { + group = { + key, + subjectLabel: (t) => t(`subject.${rule.subjectType}`, { name: rule.subjectName ?? '' }), + rules: [], + }; + map.set(key, group); + } + group.rules.push(rule); + } + return [...map.values()].sort((a, b) => { + const at = a.key.split(':')[0] as GrantSubjectType; + const bt = b.key.split(':')[0] as GrantSubjectType; + return order[at] - order[bt]; + }); +} diff --git a/apps/web/src/access/use-access-rules.ts b/apps/web/src/access/use-access-rules.ts new file mode 100644 index 0000000..24c972b --- /dev/null +++ b/apps/web/src/access/use-access-rules.ts @@ -0,0 +1,38 @@ +import type { AccessRuleView, CreateGrantInput } from '@dorfteich/shared'; +import { useQuery, useQueryClient, type UseQueryResult } from '@tanstack/react-query'; + +import { apiDelete, apiGet, apiPost } from '../lib/api'; + +/** Query key for a pond's enriched access rules (issue #55). */ +export const accessRulesKey = (pondId: string): string[] => ['access-rules', pondId]; + +/** The pond's grants enriched with subject/scope names (Pond-Admin only). */ +export function useAccessRules(pondId: string | undefined): UseQueryResult { + return useQuery({ + queryKey: accessRulesKey(pondId ?? ''), + queryFn: () => apiGet(`/ponds/${pondId}/grants/access-rules`), + enabled: Boolean(pondId), + retry: false, // a non-admin gets 403 — do not hammer the endpoint + }); +} + +/** Add/remove access-rule grants; each refreshes the rule list. */ +export function useAccessRuleMutations(pondId: string): { + add: (input: CreateGrantInput) => Promise; + remove: (grantId: string) => Promise; +} { + const queryClient = useQueryClient(); + const invalidate = (): Promise => + queryClient.invalidateQueries({ queryKey: accessRulesKey(pondId) }); + + return { + add: async (input) => { + await apiPost(`/ponds/${pondId}/grants`, input); + await invalidate(); + }, + remove: async (grantId) => { + await apiDelete(`/ponds/${pondId}/grants/${grantId}`); + await invalidate(); + }, + }; +} diff --git a/apps/web/src/i18n/index.ts b/apps/web/src/i18n/index.ts index 123fe96..2e441c4 100644 --- a/apps/web/src/i18n/index.ts +++ b/apps/web/src/i18n/index.ts @@ -1,3 +1,4 @@ +import deAccess from '@dorfteich/shared/i18n/de/access.json'; import deAuth from '@dorfteich/shared/i18n/de/auth.json'; import deCommon from '@dorfteich/shared/i18n/de/common.json'; import deEditor from '@dorfteich/shared/i18n/de/editor.json'; @@ -7,6 +8,7 @@ import deLinks from '@dorfteich/shared/i18n/de/links.json'; import deMembers from '@dorfteich/shared/i18n/de/members.json'; import deSearch from '@dorfteich/shared/i18n/de/search.json'; import deSettings from '@dorfteich/shared/i18n/de/settings.json'; +import enAccess from '@dorfteich/shared/i18n/en/access.json'; import enAuth from '@dorfteich/shared/i18n/en/auth.json'; import enCommon from '@dorfteich/shared/i18n/en/common.json'; import enEditor from '@dorfteich/shared/i18n/en/editor.json'; @@ -34,6 +36,7 @@ void i18n en: { common: enCommon, errors: enErrors, + access: enAccess, auth: enAuth, settings: enSettings, editor: enEditor, @@ -45,6 +48,7 @@ void i18n de: { common: deCommon, errors: deErrors, + access: deAccess, auth: deAuth, settings: deSettings, editor: deEditor, diff --git a/apps/web/src/pages/PondSettingsPage.tsx b/apps/web/src/pages/PondSettingsPage.tsx index b236d46..a0c78a0 100644 --- a/apps/web/src/pages/PondSettingsPage.tsx +++ b/apps/web/src/pages/PondSettingsPage.tsx @@ -7,6 +7,7 @@ import { useAuth } from '../auth/auth-context'; import { FormError } from '../components/forms'; import { LabelManager } from '../labels/LabelManager'; import { PhantomPagesView } from '../links/PhantomPagesView'; +import { AccessRulesManager } from '../access/AccessRulesManager'; import { apiGet } from '../lib/api'; import { MemberManager } from '../members/MemberManager'; @@ -43,6 +44,7 @@ export function PondSettingsPage(): React.JSX.Element {

{tMembers('title')}

+

{t('settings.title')}

{canModify ? ( diff --git a/apps/web/src/styles/base.css b/apps/web/src/styles/base.css index 5dc2eec..c061823 100644 --- a/apps/web/src/styles/base.css +++ b/apps/web/src/styles/base.css @@ -1546,3 +1546,69 @@ button { border-radius: 2px; padding: 0 2px; } + +/* Access rules (issue #55) */ +.access-rules__intro, +.access-rules__empty { + color: var(--color-text-muted); + margin-bottom: var(--space-3); +} + +.rule-add { + border: 1px solid var(--color-border, rgba(127, 127, 127, 0.25)); + border-radius: var(--radius-sm, 0.375rem); + padding: var(--space-3); + margin-bottom: var(--space-4); +} + +.rule-add__title { + margin: 0 0 var(--space-2); + font-size: 0.875rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--color-text-muted); +} + +.rule-add__row { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); + margin-bottom: var(--space-2); +} + +.rule-add__field { + display: flex; + flex-direction: column; + gap: var(--space-1); + font-size: 0.875rem; +} + +.rule-add__public-warning { + display: flex; + align-items: center; + gap: var(--space-2); +} + +.rule-groups, +.rule-group__list { + list-style: none; + margin: 0; + padding: 0; +} + +.rule-group { + margin-bottom: var(--space-3); +} + +.rule-group__subject { + margin: 0 0 var(--space-1); + font-size: 0.9375rem; +} + +.rule-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-2); + padding: var(--space-1) 0; +} diff --git a/packages/shared/i18n/de/access.json b/packages/shared/i18n/de/access.json new file mode 100644 index 0000000..40cdc0a --- /dev/null +++ b/packages/shared/i18n/de/access.json @@ -0,0 +1,42 @@ +{ + "title": "Zugriffsregeln", + "description": "Feingranulare Regeln zusätzlich zu den Basisrollen. Die spezifischere Regel gewinnt: eine Regel auf einer Seite schlägt eine auf einem Label, und die schlägt eine für den ganzen Teich.", + "subject": { + "user": "{{name}}", + "authenticated": "Angemeldete Personen", + "public": "Alle (öffentlich)" + }, + "ability": { + "reader": "lesen", + "editor": "bearbeiten", + "pond_admin": "verwalten" + }, + "scope": { + "pond": "alles in diesem Teich", + "label": "Seiten mit dem Label „{{name}}“", + "page": "die Seite „{{name}}“" + }, + "sentence": { + "allow": "{{subject}} darf {{scope}} {{ability}}.", + "deny": "{{subject}} darf {{scope}} nicht {{ability}}." + }, + "noRules": "Noch keine Regeln — es gelten nur die Basisrollen.", + "add": { + "title": "Regel hinzufügen", + "subject": "Wer", + "scopeType": "Wo", + "scopeLabel": "Label", + "scopePage": "Seite", + "role": "Recht", + "effect": "Wirkung", + "allow": "Erlauben", + "deny": "Verbieten", + "submit": "Regel hinzufügen", + "pickScope": "Wähle ein Label oder eine Seite.", + "shadowedWarning": "Eine spezifischere Regel für dieses Subjekt entscheidet hier bereits das Gegenteil — diese Regel bleibt womöglich wirkungslos.", + "publicWarning": "Damit werden Inhalte für alle im Internet sichtbar. Bist du sicher?", + "publicConfirm": "Ja, öffentlich machen" + }, + "remove": "Entfernen", + "loadError": "Zugriffsregeln konnten nicht geladen werden." +} diff --git a/packages/shared/i18n/en/access.json b/packages/shared/i18n/en/access.json new file mode 100644 index 0000000..ff76b1d --- /dev/null +++ b/packages/shared/i18n/en/access.json @@ -0,0 +1,42 @@ +{ + "title": "Access rules", + "description": "Fine-grained rules on top of the base roles. More specific rules win: a rule on a page beats one on a label, which beats one on the whole pond.", + "subject": { + "user": "{{name}}", + "authenticated": "Signed-in users", + "public": "Everyone (public)" + }, + "ability": { + "reader": "read", + "editor": "edit", + "pond_admin": "administer" + }, + "scope": { + "pond": "everything in this pond", + "label": "pages labeled “{{name}}”", + "page": "the page “{{name}}”" + }, + "sentence": { + "allow": "{{subject}} may {{ability}} {{scope}}.", + "deny": "{{subject}} may not {{ability}} {{scope}}." + }, + "noRules": "No rules yet — only the base roles apply.", + "add": { + "title": "Add a rule", + "subject": "Who", + "scopeType": "Where", + "scopeLabel": "Label", + "scopePage": "Page", + "role": "Ability", + "effect": "Effect", + "allow": "Allow", + "deny": "Deny", + "submit": "Add rule", + "pickScope": "Choose a label or page.", + "shadowedWarning": "A more specific rule for this subject already decides the opposite here — this rule may have no effect.", + "publicWarning": "This makes content visible to everyone on the internet. Are you sure?", + "publicConfirm": "Yes, make it public" + }, + "remove": "Remove", + "loadError": "Access rules could not be loaded." +} diff --git a/packages/shared/src/permissions/conflicts.test.ts b/packages/shared/src/permissions/conflicts.test.ts new file mode 100644 index 0000000..ebe828b --- /dev/null +++ b/packages/shared/src/permissions/conflicts.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest'; + +import { isRuleShadowed, sameGrantSubject, scopeSpecificity } from './conflicts'; +import type { Grant } from './types'; + +const grant = (over: Partial): Grant => ({ + subjectType: 'user', + subjectId: 'u1', + role: 'editor', + scopeType: 'pond', + scopeId: null, + effect: 'allow', + ...over, +}); + +describe('scopeSpecificity', () => { + it('orders page > label > pond', () => { + expect(scopeSpecificity('page')).toBeGreaterThan(scopeSpecificity('label')); + expect(scopeSpecificity('label')).toBeGreaterThan(scopeSpecificity('pond')); + }); +}); + +describe('sameGrantSubject', () => { + it('matches the same user and the same pseudo-subject', () => { + expect(sameGrantSubject(grant({}), grant({ role: 'reader' }))).toBe(true); + expect( + sameGrantSubject( + grant({ subjectType: 'public', subjectId: null }), + grant({ subjectType: 'public', subjectId: null }), + ), + ).toBe(true); + }); + it('distinguishes different users and subject types', () => { + expect(sameGrantSubject(grant({}), grant({ subjectId: 'u2' }))).toBe(false); + expect( + sameGrantSubject(grant({}), grant({ subjectType: 'authenticated', subjectId: null })), + ).toBe(false); + }); +}); + +describe('isRuleShadowed', () => { + it('flags a pond rule shadowed by an opposite, more specific label rule for the same subject', () => { + const existing = [grant({ scopeType: 'label', scopeId: 'l1', effect: 'deny' })]; + expect(isRuleShadowed(grant({ scopeType: 'pond', effect: 'allow' }), existing)).toBe(true); + }); + it('does not flag when the more specific rule agrees in effect', () => { + const existing = [grant({ scopeType: 'label', scopeId: 'l1', effect: 'allow' })]; + expect(isRuleShadowed(grant({ scopeType: 'pond', effect: 'allow' }), existing)).toBe(false); + }); + it('does not flag a more specific candidate against a less specific existing rule', () => { + const existing = [grant({ scopeType: 'pond', effect: 'deny' })]; + expect( + isRuleShadowed(grant({ scopeType: 'page', scopeId: 'p1', effect: 'allow' }), existing), + ).toBe(false); + }); + it('ignores rules for other subjects', () => { + const existing = [grant({ subjectId: 'u2', scopeType: 'page', scopeId: 'p1', effect: 'deny' })]; + expect(isRuleShadowed(grant({ scopeType: 'pond', effect: 'allow' }), existing)).toBe(false); + }); +}); diff --git a/packages/shared/src/permissions/conflicts.ts b/packages/shared/src/permissions/conflicts.ts new file mode 100644 index 0000000..8966d2d --- /dev/null +++ b/packages/shared/src/permissions/conflicts.ts @@ -0,0 +1,32 @@ +import type { Grant } from './types'; + +/** + * Scope specificity, most specific highest — the ordering the resolver uses + * (permissions.md §Resolution: page > label > pond). Shared so the UI's + * conflict hint (issue #55) reasons about grants the same way the server does. + */ +export function scopeSpecificity(scopeType: Grant['scopeType']): number { + return scopeType === 'page' ? 2 : scopeType === 'label' ? 1 : 0; +} + +/** Whether two grants target the same subject (same user, or same pseudo-subject). */ +export function sameGrantSubject(a: Grant, b: Grant): boolean { + if (a.subjectType !== b.subjectType) return false; + return a.subjectType !== 'user' || a.subjectId === b.subjectId; +} + +/** + * A client-side hint (issue #55 acceptance criterion): a candidate rule is + * "shadowed" when the same subject already holds a rule at a strictly more + * specific scope with the opposite effect — on the objects both touch, the more + * specific existing rule wins, so the candidate changes nothing there. Pure and + * testable; the server remains the source of truth (permissions.md). + */ +export function isRuleShadowed(candidate: Grant, existing: Grant[]): boolean { + return existing.some( + (g) => + sameGrantSubject(g, candidate) && + scopeSpecificity(g.scopeType) > scopeSpecificity(candidate.scopeType) && + g.effect !== candidate.effect, + ); +} diff --git a/packages/shared/src/permissions/index.ts b/packages/shared/src/permissions/index.ts index d6d8ff8..90130db 100644 --- a/packages/shared/src/permissions/index.ts +++ b/packages/shared/src/permissions/index.ts @@ -3,3 +3,4 @@ export * from './resolve'; export * from './pond'; export * from './schemas'; export * from './validate'; +export * from './conflicts'; diff --git a/packages/shared/src/permissions/schemas.ts b/packages/shared/src/permissions/schemas.ts index 518d445..d5eb3b1 100644 --- a/packages/shared/src/permissions/schemas.ts +++ b/packages/shared/src/permissions/schemas.ts @@ -34,6 +34,18 @@ export interface GrantView extends Grant { createdAt: string; } +/** + * A grant enriched with the display names the access-rules UI renders as + * sentences (issue #55): the subject's display name and the scoped label/page + * name. Names are resolved server-side so the client needs no id lookups. + */ +export interface AccessRuleView extends GrantView { + /** The user subject's display name; `null` for `authenticated`/`public`. */ + subjectName: string | null; + /** Label name or page title for label/page scope; `null` at pond scope. */ + scopeName: string | null; +} + /** The parsed input as the resolver's grant shape (nullish → null). */ export function grantOfInput(input: CreateGrantInput): Grant { return {