diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..afbbab1 --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +# Public browser configuration. Use a same-origin path, never a credential. +VITE_KEEPSIDE_API_URL=/api diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 0000000..c824610 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,60 @@ +name: Bug report +description: Report a reproducible problem in Keepside +title: "[Bug]: " +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to report this. Do not attach a real workspace export. Reproduce the issue with sample data. + - type: input + id: browser + attributes: + label: Browser and version + placeholder: Safari 19.0, Chrome 140, Firefox 142 + validations: + required: true + - type: input + id: revision + attributes: + label: Keepside version or commit + placeholder: v0.1.0 or a commit SHA + validations: + required: true + - type: textarea + id: steps + attributes: + label: Reproduction steps + description: Start from a fresh workspace or the built-in sample workspace. + placeholder: | + 1. Load the sample workspace + 2. Open ... + 3. Choose ... + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected result + validations: + required: true + - type: textarea + id: actual + attributes: + label: Actual result + validations: + required: true + - type: dropdown + id: mode + attributes: + label: Storage mode + options: + - Local mode + - Connected-mode client or custom server + - Not sure + validations: + required: true + - type: textarea + id: notes + attributes: + label: Safe supporting material + description: Add console errors or screenshots only after removing client data and credentials. diff --git a/.github/ISSUE_TEMPLATE/conduct.yml b/.github/ISSUE_TEMPLATE/conduct.yml new file mode 100644 index 0000000..efe7b5b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/conduct.yml @@ -0,0 +1,30 @@ +name: Conduct report +description: Flag a public project interaction for maintainer review. +title: "[Conduct] " +body: + - type: markdown + attributes: + value: | + This form creates a public issue. Link to the interaction, but do not include private client information, contact details, or other sensitive material. For threats, doxxing, or private evidence, use GitHub's abuse reporting route instead. + - type: input + id: location + attributes: + label: Interaction + description: Link to the issue, pull request, comment, or other public project interaction. + placeholder: https://github.com/SebastianSteele/keepside-web/... + validations: + required: true + - type: textarea + id: concern + attributes: + label: What needs review? + description: Give the maintainer enough context to locate the concern without repeating harmful or private content. + validations: + required: true + - type: checkboxes + id: privacy + attributes: + label: Public report + options: + - label: I understand this report is public and contains no private client data or sensitive evidence. + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..44c36f6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Private security report + url: https://github.com/SebastianSteele/keepside-web/security/advisories/new + about: Report a vulnerability without publishing sensitive details. diff --git a/.github/ISSUE_TEMPLATE/feature.yml b/.github/ISSUE_TEMPLATE/feature.yml new file mode 100644 index 0000000..f6df4aa --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature.yml @@ -0,0 +1,38 @@ +name: Feature proposal +description: Suggest a focused improvement to client work +title: "[Proposal]: " +body: + - type: markdown + attributes: + value: Do not attach a real workspace export or include client data. Use the sample workspace for examples. + - type: textarea + id: problem + attributes: + label: What client-work problem are you trying to solve? + description: Describe the real workflow before describing a screen or implementation. + validations: + required: true + - type: textarea + id: current + attributes: + label: How do you handle it today? + validations: + required: true + - type: textarea + id: change + attributes: + label: Smallest useful change + description: Explain the outcome, including what Keepside can leave out. + validations: + required: true + - type: dropdown + id: data + attributes: + label: Does this change stored or transmitted data? + options: + - No + - Yes, local workspace data + - Yes, connected-mode data + - Not sure + validations: + required: true diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..0d4f88c --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,21 @@ +## What changed + + +## Why + + +## Visual proof + +Add before and after screenshots for interface changes. Use the sample workspace only. + +## Checks + +- [ ] `npm run typecheck` +- [ ] `npm test` +- [ ] `npm run build` +- [ ] `npm audit --audit-level=high` + +## Data boundary + +- [ ] I described any workspace schema, persistence, privacy, or network impact. +- [ ] I did not include real client data, workspace exports, or credentials. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ecb4f34..cf22336 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: 22 + node-version: 20.19.0 cache: npm - run: npm ci - run: npm run typecheck diff --git a/.gitignore b/.gitignore index 5e4eb0c..7ecd655 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,9 @@ dist .DS_Store *.local *.log +.env +.env.* +!.env.example coverage *.tsbuildinfo vite.config.js diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 5318b39..47c88b2 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -10,11 +10,24 @@ Keepside uses a single workspace document as its source of truth. - `src/App.tsx` owns the current workspace instance and presentation state. - Screens render derived data and open editors. They do not write browser storage directly. - Editors return complete typed records to the application. They do not implement persistence rules. +- `src/infrastructure/serverClients.ts` owns the optional connected-service transport. It does not redefine domain records. -## Storage +## Local mode The whole workspace is validated before it is read, saved, imported, or exported. Invalid stored data falls back to an empty workspace. Deleting a client also removes records owned by that client. Deleting a contact clears its references from meetings and email records. +Local mode persists that document in one browser `localStorage` entry. It makes no workspace or mail network request. + +## Connected-mode seam + +Connected mode is an experimental integration boundary, not a second active data model. `WorkspaceApiClient` loads and saves the same `WorkspaceData` document through the contract in `docs/openapi.yaml`. Every response is passed through the existing domain validator. Conditional writes use the last server `ETag`, so a stale client cannot overwrite a newer revision silently. + +A product that activates connected storage must choose it explicitly. It should upload once or open the existing server workspace, then use one store only. Automatic merges and local-plus-server dual writes are intentionally absent. + +`MailRelayClient` submits one outgoing draft after an explicit user action. It marks nothing sent by itself. When **Send through relay** is used, the editor changes the record to sent only after the relay returns an accepted receipt. A person can still log an already-sent message manually. + +The reference PostgreSQL shape stores one JSON document per authenticated owner. Delivery metadata lives in a separate table because it describes the transport, not the client record. + ## Product boundary -Keepside is a manual client manager. Hosted sync, AI features, analytics, inbox access, automations, credential storage, and third-party integrations are outside this repository. +Keepside is a manual client manager. AI features, analytics, inbox ingestion, automations, browser-side credential storage, automatic conflict merging, and public mail-service infrastructure are outside this repository. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..49c4f0b --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,21 @@ +# Code of conduct + +Keepside should be a useful place to do careful work together. + +## Expected conduct + +- Treat people and their time with respect. +- Critique the work without belittling the person behind it. +- Make room for different levels of experience and different kinds of client work. +- Keep private client information, credentials, and workspace exports out of public issues. +- Accept a clear boundary when someone asks to end an interaction. + +Harassment, discrimination, threats, sexual attention, doxxing, and deliberate disruption are not welcome in project spaces. + +## Scope and enforcement + +This applies in the repository, issue tracker, pull requests, and public conversations where someone represents Keepside. + +For project moderation, open a short issue that links to the interaction without repeating private details. The maintainer may edit or remove content, close participation, or ban an account when needed to protect the community. + +For a private or platform-level report, use [GitHub's abuse reporting route](https://support.github.com/contact/report-abuse). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d2d5be0..f4952b7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,6 @@ # Contributing -Small, focused pull requests are welcome. +Small, focused pull requests are welcome. Start with the workflow problem and keep the implementation as narrow as the fix allows. Before opening one, run: @@ -8,9 +8,29 @@ Before opening one, run: npm run typecheck npm test npm run build -npm audit +npm audit --audit-level=high ``` Keep domain rules in `src/domain/workspace.ts`. Do not introduce a second persistence path or duplicate entity logic inside a screen. New dependencies should solve a demonstrated problem that browser APIs and the existing code cannot cover simply. The interface should remain calm, readable, and specific to client work. Avoid generic dashboard cards, decorative statistics, chat assistants, automation builders, gradients used as filler, or placeholder business metrics. + +## Data and screenshots + +- Use the built-in sample workspace for reproduction steps and screenshots. +- Never commit a real workspace export, client address, inbox message, API key, or `.env` file. +- Call out changes to `WorkspaceData`, import compatibility, storage, network requests, and deletion behavior in the pull request. +- For connected-mode work, keep `src/domain` canonical and update `docs/openapi.yaml` when the public transport changes. + +## Interface work + +Check desktop and mobile widths, keyboard focus, reduced motion, empty states, long names, and destructive confirmations. Visual changes need before and after screenshots in the pull request. + +For a reproducible production build with the sample workspace preloaded: + +```bash +VITE_KEEPSIDE_DEMO=true VITE_KEEPSIDE_API_URL=/api npm run build +npm exec vite -- preview +``` + +In that build only, `?section=clients&client=client-studio` opens the sample client and `?section=mail&compose=email-studio-review-draft` opens the sample draft. Default builds ignore those query values. diff --git a/README.md b/README.md index 514244d..aabc8a3 100644 --- a/README.md +++ b/README.md @@ -1,69 +1,112 @@ -# Keepside for the web +# Keepside -Keepside is a private client relationship ledger that runs entirely in the browser. It keeps client profiles, contacts, work, meetings, notes, and email history together without an account, hosted database, automation system, or tracking layer. +**Client work without the CRM ceremony.** -This is the cross-platform React edition. The separate [native macOS edition](https://github.com/SebastianSteele/keepside) remains its own project. +Keepside puts the people, promises, notes, meetings, and email around a client in one place. It is built for independent professionals and small practices that need a clear record of the relationship, not a sales machine. + +This web edition is early software. Local mode is ready to use. The connected database and outbound mail boundary is available for server implementers and remains experimental. + +![Keepside dashboard showing upcoming client work, notes, mail, and the client rail](docs/screenshots/today.png) + +## One client record, everything connected + +A client record holds the work that is still open, conversations on the calendar, people involved, useful notes, and email history. Every edit goes back through the same validated workspace document. + +![Willow and Pine client record with tasks, meetings, notes, contacts, details, and mail](docs/screenshots/client.png) + +## Email stays with the relationship -## What it handles +Keepside can open a draft in the computer's default mail app. A self-hosted build can also enable the outbound relay client. When **Send through relay** is used, Keepside marks the record Sent only after that relay returns an accepted receipt. -- Client and organization records -- Primary and supporting contacts -- Projects, tasks, services, and follow-ups -- Meeting agendas and outcomes -- Pinned client notes -- Sent and received email history -- Email composition through the default mail app -- Portable JSON import and export -- Responsive desktop and mobile layouts +![Keepside email editor with a client draft and its linked record details](docs/screenshots/mail.png) -## Privacy model +## What it keeps together -Keepside stores one validated workspace in browser `localStorage`. There is no server component. Nothing is uploaded by the application. +- clients and organizations +- primary and supporting contacts +- projects, tasks, services, and follow-ups +- meeting agendas and outcomes +- pinned notes and longer client context +- sent, received, and draft email records +- one portable JSON export of the complete workspace -Browser storage can be cleared by the user, the browser, or device-management policies. Export the workspace regularly if the data matters. Keepside is not a replacement for an encrypted records system when handling regulated or highly sensitive information. +There are no pipelines, lead scores, automation builders, AI summaries, or decorative performance widgets. -## Run locally +## Two clear modes + +| | Local mode | Connected-mode framework | +| --- | --- | --- | +| Status | Default and usable now | Experimental integration boundary | +| Storage | One validated document in browser storage | The same document in your PostgreSQL service | +| Account | None | Supplied by your server | +| Email | Opens the default mail app | Optional authenticated outbound relay | +| Secrets | None | Server only, never in a `VITE_` variable | +| Conflicts | One browser profile | Conditional saves with `ETag` and `If-Match` | + +Local mode makes no workspace or mail network request. Browser storage can still be cleared, so export a backup if the records matter. + +Connected mode is not a hosted Keepside service. This repository supplies tested browser clients, an [OpenAPI contract](docs/openapi.yaml), a compact [PostgreSQL schema](docs/postgres.sql), and the [self-hosting security boundary](docs/SELF_HOSTING.md). You provide the authenticated server and mail-provider account. + +## Run it locally Requires Node.js 20.19 or newer. ```bash +git clone https://github.com/SebastianSteele/keepside-web.git +cd keepside-web npm install npm run dev ``` -Open `http://localhost:5173`. +Open `http://localhost:5173`, then choose **Use sample workspace** or add a first client. -## Verify a change +## Configure a connected API + +The only browser setting is a public URL: ```bash -npm run typecheck -npm test -npm run build -npm audit +cp .env.example .env.local +``` + +```dotenv +VITE_KEEPSIDE_API_URL=/api ``` -## Architecture +That enables the outbound relay action in the email editor. `WorkspaceApiClient` is the storage seam for a server-backed build, but Keepside does not switch a local workspace to remote storage automatically. A real product must ask whether to upload the local workspace or open the server copy, then use one store only. + +Read [Connected mode reference](docs/SELF_HOSTING.md) before implementing the server. It covers owner isolation, revision conflicts, session cookies, origin checks, mail idempotency, rate limits, provider failures, and backups. + +## Project shape + +- `src/domain/types.ts` owns the record model. +- `src/domain/workspace.ts` owns validation and every persistence rule. +- `src/domain/sample.ts` owns the reproducible demonstration workspace. +- `src/infrastructure/serverClients.ts` owns the optional server transport. +- `src/screens` renders data and returns complete records. It does not persist them. -The application has one canonical `WorkspaceData` document. Domain rules, validation, persistence, import, export, cascade deletion, and sorting live in `src/domain/workspace.ts`. React screens receive that workspace and route all mutations back through the domain layer. +The app uses React, TypeScript, Vite, and browser APIs. It has no router, state library, component kit, icon dependency, date package, or styling framework. -No state library, router, component kit, icon package, date package, or styling framework is used. The small dependency surface is deliberate. +See [Architecture](ARCHITECTURE.md) for the full ownership boundary. -## Design +## Verify a change -Keepside uses Onest for the dense product interface and the supplied Lutoria Serif family selectively for longer, editorial text. Its carbon ledger layout, chrome K lockup, heated-metal accent, and persistent client rail form the visual identity. Source and license details are in the [font credits](public/fonts/README.md). +```bash +npm run typecheck +npm test +npm run build +npm audit --audit-level=high +``` -Lutoria is an optional local font and is not included in public commits without confirmed redistribution rights. A clean clone falls back to Georgia for editorial text while keeping Onest for the full product interface. Licensed owners can place the three WOFF2 files named in the font credits inside `public/fonts/`. +The current screenshot build was checked in Chrome 150 on macOS at a 1440-pixel desktop width. Responsive layouts are included; reproducible browser reports are welcome. -## Browser support +## Data care -Keepside targets current evergreen browsers with support for ES2020, `localStorage`, and `crypto.randomUUID`. The application includes a fallback identifier generator for older compatible browsers. +Keepside is not an encrypted records vault. Do not put regulated or highly sensitive material in browser storage without the device protection, retention policy, and backup process your work requires. Never attach a real workspace export to a public issue. -## Current limitations +Security reports have a [private reporting route](https://github.com/SebastianSteele/keepside-web/security/advisories/new). General contributions are covered in [Contributing](CONTRIBUTING.md) and the [Code of conduct](CODE_OF_CONDUCT.md). -- Data is local to one browser profile and does not sync between devices. -- Email composition opens the default mail application; Keepside does not connect to an inbox or confirm delivery. -- Browser storage can be cleared, so important workspaces should be exported regularly. +## Editions and license -## License +This is the cross-platform React edition. The separate [native macOS edition](https://github.com/SebastianSteele/keepside) remains its own project. -[MIT](LICENSE) +Keepside is released under the [MIT License](LICENSE). The bundled Onest typeface uses the SIL Open Font License; details are in the [font credits](public/fonts/README.md). diff --git a/SECURITY.md b/SECURITY.md index 4020573..254b99e 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -6,8 +6,18 @@ Security fixes are applied to the latest commit on `main`. ## Reporting a vulnerability -Please use GitHub private vulnerability reporting for this repository. Do not include real client data, credentials, or other sensitive records in a report. +Use the repository's [private vulnerability reporting form](https://github.com/SebastianSteele/keepside-web/security/advisories/new). Do not include real client data, credentials, or a workspace export. Reproduce the issue with the built-in sample workspace when possible. -## Data boundary +## Local-mode boundary -Keepside has no backend and does not transmit workspace data. Records remain in the browser profile until the user exports or clears them. Browser storage is not encrypted by Keepside and should not be used for regulated or highly sensitive information without additional device-level protections. +The default build keeps one workspace in the browser and does not transmit it. Browser storage is not encrypted by Keepside and can be cleared by the browser or device policy. Do not use it for regulated or highly sensitive information without appropriate device-level protection and a tested backup routine. + +## Connected-mode boundary + +The repository includes optional browser clients and reference contracts for a self-hosted database and outbound mail relay. It does not include an authentication server or hosted service. A deployment that sets `VITE_KEEPSIDE_API_URL` sends data to that operator's API. + +The operator is responsible for HTTPS, authentication, owner-level access control, conditional writes, CSRF and origin protection, rate limits, encrypted backups, mail abuse controls, provider secrets, and incident response. Database and provider credentials must never use a `VITE_` environment variable. + +The relay is outbound-only. It must use a server-controlled sender and store only the minimum delivery metadata needed for idempotency and support. Provider acceptance does not guarantee final delivery. + +See [the connected-mode reference](docs/SELF_HOSTING.md) for the complete trust boundary. diff --git a/docs/SELF_HOSTING.md b/docs/SELF_HOSTING.md new file mode 100644 index 0000000..33dfb5c --- /dev/null +++ b/docs/SELF_HOSTING.md @@ -0,0 +1,120 @@ +# Connected mode reference + +Keepside works without a server. Connected mode is an experimental boundary for people who want to provide their own workspace database and outbound email relay. + +This repository includes the browser clients, API contract, and PostgreSQL schema. It does not ship an authentication server or a public mail service. Those choices are deployment-specific and carry real security and abuse responsibilities. + +## The boundary + +```text +Keepside in the browser + | + | authenticated HTTPS requests + v +your same-origin API + |--------------------| + v v +PostgreSQL mail provider +WorkspaceData outbound only +``` + +Local mode remains the default. A connected build should make activation explicit: upload the local workspace once or use the existing server workspace. Do not merge automatically and do not write to browser storage and PostgreSQL at the same time. + +## Browser configuration + +Copy `.env.example` to `.env.local` and set only the public API location: + +```bash +VITE_KEEPSIDE_API_URL=/api +``` + +Every Vite variable with a `VITE_` prefix is included in the browser bundle. Never place a database URL, session secret, or mail-provider key in one. + +The reference client assumes the app and API share an origin. A cross-origin API needs a deliberate cookie, CORS, and CSRF design; changing the URL alone is not enough. + +The included clients expect: + +- `GET /v1/workspace`, `POST /v1/workspace`, and `PUT /v1/workspace` +- `POST /v1/mail/send` +- secure session cookies sent with `credentials: include` +- JSON responses that match `docs/openapi.yaml` +- an `ETag` on every successful workspace load and save + +`WorkspaceApiClient` validates every returned document through `parseWorkspace`. `MailRelayClient` sends a stable idempotency key built from the workspace and email IDs. + +The OpenAPI workspace shape is a transport envelope, not a replacement validator. Reuse `parseWorkspace` in a TypeScript server or port it with the same contract tests. An OpenAPI-generated object check alone is not sufficient. + +## Server configuration + +These names are suggestions for the service you write, not values consumed by Vite: + +```bash +DATABASE_URL=postgresql://... +SESSION_SECRET=... +EMAIL_PROVIDER_API_KEY=... +EMAIL_FROM=mail@example.com +EMAIL_FROM_NAME=Keepside +APP_ORIGIN=https://clients.example.com +``` + +Apply the reference schema with your normal migration system, or directly during development: + +```bash +psql "$DATABASE_URL" -f docs/postgres.sql +``` + +Store the complete validated `WorkspaceData` document in `workspace_documents.document`. Do not create a second hand-maintained model of clients, tasks, notes, and mail unless the product later needs server-side queries. + +## Workspace writes + +The server resolves `owner_subject` from the authenticated session. It never accepts an owner ID from the request body. + +Create the first remote workspace with `POST /v1/workspace`. The server returns `409 Conflict` if one already exists for that owner. + +For every later save: + +1. Verify the request origin and authenticated owner. +2. Require `If-Match` and decode its revision. +3. Validate the full workspace and its record references. +4. Update only where both owner and revision match. +5. Return `412 Precondition Failed` when no row matches. +6. Return the saved document with a new opaque `ETag`. + +This prevents a stale tab from silently overwriting a newer workspace. + +## Outbound mail relay + +The first relay should send plain text only. It is not inbox sync and it does not claim final delivery. + +Before calling a provider, the server must: + +- authenticate the owner and verify the workspace belongs to that session +- reject incoming records and invalid addresses +- strip CR and LF from any value used as a header +- use the server-controlled `EMAIL_FROM` value +- allow at most 20 recipients, a 200-character subject, and a 100 KB body +- reserve the idempotency key and a SHA-256 payload hash in `mail_dispatches` +- limit each owner to a small hourly send quota +- omit message bodies and recipient addresses from logs + +If the provider accepts the request, record its message ID and return `202` with `acceptedAt` and the effective sender mailbox in `from`. Only then should the browser change the Keepside record from draft to sent. If the provider call times out after submission, return `503` and mark the dispatch uncertain. Do not retry automatically. + +A shared public relay needs abuse review, suppression handling, per-user quotas, and verified sender domains. Keep sign-up invite-only until those controls exist. + +## Web security checklist + +- HTTPS only, including local staging behind a trusted proxy +- `Secure`, `HttpOnly`, `SameSite=Strict` session cookies +- strict `APP_ORIGIN` validation on every state-changing request +- no wildcard CORS with credentialed requests +- rate limits on authentication, workspace writes, and mail +- generic public errors with detailed server-only logs +- `Cache-Control: no-store` for workspace and relay responses +- database roles that cannot read another owner's row +- encrypted database backups with a tested restore procedure + +## Backups and upgrades + +Keep daily database backups and test a restore before calling the deployment production-ready. Preserve `schemaVersion` in every document. When a new document schema ships, migrate a copy first and retain the previous backup until the new build has been verified. + +The browser's JSON export remains the portable escape hatch in both modes. diff --git a/docs/openapi.yaml b/docs/openapi.yaml new file mode 100644 index 0000000..d7091bf --- /dev/null +++ b/docs/openapi.yaml @@ -0,0 +1,354 @@ +openapi: 3.1.2 +info: + title: Keepside connected service + version: 0.1.0 + summary: Optional workspace transport and outbound email relay contract + description: | + This contract is the boundary for experimental connected mode. Local mode + does not call these endpoints. WorkspaceData and its validator in + src/domain are the canonical record model. The Workspace component below is + a transport envelope. Server implementations must reuse or port + parseWorkspace and its contract tests; generated OpenAPI validation alone is + not sufficient. +servers: + - url: /api +security: + - sessionCookie: [] +paths: + /v1/workspace: + get: + operationId: loadWorkspace + summary: Load the authenticated owner's workspace + responses: + '200': + description: Current validated workspace + headers: + ETag: + description: Opaque workspace revision used for conditional saves + schema: + type: string + example: '"revision-7"' + content: + application/json: + schema: + $ref: '#/components/schemas/Workspace' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + description: The authenticated owner has no connected workspace + '500': + $ref: '#/components/responses/ServerError' + post: + operationId: createWorkspace + summary: Create the authenticated owner's first connected workspace + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Workspace' + responses: + '201': + description: Workspace created after full validation + headers: + ETag: + description: Initial opaque workspace revision + schema: + type: string + content: + application/json: + schema: + $ref: '#/components/schemas/Workspace' + '400': + $ref: '#/components/responses/InvalidRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '409': + description: A connected workspace already exists for this owner + '413': + description: Workspace document exceeds the server limit + '500': + $ref: '#/components/responses/ServerError' + put: + operationId: saveWorkspace + summary: Replace the authenticated owner's workspace + parameters: + - name: If-Match + in: header + required: true + description: ETag returned by the last successful load or save + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Workspace' + responses: + '200': + description: Saved and revalidated workspace + headers: + ETag: + description: New opaque workspace revision + schema: + type: string + content: + application/json: + schema: + $ref: '#/components/schemas/Workspace' + '400': + $ref: '#/components/responses/InvalidRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '412': + description: The supplied ETag is stale; no data was overwritten + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '413': + description: Workspace document exceeds the server limit + '500': + $ref: '#/components/responses/ServerError' + /v1/mail/send: + post: + operationId: sendEmail + summary: Submit one outgoing email to the configured relay + description: | + The server must authenticate the owner, validate the message, enforce a + server-controlled sender, reserve the idempotency key, and persist only + delivery metadata. A provider acceptance is not proof of final delivery. + parameters: + - name: Idempotency-Key + in: header + required: true + description: Stable workspace and email identifier + schema: + type: string + minLength: 3 + maxLength: 300 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/RelayRequest' + responses: + '202': + description: The mail provider accepted the message + content: + application/json: + schema: + $ref: '#/components/schemas/RelayReceipt' + '400': + $ref: '#/components/responses/InvalidRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '409': + description: The idempotency key is already reserved for different content + '413': + description: Message exceeds the relay limit + '422': + description: Message or recipients failed validation + '429': + description: Per-owner relay limit exceeded + '502': + description: Relay or provider state could not be confirmed; do not retry automatically + '503': + description: Delivery state is uncertain; do not retry automatically +components: + securitySchemes: + sessionCookie: + type: apiKey + in: cookie + name: keepside_session + description: Secure, HttpOnly, SameSite session scoped to the API origin + responses: + InvalidRequest: + description: Request could not be validated + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + Unauthorized: + description: A valid owner session is required + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + ServerError: + description: The service could not complete the request + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + schemas: + Workspace: + type: object + description: | + Complete WorkspaceData transport envelope. Servers must validate the full + document with the canonical parseWorkspace rules before persistence. + required: + - schemaVersion + - id + - name + - settings + - clients + - contacts + - workItems + - meetings + - notes + - emails + - activities + - createdAt + - updatedAt + properties: + schemaVersion: + const: 1 + id: + type: string + minLength: 1 + name: + type: string + settings: + type: object + clients: + type: array + items: + type: object + contacts: + type: array + items: + type: object + workItems: + type: array + items: + type: object + meetings: + type: array + items: + type: object + notes: + type: array + items: + type: object + emails: + type: array + items: + type: object + activities: + type: array + items: + type: object + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + RelayRequest: + type: object + additionalProperties: false + required: + - workspaceId + - email + properties: + workspaceId: + type: string + minLength: 1 + email: + $ref: '#/components/schemas/OutgoingEmail' + OutgoingEmail: + type: object + additionalProperties: false + description: To and CC combined must contain no more than 20 recipients + x-keepside-total-recipient-limit: 20 + required: + - id + - clientId + - direction + - status + - from + - to + - cc + - subject + - body + - occurredAt + - createdAt + - updatedAt + properties: + id: + type: string + minLength: 1 + maxLength: 128 + clientId: + type: string + contactId: + type: string + direction: + const: outgoing + status: + const: draft + from: + type: string + format: email + description: Local record value; the server must ignore it and use its configured sender + to: + type: array + minItems: 1 + maxItems: 20 + description: To and CC combined must contain no more than 20 recipients + items: + type: string + format: email + cc: + type: array + maxItems: 20 + description: To and CC combined must contain no more than 20 recipients + items: + type: string + format: email + subject: + type: string + minLength: 1 + maxLength: 200 + body: + type: string + minLength: 1 + maxLength: 100000 + occurredAt: + type: string + format: date-time + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + RelayReceipt: + type: object + additionalProperties: false + required: [status, providerMessageId, acceptedAt, from] + properties: + status: + const: accepted + providerMessageId: + type: string + minLength: 1 + acceptedAt: + type: string + format: date-time + from: + type: string + format: email + description: Effective server-controlled sender mailbox used by the provider + Error: + type: object + additionalProperties: false + required: [code, message] + properties: + code: + type: string + message: + type: string diff --git a/docs/postgres.sql b/docs/postgres.sql new file mode 100644 index 0000000..f0f8382 --- /dev/null +++ b/docs/postgres.sql @@ -0,0 +1,44 @@ +-- Keepside connected-mode reference schema for PostgreSQL 15+. +-- The authenticated owner subject comes from the server session, never the request body. + +create table if not exists workspace_documents ( + owner_subject text primary key, + workspace_id text not null, + revision bigint not null default 1 check (revision >= 1), + document jsonb not null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + check (jsonb_typeof(document) = 'object'), + check (document ->> 'id' = workspace_id), + check (document ->> 'schemaVersion' = '1'), + check (octet_length(document::text) <= 5000000) +); + +create table if not exists mail_dispatches ( + owner_subject text not null references workspace_documents(owner_subject) on delete cascade, + email_id text not null check (length(email_id) between 1 and 128), + idempotency_key text not null check (length(idempotency_key) between 3 and 300), + payload_sha256 text not null check (length(payload_sha256) = 64), + status text not null check (status in ('sending', 'accepted', 'failed', 'uncertain')), + provider_message_id text, + recipient_count integer not null check (recipient_count between 1 and 20), + accepted_at timestamptz, + error_code text, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + primary key (owner_subject, email_id), + unique (owner_subject, idempotency_key), + check (status <> 'accepted' or (provider_message_id is not null and accepted_at is not null)) +); + +-- Save with optimistic concurrency. A zero-row result means the caller's ETag is stale. +-- +-- update workspace_documents +-- set document = $3, revision = revision + 1, updated_at = now() +-- where owner_subject = $1 and revision = $2 +-- returning revision, document; + +comment on table workspace_documents is + 'One canonical, validated Keepside WorkspaceData document per authenticated owner.'; +comment on table mail_dispatches is + 'Delivery metadata only. Message bodies and recipient addresses are deliberately omitted.'; diff --git a/docs/screenshots/client.png b/docs/screenshots/client.png new file mode 100644 index 0000000..462bb52 Binary files /dev/null and b/docs/screenshots/client.png differ diff --git a/docs/screenshots/mail.png b/docs/screenshots/mail.png new file mode 100644 index 0000000..9a75b38 Binary files /dev/null and b/docs/screenshots/mail.png differ diff --git a/docs/screenshots/today.png b/docs/screenshots/today.png new file mode 100644 index 0000000..220bd8b Binary files /dev/null and b/docs/screenshots/today.png differ diff --git a/index.html b/index.html index 6c022a7..a575982 100644 --- a/index.html +++ b/index.html @@ -5,14 +5,14 @@ - + - - + + - Keepside | Client relationship ledger + Keepside | Client work without the CRM ceremony
diff --git a/package.json b/package.json index 331f1df..20beafb 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "keepside-web", "private": true, "version": "0.1.0", - "description": "A private, local-first client relationship ledger for the web.", + "description": "A local-first client relationship ledger for the web.", "license": "MIT", "type": "module", "engines": { diff --git a/public/fonts/README.md b/public/fonts/README.md index 1f28c3f..9781649 100644 --- a/public/fonts/README.md +++ b/public/fonts/README.md @@ -1,8 +1,7 @@ # Typeface files -Keepside uses Onest for the product interface and Lutoria Serif selectively for longer editorial text. +Keepside ships one type family so a clean clone looks the same as the public screenshots. -- `LutoriaSerif-Regular.woff2`, `LutoriaSerif-Semibold.woff2`, and `LutoriaSerif-Bold.woff2` are optional local assets converted from the font package supplied by the project owner. The archive did not include a license file, and its metadata says all rights reserved. These files are ignored by Git. Only add them to a public release after obtaining explicit web-embedding and redistribution rights. -- `Onest-Variable.woff2` comes from the [official Onest repository](https://github.com/simpals/onest). It is distributed under the SIL Open Font License 1.1; see [Onest-OFL.txt](Onest-OFL.txt). +`Onest-Variable.woff2` comes from the [official Onest repository](https://github.com/simpals/onest). It is distributed under the SIL Open Font License 1.1; see [Onest-OFL.txt](Onest-OFL.txt). -The repository's MIT license does not grant rights to third-party font files. +The repository's MIT license does not replace the font's own license. diff --git a/src/App.tsx b/src/App.tsx index 012eb3f..d50814e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -36,6 +36,7 @@ import { upsertNote, upsertWorkItem, } from './domain/workspace' +import { MailRelayClient } from './infrastructure/serverClients' import { EditableRecord, EditorTarget, RecordEditor, SettingsEditor } from './screens/Editors' import { ClientsScreen, @@ -50,13 +51,23 @@ import { } from './screens/Screens' const recordId = () => globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(16).slice(2)}` +const connectedApiUrl = import.meta.env.VITE_KEEPSIDE_API_URL?.trim() +const mailRelay = connectedApiUrl ? new MailRelayClient(connectedApiUrl) : undefined +const demoMode = import.meta.env.VITE_KEEPSIDE_DEMO === 'true' +const demoParams = demoMode ? new URLSearchParams(window.location.search) : undefined +const sectionValues: Section[] = ['today', 'clients', 'work', 'meetings', 'mail', 'notes', 'contacts'] +const demoSection = sectionValues.find((value) => value === demoParams?.get('section')) ?? 'today' export default function App() { - const [loaded] = useState(loadWorkspaceResult) + const [loaded] = useState(() => demoMode ? { workspace: createSampleWorkspace() } : loadWorkspaceResult()) const [workspace, setWorkspace] = useState(loaded.workspace) - const [section, setSection] = useState
('today') - const [selectedClientId, setSelectedClientId] = useState() - const [editor, setEditor] = useState() + const [section, setSection] = useState
(demoSection) + const [selectedClientId, setSelectedClientId] = useState(() => demoParams?.get('client') ?? undefined) + const [editor, setEditor] = useState(() => { + const emailId = demoParams?.get('compose') + const record = emailId ? loaded.workspace.emails.find(({ id }) => id === emailId) : undefined + return record ? { kind: 'email', record } : undefined + }) const [settingsOpen, setSettingsOpen] = useState(false) const [searchOpen, setSearchOpen] = useState(false) const [mobileOpen, setMobileOpen] = useState(false) @@ -71,6 +82,7 @@ export default function App() { }, [workspace]) useEffect(() => { const shortcuts = (event: KeyboardEvent) => { + if (document.querySelector('[role="dialog"][aria-modal="true"]')) return if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') { event.preventDefault(); setSearchOpen(true); return } if (!event.shiftKey || event.metaKey || event.ctrlKey || event.altKey || ['INPUT', 'TEXTAREA', 'SELECT'].includes((event.target as HTMLElement).tagName)) return const kind = ({ n: 'client', t: 'work', m: 'meeting', e: 'email', d: 'note' } as Partial>)[event.key.toLowerCase()] @@ -117,7 +129,7 @@ export default function App() { {section === 'notes' && } {section === 'contacts' && } {searchOpen && setSearchOpen(false)} onSection={(next) => { if (next === 'clients') setSelectedClientId(undefined); setSection(next) }} onClient={actions.openClient} onEdit={(kind, record) => setEditor({ kind, record })} />} - {editor && setEditor(undefined)} onSave={saveRecord} onDelete={removeRecord} />} + {editor && setEditor(undefined)} onSave={saveRecord} onDelete={removeRecord} />} {settingsOpen && setSettingsOpen(false)} onSettings={(settings) => { setWorkspace((current) => updateWorkspaceSettings(current, settings)); setSettingsOpen(false) }} onImport={(serialized) => { const imported = importWorkspace(serialized); if (!imported) return false; setWorkspace(imported); setSelectedClientId(undefined); return true }} onSample={() => { if (window.confirm('Replace this workspace with sample data?')) { setWorkspace(createSampleWorkspace()); setSelectedClientId(undefined); setSettingsOpen(false) } }} onClear={() => { setWorkspace(createEmptyWorkspace()); setSelectedClientId(undefined); setSettingsOpen(false) }} />} {loadError &&
The saved workspace could not be read, so Keepside left it untouched. Import a valid backup before making changes.
}{storageError &&
Keepside cannot save in this browser right now. Export a workspace copy from Settings before closing this tab.
} } diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx index 49f9e26..c309560 100644 --- a/src/components/Modal.tsx +++ b/src/components/Modal.tsx @@ -8,18 +8,19 @@ interface ModalProps { children: ReactNode onClose: () => void wide?: boolean + dismissible?: boolean } -export function Modal({ title, subtitle, children, onClose, wide }: ModalProps) { +export function Modal({ title, subtitle, children, onClose, wide, dismissible = true }: ModalProps) { const dialogRef = useRef(null) - useDialogFocus(dialogRef, onClose) + useDialogFocus(dialogRef, dismissible ? onClose : undefined) return ( -
event.target === event.currentTarget && onClose()}> -
+
dismissible && event.target === event.currentTarget && onClose()}> +
{subtitle &&

{subtitle}

}
- +
{children}
diff --git a/src/components/useDialogFocus.ts b/src/components/useDialogFocus.ts index ad942e6..02f0e76 100644 --- a/src/components/useDialogFocus.ts +++ b/src/components/useDialogFocus.ts @@ -2,20 +2,21 @@ import { useEffect, type RefObject } from 'react' const focusable = 'button:not(:disabled), [href], input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex="-1"])' -export function useDialogFocus(dialogRef: RefObject, onClose: () => void) { +export function useDialogFocus(dialogRef: RefObject, onClose?: () => void) { useEffect(() => { const dialog = dialogRef.current if (!dialog) return const previous = document.activeElement instanceof HTMLElement ? document.activeElement : null const elements = () => [...dialog.querySelectorAll(focusable)].filter((element) => !element.hidden) const focusFrame = requestAnimationFrame(() => { - if (!dialog.contains(document.activeElement)) elements()[0]?.focus() + const items = elements() + if (!dialog.contains(document.activeElement) || document.activeElement !== dialog && !items.includes(document.activeElement as HTMLElement)) (items[0] ?? dialog).focus() }) const handleKey = (event: KeyboardEvent) => { - if (event.key === 'Escape') { event.preventDefault(); onClose(); return } + if (event.key === 'Escape' && onClose) { event.preventDefault(); onClose(); return } if (event.key !== 'Tab') return const items = elements() - if (!items.length) { event.preventDefault(); return } + if (!items.length) { event.preventDefault(); dialog.focus(); return } const first = items[0] const last = items[items.length - 1] if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus() } diff --git a/src/domain/sample.ts b/src/domain/sample.ts index fc7a357..67711b8 100644 --- a/src/domain/sample.ts +++ b/src/domain/sample.ts @@ -13,10 +13,13 @@ import { WorkspaceTheme, } from './types' -const DAY = 86_400_000 - export function createSampleWorkspace(now = new Date()): WorkspaceData { - const at = (days: number, hours = 0) => new Date(now.getTime() + days * DAY + hours * 3_600_000).toISOString() + const at = (days: number, hour = 12) => { + const value = new Date(now) + value.setDate(value.getDate() + days) + value.setHours(hour, 0, 0, 0) + return value.toISOString() + } const createdAt = at(-150) return { @@ -158,7 +161,7 @@ export function createSampleWorkspace(now = new Date()): WorkspaceData { { id: 'work-studio-invoice', clientId: 'client-studio', - title: 'Send February project invoice', + title: 'Send final project invoice', type: WorkItemType.Task, status: WorkItemStatus.Completed, priority: WorkItemPriority.Normal, @@ -285,16 +288,31 @@ export function createSampleWorkspace(now = new Date()): WorkspaceData { createdAt: at(-2), updatedAt: at(-2), }, + { + id: 'email-studio-review-draft', + clientId: 'client-studio', + contactId: 'contact-nora', + direction: EmailDirection.Outgoing, + status: EmailStatus.Draft, + from: 'jordan@example.com', + to: ['nora@willowandpine.example'], + cc: [], + subject: 'A few details before our review', + body: 'Hi Nora,\n\nI have the final copy and photography notes ready for our review. Could you bring the latest print estimate so we can settle quantities together?\n\nThanks,\nJordan', + occurredAt: at(0), + createdAt: at(0), + updatedAt: at(0), + }, ], activities: [ { - id: 'activity-studio-email', - clientId: 'client-studio', + id: 'activity-consultant-email', + clientId: 'client-consultant', entityType: ActivityEntityType.Email, - entityId: 'email-studio-copy', - type: ActivityType.EmailReceived, - summary: 'Received revised welcome guide copy from Nora Bennett', - occurredAt: at(-3), + entityId: 'email-consultant-proposal', + type: ActivityType.EmailSent, + summary: 'Sent retreat proposal outline to Elena Marquez', + occurredAt: at(-1), }, { id: 'activity-home-meeting', @@ -315,13 +333,13 @@ export function createSampleWorkspace(now = new Date()): WorkspaceData { occurredAt: at(-2), }, { - id: 'activity-consultant-email', - clientId: 'client-consultant', + id: 'activity-studio-email', + clientId: 'client-studio', entityType: ActivityEntityType.Email, - entityId: 'email-consultant-proposal', - type: ActivityType.EmailSent, - summary: 'Sent retreat proposal outline to Elena Marquez', - occurredAt: at(-1), + entityId: 'email-studio-copy', + type: ActivityType.EmailReceived, + summary: 'Received revised welcome guide copy from Nora Bennett', + occurredAt: at(-3), }, { id: 'activity-studio-work', @@ -329,7 +347,7 @@ export function createSampleWorkspace(now = new Date()): WorkspaceData { entityType: ActivityEntityType.WorkItem, entityId: 'work-studio-invoice', type: ActivityType.WorkCompleted, - summary: 'Completed February project invoice', + summary: 'Completed final project invoice', occurredAt: at(-8), }, ], diff --git a/src/domain/workspace.test.ts b/src/domain/workspace.test.ts index c4f283c..a78e4ae 100644 --- a/src/domain/workspace.test.ts +++ b/src/domain/workspace.test.ts @@ -133,6 +133,16 @@ describe('workspace domain', () => { expect(parseWorkspace({ ...workspace, settings: { ...workspace.settings, locale: 'en_US' } })).toBeNull() }) + it('rejects record links that cross client boundaries', () => { + const workspace = createSampleWorkspace(new Date(t0)) + const otherContactId = workspace.contacts.find(({ clientId }) => clientId === 'client-consultant')!.id + const otherWorkItemId = workspace.workItems.find(({ clientId }) => clientId === 'client-consultant')!.id + + expect(parseWorkspace({ ...workspace, meetings: workspace.meetings.map((meeting, index) => index === 0 ? { ...meeting, contactIds: [otherContactId] } : meeting) })).toBeNull() + expect(parseWorkspace({ ...workspace, notes: workspace.notes.map((note, index) => index === 0 ? { ...note, workItemId: otherWorkItemId } : note) })).toBeNull() + expect(parseWorkspace({ ...workspace, emails: workspace.emails.map((email, index) => index === 0 ? { ...email, contactId: otherContactId } : email) })).toBeNull() + }) + it('reports browser storage failures', () => { const storage: WorkspaceStorage = { getItem: () => null, diff --git a/src/domain/workspace.ts b/src/domain/workspace.ts index d8ac4e7..ebb7759 100644 --- a/src/domain/workspace.ts +++ b/src/domain/workspace.ts @@ -228,13 +228,13 @@ export function parseWorkspace(value: unknown): WorkspaceData | null { || !hasUniqueIds(activities)) return null const clientIds = new Set(clients.map(({ id }) => id)) - const contactIds = new Set(contacts.map(({ id }) => id)) - const workItemIds = new Set(workItems.map(({ id }) => id)) + const contactClients = new Map(contacts.map(({ id, clientId }) => [id, clientId])) + const workItemClients = new Map(workItems.map(({ id, clientId }) => [id, clientId])) if (contacts.some(({ clientId }) => !clientIds.has(clientId)) || workItems.some(({ clientId }) => !clientIds.has(clientId)) - || meetings.some(({ clientId, contactIds: ids }) => !clientIds.has(clientId) || ids.some((id) => !contactIds.has(id))) - || notes.some(({ clientId, workItemId }) => !clientIds.has(clientId) || workItemId !== undefined && !workItemIds.has(workItemId)) - || emails.some(({ clientId, contactId }) => !clientIds.has(clientId) || contactId !== undefined && !contactIds.has(contactId)) + || meetings.some(({ clientId, contactIds: ids }) => !clientIds.has(clientId) || ids.some((id) => contactClients.get(id) !== clientId)) + || notes.some(({ clientId, workItemId }) => !clientIds.has(clientId) || workItemId !== undefined && workItemClients.get(workItemId) !== clientId) + || emails.some(({ clientId, contactId }) => !clientIds.has(clientId) || contactId !== undefined && contactClients.get(contactId) !== clientId) || activities.some(({ clientId }) => clientId !== undefined && !clientIds.has(clientId))) return null return { diff --git a/src/infrastructure/serverClients.test.ts b/src/infrastructure/serverClients.test.ts new file mode 100644 index 0000000..258bf45 --- /dev/null +++ b/src/infrastructure/serverClients.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it, vi } from 'vitest' +import { createSampleWorkspace } from '../domain/sample' +import { EmailDirection } from '../domain/types' +import { ConnectedError, MailRelayClient, WorkspaceApiClient } from './serverClients' + +const workspace = createSampleWorkspace(new Date('2026-07-18T12:00:00.000Z')) + +describe('connected service clients', () => { + it('validates a remote workspace and keeps its ETag', async () => { + const fetcher = vi.fn(async () => new Response(JSON.stringify(workspace), { + status: 200, + headers: { 'Content-Type': 'application/json', ETag: '"revision-4"' }, + })) + const snapshot = await new WorkspaceApiClient('https://keepside.example/api/', fetcher).load() + + expect(snapshot).toEqual({ workspace, etag: '"revision-4"' }) + expect(fetcher).toHaveBeenCalledWith('https://keepside.example/api/v1/workspace', expect.objectContaining({ credentials: 'include' })) + }) + + it('uses If-Match when saving a workspace', async () => { + const fetcher = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => new Response(init?.body, { + status: 200, + headers: { 'Content-Type': 'application/json', ETag: '"revision-5"' }, + })) + const snapshot = await new WorkspaceApiClient('https://keepside.example/api', fetcher).save(workspace, '"revision-4"') + + expect(snapshot.etag).toBe('"revision-5"') + expect(fetcher).toHaveBeenCalledWith('https://keepside.example/api/v1/workspace', expect.objectContaining({ + method: 'PUT', + headers: expect.objectContaining({ 'If-Match': '"revision-4"' }), + })) + }) + + it('creates a first connected workspace without inventing a revision', async () => { + const fetcher = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => new Response(init?.body, { + status: 201, + headers: { 'Content-Type': 'application/json', ETag: '"revision-1"' }, + })) + const snapshot = await new WorkspaceApiClient('/api', fetcher).create(workspace) + + expect(snapshot.etag).toBe('"revision-1"') + expect(fetcher).toHaveBeenCalledWith('/api/v1/workspace', expect.objectContaining({ method: 'POST' })) + }) + + it('reports revision conflicts without overwriting', async () => { + const fetcher = vi.fn(async () => new Response(null, { status: 412 })) + + await expect(new WorkspaceApiClient('/api', fetcher).save(workspace, '"revision-2"')) + .rejects.toMatchObject({ kind: 'conflict', status: 412 }) + }) + + it('sends outgoing mail with a stable idempotency key', async () => { + const email = workspace.emails.find(({ id }) => id === 'email-studio-review-draft')! + const receipt = { status: 'accepted', providerMessageId: 'provider-123', acceptedAt: '2026-07-18T14:00:00.000Z', from: 'mail@example.com' } as const + const fetcher = vi.fn(async () => new Response(JSON.stringify(receipt), { + status: 202, + headers: { 'Content-Type': 'application/json' }, + })) + + await expect(new MailRelayClient('/api', fetcher).send(workspace.id, email)).resolves.toEqual(receipt) + expect(fetcher).toHaveBeenCalledWith('/api/v1/mail/send', expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ 'Idempotency-Key': `${workspace.id}:${email.id}` }), + })) + }) + + it('rejects incoming records before contacting the relay', async () => { + const email = workspace.emails.find(({ direction }) => direction === EmailDirection.Incoming)! + const fetcher = vi.fn() + + await expect(new MailRelayClient('/api', fetcher).send(workspace.id, email)).rejects.toBeInstanceOf(ConnectedError) + expect(fetcher).not.toHaveBeenCalled() + }) + + it('rejects more than twenty combined recipients before contacting the relay', async () => { + const email = workspace.emails.find(({ id }) => id === 'email-studio-review-draft')! + const fetcher = vi.fn() + const tooMany = { ...email, to: Array.from({ length: 20 }, (_, index) => `person${index}@example.com`), cc: ['extra@example.com'] } + + await expect(new MailRelayClient('/api', fetcher).send(workspace.id, tooMany)).rejects.toMatchObject({ kind: 'invalid' }) + expect(fetcher).not.toHaveBeenCalled() + }) + + it.each([ + [409, 'relay_conflict'], + [408, 'uncertain'], + [502, 'uncertain'], + [503, 'uncertain'], + [504, 'uncertain'], + ])('keeps relay failure %i distinct as %s', async (status, kind) => { + const email = workspace.emails.find(({ id }) => id === 'email-studio-review-draft')! + const fetcher = vi.fn(async () => new Response(null, { status })) + + await expect(new MailRelayClient('/api', fetcher).send(workspace.id, email)).rejects.toMatchObject({ kind, status }) + }) + + it('treats a lost network response as uncertain', async () => { + const email = workspace.emails.find(({ id }) => id === 'email-studio-review-draft')! + const fetcher = vi.fn(async () => { throw new TypeError('Failed to fetch') }) + + await expect(new MailRelayClient('/api', fetcher).send(workspace.id, email)).rejects.toMatchObject({ kind: 'uncertain' }) + }) + + it('treats an invalid accepted receipt as uncertain', async () => { + const email = workspace.emails.find(({ id }) => id === 'email-studio-review-draft')! + const fetcher = vi.fn(async () => new Response(JSON.stringify({ status: 'accepted' }), { + status: 202, + headers: { 'Content-Type': 'application/json' }, + })) + + await expect(new MailRelayClient('/api', fetcher).send(workspace.id, email)).rejects.toMatchObject({ kind: 'uncertain', status: 202 }) + }) +}) diff --git a/src/infrastructure/serverClients.ts b/src/infrastructure/serverClients.ts new file mode 100644 index 0000000..a10db26 --- /dev/null +++ b/src/infrastructure/serverClients.ts @@ -0,0 +1,173 @@ +import { Email, EmailDirection, EmailStatus } from '../domain/types' +import { parseWorkspace } from '../domain/workspace' +import type { WorkspaceData } from '../domain/types' + +type Fetcher = typeof fetch + +export type ConnectedErrorKind = 'conflict' | 'invalid' | 'relay_conflict' | 'rate_limited' | 'unauthorized' | 'uncertain' | 'unavailable' + +export class ConnectedError extends Error { + constructor(readonly kind: ConnectedErrorKind, readonly status?: number) { + super(messageFor(kind)) + this.name = 'ConnectedError' + } +} + +export interface WorkspaceSnapshot { + workspace: WorkspaceData + etag: string +} + +export interface RelayReceipt { + status: 'accepted' + providerMessageId: string + acceptedAt: string + from: string +} + +const endpoint = (baseUrl: string, path: string) => `${baseUrl.replace(/\/+$/, '')}${path}` +const isMailbox = (value: string) => value.length <= 320 + && value === value.trim() + && !/[\r\n]/.test(value) + && /^[^\s@]+@[^\s@]+$/.test(value) + +async function readJson(response: Response): Promise { + try { return await response.json() } catch { throw new ConnectedError('invalid', response.status) } +} + +function ensureOk(response: Response): void { + if (response.ok) return + if (response.status === 401 || response.status === 403) throw new ConnectedError('unauthorized', response.status) + if (response.status === 409 || response.status === 412) throw new ConnectedError('conflict', response.status) + if (response.status === 400 || response.status === 413 || response.status === 422) throw new ConnectedError('invalid', response.status) + if (response.status === 429) throw new ConnectedError('rate_limited', response.status) + throw new ConnectedError('unavailable', response.status) +} + +async function readWorkspaceSnapshot(response: Response): Promise { + const workspace = parseWorkspace(await readJson(response)) + const etag = response.headers.get('etag') + if (!workspace || !etag) throw new ConnectedError('invalid', response.status) + return { workspace, etag } +} + +function messageFor(kind: ConnectedErrorKind): string { + switch (kind) { + case 'conflict': return 'This workspace changed elsewhere. Reload it before saving again.' + case 'invalid': return 'The connected service returned data Keepside could not verify.' + case 'relay_conflict': return 'This draft ID was already used for different email content. Save a new draft instead.' + case 'unauthorized': return 'Sign in to the connected service and try again.' + case 'rate_limited': return 'The connected service is receiving too many requests. Try again shortly.' + case 'uncertain': return 'The relay lost confirmation and this message may have been sent. Check the provider before trying again.' + case 'unavailable': return 'The connected service could not complete the request.' + } +} + +export class WorkspaceApiClient { + constructor(private readonly baseUrl: string, private readonly fetcher: Fetcher = fetch) {} + + async load(): Promise { + const response = await this.fetcher(endpoint(this.baseUrl, '/v1/workspace'), { + credentials: 'include', + cache: 'no-store', + headers: { Accept: 'application/json' }, + }) + if (response.status === 404) return null + ensureOk(response) + return readWorkspaceSnapshot(response) + } + + async create(workspace: WorkspaceData): Promise { + const validWorkspace = parseWorkspace(workspace) + if (!validWorkspace) throw new ConnectedError('invalid') + const response = await this.fetcher(endpoint(this.baseUrl, '/v1/workspace'), { + method: 'POST', + credentials: 'include', + cache: 'no-store', + headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, + body: JSON.stringify(validWorkspace), + }) + ensureOk(response) + return readWorkspaceSnapshot(response) + } + + async save(workspace: WorkspaceData, expectedEtag: string): Promise { + const validWorkspace = parseWorkspace(workspace) + if (!validWorkspace || !expectedEtag) throw new ConnectedError('invalid') + const response = await this.fetcher(endpoint(this.baseUrl, '/v1/workspace'), { + method: 'PUT', + credentials: 'include', + cache: 'no-store', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'If-Match': expectedEtag, + }, + body: JSON.stringify(validWorkspace), + }) + ensureOk(response) + return readWorkspaceSnapshot(response) + } +} + +export class MailRelayClient { + constructor(private readonly baseUrl: string, private readonly fetcher: Fetcher = fetch) {} + + async send(workspaceId: string, email: Email): Promise { + if (email.direction !== EmailDirection.Outgoing + || email.status !== EmailStatus.Draft + || !workspaceId + || !email.id + || !isMailbox(email.from) + || email.to.length === 0 + || email.to.length + email.cc.length > 20 + || ![...email.to, ...email.cc].every(isMailbox) + || !email.subject.trim() + || email.subject.length > 200 + || /[\r\n]/.test(email.subject) + || !email.body.trim() + || email.body.length > 100000) throw new ConnectedError('invalid') + const idempotencyKey = `${encodeURIComponent(workspaceId)}:${encodeURIComponent(email.id)}` + if (idempotencyKey.length > 300) throw new ConnectedError('invalid') + + let response: Response + try { + response = await this.fetcher(endpoint(this.baseUrl, '/v1/mail/send'), { + method: 'POST', + credentials: 'include', + cache: 'no-store', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'Idempotency-Key': idempotencyKey, + }, + body: JSON.stringify({ workspaceId, email }), + }) + } catch { + throw new ConnectedError('uncertain') + } + if (response.status === 409) throw new ConnectedError('relay_conflict', response.status) + if (response.status === 408 || response.status >= 500) throw new ConnectedError('uncertain', response.status) + ensureOk(response) + let receipt: unknown + try { + receipt = await readJson(response) + } catch { + throw new ConnectedError('uncertain', response.status) + } + if (!isRelayReceipt(receipt)) throw new ConnectedError('uncertain', response.status) + return receipt + } +} + +function isRelayReceipt(value: unknown): value is RelayReceipt { + if (typeof value !== 'object' || value === null) return false + const receipt = value as Partial + return receipt.status === 'accepted' + && typeof receipt.providerMessageId === 'string' + && receipt.providerMessageId.length > 0 + && typeof receipt.acceptedAt === 'string' + && !Number.isNaN(Date.parse(receipt.acceptedAt)) + && typeof receipt.from === 'string' + && isMailbox(receipt.from) +} diff --git a/src/screens/Editors.tsx b/src/screens/Editors.tsx index 1b4bf7d..81cd8ca 100644 --- a/src/screens/Editors.tsx +++ b/src/screens/Editors.tsx @@ -20,6 +20,7 @@ import { WorkspaceSettings, } from '../domain/types' import { exportWorkspace, isValidLocale } from '../domain/workspace' +import { ConnectedError, type MailRelayClient } from '../infrastructure/serverClients' import type { RecordKind } from './Screens' export type EditableRecord = Client | Contact | WorkItem | Meeting | Note | Email @@ -28,6 +29,7 @@ export type EditorTarget = { kind: RecordKind; record?: EditableRecord; clientId interface EditorProps { target: EditorTarget workspace: WorkspaceData + mailRelay?: MailRelayClient onClose: () => void onSave: (kind: RecordKind, record: EditableRecord) => void onDelete: (kind: RecordKind, id: string) => void @@ -39,8 +41,8 @@ const now = () => new Date().toISOString() const inputDate = (value?: string) => value ? new Date(value).toISOString().slice(0, 16) : '' const isoDate = (value: string) => value ? new Date(value).toISOString() : undefined -function Actions({ existing, onClose, onDelete, submit = 'Save record', extra }: { existing: boolean; onClose: () => void; onDelete: () => void; submit?: string; extra?: React.ReactNode }) { - return
{existing && }{extra}
+function Actions({ existing, onClose, onDelete, submit = 'Save record', extra, disabled = false }: { existing: boolean; onClose: () => void; onDelete: () => void; submit?: string; extra?: React.ReactNode; disabled?: boolean }) { + return
{existing && }{extra}
} export function RecordEditor(props: EditorProps) { @@ -146,15 +148,30 @@ function NoteEditor({ record, target, workspace, onClose, onSave, onDelete }: Ed
confirmDelete('note', draft.id, onDelete)} /> } -function EmailEditor({ record, target, workspace, onClose, onSave, onDelete }: EditorProps & { record?: Email }) { +function EmailEditor({ record, target, workspace, mailRelay, onClose, onSave, onDelete }: EditorProps & { record?: Email }) { const stamp = now() + const formRef = useRef(null) const initialClient = target.clientId ?? workspace.clients[0]?.id ?? '' const client = workspace.clients.find(({ id: clientId }) => clientId === initialClient) const contact = workspace.contacts.find(({ clientId, isPrimary }) => clientId === initialClient && isPrimary) const [draft, setDraft] = useState(record ?? { id: id(), clientId: initialClient, contactId: contact?.id, direction: EmailDirection.Outgoing, status: EmailStatus.Draft, from: '', to: contact?.email ? [contact.email] : client?.email ? [client.email] : [], cc: [], subject: '', body: '', occurredAt: stamp, createdAt: stamp, updatedAt: stamp }) + const [sending, setSending] = useState(false) + const [sendError, setSendError] = useState('') const set = (key: K, value: Email[K]) => setDraft((current) => ({ ...current, [key]: value })) const contacts = workspace.contacts.filter(({ clientId }) => clientId === draft.clientId) const statuses = draft.direction === EmailDirection.Outgoing ? [EmailStatus.Draft, EmailStatus.Sent, EmailStatus.Archived] : [EmailStatus.Received, EmailStatus.Archived] + const changeClient = (clientId: string) => { + const nextContact = workspace.contacts.find(({ clientId: ownerId, isPrimary }) => ownerId === clientId && isPrimary) + const nextClient = workspace.clients.find(({ id: candidateId }) => candidateId === clientId) + const recipient = nextContact?.email ?? nextClient?.email + setDraft((current) => ({ + ...current, + clientId, + contactId: nextContact?.id, + to: current.direction === EmailDirection.Outgoing ? recipient ? [recipient] : [] : current.to, + cc: current.direction === EmailDirection.Outgoing ? [] : current.cc, + })) + } const save = (openMail: boolean) => { const email = { ...draft, status: openMail ? EmailStatus.Draft : draft.status, occurredAt: draft.occurredAt || stamp } onSave('email', email) @@ -164,12 +181,28 @@ function EmailEditor({ record, target, workspace, onClose, onSave, onDelete }: E window.location.href = `mailto:${email.to.join(',')}?${params}` } } + const sendThroughRelay = async () => { + if (!mailRelay) return + setSending(true) + setSendError('') + try { + const receipt = await mailRelay.send(workspace.id, draft) + setSending(false) + onSave('email', { ...draft, from: receipt.from, status: EmailStatus.Sent, occurredAt: receipt.acceptedAt }) + } catch (error) { + setSending(false) + const message = error instanceof ConnectedError ? error.message : 'The relay lost confirmation and this message may have been sent. Check the provider before trying again.' + setSendError(`${message} Keepside left the record as a draft.`) + } + } const submit = (event: FormEvent) => { event.preventDefault() - const submitter = (event.nativeEvent as SubmitEvent).submitter as HTMLButtonElement | null - save(submitter?.value === 'open-mail') + save(false) } - return
+ const canSend = draft.direction === EmailDirection.Outgoing && draft.status === EmailStatus.Draft + const relayAction = mailRelay && canSend ? : undefined + const mailAction = canSend ? : undefined + return
set('to', event.target.value.split(',').map((value) => value.trim()).filter(Boolean))} placeholder="client@example.com" /> set('cc', event.target.value.split(',').map((value) => value.trim()).filter(Boolean))} /> set('subject', event.target.value)} /> @@ -177,11 +210,12 @@ function EmailEditor({ record, target, workspace, onClose, onSave, onDelete }: E

Record details

- setDraft((current) => ({ ...current, clientId: value, contactId: undefined }))} /> + set('occurredAt', isoDate(event.target.value) ?? stamp)} /> set('from', event.target.value)} placeholder="you@example.com" /> -
confirmDelete('email record', draft.id, onDelete)} submit={draft.status === EmailStatus.Draft ? 'Save draft' : 'Save record'} extra={!record && draft.direction === EmailDirection.Outgoing ? : undefined} />
+ {sendError &&

{sendError}

} + confirmDelete('email record', draft.id, onDelete)} submit={draft.status === EmailStatus.Draft ? 'Save draft' : 'Save record'} extra={<>{relayAction}{mailAction}} /> } function confirmDelete(label: string, recordId: string, onDelete: (kind: RecordKind, id: string) => void) { @@ -219,6 +253,6 @@ export function SettingsEditor({ workspace, onClose, onSettings, onImport, onSam

Workspace data

importFile(event.target.files?.[0])} />
{message &&

{message}

} -

Your workspace stays in this browser. Keepside has no account system, hosted database, trackers, automations, or background services.

+

Local mode keeps this workspace in the browser. A configured relay receives a message only when you choose Send through relay.

undefined} submit="Save settings" /> } diff --git a/src/screens/Screens.tsx b/src/screens/Screens.tsx index 8fb4175..171654e 100644 --- a/src/screens/Screens.tsx +++ b/src/screens/Screens.tsx @@ -42,6 +42,7 @@ const titleCase = (value: string) => value.replace(/_/g, ' ').replace(/\b\w/g, ( const initials = (name: string) => name.split(/\s+/).slice(0, 2).map((part) => part[0]).join('').toUpperCase() const clientTone = (id: string) => [...id].reduce((sum, letter) => sum + letter.charCodeAt(0), 0) % 6 const clientName = (workspace: WorkspaceData, clientId: string) => workspace.clients.find(({ id }) => id === clientId)?.name ?? 'Unknown client' +const recordCount = (count: number) => `${count} ${count === 1 ? 'record' : 'records'}` const formatDate = (value: string, locale: string, options: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric' }) => new Intl.DateTimeFormat(locale, options).format(new Date(value)) const formatTime = (value: string, locale: string) => new Intl.DateTimeFormat(locale, { hour: 'numeric', minute: '2-digit' }).format(new Date(value)) @@ -203,7 +204,7 @@ export function ClientsScreen({ workspace, actions, selectedId, onSelect }: { wo function ClientDetail({ client, workspace, actions, onBack }: { client: Client; workspace: WorkspaceData; actions: ScreenActions; onBack: () => void }) { const contacts = selectContactsForClient(workspace, client.id) - const work = selectWorkItemsForClient(workspace, client.id) + const work = selectWorkItemsForClient(workspace, client.id).filter(({ status }) => status !== WorkItemStatus.Completed && status !== WorkItemStatus.Canceled) const meetings = selectMeetingsForClient(workspace, client.id) const notes = selectNotesForClient(workspace, client.id) const emails = selectEmailsForClient(workspace, client.id) @@ -213,13 +214,13 @@ function ClientDetail({ client, workspace, actions, onBack }: { client: Client;
{initials(client.name)}

{client.name}

{client.industry || 'General services'} · {titleCase(client.status)}

-

Open tasks

{work.length} records
{work.slice(0, 6).map((item) => actions.edit('work', item)} />)}{!work.length &&

No tasks have been recorded for this client.

}
-

Meetings

{meetings.length} records
{meetings.slice(0, 4).map((meeting) => actions.edit('meeting', meeting)} />)}{!meetings.length &&

No meetings recorded.

}
-

Notes

{notes.length} records
{notes.slice(0, 4).map((note) => )}{!notes.length &&

No notes recorded.

}
+

Open tasks

{recordCount(work.length)}
{work.slice(0, 6).map((item) => actions.edit('work', item)} />)}{!work.length &&

No open tasks for this client.

}
+

Meetings

{recordCount(meetings.length)}
{meetings.slice(0, 4).map((meeting) => actions.edit('meeting', meeting)} />)}{!meetings.length &&

No meetings recorded.

}
+

Notes

{recordCount(notes.length)}
{notes.slice(0, 4).map((note) => )}{!notes.length &&

No notes recorded.

}
} diff --git a/src/styles.css b/src/styles.css index e1990c4..380756f 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1,27 +1,3 @@ -@font-face { - font-family: "Lutoria Serif"; - src: url("/fonts/LutoriaSerif-Regular.woff2") format("woff2"); - font-display: swap; - font-style: normal; - font-weight: 400; -} - -@font-face { - font-family: "Lutoria Serif"; - src: url("/fonts/LutoriaSerif-Semibold.woff2") format("woff2"); - font-display: swap; - font-style: normal; - font-weight: 600; -} - -@font-face { - font-family: "Lutoria Serif"; - src: url("/fonts/LutoriaSerif-Bold.woff2") format("woff2"); - font-display: swap; - font-style: normal; - font-weight: 700; -} - @font-face { font-family: "Onest"; src: url("/fonts/Onest-Variable.woff2") format("woff2"); @@ -60,7 +36,7 @@ --red: #ee706a; --green: #62c58b; --display: "Onest", system-ui, sans-serif; - --editorial: "Lutoria Serif", Georgia, serif; + --editorial: "Onest", system-ui, sans-serif; --radius-sm: 9px; --radius: 14px; --radius-lg: 18px; @@ -239,7 +215,7 @@ button:focus-visible, a:focus-visible, input:focus-visible, textarea:focus-visib .rail-row strong { font-size: 12px; font-weight: 620; } .rail-row small { margin-top: 3px; color: var(--faint); font-size: 10px; } .rail-empty { margin: 0; padding: 18px 2px; color: var(--faint); border-bottom: 1px solid var(--line); font-size: 11px; } -.recent-clients { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; padding-top: 12px; } +.recent-clients { display: grid; grid-template-columns: 1fr; gap: 8px; padding-top: 12px; } .recent-clients button { display: grid; grid-template-columns: 30px minmax(0, 1fr); gap: 9px; align-items: center; min-height: 53px; padding: 8px; color: inherit; background: var(--surface); border: 1px solid var(--line); border-radius: 10px; cursor: pointer; text-align: left; transition: background var(--motion-fast) ease, border-color var(--motion-fast) ease, transform var(--motion-fast) var(--ease-out); } .recent-clients button:hover { background: var(--surface-hover); border-color: var(--line-strong); transform: translateY(-2px); } .recent-clients strong, .recent-clients small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } @@ -339,6 +315,7 @@ button:focus-visible, a:focus-visible, input:focus-visible, textarea:focus-visib .client-detail-page { max-width: 1370px; } .back-link { display: inline-flex !important; margin-bottom: 25px; } .client-detail__head { display: grid; grid-template-columns: 70px minmax(0, 1fr) auto; gap: 18px; align-items: center; padding-bottom: 26px; background: var(--metal-line) bottom / 100% 1px no-repeat; } +.client-detail__head > div { min-width: 0; } .client-detail__head .monogram { width: 64px; height: 64px; font-size: 15px; } .client-detail__head h1 { margin: 0; font-size: clamp(36px, 4vw, 52px); font-weight: 680; letter-spacing: -.045em; line-height: 1.02; } .client-detail__head p { margin: 10px 0 0; color: var(--muted); font-family: var(--editorial); font-size: 15px; } @@ -383,6 +360,7 @@ button:focus-visible, a:focus-visible, input:focus-visible, textarea:focus-visib .modal__header p { max-width: 580px; margin: 10px 0 0; color: var(--muted); font-family: var(--editorial); font-size: 14px; line-height: 1.45; } .form { padding: 24px 26px 27px; } .form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px 15px; } +fieldset.form-grid { min-width: 0; margin: 0; padding: 0; border: 0; } .field { display: flex; min-width: 0; flex-direction: column; gap: 7px; } .field--span { grid-column: 1 / -1; } .field > span { color: var(--muted); font-size: 10.5px; font-weight: 620; } @@ -394,6 +372,7 @@ button:focus-visible, a:focus-visible, input:focus-visible, textarea:focus-visib .field input:focus, .field textarea:focus, .field select:focus { background: #0e1013; border-color: #6b7079; box-shadow: 0 0 0 3px rgba(255,255,255,.035); } .form__section { grid-column: 1 / -1; margin: 8px 0 -2px; padding-top: 15px; color: var(--ink); border-top: 1px solid var(--line); font-size: 18px; font-weight: 650; letter-spacing: -.02em; } .form__actions { display: flex; align-items: center; justify-content: flex-end; gap: 8px; margin-top: 27px; padding-top: 18px; border-top: 1px solid var(--line); } +.email-editor .form__actions { position: sticky; z-index: 2; bottom: -27px; margin: 27px -26px -27px; padding: 16px 26px 18px; background: rgba(21,23,27,.96); box-shadow: 0 -14px 30px rgba(9,10,12,.24); backdrop-filter: blur(14px); } .form__spacer { flex: 1; } .delete-action { margin-right: auto; } .checkbox { display: flex; min-height: 40px; align-items: center; flex-direction: row; gap: 9px; } @@ -521,7 +500,10 @@ button:focus-visible, a:focus-visible, input:focus-visible, textarea:focus-visib .client-directory-row { grid-template-columns: 37px minmax(0, 1fr) 42px 15px; gap: 10px; } .client-directory-row .status { display: none; } .client-directory-row__open { grid-column: 3; } - .client-detail__head h1 { font-size: 37px; } + .client-detail__head { grid-template-columns: 58px minmax(0, 1fr); gap: 14px; } + .client-detail__head h1 { overflow-wrap: anywhere; font-size: 32px; letter-spacing: -.035em; } + .client-detail__actions { flex-wrap: wrap; } + .client-detail-page, .client-detail__head, .quick-actions, .client-detail__grid { width: 100%; max-width: calc(100vw - 32px); } .contact-directory-row { grid-template-columns: 38px minmax(0, 1fr) 58px; gap: 10px; } .contact-directory-row__client { display: none; } .form-grid { grid-template-columns: 1fr; } @@ -531,6 +513,7 @@ button:focus-visible, a:focus-visible, input:focus-visible, textarea:focus-visib .modal__header { padding: 21px 17px 17px; } .form { padding: 20px 17px 24px; } .form__actions { flex-wrap: wrap; } + .email-editor .form__actions { bottom: -24px; margin-right: -17px; margin-bottom: -24px; margin-left: -17px; padding-right: 17px; padding-left: 17px; } .form__spacer { display: none; } .delete-action { margin-right: auto; } .settings-grid { grid-template-columns: 1fr; } diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 11f02fe..bb0d9ff 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -1 +1,10 @@ /// + +interface ImportMetaEnv { + readonly VITE_KEEPSIDE_API_URL?: string + readonly VITE_KEEPSIDE_DEMO?: 'true' | 'false' +} + +interface ImportMeta { + readonly env: ImportMetaEnv +}