Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,41 @@ docker compose -f docker-compose.release.yml exec api \

Open **http://localhost:13002** — or whatever `DONNA_WEB_HOST_PORT` you set in `.env` (`13002` is the default in `.env.example`) — and sign in with `admin@lq.ai` / `DonnaE2ePassw0rd!`.

### Adding your colleagues

The first-run admin is one account. To put other people on the deployment, create them from the
admin account — each gets a one-time password and is forced to change it on first login:

```bash
TOKEN=... # an access token for the admin account
curl -X POST http://localhost:18000/api/v1/admin/users \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"email":"colleague@yourfirm.example","display_name":"A Colleague","role":"member"}'
```

The response carries `initial_password` **once** — it is never stored or logged, so hand it over
then. If it is lost, `POST /api/v1/admin/users/{id}/reset-password` issues a new one and signs the
user out everywhere.

Roles are `admin` (manage users and deployment settings), `member` (ordinary use), `viewer`, and
`auditor` (read-only cross-user review of citation ledgers and receipts). Change one with
`PATCH /api/v1/admin/users/{id}/role`.

**A new matter reaches only its creator by default.** To share matters, add people to a matter's
**People** section in the app, or flip the deployment default so new matters are readable
firm-wide:

```bash
LQ_AI_MATTER_DEFAULT_SHARE_SCOPE=org # default: personal
```

Firm-wide grants _reading_; contributing to a matter always needs an explicit place on its roster.
See [docs/GUIDE.md → People on a matter](docs/GUIDE.md#people-on-a-matter--working-it-together) for
the practitioner view, and lq-ai's
[`docs/security/matter-access-control.md`](https://github.com/LegalQuants/lq-ai/blob/main/docs/security/matter-access-control.md)
for the operator view — including ethical screens, which override administrator rights, and the
limits of what application-layer authorization guarantees.

Images are published from this repo to GHCR — `ghcr.io/legalquants/donna-web`, `donna-api`, and
`donna-gateway` (multi-arch: Intel/AMD + Apple Silicon). This still needs a filled `.env`; it removes
the _build_, not the _config_. For a fully free, no-cloud setup, leave the provider keys blank and run **Ollama on your host**, then set `OLLAMA_BASE_URL=http://host.docker.internal:11434` in `.env` (the pre-built stack does not bundle an Ollama container) — then pick a local model in the app's Models settings. Deploying beyond `localhost` still requires TLS in front of `donna-web` (see
Expand Down
36 changes: 36 additions & 0 deletions docs/GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,42 @@ so sensitive work can't silently route to an underpowered or lower-trust model.
matter and it opens already scoped: standing context applied, knowledge and skills available, tier
floor enforced.

### People on a matter — working it together

A matter has a **People** section: who is on it, and in what capacity. Colleagues you add see the
matter's files, knowledge bases, standing context, and — importantly — **each other's chats in it**,
each labelled with its author. That is the point of sharing a matter: see the work already done on
it rather than repeat it.

Four roles:

- **Lead** — everything, plus deciding who else is on the matter and who can see it.
- **Contributor** — read the matter and add to it: edit the standing context, attach documents,
start chats.
- **Reader** — read it. Change nothing.
- **Screened** — an ethical wall. See below.

Above the roster, **who can see this matter** sets the ambient reach: _Just me_, _Named people only_,
or _Everyone at the firm_. Firm-wide grants **reading** and nothing more — contributing still needs a
place on the list, so the roster stays a truthful record of who actually worked the matter. That
record is what you will want months later, when the question is who did what.

**Screening someone off a matter.** Set their role to **Screened** and the matter disappears for
them: not a permission error, just gone, along with every chat in it. A screen overrides everything
else — firm-wide visibility, an explicit role, and **administrator rights**. An administrator who
must see a screened matter has to lift the screen, which is recorded against their name. That is
deliberate: a wall the administrator can walk through is not a wall, and in a small firm the
administrator is usually also a practising lawyer. Screened people are listed separately from the
working team, so the wall is visible rather than buried in a roster.

Every change to the roster is written to the audit log — who was added, who changed a role, and
specifically when a screen went up and when it came down.

**One thing you cannot do:** post into a colleague's chat. You can read every thread in a shared
matter; only its author can add to it — a matter lead included. Interleaving two lawyers' turns in
one conversation would blur which of them directed which answer, and that is exactly the record
that has to stay unambiguous. To act on a shared matter, start your own thread in it.

### Knowledge bases — grounding answers in your documents

A **knowledge base** is a named collection of documents Donna searches when answering — the mechanism
Expand Down
25 changes: 25 additions & 0 deletions src/lib/matters/SharedChip.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<script lang="ts">
import { Users } from '@lucide/svelte';
import type { SharedMatter } from '$lib/matters/types';

let { basis }: { basis: SharedMatter['caller_access_basis'] } = $props();

// "Shared with you" and "firm-wide" are different facts and a lawyer will
// want to tell them apart: one means somebody put you on this matter, the
// other means nobody had to.
const text = $derived(basis === 'org' ? 'Firm-wide' : 'Shared');
const title = $derived(
basis === 'org'
? 'Readable by everyone at the firm'
: 'You were added to this matter by someone else'
);
</script>

<span
aria-label={title}
{title}
class="inline-flex items-center gap-1 rounded-full border border-mlq-subtle px-2 py-0.5 text-xs text-mlq-muted"
>
<Users size={12} aria-hidden="true" />
{text}
</span>
18 changes: 18 additions & 0 deletions src/lib/matters/SharedChip.svelte.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/// <reference types="@testing-library/jest-dom/vitest" />
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import SharedChip from './SharedChip.svelte';

describe('SharedChip', () => {
it('distinguishes a matter somebody put you on from one nobody had to', () => {
render(SharedChip, { props: { basis: 'member' } });
expect(screen.getByText('Shared')).toBeInTheDocument();
expect(screen.getByLabelText(/added to this matter by someone else/i)).toBeInTheDocument();
});

it('labels firm-wide readability as such', () => {
render(SharedChip, { props: { basis: 'org' } });
expect(screen.getByText('Firm-wide')).toBeInTheDocument();
expect(screen.getByLabelText(/readable by everyone at the firm/i)).toBeInTheDocument();
});
});
243 changes: 243 additions & 0 deletions src/lib/matters/sections/TeamSection.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { X } from '@lucide/svelte';
import {
MATTER_ROLE_HINTS,
MATTER_ROLE_LABELS,
SHARE_SCOPE_HINTS,
SHARE_SCOPE_LABELS,
personLabel,
type DirectoryEntry,
type MatterMember,
type MatterRole,
type ShareScope
} from '$lib/matters/types';

let {
members,
directory = [],
shareScope,
canManage,
privileged = false,
error = ''
}: {
members: MatterMember[];
directory?: DirectoryEntry[];
shareScope: ShareScope;
canManage: boolean;
privileged?: boolean;
error?: string;
} = $props();

// Screened people are listed apart from the working team. They are not
// "members with a lesser role" — they are the record of a wall, and
// mixing them into the roster makes the wall easy to miss.
const team = $derived(members.filter((m) => m.role !== 'blocked'));
const screened = $derived(members.filter((m) => m.role === 'blocked'));

const ASSIGNABLE: MatterRole[] = ['lead', 'contributor', 'reader'];
const SCOPES: ShareScope[] = ['personal', 'members', 'org'];

let addOpen = $state(false);
let addUserId = $state('');
let addRole = $state<MatterRole>('contributor');

// Anyone already on the roster — including screened people — is off the
// picker; the backend would 409 and the list would be misleading.
const onRoster = $derived(new Set(members.map((m) => m.user_id)));
const candidates = $derived(directory.filter((p) => !onRoster.has(p.id)));

const label = personLabel;
</script>

<section class="mt-6">
<h2 class="mb-2 text-xs font-medium tracking-wide text-mlq-muted uppercase">
People · {team.length}
</h2>

{#if error}
<p class="mb-2 text-xs text-mlq-error">{error}</p>
{/if}

<!-- Who can reach this matter at all -->
<div class="mb-3 rounded-mlq-control border border-mlq-subtle px-3 py-3">
{#if canManage}
<form
method="POST"
action="?/setShareScope"
use:enhance
class="flex flex-wrap items-center gap-2"
>
<label for="share-scope" class="text-xs text-mlq-muted">Who can see this matter</label>
<select
id="share-scope"
name="share_scope"
value={shareScope}
onchange={(e) => e.currentTarget.form?.requestSubmit()}
class="rounded-mlq-control border border-mlq-subtle bg-mlq-surface px-2 py-1 text-xs text-mlq-text"
>
{#each SCOPES as s (s)}
<option value={s}>{SHARE_SCOPE_LABELS[s]}</option>
{/each}
</select>
<span class="text-xs text-mlq-muted">{SHARE_SCOPE_HINTS[shareScope]}</span>
</form>
{#if privileged && shareScope === 'org'}
<p class="mt-2 text-xs text-mlq-muted">
This matter is marked privileged and readable firm-wide. If someone must be walled off it,
screen them below — a screen overrides firm-wide access.
</p>
{/if}
{:else}
<p class="text-xs text-mlq-muted">
<span class="text-mlq-text">{SHARE_SCOPE_LABELS[shareScope]}</span> ·
{SHARE_SCOPE_HINTS[shareScope]}
</p>
{/if}
</div>

<!-- The working team -->
<div class="rounded-mlq-control border border-mlq-subtle">
{#each team as m (m.user_id)}
<div class="flex items-center gap-3 border-b border-mlq-subtle px-3 py-2 last:border-b-0">
<div class="min-w-0 flex-1">
<span class="block truncate text-sm text-mlq-text">{label(m)}</span>
{#if m.display_name}
<span class="block truncate text-xs text-mlq-muted">{m.email}</span>
{/if}
</div>

{#if m.is_owner}
<span class="shrink-0 text-xs text-mlq-muted">Owner · Lead</span>
{:else if canManage}
<form method="POST" action="?/changeMemberRole" use:enhance class="shrink-0">
<input type="hidden" name="user_id" value={m.user_id} />
<select
name="role"
value={m.role}
aria-label={`Role for ${label(m)}`}
onchange={(e) => e.currentTarget.form?.requestSubmit()}
class="rounded-mlq-control border border-mlq-subtle bg-mlq-surface px-2 py-1 text-xs text-mlq-text"
>
{#each ASSIGNABLE as r (r)}
<option value={r}>{MATTER_ROLE_LABELS[r]}</option>
{/each}
<option value="blocked">{MATTER_ROLE_LABELS.blocked}</option>
</select>
</form>
<form
method="POST"
action="?/removeMember"
use:enhance
aria-label={`Remove ${label(m)}`}
class="shrink-0"
>
<input type="hidden" name="user_id" value={m.user_id} />
<button
type="submit"
aria-label={`Remove ${label(m)}`}
class="rounded-mlq-control p-1 text-mlq-muted hover:text-mlq-error"
><X size={14} /></button
>
</form>
{:else}
<span class="shrink-0 text-xs text-mlq-muted">{MATTER_ROLE_LABELS[m.role]}</span>
{/if}
</div>
{/each}
</div>

{#if canManage}
<div class="mt-2 flex justify-end">
{#if addOpen}
<form
method="POST"
action="?/addMember"
use:enhance={() => {
return async ({ update }) => {
await update();
addOpen = false;
addUserId = '';
};
}}
class="flex flex-wrap items-center gap-2"
>
<label for="add-user-id" class="sr-only">Person</label>
<select
id="add-user-id"
name="user_id"
bind:value={addUserId}
required
class="w-72 rounded-mlq-control border border-mlq-subtle bg-mlq-surface px-2 py-1 text-xs text-mlq-text"
>
<option value="" disabled>Choose someone…</option>
{#each candidates as p (p.id)}
<option value={p.id}>{label(p)}</option>
{/each}
</select>
<label for="add-role" class="sr-only">Role</label>
<select
id="add-role"
name="role"
bind:value={addRole}
class="rounded-mlq-control border border-mlq-subtle bg-mlq-surface px-2 py-1 text-xs text-mlq-text"
>
{#each ASSIGNABLE as r (r)}
<option value={r}>{MATTER_ROLE_LABELS[r]}</option>
{/each}
<option value="blocked">{MATTER_ROLE_LABELS.blocked}</option>
</select>
<button
type="submit"
class="rounded-mlq-control bg-mlq-strong px-3 py-1 text-xs font-medium text-white"
>Add</button
>
<button
type="button"
onclick={() => (addOpen = false)}
class="rounded-mlq-control border border-mlq-subtle px-3 py-1 text-xs text-mlq-text"
>Cancel</button
>
<span class="w-full text-right text-xs text-mlq-muted">{MATTER_ROLE_HINTS[addRole]}</span>
</form>
{:else if candidates.length > 0}
<button
type="button"
onclick={() => (addOpen = true)}
class="rounded-mlq-control border border-mlq-subtle px-3 py-1 text-xs text-mlq-text"
>+ Add someone</button
>
{:else}
<p class="text-xs text-mlq-muted">Everyone in the firm is already on this matter.</p>
{/if}
</div>
{/if}

{#if screened.length > 0}
<h3 class="mt-4 mb-2 text-xs font-medium tracking-wide text-mlq-muted uppercase">
Screened · {screened.length}
</h3>
<div class="rounded-mlq-control border border-mlq-error/40">
{#each screened as m (m.user_id)}
<div class="flex items-center gap-3 border-b border-mlq-subtle px-3 py-2 last:border-b-0">
<div class="min-w-0 flex-1">
<span class="block truncate text-sm text-mlq-text">{label(m)}</span>
<span class="block truncate text-xs text-mlq-muted">
Cannot see this matter, whatever else grants access.
</span>
</div>
{#if canManage}
<form method="POST" action="?/removeMember" use:enhance class="shrink-0">
<input type="hidden" name="user_id" value={m.user_id} />
<button
type="submit"
class="rounded-mlq-control border border-mlq-subtle px-2 py-0.5 text-xs text-mlq-text"
>Lift screen</button
>
</form>
{/if}
</div>
{/each}
</div>
{/if}
</section>
Loading