Skip to content
Merged
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
8 changes: 8 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,13 +128,21 @@
- Calendar writeback UI must fail closed while the signed source registry is
loading or errored; do not emit intent POSTs without a confirmed opaque
`target_source_id`, and keep tests covering the loading/error boundary.
- Calendar and WebDAV workspaces must expose the current opaque writeback source
as a deliberate user selection with capability and ETag/If-Match state.
Automatic first-source fallback may initialize the control, but intent POSTs
must use the selected opaque source and `409` responses must render as
conflicts, not generic errors or completed writes.
- Calendar and WebDAV writeback source selection must resolve through opaque
`source_uid` values, signed-session organization scope, and persisted
writeback eligibility, not sequential CalDAV or WebDAV account ids.
Missing writeback eligibility must fail closed. Browser-visible source ids
must not reveal or be deterministically derived from account primary keys, and
provider mutations remain future work until connector execution can enforce
capability, consent, and ETag/If-Match checks.
- Mobile workspace drawers must lock background body scroll while open and keep
the drawer itself scrollable; responsive E2E screenshots should cover the open
hamburger state after scrolling the drawer.
- Self-sent knowledge extraction must first prove true self-to-self addressing,
stay idempotent per source email, preserve email/thread provenance, and store
only plain-text task titles. Do not create unlinked knowledge tasks from raw
Expand Down
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -275,14 +275,20 @@ provenance. Calendar source selection now reads opaque
`calendar_writeback_sources.source_uid` rows instead of exposing sequential
CalDAV account ids, and the Calendar workspace loads those rows through signed
`/api/calendar/writeback-sources` before posting an opaque `target_source_id`.
The workspace now presents those sources as explicit selectable writeback
targets and shows the selected source ETag/capability state before intent
creation.
The browser no longer claims `/api/calendar/sync` success from the mail-detail
action path; direct provider writes stay deferred until connector execution can
enforce ETag/If-Match and owner capability checks.
WebDAV writeback and self-sent knowledge materialization use
`webdav_accounts.source_uid` as the browser-visible source id, scope lookup by
the signed session organization, honor persisted `writeback_enabled`
eligibility, reject legacy `target_account_id` payloads, and keep sequential
`account_id` values internal-only.
`account_id` values internal-only. The Data workspace exposes the WebDAV source
as an explicit selected target and treats `409` If-Match/ETag responses as
conflicts instead of generic failures, so UI copy never implies a provider write
overwrote customer-owned files.

## Operations and release docs

Expand Down
7 changes: 5 additions & 2 deletions docs/plans/2026-05-27-calendar-writeback-intent-ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,11 @@ is the calendar server or directly writing provider data from the browser.
## Implemented Slice

- `/calendar` now exposes a CalDAV/CardDAV/WebDAV intent check panel.
- The intent panel lists signed-session CalDAV registry sources and chooses an
eligible write-capable customer source before posting intent.
- The intent panel lists signed-session CalDAV registry sources, initializes an
eligible write-capable customer source, and lets the user deliberately select
the opaque target source before posting intent.
- Source cards show provider, protocol, capabilities, persisted ETag state, and
writeback eligibility so source selection is visible before any intent POST.
- The monthly, weekly, detail, coordination, and candidate calendar tabs all
render concrete surfaces instead of an inert "under implementation" state.
- Mobile layout keeps the header and monthly grid bounded, and adds bottom safe
Expand Down
13 changes: 9 additions & 4 deletions docs/plans/2026-05-27-data-webdav-writeback-intent-ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,15 @@ AI-organized files or attachments are written back to their own storage.
## Implementation

- `/data` now exposes a WebDAV writeback intent approval panel.
- The intent panel uses the first connected customer WebDAV account as the
target source through `webdav_accounts.source_uid`, only chooses accounts with
persisted `writeback_enabled=true`, and fails closed when no eligible source
or signed session is available.
- The intent panel initializes from the first connected customer WebDAV account
but exposes it as an explicit selected source through
`webdav_accounts.source_uid`, only permits accounts with persisted
`writeback_enabled=true`, and fails closed when no eligible source or signed
session is available.
- The source card shows the WebDAV endpoint, opaque source id, username,
writeback eligibility, and current ETag state before the intent request.
- `409` responses now render as If-Match/ETag conflicts instead of generic
failures, preserving the customer-owned source-of-truth boundary.
- Data repository cards now use responsive grid tracks so mobile verification
does not depend on desktop-only three-column layouts.
- Unit and Playwright tests assert signed `Authorization: Bearer` handling and
Expand Down
72 changes: 72 additions & 0 deletions docs/plans/2026-05-28-dav-source-selection-conflict-ui.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# DAV Source Selection and Conflict UI Phase

## Goal

Close the next verified `docs/plans` and `frontend/branding` gap without
changing Naruon's source-of-truth boundary: Naruon remains a signed web
workspace/control plane over customer-owned CalDAV/CardDAV/WebDAV providers.

## Verified Inputs

- `frontend/branding/naruon-ux-mockup-1.png` shows a dense operational home
workspace with navigable decisions, tasks, calendar, and mail surfaces rather
than static hero copy.
- `frontend/branding/naruon-ux-mockup-3.png` shows the Calendar workspace as a
month/week/detail/coordination/candidate surface with provider-backed
scheduling states.
- `docs/plans/2026-05-27-calendar-writeback-intent-ui.md` and
`docs/plans/2026-05-27-data-webdav-writeback-intent-ui.md` already require
signed source-registry reads, opaque target ids, ETag/If-Match visibility, and
no direct browser provider writes.
- Subagent audits confirmed that Calendar and Data already call signed intent
APIs, but Calendar auto-selected the first source and Data collapsed WebDAV
`409` conflicts into a generic error.

## Implemented Slice

- Calendar source cards are selectable controls. The UI still initializes from
the first eligible source, but the selected opaque `source_id`, protocol,
capability list, ETag value, and writeback eligibility are visible before
`POST /api/calendar/writeback-intent`.
- Data WebDAV source cards are selectable controls. `POST
/api/webdav/writeback-intent` uses the selected opaque `source_id` and never
exposes sequential account ids in the browser request.
- WebDAV `409` responses render as If-Match/ETag conflicts so the UI does not
imply that Naruon overwrote a customer-owned file.
- The mobile hamburger drawer locks background body scroll while open and keeps
the drawer itself scrollable, matching the branding requirement for usable
mobile navigation across all primary destinations.
- README and AGENTS guardrails now record this bug pattern so future copied
Calendar/WebDAV examples do not revert to implicit source selection or generic
conflict errors.

## Still Future Work

- Real connector/provider write execution remains out of scope until the
connector can enforce consent, capability, credential reference, remote href,
and ETag/If-Match checks server-side.
- `CaldavAccount` and `/dav` still need deeper registry consolidation so legacy
account ids and skeleton PUT behavior cannot be mistaken for a complete
provider write path.
- WebDAV project folder responses still need opaque folder ids in a backend
slice; this UI phase did not change that API contract.

## Verification

```bash
npm test -- src/app/calendar/page.test.tsx src/app/data/page.test.tsx
npm run typecheck
npm run build
npx playwright test tests/e2e/dashboard-branding.spec.ts --project=desktop -g "calendar writeback|data WebDAV"
npx playwright test tests/e2e/mobile-hamburger.spec.ts --project=desktop
```

Screenshots to inspect:

- `calendar-writeback-intent-desktop.png`
- `calendar-writeback-intent-mobile.png`
- `calendar-writeback-intent-mobile-scroll.png`
- `data-webdav-writeback-intent-desktop.png`
- `data-webdav-writeback-intent-mobile.png`
- `data-webdav-writeback-intent-mobile-scroll.png`
- `mobile-hamburger-open.png`
73 changes: 73 additions & 0 deletions frontend/src/app/calendar/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,16 @@ const calendarSourceList = [
writeback_enabled: true,
etag: "etag-caldav-1",
},
{
source_id: "caldav-team",
provider: "Team CalDAV",
protocol: "caldav",
owner_id: "user-1",
organization_id: "org-acme",
capabilities: ["read", "write", "etag"],
writeback_enabled: true,
etag: "etag-team-2",
},
];

describe("CalendarPage", () => {
Expand Down Expand Up @@ -139,6 +149,7 @@ describe("CalendarPage", () => {
});
await flushAsyncWork();
expect(container.textContent).toContain("Customer CalDAV");
expect(container.textContent).toContain("etag=etag-caldav-1");

const button = Array.from(container.querySelectorAll("button")).find((node) => node.textContent?.includes("새 일정 intent 점검"));
expect(button).toBeTruthy();
Expand All @@ -153,6 +164,68 @@ describe("CalendarPage", () => {
expect(container.textContent).toContain("calendar.writeback_intent.created");
});

it("lets the user choose a specific customer-owned calendar source before intent creation", async () => {
localStorage.setItem("naruon_session_token", "signed-calendar-source-selection");
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
if (String(input) === "/api/calendar/writeback-sources") {
expect(init?.headers).toEqual(expect.objectContaining({
"Content-Type": "application/json",
Authorization: "Bearer signed-calendar-source-selection",
}));
return jsonResponse(calendarSourceList);
}
expect(String(input)).toBe("/api/calendar/writeback-intent");
expect(init?.headers).toEqual(expect.objectContaining({
"Content-Type": "application/json",
Authorization: "Bearer signed-calendar-source-selection",
}));
expect(JSON.parse(String(init?.body))).toEqual({
action: "create",
summary: "Naruon 일정 후보 writeback intent 점검",
target_source_id: "caldav-team",
});
return jsonResponse({
workspace_id: "workspace-org-acme",
target_source_id: "caldav-team",
protocol: "caldav",
writeback_mode: "customer_owned",
requires_if_match: false,
if_match: null,
provenance: {
created_by: "user-1",
source_provider: "Team CalDAV",
source_protocol: "caldav",
},
audit_event: "calendar.writeback_intent.created",
});
});
vi.stubGlobal("fetch", fetchMock);
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);

act(() => {
root?.render(<CalendarPage />);
});
await flushAsyncWork();

const teamSourceButton = Array.from(container.querySelectorAll("button")).find((node) => node.textContent?.includes("Team CalDAV"));
expect(teamSourceButton).toBeTruthy();
await act(async () => {
teamSourceButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});

const createButton = Array.from(container.querySelectorAll("button")).find((node) => node.textContent?.includes("새 일정 intent 점검"));
await act(async () => {
createButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushAsyncWork();

expect(fetchMock).toHaveBeenCalledTimes(2);
expect(container.textContent).toContain("caldav-team");
expect(container.textContent).toContain("Team CalDAV");
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

it("does not post writeback intent before source registry readiness", async () => {
const fetchMock = vi.fn((input: RequestInfo | URL) => {
expect(String(input)).toBe("/api/calendar/writeback-sources");
Expand Down
72 changes: 70 additions & 2 deletions frontend/src/app/data/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,11 @@ vi.mock("lucide-react", () => ({

import DataPage from "./page";

function jsonResponse(body: unknown) {
function jsonResponse(body: unknown, ok = true, status = ok ? 200 : 500) {
return {
ok: true,
ok,
status,
statusText: ok ? "OK" : "Error",
json: async () => body,
};
}
Expand All @@ -40,6 +42,14 @@ function mockWebdavFetch() {
server_url: "https://webdav.naruon.net",
username: "demo_user",
writeback_enabled: true,
etag: "etag-webdav-primary",
},
{
source_id: "webdav_src_team",
server_url: "https://files.acme.example",
username: "team_user",
writeback_enabled: true,
etag: "etag-webdav-team",
},
]);
}
Expand Down Expand Up @@ -123,6 +133,7 @@ describe("DataPage", () => {
expect(container.textContent).toContain("WebDAV 원본");
expect(container.textContent).toContain("로컬 캐시");
expect(container.textContent).toContain("WebDAV writeback intent 승인");
expect(container.textContent).toContain("etag=etag-webdav-primary");
});

it("creates a signed customer-owned WebDAV writeback intent", async () => {
Expand Down Expand Up @@ -178,6 +189,63 @@ describe("DataPage", () => {
expect(container.textContent).toContain("https://webdav.naruon.net");
});

it("lets the user choose a specific WebDAV source and distinguishes If-Match conflicts", async () => {
localStorage.setItem("naruon_session_token", "signed-webdav-conflict-session");
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const path = String(input);
if (path === "/api/webdav/accounts") {
return jsonResponse([
{
source_id: "webdav_src_primary",
server_url: "https://webdav.naruon.net",
username: "demo_user",
writeback_enabled: true,
etag: "etag-webdav-primary",
},
{
source_id: "webdav_src_team",
server_url: "https://files.acme.example",
username: "team_user",
writeback_enabled: true,
etag: "etag-webdav-team",
},
]);
}
if (path === "/api/webdav/folders") return jsonResponse([]);
expect(path).toBe("/api/webdav/writeback-intent");
expect(JSON.parse(String(init?.body))).toEqual({
target_source_id: "webdav_src_team",
});
return jsonResponse({ detail: "If-Match conflict" }, false, 409);
});
vi.stubGlobal("fetch", fetchMock);
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);

await act(async () => {
root?.render(<DataPage />);
});

const teamSourceButton = Array.from(container.querySelectorAll("button")).find((candidate) =>
candidate.textContent?.includes("webdav_src_team"),
);
expect(teamSourceButton).toBeDefined();
await act(async () => {
teamSourceButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});

const writebackButton = Array.from(container.querySelectorAll("button")).find((candidate) =>
candidate.textContent?.includes("WebDAV intent 승인 점검"),
);
await act(async () => {
writebackButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});

expect(container.textContent).toContain("If-Match/ETag 충돌");
expect(container.textContent).toContain("webdav_src_team");
});

it("keeps WebDAV writeback disabled when account loading fails", async () => {
localStorage.setItem("naruon_session_token", "signed-webdav-session");
const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
Expand Down
Loading
Loading