Remove the company brand color and per-company attachment limit (#12291)
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - A company is the top-level container, and the company General page
holds its settings
> - Two of those settings did almost nothing: the brand color only
tinted the generated company icon, and the attachment size limit sat
under the deployment-level `PAPERCLIP_ATTACHMENT_MAX_BYTES` cap that
already bounded every upload
> - A setting that changes one icon hue, and a setting that can only
lower a limit the operator already set, are not worth the page space or
the code that carries them
> - This pull request deletes both settings from the UI, the validators,
the API contract, the server, and the database
> - With the deployment cap as the only limit left, the message a person
sees when an upload is rejected has to name that limit in terms they can
act on, so the raw byte count becomes a human-readable size
> - The benefit is a shorter company General page for every deployment,
one attachment limit instead of two, and less code between an upload and
its ceiling
## Linked Issues or Issue Description
No existing issue. The description below follows
`.github/ISSUE_TEMPLATE/enhancement.yml`.
**What existing behavior does this improve?**
The company General page (`/company/settings`), the `PATCH
/api/companies/{companyId}` and `PATCH
/api/companies/{companyId}/branding` request contracts, and the
attachment upload limit on task, case, and company-import uploads.
**Subsystem affected**
Cross-cutting: `ui/`, `server/`, `packages/shared`, `packages/db`.
**Current behavior**
The company General page shows an "Appearance" section with three
controls: Logo, Brand color, and Attachment size limit. The brand color
is a hex value that feeds one thing — the hue of the generated company
pattern icon. Companies that never set one already get a hue derived
from the company name. The attachment size limit is a per-company byte
count stored on `companies.attachment_max_bytes`. Every upload path
clamps it against the deployment-level `PAPERCLIP_ATTACHMENT_MAX_BYTES`
cap, so the per-company value can only lower a limit the operator
already chose.
**Proposed behavior**
The Appearance section keeps the Logo control only. The company pattern
icon always derives its hue from the company name. Every attachment path
reads the deployment cap directly, so `PAPERCLIP_ATTACHMENT_MAX_BYTES`
is the single limit. An upload rejected by that limit says so in human
units — "File is larger than the 10 MB limit" rather than a raw byte
count. The `companies.brand_color` and `companies.attachment_max_bytes`
columns are dropped, and both fields leave the company API contract.
**Reason and benefit**
Both settings ask an operator to make a decision that changes almost
nothing. The brand color moves one icon hue on a page that also lets you
upload a real logo, which overrides the icon entirely. The attachment
limit reads as a real control but cannot raise anything, so it is a
second place to look when an upload is rejected. Removing both shortens
the page every deployment sees, removes a company-scoped read from the
task attachment upload path, and leaves one attachment limit to reason
about instead of two.
**Breaking changes**
The company API responses no longer include `brandColor` or
`attachmentMaxBytes`, and `GET /api/invites/{token}` no longer includes
`companyBrandColor`. `PATCH /api/companies/{companyId}/branding` is
strict, so a request that sends `brandColor` now returns 400; the
non-strict `PATCH /api/companies/{companyId}` schema strips it. Company
packages exported by older versions still import: the portability
company manifest schema is non-strict, so the retired keys are stripped
and ignored rather than rejected. Companies that stored a brand color
lose it — their icon reverts to the name-derived hue that every company
without a color already used.
## What Changed
- Removed the "Brand color" and "Attachment size limit" fields from the
company General page, along with their state, dirty checks, save
payload, and Save-button gating.
- Removed `brandColor` and `attachmentMaxBytes` from
`createCompanySchema`, `updateCompanySchema`, and
`updateCompanyBrandingSchema`, and deleted the now-orphaned
`DEFAULT_COMPANY_ATTACHMENT_MAX_BYTES` and
`MAX_COMPANY_ATTACHMENT_MAX_BYTES` constants.
- Removed both fields from the `Company` type, the portability manifest
type and schema, and the `companiesApi.update` payload allowlist.
- Dropped `brandColor` from `CompanyPatternIcon` and its callers, so the
icon hue always comes from the company name. Deleted the now-unused
`hexToHue` helper and the now-unused `pickTextColorForSolidBg` export.
- Stopped emitting `brandColor` from the company service selection and
from the invite-summary and invite-branding payloads in
`server/src/routes/access.ts`.
- Replaced `normalizeIssueAttachmentMaxBytes` with the deployment cap:
task attachments, case attachments, and company import now use
`MAX_ATTACHMENT_BYTES` directly. The helper is deleted.
- Added `formatAttachmentSize()` next to `MAX_ATTACHMENT_BYTES` and
routed every over-limit message through it, so a rejected upload names
the limit in human units instead of raw bytes: `Image exceeds 10485760
bytes` becomes `Image is larger than the 10 MB limit`. Enforcement is
unchanged — the same single cap, the same multer limits, the same status
codes and response shapes.
- Added migration
`0229_drop_company_brand_color_and_attachment_max_bytes.sql` and removed
both columns from the Drizzle `companies` schema.
- Kept legacy imports working: the portability company manifest schema
is non-strict, so older packages carrying the retired keys still import
with the keys ignored.
- Updated the skill API reference and the implementation spec, and
pruned the token-extraction allowlist entries that the removed code made
stale.
## Verification
Commands run from the repository root:
- `pnpm --filter @paperclipai/shared typecheck` — pass
- `pnpm --filter @paperclipai/db typecheck` — pass (includes
`check:migrations`, which validates the new migration number and journal
entry)
- `pnpm --filter @paperclipai/ui typecheck` — pass
- server typecheck via `node_modules/.bin/tsc --noEmit` in `server/` —
pass. `pnpm --filter @paperclipai/server typecheck` could not run
locally because it builds the Rust runner first and `cargo` is not
installed on this machine; the TypeScript step it wraps is the command
above.
- `npx vitest run packages/shared/src/validators/company.test.ts` — 6
passed
- `npx vitest run server/src/__tests__/company-portability.test.ts` — 90
passed
- `npx vitest run server/src/__tests__/attachment-types.test.ts
server/src/__tests__/assets.test.ts
server/src/__tests__/issue-attachment-routes.test.ts
server/src/__tests__/company-portability.test.ts
server/src/__tests__/cases-routes.test.ts` — 165 passed (the
human-readable limit messages)
- `npx vitest run server/src/__tests__/company-branding-route.test.ts
server/src/__tests__/issue-attachment-routes.test.ts
server/src/__tests__/invite-summary-route.test.ts
server/src/__tests__/openclaw-invite-prompt-route.test.ts
server/src/__tests__/companies-route-cross-company-authz.test.ts` — all
passed
- `npx vitest run cli/src/__tests__/company.test.ts
cli/src/__tests__/company-delete.test.ts` — 27 passed
- `npx vitest run` in `ui/` — 4425 passed, 1 pre-existing failure
unrelated to this change (`OnboardingWizard.test.tsx` "renders instead
of throwing when the browser denies storage access", which also fails on
`master`)
- `npx vitest run` in `server/` — see the note below
- `node scripts/check-token-gates.mjs` — no new violations; the only
reported violations are the pre-existing `PillGuy.tsx` ones present on
`master`
New tests added:
- `packages/shared/src/validators/company.test.ts` — the create and
update schemas strip the retired keys, the strict branding schema
rejects `brandColor`, and the portability manifest schema accepts a
legacy entry carrying both keys and drops them.
- `server/src/__tests__/company-branding-route.test.ts` — `PATCH
/api/companies/{companyId}/branding` returns 400 for `brandColor` and
does not call the company service.
- `server/src/__tests__/company-portability.test.ts` — a legacy package
that declares `brandColor` and `attachmentMaxBytes` imports
successfully, and neither key reaches `companies.create`.
- `server/src/__tests__/issue-attachment-routes.test.ts` — the effective
task attachment limit is the deployment cap, and the route no longer
loads the company to size an upload.
- `server/src/__tests__/attachment-types.test.ts` —
`formatAttachmentSize()` renders the default cap as `10 MB`, keeps one
decimal place for fractional sizes and drops a trailing `.0`, falls back
to KB and bytes for small caps, steps up to GB, and never emits `NaN`
for a degenerate input.
- `server/src/__tests__/assets.test.ts` — the asset-image and
company-logo routes both return the human-readable limit message on an
over-cap upload.
## Merge with master
`master` moved while this was open, and the merge needed two
resolutions:
- **`ui/src/pages/CompanySettings.tsx`.** #12243 reworded the
user-facing
copy from "company" to "organization", and that rewording landed inside
the "Brand color" and "Attachment size limit" hints — the two fields
this change deletes. Both fields are removed, so the conflicted block is
dropped whole. The Logo field and every other copy change from #12243
are
kept.
- **Migration renumbered 0228 -> 0229.** #12307 landed
`0228_nasty_grim_reaper`, so this migration is now
`0229_drop_company_brand_color_and_attachment_max_bytes`. Its snapshot
is
rebuilt from master's `0228_snapshot.json` with only the two `companies`
columns removed, and `meta/_journal.json` is master's journal plus a
single `idx: 229` entry. `pnpm --filter @paperclipai/db
check:migrations`
passes.
The snapshot was rebuilt by hand rather than taken from `drizzle-kit
generate`, because master's `0228_snapshot.json` has drifted from
master's
own schema: `issue_question_response_deliveries.error_count` is created
by
master's 0228 SQL but missing from its snapshot, and the snapshot still
carries `decision_archive_notification_outbox.error_count`. Regenerating
folds both into this migration, and the resulting `ADD COLUMN
error_count`
would fail on a fresh database where master's 0228 already created that
column. Rebuilding from master's snapshot leaves that drift exactly
where
it is and keeps this migration to the two column drops. The drift is
pre-existing on master and is not addressed here.
## Risks
- **The migration is a destructive column drop.**
`0229_drop_company_brand_color_and_attachment_max_bytes.sql` removes
`companies.brand_color` and `companies.attachment_max_bytes`. It is safe
because both features are removed in the same change and nothing reads
either column after it. The statements use `DROP COLUMN IF EXISTS`,
matching the convention of the recent drop migrations in this
repository. The drop is not reversible: a downgrade after this migration
loses any stored values.
- **Stored brand colors are lost.** A company that had set a color now
renders the name-derived icon hue that every company without a color
already used. No other surface changes, and an uploaded logo still
overrides the icon.
- **API response shape narrows.** `brandColor` and `attachmentMaxBytes`
leave the company payloads, and `companyBrandColor` leaves the invite
summary payload. A client reading those fields now sees `undefined`. The
bundled UI and CLI are updated in this change.
- **Legacy imports are covered.** Packages exported by older versions
still carry both keys. The manifest schema is non-strict, so the keys
are stripped rather than rejected, and a test locks that in.
- **The over-limit message strings changed.** Anything matching on the
old `... exceeds N bytes` text — a test, a script, or a client that
string-matches `body.error` — needs updating. The status codes (422) and
response shapes are unchanged, so structured clients are unaffected.
- **Attachment limits can only widen.** A deployment that had lowered a
company below the deployment cap now allows uploads up to the cap for
that company. Lower `PAPERCLIP_ATTACHMENT_MAX_BYTES` if a smaller
ceiling is needed.
- **Storybook visual baselines shift** for the `CompanyPatternIcon`
matrix story, because those fixtures had brand colors. That workflow
runs only on a PR labeled `storybook-visual`, so it does not gate this
PR; regenerate the baselines if the label is added.
## Model Used
Claude (Anthropic), Claude Opus, agentic tool use via Claude Code.
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
This commit is contained in:
parent
7b91fe9ea7
commit
bc1a21564f
|
|
@ -14,13 +14,11 @@ function makeCompany(overrides: Partial<Company>): Company {
|
|||
issueCounter: 1,
|
||||
budgetMonthlyCents: 0,
|
||||
spentMonthlyCents: 0,
|
||||
attachmentMaxBytes: 10 * 1024 * 1024,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
feedbackDataSharingEnabled: false,
|
||||
feedbackDataSharingConsentAt: null,
|
||||
feedbackDataSharingConsentByUserId: null,
|
||||
feedbackDataSharingTermsVersion: null,
|
||||
brandColor: null,
|
||||
logoAssetId: null,
|
||||
logoUrl: null,
|
||||
defaultResponsibleUserId: null,
|
||||
|
|
|
|||
|
|
@ -49,13 +49,11 @@ function company(overrides: Record<string, unknown> = {}) {
|
|||
issueCounter: 1,
|
||||
budgetMonthlyCents: 0,
|
||||
spentMonthlyCents: 0,
|
||||
attachmentMaxBytes: 1073741824,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
feedbackDataSharingEnabled: false,
|
||||
feedbackDataSharingConsentAt: null,
|
||||
feedbackDataSharingConsentByUserId: null,
|
||||
feedbackDataSharingTermsVersion: null,
|
||||
brandColor: "#5c5fff",
|
||||
logoAssetId: null,
|
||||
createdAt: "2026-06-04T00:00:00.000Z",
|
||||
updatedAt: "2026-06-04T00:00:00.000Z",
|
||||
|
|
@ -347,8 +345,6 @@ describe("renderCompanyImportPreview", () => {
|
|||
path: "COMPANY.md",
|
||||
name: "Source Co",
|
||||
description: null,
|
||||
attachmentMaxBytes: null,
|
||||
brandColor: null,
|
||||
logoPath: null,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
feedbackDataSharingEnabled: false,
|
||||
|
|
@ -584,8 +580,6 @@ describe("import selection catalog", () => {
|
|||
path: "COMPANY.md",
|
||||
name: "Source Co",
|
||||
description: null,
|
||||
attachmentMaxBytes: null,
|
||||
brandColor: null,
|
||||
logoPath: "images/company-logo.png",
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
feedbackDataSharingEnabled: false,
|
||||
|
|
@ -761,8 +755,6 @@ describe("import selection catalog", () => {
|
|||
path: "COMPANY.md",
|
||||
name: "Source Co",
|
||||
description: null,
|
||||
attachmentMaxBytes: null,
|
||||
brandColor: null,
|
||||
logoPath: null,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
feedbackDataSharingEnabled: false,
|
||||
|
|
|
|||
|
|
@ -139,10 +139,8 @@ Human auth tables (`users`, `sessions`, and provider-specific auth artifacts) ar
|
|||
- `issue_counter` int not null
|
||||
- `budget_monthly_cents` int not null default 0
|
||||
- `spent_monthly_cents` int not null default 0
|
||||
- `attachment_max_bytes` int not null
|
||||
- `require_board_approval_for_new_agents` boolean not null default false
|
||||
- feedback sharing consent fields
|
||||
- branding fields such as `brand_color`
|
||||
|
||||
Invariant: every business record belongs to exactly one company.
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
-- Drop the two retired per-company settings. "brand_color" only tinted the
|
||||
-- generated company icon, which now always derives its hue from the company
|
||||
-- name, and "attachment_max_bytes" duplicated the deployment-level
|
||||
-- PAPERCLIP_ATTACHMENT_MAX_BYTES cap that already bounds every upload. Both
|
||||
-- columns lost their last reader when the settings were removed.
|
||||
ALTER TABLE "companies" DROP COLUMN IF EXISTS "attachment_max_bytes";--> statement-breakpoint
|
||||
ALTER TABLE "companies" DROP COLUMN IF EXISTS "brand_color";
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1590,6 +1590,13 @@
|
|||
"when": 1787837657366,
|
||||
"tag": "0228_nasty_grim_reaper",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 229,
|
||||
"version": "7",
|
||||
"when": 1787854369379,
|
||||
"tag": "0229_drop_company_brand_color_and_attachment_max_bytes",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -14,9 +14,6 @@ export const companies = pgTable(
|
|||
issueCounter: integer("issue_counter").notNull().default(0),
|
||||
budgetMonthlyCents: integer("budget_monthly_cents").notNull().default(0),
|
||||
spentMonthlyCents: integer("spent_monthly_cents").notNull().default(0),
|
||||
attachmentMaxBytes: integer("attachment_max_bytes")
|
||||
.notNull()
|
||||
.default(10 * 1024 * 1024),
|
||||
defaultResponsibleUserId: text("default_responsible_user_id"),
|
||||
requireBoardApprovalForNewAgents: boolean("require_board_approval_for_new_agents")
|
||||
.notNull()
|
||||
|
|
@ -31,7 +28,6 @@ export const companies = pgTable(
|
|||
feedbackDataSharingConsentAt: timestamp("feedback_data_sharing_consent_at", { withTimezone: true }),
|
||||
feedbackDataSharingConsentByUserId: text("feedback_data_sharing_consent_by_user_id"),
|
||||
feedbackDataSharingTermsVersion: text("feedback_data_sharing_terms_version"),
|
||||
brandColor: text("brand_color"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,9 +1,6 @@
|
|||
export const COMPANY_STATUSES = ["active", "paused", "archived"] as const;
|
||||
export type CompanyStatus = (typeof COMPANY_STATUSES)[number];
|
||||
|
||||
export const DEFAULT_COMPANY_ATTACHMENT_MAX_BYTES = 10 * 1024 * 1024;
|
||||
export const MAX_COMPANY_ATTACHMENT_MAX_BYTES = 1024 * 1024 * 1024;
|
||||
|
||||
export const DEPLOYMENT_MODES = ["local_trusted", "authenticated"] as const;
|
||||
export type DeploymentMode = (typeof DEPLOYMENT_MODES)[number];
|
||||
|
||||
|
|
|
|||
|
|
@ -279,8 +279,6 @@ export {
|
|||
} from "./humanize-connection.js";
|
||||
export {
|
||||
COMPANY_STATUSES,
|
||||
DEFAULT_COMPANY_ATTACHMENT_MAX_BYTES,
|
||||
MAX_COMPANY_ATTACHMENT_MAX_BYTES,
|
||||
DEPLOYMENT_MODES,
|
||||
DEPLOYMENT_EXPOSURES,
|
||||
BIND_MODES,
|
||||
|
|
|
|||
|
|
@ -34,9 +34,7 @@ export interface CompanyPortabilityCompanyManifestEntry {
|
|||
path: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
brandColor: string | null;
|
||||
logoPath: string | null;
|
||||
attachmentMaxBytes: number | null;
|
||||
requireBoardApprovalForNewAgents: boolean;
|
||||
feedbackDataSharingEnabled: boolean;
|
||||
feedbackDataSharingConsentAt: string | null;
|
||||
|
|
|
|||
|
|
@ -25,7 +25,6 @@ export interface Company {
|
|||
issueCounter: number;
|
||||
budgetMonthlyCents: number;
|
||||
spentMonthlyCents: number;
|
||||
attachmentMaxBytes: number;
|
||||
defaultResponsibleUserId: string | null;
|
||||
requireBoardApprovalForNewAgents: boolean;
|
||||
interactionResolverGovernance: InteractionResolverGovernance;
|
||||
|
|
@ -33,7 +32,6 @@ export interface Company {
|
|||
feedbackDataSharingConsentAt: Date | null;
|
||||
feedbackDataSharingConsentByUserId: string | null;
|
||||
feedbackDataSharingTermsVersion: string | null;
|
||||
brandColor: string | null;
|
||||
logoAssetId: string | null;
|
||||
logoUrl: string | null;
|
||||
createdAt: Date;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { z } from "zod";
|
||||
import { PERMISSION_KEYS } from "../constants.js";
|
||||
import { MAX_COMPANY_ATTACHMENT_MAX_BYTES } from "../constants.js";
|
||||
import {
|
||||
issueCommentAuthorTypeSchema,
|
||||
issueCommentMetadataSchema,
|
||||
|
|
@ -38,13 +37,15 @@ export const portabilityFileEntrySchema = z.union([
|
|||
}),
|
||||
]);
|
||||
|
||||
// Deliberately non-strict: packages exported by older versions still carry
|
||||
// retired company keys such as `brandColor` and `attachmentMaxBytes`. Zod
|
||||
// strips keys the schema does not name, so those bundles keep importing —
|
||||
// the retired settings are simply ignored.
|
||||
export const portabilityCompanyManifestEntrySchema = z.object({
|
||||
path: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
description: z.string().nullable(),
|
||||
brandColor: z.string().nullable(),
|
||||
logoPath: z.string().nullable(),
|
||||
attachmentMaxBytes: z.number().int().min(1).max(MAX_COMPANY_ATTACHMENT_MAX_BYTES).nullable().default(null),
|
||||
requireBoardApprovalForNewAgents: z.boolean(),
|
||||
feedbackDataSharingEnabled: z.boolean().default(false),
|
||||
feedbackDataSharingConsentAt: z.string().datetime().nullable().default(null),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,74 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createCompanySchema,
|
||||
updateCompanyBrandingSchema,
|
||||
updateCompanySchema,
|
||||
} from "./company.js";
|
||||
import { portabilityCompanyManifestEntrySchema } from "./company-portability.js";
|
||||
|
||||
describe("company schemas without the retired settings", () => {
|
||||
it("strips brandColor and attachmentMaxBytes from a create payload", () => {
|
||||
const parsed = createCompanySchema.parse({
|
||||
name: "Acme",
|
||||
brandColor: "#123456",
|
||||
attachmentMaxBytes: 25_000_000,
|
||||
});
|
||||
|
||||
expect(parsed).not.toHaveProperty("brandColor");
|
||||
expect(parsed).not.toHaveProperty("attachmentMaxBytes");
|
||||
expect(parsed.name).toBe("Acme");
|
||||
});
|
||||
|
||||
it("strips brandColor and attachmentMaxBytes from an update payload", () => {
|
||||
const parsed = updateCompanySchema.parse({
|
||||
description: "Updated",
|
||||
brandColor: "#123456",
|
||||
attachmentMaxBytes: 25_000_000,
|
||||
});
|
||||
|
||||
expect(parsed).not.toHaveProperty("brandColor");
|
||||
expect(parsed).not.toHaveProperty("attachmentMaxBytes");
|
||||
expect(parsed.description).toBe("Updated");
|
||||
});
|
||||
|
||||
it("rejects brandColor on the strict branding schema", () => {
|
||||
const result = updateCompanyBrandingSchema.safeParse({
|
||||
name: "Acme",
|
||||
brandColor: "#123456",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("still accepts the remaining branding fields", () => {
|
||||
const result = updateCompanyBrandingSchema.safeParse({
|
||||
name: "Acme",
|
||||
description: null,
|
||||
logoAssetId: "11111111-1111-4111-8111-111111111111",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("requires at least one branding field", () => {
|
||||
expect(updateCompanyBrandingSchema.safeParse({}).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("portability company manifest tolerance", () => {
|
||||
it("accepts a legacy manifest entry carrying the retired keys and ignores them", () => {
|
||||
const parsed = portabilityCompanyManifestEntrySchema.parse({
|
||||
path: "company.md",
|
||||
name: "Acme",
|
||||
description: null,
|
||||
brandColor: "#5c5fff",
|
||||
logoPath: null,
|
||||
attachmentMaxBytes: 25_000_000,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
|
||||
expect(parsed).not.toHaveProperty("brandColor");
|
||||
expect(parsed).not.toHaveProperty("attachmentMaxBytes");
|
||||
expect(parsed.name).toBe("Acme");
|
||||
});
|
||||
});
|
||||
|
|
@ -2,18 +2,11 @@ import { z } from "zod";
|
|||
import {
|
||||
COMPANY_STATUSES,
|
||||
ISSUE_THREAD_INTERACTION_RESOLVER_POLICIES,
|
||||
MAX_COMPANY_ATTACHMENT_MAX_BYTES,
|
||||
} from "../constants.js";
|
||||
import { objectWithoutDefaults } from "./partial.js";
|
||||
|
||||
const logoAssetIdSchema = z.string().guid().nullable().optional();
|
||||
const brandColorSchema = z.string().regex(/^#[0-9a-fA-F]{6}$/).nullable().optional();
|
||||
const feedbackDataSharingTermsVersionSchema = z.string().min(1).nullable().optional();
|
||||
const attachmentMaxBytesSchema = z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(MAX_COMPANY_ATTACHMENT_MAX_BYTES);
|
||||
|
||||
const interactionResolverKindGovernanceSchema = z.object({
|
||||
defaultPolicy: z.enum(ISSUE_THREAD_INTERACTION_RESOLVER_POLICIES).optional(),
|
||||
|
|
@ -32,7 +25,6 @@ export const createCompanySchema = z.object({
|
|||
name: z.string().min(1),
|
||||
description: z.string().optional().nullable(),
|
||||
budgetMonthlyCents: z.number().int().nonnegative().optional().default(0),
|
||||
attachmentMaxBytes: attachmentMaxBytesSchema.optional(),
|
||||
defaultResponsibleUserId: z.string().min(1).nullable().optional(),
|
||||
});
|
||||
|
||||
|
|
@ -50,9 +42,7 @@ export const updateCompanySchema = objectWithoutDefaults(
|
|||
feedbackDataSharingConsentAt: z.coerce.date().nullable().optional(),
|
||||
feedbackDataSharingConsentByUserId: z.string().min(1).nullable().optional(),
|
||||
feedbackDataSharingTermsVersion: feedbackDataSharingTermsVersionSchema,
|
||||
brandColor: brandColorSchema,
|
||||
logoAssetId: logoAssetIdSchema,
|
||||
attachmentMaxBytes: attachmentMaxBytesSchema.optional(),
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
@ -62,7 +52,6 @@ export const updateCompanyBrandingSchema = z
|
|||
.object({
|
||||
name: z.string().min(1).optional(),
|
||||
description: z.string().nullable().optional(),
|
||||
brandColor: brandColorSchema,
|
||||
logoAssetId: logoAssetIdSchema,
|
||||
})
|
||||
.strict()
|
||||
|
|
@ -70,7 +59,6 @@ export const updateCompanyBrandingSchema = z
|
|||
(value) =>
|
||||
value.name !== undefined
|
||||
|| value.description !== undefined
|
||||
|| value.brandColor !== undefined
|
||||
|| value.logoAssetId !== undefined,
|
||||
"At least one branding field must be provided",
|
||||
);
|
||||
|
|
|
|||
|
|
@ -257,6 +257,21 @@ describe("POST /api/companies/:companyId/assets/images", () => {
|
|||
expect(res.body.contentPath).toBe("/api/assets/asset-1/content");
|
||||
expect(res.body.contentType).toBe("text/plain");
|
||||
});
|
||||
|
||||
it("names the limit in human units when a file exceeds the attachment cap", async () => {
|
||||
const app = await createApp(createStorageService());
|
||||
createAssetMock.mockResolvedValue(createAsset());
|
||||
|
||||
const file = Buffer.alloc(MAX_ATTACHMENT_BYTES + 1, "a");
|
||||
const res = await requestApp(app, (baseUrl) =>
|
||||
request(baseUrl)
|
||||
.post("/api/companies/company-1/assets/images")
|
||||
.attach("file", file, "too-large.png"),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(422);
|
||||
expect(res.body.error).toBe("File is larger than the 10 MB limit");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/companies/:companyId/logo", () => {
|
||||
|
|
@ -359,7 +374,7 @@ describe("POST /api/companies/:companyId/logo", () => {
|
|||
);
|
||||
|
||||
expect(res.status).toBe(422);
|
||||
expect(res.body.error).toBe(`Image exceeds ${MAX_ATTACHMENT_BYTES} bytes`);
|
||||
expect(res.body.error).toBe("Image is larger than the 10 MB limit");
|
||||
});
|
||||
|
||||
it("rejects unsupported image types", async () => {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
DEFAULT_ALLOWED_TYPES,
|
||||
formatAttachmentSize,
|
||||
INLINE_ATTACHMENT_TYPES,
|
||||
inferOfficeAttachmentContentTypeFromFilename,
|
||||
isInlineAttachmentContentType,
|
||||
matchesContentType,
|
||||
MAX_ATTACHMENT_BYTES,
|
||||
normalizeContentType,
|
||||
normalizeUploadAttachmentContentType,
|
||||
parseAllowedTypes,
|
||||
|
|
@ -199,3 +201,38 @@ describe("isInlineAttachmentContentType", () => {
|
|||
expect(isInlineAttachmentContentType("application/zip")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatAttachmentSize", () => {
|
||||
it("renders the default deployment cap as a round megabyte figure", () => {
|
||||
expect(MAX_ATTACHMENT_BYTES).toBe(10 * 1024 * 1024);
|
||||
expect(formatAttachmentSize(MAX_ATTACHMENT_BYTES)).toBe("10 MB");
|
||||
});
|
||||
|
||||
it("keeps one decimal place for fractional sizes and drops a trailing .0", () => {
|
||||
expect(formatAttachmentSize(10.5 * 1024 * 1024)).toBe("10.5 MB");
|
||||
expect(formatAttachmentSize(1024 * 1024)).toBe("1 MB");
|
||||
expect(formatAttachmentSize(2.25 * 1024 * 1024)).toBe("2.3 MB");
|
||||
});
|
||||
|
||||
it("renders sub-megabyte values in kilobytes", () => {
|
||||
expect(formatAttachmentSize(1024)).toBe("1 KB");
|
||||
expect(formatAttachmentSize(512 * 1024)).toBe("512 KB");
|
||||
expect(formatAttachmentSize(1536)).toBe("1.5 KB");
|
||||
});
|
||||
|
||||
it("steps up to gigabytes for very large caps", () => {
|
||||
expect(formatAttachmentSize(2 * 1024 * 1024 * 1024)).toBe("2 GB");
|
||||
});
|
||||
|
||||
it("keeps sub-kilobyte values in bytes rather than collapsing to 0 KB", () => {
|
||||
expect(formatAttachmentSize(10)).toBe("10 bytes");
|
||||
expect(formatAttachmentSize(1)).toBe("1 byte");
|
||||
expect(formatAttachmentSize(1023)).toBe("1023 bytes");
|
||||
});
|
||||
|
||||
it("never renders a nonsense figure for a degenerate input", () => {
|
||||
expect(formatAttachmentSize(0)).toBe("0 bytes");
|
||||
expect(formatAttachmentSize(-1)).toBe("0 bytes");
|
||||
expect(formatAttachmentSize(Number.NaN)).toBe("0 bytes");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -94,10 +94,8 @@ function createCompany(id: string) {
|
|||
spentMonthlyCents: 0,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
feedbackDataSharingEnabled: false,
|
||||
brandColor: "#123456",
|
||||
logoAssetId: null,
|
||||
logoUrl: null,
|
||||
attachmentMaxBytes: 25_000_000,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
|
@ -235,7 +233,7 @@ describe.sequential("company route cross-company authorization", () => {
|
|||
},
|
||||
{
|
||||
label: "PATCH /api/companies/:companyId/branding",
|
||||
request: (app: express.Express) => request(app).patch(`/api/companies/${companyBId}/branding`).send({ brandColor: "#654321" }),
|
||||
request: (app: express.Express) => request(app).patch(`/api/companies/${companyBId}/branding`).send({ description: "Nope" }),
|
||||
},
|
||||
{
|
||||
label: "POST /api/companies/:companyId/archive",
|
||||
|
|
@ -279,8 +277,8 @@ describe.sequential("company route cross-company authorization", () => {
|
|||
const app = await createApp(companyACeoActor());
|
||||
|
||||
await request(app).get(`/api/companies/${companyAId}`).expect(200);
|
||||
await request(app).patch(`/api/companies/${companyAId}`).send({ brandColor: "#abcdef" }).expect(200);
|
||||
await request(app).patch(`/api/companies/${companyAId}/branding`).send({ brandColor: "#abcdef" }).expect(200);
|
||||
await request(app).patch(`/api/companies/${companyAId}`).send({ description: "Branding" }).expect(200);
|
||||
await request(app).patch(`/api/companies/${companyAId}/branding`).send({ description: "Branding" }).expect(200);
|
||||
await request(app).post(`/api/companies/${companyAId}/export`).send(exportRequest).expect(200);
|
||||
await request(app).post(`/api/companies/${companyAId}/exports/preview`).send(exportRequest).expect(200);
|
||||
await request(app).post(`/api/companies/${companyAId}/imports/preview`).send(importRequest(companyAId)).expect(200);
|
||||
|
|
@ -322,7 +320,7 @@ describe.sequential("company route cross-company authorization", () => {
|
|||
memberships: [{ companyId: companyBId, membershipRole: "member", status: "active" }],
|
||||
}));
|
||||
await request(memberApp).patch(`/api/companies/${companyBId}`).send({ description: "Updated" }).expect(200);
|
||||
await request(memberApp).patch(`/api/companies/${companyBId}/branding`).send({ brandColor: "#abcdef" }).expect(200);
|
||||
await request(memberApp).patch(`/api/companies/${companyBId}/branding`).send({ description: "Branding" }).expect(200);
|
||||
await request(memberApp).post(`/api/companies/${companyBId}/archive`).send({}).expect(200);
|
||||
await request(memberApp).delete(`/api/companies/${companyBId}`).expect(200);
|
||||
await request(memberApp).post(`/api/companies/${companyBId}/export`).send(exportRequest).expect(200);
|
||||
|
|
|
|||
|
|
@ -69,7 +69,6 @@ function createCompany() {
|
|||
budgetMonthlyCents: 0,
|
||||
spentMonthlyCents: 0,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
brandColor: "#123456",
|
||||
logoAssetId: "11111111-1111-4111-8111-111111111111",
|
||||
logoUrl: "/api/assets/11111111-1111-4111-8111-111111111111/content",
|
||||
createdAt: now,
|
||||
|
|
@ -168,14 +167,12 @@ describe("PATCH /api/companies/:companyId/branding", () => {
|
|||
.patch("/api/companies/company-1/branding")
|
||||
.send({
|
||||
logoAssetId: "11111111-1111-4111-8111-111111111111",
|
||||
brandColor: "#123456",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.logoAssetId).toBe(company.logoAssetId);
|
||||
expect(mockCompanyService.update).toHaveBeenCalledWith("company-1", {
|
||||
logoAssetId: "11111111-1111-4111-8111-111111111111",
|
||||
brandColor: "#123456",
|
||||
});
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
|
|
@ -188,7 +185,6 @@ describe("PATCH /api/companies/:companyId/branding", () => {
|
|||
action: "company.branding_updated",
|
||||
details: {
|
||||
logoAssetId: "11111111-1111-4111-8111-111111111111",
|
||||
brandColor: "#123456",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
|
@ -198,7 +194,6 @@ describe("PATCH /api/companies/:companyId/branding", () => {
|
|||
const company = createCompany();
|
||||
mockCompanyService.update.mockResolvedValue({
|
||||
...company,
|
||||
brandColor: null,
|
||||
logoAssetId: null,
|
||||
logoUrl: null,
|
||||
});
|
||||
|
|
@ -210,13 +205,31 @@ describe("PATCH /api/companies/:companyId/branding", () => {
|
|||
|
||||
const res = await request(app)
|
||||
.patch("/api/companies/company-1/branding")
|
||||
.send({ brandColor: null, logoAssetId: null });
|
||||
.send({ description: null, logoAssetId: null });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.brandColor ?? null).toBeNull();
|
||||
expect(res.body.logoAssetId ?? null).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects the retired brandColor field in the request body", async () => {
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
source: "local_implicit",
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.patch("/api/companies/company-1/branding")
|
||||
.send({
|
||||
logoAssetId: "11111111-1111-4111-8111-111111111111",
|
||||
brandColor: "#123456",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBe("Validation error");
|
||||
expect(mockCompanyService.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects non-branding fields in the request body", async () => {
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
|
|
|
|||
|
|
@ -203,7 +203,6 @@ describe("company portability", () => {
|
|||
name: "Paperclip",
|
||||
description: null,
|
||||
issuePrefix: "PAP",
|
||||
brandColor: "#5c5fff",
|
||||
logoAssetId: null,
|
||||
logoUrl: null,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
|
|
@ -642,7 +641,6 @@ describe("company portability", () => {
|
|||
name: "Paperclip",
|
||||
description: null,
|
||||
issuePrefix: "PAP",
|
||||
brandColor: "#5c5fff",
|
||||
logoAssetId: null,
|
||||
logoUrl: null,
|
||||
requireBoardApprovalForNewAgents: true,
|
||||
|
|
@ -948,7 +946,6 @@ describe("company portability", () => {
|
|||
name: "Paperclip",
|
||||
description: null,
|
||||
issuePrefix: "PAP",
|
||||
brandColor: "#5c5fff",
|
||||
logoAssetId: "logo-1",
|
||||
logoUrl: "/api/assets/logo-1/content",
|
||||
requireBoardApprovalForNewAgents: true,
|
||||
|
|
@ -3310,10 +3307,13 @@ describe("company portability", () => {
|
|||
data: Buffer.from("png-bytes").toString("base64"),
|
||||
contentType: "image/png",
|
||||
};
|
||||
exported.files[".paperclip.yaml"] = `${exported.files[".paperclip.yaml"]}`.replace(
|
||||
'brandColor: "#5c5fff"\n',
|
||||
'brandColor: "#5c5fff"\n logoPath: "images/company-logo.png"\n',
|
||||
);
|
||||
// Declare the packaged logo in the bundle's company block. The exported
|
||||
// company map is empty for this fixture, so the block is appended rather
|
||||
// than patched into an existing one.
|
||||
const paperclipYaml = `${exported.files[".paperclip.yaml"]}`;
|
||||
expect(paperclipYaml).not.toContain("company:");
|
||||
exported.files[".paperclip.yaml"] =
|
||||
`${paperclipYaml}company:\n logoPath: "images/company-logo.png"\n`;
|
||||
|
||||
agentSvc.list.mockResolvedValue([]);
|
||||
|
||||
|
|
@ -3483,7 +3483,6 @@ describe("company portability", () => {
|
|||
id: "company-1",
|
||||
name: "Paperclip",
|
||||
description: "Existing company",
|
||||
brandColor: "#123456",
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
agentSvc.create.mockResolvedValue({
|
||||
|
|
@ -4576,7 +4575,7 @@ describe("company portability", () => {
|
|||
"Attachment notes.bin on task pap-1 was exported under its recomputed content hash because the stored hash did not match.",
|
||||
);
|
||||
|
||||
companySvc.create.mockResolvedValue({ id: "company-imported", name: "Imported", attachmentMaxBytes: null });
|
||||
companySvc.create.mockResolvedValue({ id: "company-imported", name: "Imported" });
|
||||
accessSvc.ensureMembership.mockResolvedValue(undefined);
|
||||
agentSvc.list.mockResolvedValue([]);
|
||||
issueSvc.create.mockResolvedValue({ id: "issue-imported", title: "Attachment task", projectId: null });
|
||||
|
|
@ -4702,54 +4701,69 @@ describe("company portability", () => {
|
|||
});
|
||||
|
||||
it("skips oversized and missing-blob attachments with warnings instead of failing", async () => {
|
||||
const storage = fakeAttachmentStorage();
|
||||
const portability = companyPortabilityService({} as any, storage as any);
|
||||
mockAttachmentExportSources([
|
||||
{
|
||||
id: "attachment-3",
|
||||
issueId: "issue-1",
|
||||
issueCommentId: null,
|
||||
provider: "local_disk",
|
||||
objectKey: "issues/issue-1/big.bin",
|
||||
contentType: "application/octet-stream",
|
||||
byteSize: 20,
|
||||
sha256: sha256Of("twenty-byte-payload!"),
|
||||
originalFilename: "big.bin",
|
||||
createdAt: new Date("2026-06-04T00:00:00.000Z"),
|
||||
},
|
||||
]);
|
||||
const sha = sha256Of("png-bytes");
|
||||
// The deployment-level cap is read once when the service module loads, so
|
||||
// this test re-imports the module under a 10-byte cap to reach the skip.
|
||||
const previousCap = process.env.PAPERCLIP_ATTACHMENT_MAX_BYTES;
|
||||
process.env.PAPERCLIP_ATTACHMENT_MAX_BYTES = "10";
|
||||
vi.resetModules();
|
||||
try {
|
||||
const { companyPortabilityService: cappedPortabilityService } =
|
||||
await import("../services/company-portability.js");
|
||||
const storage = fakeAttachmentStorage();
|
||||
const portability = cappedPortabilityService({} as any, storage as any);
|
||||
mockAttachmentExportSources([
|
||||
{
|
||||
id: "attachment-3",
|
||||
issueId: "issue-1",
|
||||
issueCommentId: null,
|
||||
provider: "local_disk",
|
||||
objectKey: "issues/issue-1/big.bin",
|
||||
contentType: "application/octet-stream",
|
||||
byteSize: 20,
|
||||
sha256: sha256Of("twenty-byte-payload!"),
|
||||
originalFilename: "big.bin",
|
||||
createdAt: new Date("2026-06-04T00:00:00.000Z"),
|
||||
},
|
||||
]);
|
||||
const sha = sha256Of("png-bytes");
|
||||
|
||||
const exported = await portability.exportBundle("company-1", {
|
||||
include: { company: true, agents: false, projects: false, issues: true },
|
||||
});
|
||||
delete exported.files[`blobs/${sha}`];
|
||||
const exported = await portability.exportBundle("company-1", {
|
||||
include: { company: true, agents: false, projects: false, issues: true },
|
||||
});
|
||||
delete exported.files[`blobs/${sha}`];
|
||||
|
||||
// The target company only accepts attachments up to 10 bytes.
|
||||
companySvc.create.mockResolvedValue({ id: "company-imported", name: "Imported", attachmentMaxBytes: 10 });
|
||||
companySvc.update.mockResolvedValue({ id: "company-imported", name: "Imported", attachmentMaxBytes: 10 });
|
||||
accessSvc.ensureMembership.mockResolvedValue(undefined);
|
||||
agentSvc.list.mockResolvedValue([]);
|
||||
issueSvc.create.mockResolvedValue({ id: "issue-imported", title: "Attachment task", projectId: null });
|
||||
companySvc.create.mockResolvedValue({ id: "company-imported", name: "Imported" });
|
||||
companySvc.update.mockResolvedValue({ id: "company-imported", name: "Imported" });
|
||||
accessSvc.ensureMembership.mockResolvedValue(undefined);
|
||||
agentSvc.list.mockResolvedValue([]);
|
||||
issueSvc.create.mockResolvedValue({ id: "issue-imported", title: "Attachment task", projectId: null });
|
||||
|
||||
const result = await portability.importBundle({
|
||||
source: { type: "inline", rootPath: exported.rootPath, files: exported.files },
|
||||
include: { company: true, agents: false, projects: false, issues: true },
|
||||
target: { mode: "new_company", newCompanyName: "Imported" },
|
||||
agents: "all",
|
||||
collisionStrategy: "rename",
|
||||
}, "user-1");
|
||||
const result = await portability.importBundle({
|
||||
source: { type: "inline", rootPath: exported.rootPath, files: exported.files },
|
||||
include: { company: true, agents: false, projects: false, issues: true },
|
||||
target: { mode: "new_company", newCompanyName: "Imported" },
|
||||
agents: "all",
|
||||
collisionStrategy: "rename",
|
||||
}, "user-1");
|
||||
|
||||
expect(issueSvc.addImportedAttachments).not.toHaveBeenCalled();
|
||||
expect(result.warnings).toContain(
|
||||
`Task pap-1 attachment notes.bin was skipped because its blob is missing from the package: blobs/${sha}`,
|
||||
);
|
||||
expect(result.warnings).toContain(
|
||||
`Task pap-1 attachment screenshot.png was skipped because its blob is missing from the package: blobs/${sha}`,
|
||||
);
|
||||
expect(result.warnings).toContain(
|
||||
"Task pap-1 attachment big.bin was skipped because it exceeds this board's attachment size limit of 10 bytes.",
|
||||
);
|
||||
expect(issueSvc.addImportedAttachments).not.toHaveBeenCalled();
|
||||
expect(result.warnings).toContain(
|
||||
`Task pap-1 attachment notes.bin was skipped because its blob is missing from the package: blobs/${sha}`,
|
||||
);
|
||||
expect(result.warnings).toContain(
|
||||
`Task pap-1 attachment screenshot.png was skipped because its blob is missing from the package: blobs/${sha}`,
|
||||
);
|
||||
expect(result.warnings).toContain(
|
||||
"Task pap-1 attachment big.bin was skipped because it exceeds this deployment's attachment size limit of 10 bytes.",
|
||||
);
|
||||
} finally {
|
||||
if (previousCap === undefined) {
|
||||
delete process.env.PAPERCLIP_ATTACHMENT_MAX_BYTES;
|
||||
} else {
|
||||
process.env.PAPERCLIP_ATTACHMENT_MAX_BYTES = previousCap;
|
||||
}
|
||||
vi.resetModules();
|
||||
}
|
||||
});
|
||||
|
||||
const EMBEDDED_ASSET_ID = "0f9a4c9e-1b2d-4e3f-8a5b-6c7d8e9f0a1b";
|
||||
|
|
@ -4844,7 +4858,7 @@ describe("company portability", () => {
|
|||
},
|
||||
]);
|
||||
|
||||
companySvc.create.mockResolvedValue({ id: "company-imported", name: "Imported", attachmentMaxBytes: null });
|
||||
companySvc.create.mockResolvedValue({ id: "company-imported", name: "Imported" });
|
||||
accessSvc.ensureMembership.mockResolvedValue(undefined);
|
||||
agentSvc.list.mockResolvedValue([]);
|
||||
issueSvc.create.mockResolvedValue({ id: "issue-imported", title: "Embedded image task", projectId: null });
|
||||
|
|
@ -4946,7 +4960,7 @@ describe("company portability", () => {
|
|||
});
|
||||
delete exported.files[`blobs/${sha}`];
|
||||
|
||||
companySvc.create.mockResolvedValue({ id: "company-imported", name: "Imported", attachmentMaxBytes: null });
|
||||
companySvc.create.mockResolvedValue({ id: "company-imported", name: "Imported" });
|
||||
accessSvc.ensureMembership.mockResolvedValue(undefined);
|
||||
agentSvc.list.mockResolvedValue([]);
|
||||
issueSvc.create.mockResolvedValue({ id: "issue-imported", title: "Embedded image task", projectId: null });
|
||||
|
|
@ -5069,6 +5083,45 @@ describe("company portability", () => {
|
|||
expect(preview.warnings.some((warning) => warning.startsWith("This package declares schemaVersion 1"))).toBe(true);
|
||||
});
|
||||
|
||||
it("imports a legacy package carrying the retired brand color and attachment limit", async () => {
|
||||
const portability = companyPortabilityService({} as any);
|
||||
|
||||
companySvc.create.mockResolvedValue({ id: "company-imported", name: "Legacy Import" });
|
||||
accessSvc.ensureMembership.mockResolvedValue(undefined);
|
||||
agentSvc.list.mockResolvedValue([]);
|
||||
issueSvc.create.mockResolvedValue({ id: "issue-imported", title: "Kickoff", projectId: null });
|
||||
|
||||
const request = {
|
||||
source: {
|
||||
type: "inline" as const,
|
||||
rootPath: "legacy-package",
|
||||
files: legacyPackageFiles([
|
||||
"company:",
|
||||
' brandColor: "#5c5fff"',
|
||||
" attachmentMaxBytes: 25000000",
|
||||
]),
|
||||
},
|
||||
include: { company: true, agents: false, projects: false, issues: true },
|
||||
target: { mode: "new_company" as const, newCompanyName: "Legacy Import" },
|
||||
agents: "all" as const,
|
||||
collisionStrategy: "rename" as const,
|
||||
};
|
||||
|
||||
const preview = await portability.previewImport(request);
|
||||
expect(preview.errors).toEqual([]);
|
||||
expect(preview.manifest.company).not.toHaveProperty("brandColor");
|
||||
expect(preview.manifest.company).not.toHaveProperty("attachmentMaxBytes");
|
||||
|
||||
await portability.importBundle(request, "user-1");
|
||||
|
||||
expect(companySvc.create).toHaveBeenCalledWith(
|
||||
expect.not.objectContaining({ brandColor: expect.anything() }),
|
||||
);
|
||||
expect(companySvc.create).toHaveBeenCalledWith(
|
||||
expect.not.objectContaining({ attachmentMaxBytes: expect.anything() }),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects packages produced by a newer Paperclip", async () => {
|
||||
const portability = companyPortabilityService({} as any);
|
||||
|
||||
|
|
|
|||
|
|
@ -128,7 +128,7 @@ function registerModuleMocks() {
|
|||
companySkillService: () => ({
|
||||
completeTestRunForIssue: vi.fn(async () => null),
|
||||
}),
|
||||
companyService: () => ({ getById: vi.fn(async () => ({ id: companyId, attachmentMaxBytes: 10_000_000 })) }),
|
||||
companyService: () => ({ getById: vi.fn(async () => ({ id: companyId })) }),
|
||||
documentAnnotationService: () => mockAnnotationService,
|
||||
documentService: () => mockDocumentService,
|
||||
environmentService: () => ({}),
|
||||
|
|
|
|||
|
|
@ -179,7 +179,6 @@ describe.sequential("execution environment route guards", () => {
|
|||
mockCompanyService.getById.mockReset();
|
||||
mockCompanyService.getById.mockResolvedValue({
|
||||
id: "company-1",
|
||||
attachmentMaxBytes: 10 * 1024 * 1024,
|
||||
});
|
||||
mockEnvironmentService.getById.mockReset();
|
||||
mockIssueReferenceService.deleteDocumentSource.mockClear();
|
||||
|
|
|
|||
|
|
@ -64,7 +64,6 @@ function createDbStub() {
|
|||
where() {
|
||||
return Promise.resolve([{
|
||||
name: "Acme Robotics",
|
||||
brandColor: "#114488",
|
||||
logoAssetId: "logo-1",
|
||||
}]);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -103,7 +103,6 @@ describe("GET /invites/:token", () => {
|
|||
[
|
||||
{
|
||||
name: "Acme Robotics",
|
||||
brandColor: "#114488",
|
||||
logoAssetId: "logo-1",
|
||||
},
|
||||
],
|
||||
|
|
@ -124,7 +123,7 @@ describe("GET /invites/:token", () => {
|
|||
expect(res.status).toBe(200);
|
||||
expect(res.body.companyId).toBe("company-1");
|
||||
expect(res.body.companyName).toBe("Acme Robotics");
|
||||
expect(res.body.companyBrandColor).toBe("#114488");
|
||||
expect(res.body).not.toHaveProperty("companyBrandColor");
|
||||
expect(res.body.companyLogoUrl).toBe("/api/invites/pcp_invite_test/logo");
|
||||
expect(res.body.inviteType).toBe("company_join");
|
||||
}, 10_000);
|
||||
|
|
@ -152,7 +151,6 @@ describe("GET /invites/:token", () => {
|
|||
[
|
||||
{
|
||||
name: "Acme Robotics",
|
||||
brandColor: "#114488",
|
||||
logoAssetId: "logo-1",
|
||||
},
|
||||
],
|
||||
|
|
@ -196,7 +194,6 @@ describe("GET /invites/:token", () => {
|
|||
[
|
||||
{
|
||||
name: "Acme Robotics",
|
||||
brandColor: "#114488",
|
||||
logoAssetId: "logo-1",
|
||||
},
|
||||
],
|
||||
|
|
@ -244,7 +241,6 @@ describe("GET /invites/:token", () => {
|
|||
};
|
||||
const companyBranding = {
|
||||
name: "Acme Robotics",
|
||||
brandColor: "#114488",
|
||||
logoAssetId: "logo-1",
|
||||
};
|
||||
const logoAsset = {
|
||||
|
|
|
|||
|
|
@ -68,7 +68,6 @@ function createDbStub() {
|
|||
where() {
|
||||
return Promise.resolve([{
|
||||
name: "Acme Robotics",
|
||||
brandColor: "#114488",
|
||||
logoAssetId: null,
|
||||
}]);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ function registerModuleMocks() {
|
|||
|
||||
vi.doMock("../services/index.js", () => ({
|
||||
companyService: () => ({
|
||||
getById: vi.fn(async () => ({ id: "company-1", attachmentMaxBytes: 10 * 1024 * 1024 })),
|
||||
getById: vi.fn(async () => ({ id: "company-1" })),
|
||||
}),
|
||||
accessService: () => mockAccessService,
|
||||
agentService: () => ({
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ vi.mock("../services/index.js", () => ({
|
|||
completeTestRunForIssue: vi.fn(async () => null),
|
||||
}),
|
||||
companyService: () => ({
|
||||
getById: vi.fn(async () => ({ id: "company-1", attachmentMaxBytes: 10 * 1024 * 1024 })),
|
||||
getById: vi.fn(async () => ({ id: "company-1" })),
|
||||
}),
|
||||
documentAnnotationService: () => ({ remapOpenThreadsForDocument: async () => [] }),
|
||||
documentService: () => ({
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ vi.mock("../services/cross-issue-influence-limit.js", async (importOriginal) =>
|
|||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
companyService: () => ({
|
||||
getById: vi.fn(async () => ({ id: "company-1", attachmentMaxBytes: 10 * 1024 * 1024 })),
|
||||
getById: vi.fn(async () => ({ id: "company-1" })),
|
||||
}),
|
||||
accessService: () => ({
|
||||
canUser: vi.fn(async () => true),
|
||||
|
|
|
|||
|
|
@ -204,16 +204,14 @@ function parseBinaryResponse(res: IncomingMessage, callback: (error: Error | nul
|
|||
res.on("error", callback);
|
||||
}
|
||||
|
||||
describe("normalizeIssueAttachmentMaxBytes", () => {
|
||||
it("keeps the process-level attachment cap as the final cap", async () => {
|
||||
describe("MAX_ATTACHMENT_BYTES", () => {
|
||||
it("reads the deployment-level attachment cap from the environment", async () => {
|
||||
const previous = process.env.PAPERCLIP_ATTACHMENT_MAX_BYTES;
|
||||
process.env.PAPERCLIP_ATTACHMENT_MAX_BYTES = "5";
|
||||
vi.resetModules();
|
||||
try {
|
||||
const { normalizeIssueAttachmentMaxBytes } = await import("../attachment-types.js");
|
||||
expect(normalizeIssueAttachmentMaxBytes(null)).toBe(5);
|
||||
expect(normalizeIssueAttachmentMaxBytes(10)).toBe(5);
|
||||
expect(normalizeIssueAttachmentMaxBytes(3)).toBe(3);
|
||||
const { MAX_ATTACHMENT_BYTES } = await import("../attachment-types.js");
|
||||
expect(MAX_ATTACHMENT_BYTES).toBe(5);
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env.PAPERCLIP_ATTACHMENT_MAX_BYTES;
|
||||
|
|
@ -255,7 +253,6 @@ describe("issue attachment routes", () => {
|
|||
});
|
||||
mockCompanyService.getById.mockResolvedValue({
|
||||
id: "company-1",
|
||||
attachmentMaxBytes: 1024 * 1024 * 1024,
|
||||
});
|
||||
mockWorkProductService.createForIssue.mockReset();
|
||||
mockWorkProductService.getById.mockReset();
|
||||
|
|
@ -437,7 +434,7 @@ describe("issue attachment routes", () => {
|
|||
expect(res.body.contentType).toBe("application/octet-stream");
|
||||
});
|
||||
|
||||
it("enforces the process-level issue attachment limit even when the company limit allows more", async () => {
|
||||
it("bounds an issue attachment by the deployment-level limit", async () => {
|
||||
const storage = createStorageService();
|
||||
mockIssueService.getById.mockResolvedValue({
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
|
|
@ -455,30 +452,11 @@ describe("issue attachment routes", () => {
|
|||
});
|
||||
|
||||
expect(res.status).toBe(422);
|
||||
expect(res.body.error).toBe("Attachment exceeds 10485760 bytes");
|
||||
expect(res.body.error).toBe("Attachment is larger than the 10 MB limit");
|
||||
expect(storage.__calls.putFile).toBeUndefined();
|
||||
});
|
||||
|
||||
it("enforces the configured per-company issue attachment limit", async () => {
|
||||
const storage = createStorageService();
|
||||
mockCompanyService.getById.mockResolvedValue({
|
||||
id: "company-1",
|
||||
attachmentMaxBytes: 4,
|
||||
});
|
||||
mockIssueService.getById.mockResolvedValue({
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
companyId: "company-1",
|
||||
identifier: "PAP-1",
|
||||
});
|
||||
|
||||
const app = await createApp(storage);
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/issues/11111111-1111-4111-8111-111111111111/attachments")
|
||||
.attach("file", Buffer.from("large"), { filename: "large.txt", contentType: "text/plain" });
|
||||
|
||||
expect(res.status).toBe(422);
|
||||
expect(res.body.error).toBe("Attachment exceeds 4 bytes");
|
||||
expect(mockIssueService.createAttachment).not.toHaveBeenCalled();
|
||||
// The deployment cap is the only limit left. The route no longer reads a
|
||||
// per-company override, so it never loads the company to size an upload.
|
||||
expect(mockCompanyService.getById).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("serves html attachments as downloads with nosniff", async () => {
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ function registerServiceMocks() {
|
|||
|
||||
vi.doMock("../services/index.js", () => ({
|
||||
companyService: () => ({
|
||||
getById: vi.fn(async () => ({ id: "company-1", attachmentMaxBytes: 10 * 1024 * 1024 })),
|
||||
getById: vi.fn(async () => ({ id: "company-1" })),
|
||||
}),
|
||||
accessService: () => mockAccessService,
|
||||
agentService: () => ({
|
||||
|
|
|
|||
|
|
@ -124,7 +124,7 @@ function registerModuleMocks() {
|
|||
|
||||
vi.doMock("../services/index.js", () => ({
|
||||
companyService: () => ({
|
||||
getById: vi.fn(async () => ({ id: "company-1", attachmentMaxBytes: 10 * 1024 * 1024 })),
|
||||
getById: vi.fn(async () => ({ id: "company-1" })),
|
||||
}),
|
||||
accessService: () => mockAccessService,
|
||||
agentService: () => ({ getById: vi.fn(async () => null) }),
|
||||
|
|
|
|||
|
|
@ -135,7 +135,7 @@ vi.mock("../services/routines.js", () => ({
|
|||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
companyService: () => ({
|
||||
getById: vi.fn(async () => ({ id: "company-1", attachmentMaxBytes: 10 * 1024 * 1024 })),
|
||||
getById: vi.fn(async () => ({ id: "company-1" })),
|
||||
}),
|
||||
accessService: () => mockAccessService,
|
||||
agentService: () => mockAgentService,
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ const mockIssueService = vi.hoisted(() => ({
|
|||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
companyService: () => ({
|
||||
getById: vi.fn(async () => ({ id: "company-1", attachmentMaxBytes: 10 * 1024 * 1024 })),
|
||||
getById: vi.fn(async () => ({ id: "company-1" })),
|
||||
}),
|
||||
accessService: () => ({
|
||||
canUser: vi.fn(),
|
||||
|
|
|
|||
|
|
@ -112,7 +112,7 @@ function registerModuleMocks() {
|
|||
|
||||
vi.doMock("../services/index.js", () => ({
|
||||
companyService: () => ({
|
||||
getById: vi.fn(async () => ({ id: "company-1", attachmentMaxBytes: 10 * 1024 * 1024 })),
|
||||
getById: vi.fn(async () => ({ id: "company-1" })),
|
||||
}),
|
||||
accessService: () => mockAccessService,
|
||||
agentService: () => mockAgentService,
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ const mockIssueApprovalService = vi.hoisted(() => ({
|
|||
function registerModuleMocks() {
|
||||
vi.doMock("../services/index.js", () => ({
|
||||
companyService: () => ({
|
||||
getById: vi.fn(async () => ({ id: "company-1", attachmentMaxBytes: 10 * 1024 * 1024 })),
|
||||
getById: vi.fn(async () => ({ id: "company-1" })),
|
||||
}),
|
||||
accessService: () => mockAccessService,
|
||||
agentService: () => ({
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ function registerModuleMocks() {
|
|||
|
||||
vi.doMock("../services/index.js", () => ({
|
||||
companyService: () => ({
|
||||
getById: vi.fn(async () => ({ id: "company-1", attachmentMaxBytes: 10 * 1024 * 1024 })),
|
||||
getById: vi.fn(async () => ({ id: "company-1" })),
|
||||
}),
|
||||
accessService: () => mockAccessService,
|
||||
agentService: () => mockAgentService,
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ function registerModuleMocks() {
|
|||
|
||||
vi.doMock("../services/index.js", () => ({
|
||||
companyService: () => ({
|
||||
getById: vi.fn(async () => ({ id: "company-1", attachmentMaxBytes: 10 * 1024 * 1024 })),
|
||||
getById: vi.fn(async () => ({ id: "company-1" })),
|
||||
}),
|
||||
accessService: () => ({
|
||||
canUser: vi.fn(),
|
||||
|
|
|
|||
|
|
@ -153,7 +153,7 @@ function registerModuleMocks() {
|
|||
}));
|
||||
vi.doMock("../services/index.js", () => ({
|
||||
companyService: () => ({
|
||||
getById: vi.fn(async () => ({ id: "company-1", attachmentMaxBytes: 10 * 1024 * 1024 })),
|
||||
getById: vi.fn(async () => ({ id: "company-1" })),
|
||||
}),
|
||||
accessService: () => ({
|
||||
canUser: vi.fn(async () => true),
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ const mockIssueThreadInteractionService = vi.hoisted(() => ({
|
|||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
companyService: () => ({
|
||||
getById: vi.fn(async () => ({ id: "company-1", attachmentMaxBytes: 10 * 1024 * 1024 })),
|
||||
getById: vi.fn(async () => ({ id: "company-1" })),
|
||||
}),
|
||||
accessService: () => ({
|
||||
canUser: vi.fn(async () => true),
|
||||
|
|
@ -106,7 +106,7 @@ vi.mock("../services/index.js", () => ({
|
|||
function registerModuleMocks() {
|
||||
vi.doMock("../services/index.js", () => ({
|
||||
companyService: () => ({
|
||||
getById: vi.fn(async () => ({ id: "company-1", attachmentMaxBytes: 10 * 1024 * 1024 })),
|
||||
getById: vi.fn(async () => ({ id: "company-1" })),
|
||||
}),
|
||||
accessService: () => ({
|
||||
canUser: vi.fn(async () => true),
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@ function registerRouteMocks() {
|
|||
|
||||
vi.doMock("../services/index.js", () => ({
|
||||
companyService: () => ({
|
||||
getById: vi.fn(async () => ({ id: "company-1", attachmentMaxBytes: 10 * 1024 * 1024 })),
|
||||
getById: vi.fn(async () => ({ id: "company-1" })),
|
||||
}),
|
||||
accessService: () => mockAccessService,
|
||||
agentService: () => mockAgentService,
|
||||
|
|
|
|||
|
|
@ -102,7 +102,7 @@ const mockDb = vi.hoisted(() => ({
|
|||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
companyService: () => ({
|
||||
getById: vi.fn(async () => ({ id: "company-1", attachmentMaxBytes: 10 * 1024 * 1024 })),
|
||||
getById: vi.fn(async () => ({ id: "company-1" })),
|
||||
}),
|
||||
accessService: () => mockAccessService,
|
||||
agentService: () => mockAgentService,
|
||||
|
|
|
|||
|
|
@ -36,7 +36,6 @@ describe("monthly spend hydration", () => {
|
|||
budgetMonthlyCents: 5000,
|
||||
spentMonthlyCents: 999999,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
brandColor: null,
|
||||
logoAssetId: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
|
|
|
|||
|
|
@ -129,7 +129,6 @@ function createApp(actor: Record<string, unknown>, db: Record<string, unknown>)
|
|||
describe.sequential("POST /companies/:companyId/openclaw/invite-prompt", () => {
|
||||
const companyBranding = {
|
||||
name: "Acme AI",
|
||||
brandColor: "#225577",
|
||||
logoAssetId: "logo-1",
|
||||
};
|
||||
const logoAsset = {
|
||||
|
|
@ -245,7 +244,7 @@ describe.sequential("POST /companies/:companyId/openclaw/invite-prompt", () => {
|
|||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.companyName).toBe("Acme AI");
|
||||
expect(res.body.companyBrandColor).toBe("#225577");
|
||||
expect(res.body).not.toHaveProperty("companyBrandColor");
|
||||
expect(res.body.companyLogoUrl).toBe("/api/invites/pcp_invite_test/logo");
|
||||
expect(res.body.inviteType).toBe("company_join");
|
||||
expect(res.body.allowedJoinTypes).toBe("agent");
|
||||
|
|
|
|||
|
|
@ -14,11 +14,6 @@
|
|||
* - Exact types: "application/pdf"
|
||||
* - Wildcards: "image/*" or "application/vnd.openxmlformats-officedocument.*"
|
||||
*/
|
||||
import {
|
||||
DEFAULT_COMPANY_ATTACHMENT_MAX_BYTES,
|
||||
MAX_COMPANY_ATTACHMENT_MAX_BYTES,
|
||||
} from "@paperclipai/shared";
|
||||
|
||||
export const DEFAULT_ALLOWED_TYPES: readonly string[] = [
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
|
|
@ -146,12 +141,37 @@ export function isAllowedContentType(contentType: string): boolean {
|
|||
return matchesContentType(contentType, allowedPatterns);
|
||||
}
|
||||
|
||||
/**
|
||||
* The one attachment size ceiling for this deployment. Every upload path —
|
||||
* assets, task attachments, cases, and company import — bounds itself by this
|
||||
* value, so an operator raises or lowers the limit in exactly one place.
|
||||
*/
|
||||
export const MAX_ATTACHMENT_BYTES =
|
||||
Number(process.env.PAPERCLIP_ATTACHMENT_MAX_BYTES) || 10 * 1024 * 1024;
|
||||
|
||||
export function normalizeIssueAttachmentMaxBytes(value: number | null | undefined): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
|
||||
return Math.min(DEFAULT_COMPANY_ATTACHMENT_MAX_BYTES, MAX_ATTACHMENT_BYTES);
|
||||
const ATTACHMENT_SIZE_UNITS: readonly string[] = ["KB", "MB", "GB"];
|
||||
|
||||
/**
|
||||
* Render a byte count the way a person reading an error message expects it:
|
||||
* 1024-based steps under the conventional consumer labels, at most one decimal
|
||||
* place, and no trailing ".0". The default cap renders as "10 MB" rather than
|
||||
* "10485760 bytes". Sub-kilobyte values stay in bytes so a tiny configured cap
|
||||
* does not collapse to "0 KB".
|
||||
*/
|
||||
export function formatAttachmentSize(bytes: number): string {
|
||||
// Defensive: the cap itself can never be negative or NaN (`Number(env) || default`
|
||||
// falls back on both), but never render "NaN bytes" at a user.
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) return "0 bytes";
|
||||
if (bytes < 1024) return bytes === 1 ? "1 byte" : `${bytes} bytes`;
|
||||
|
||||
let value = bytes / 1024;
|
||||
let unitIndex = 0;
|
||||
while (value >= 1024 && unitIndex < ATTACHMENT_SIZE_UNITS.length - 1) {
|
||||
value /= 1024;
|
||||
unitIndex += 1;
|
||||
}
|
||||
return Math.min(Math.floor(value), MAX_COMPANY_ATTACHMENT_MAX_BYTES, MAX_ATTACHMENT_BYTES);
|
||||
|
||||
// toFixed(1) then strip a trailing ".0": 10.5 -> "10.5", 10.0 -> "10".
|
||||
const rounded = value.toFixed(1).replace(/\.0$/, "");
|
||||
return `${rounded} ${ATTACHMENT_SIZE_UNITS[unitIndex]}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1096,14 +1096,13 @@ function toInviteSummaryResponse(
|
|||
| string
|
||||
| {
|
||||
name: string | null;
|
||||
brandColor: string | null;
|
||||
logoUrl: string | null;
|
||||
}
|
||||
| null = null,
|
||||
authPublicBaseUrl?: string
|
||||
) {
|
||||
const companyInfo = typeof company === "string"
|
||||
? { name: company, brandColor: null, logoUrl: null }
|
||||
? { name: company, logoUrl: null }
|
||||
: company;
|
||||
const baseUrl = resolveBaseUrl(req, authPublicBaseUrl);
|
||||
const invitePath = `/invite/${token}`;
|
||||
|
|
@ -1116,7 +1115,6 @@ function toInviteSummaryResponse(
|
|||
companyId: invite.companyId,
|
||||
companyName: companyInfo?.name ?? null,
|
||||
companyLogoUrl: companyInfo?.logoUrl ?? null,
|
||||
companyBrandColor: companyInfo?.brandColor ?? null,
|
||||
inviteType: invite.inviteType,
|
||||
allowedJoinTypes: invite.allowedJoinTypes,
|
||||
humanRole: extractInviteHumanRole(invite),
|
||||
|
|
@ -3196,17 +3194,15 @@ export function accessRoutes(
|
|||
inviteToken: string | null = null,
|
||||
): Promise<{
|
||||
name: string | null;
|
||||
brandColor: string | null;
|
||||
logoAssetId: string | null;
|
||||
logoUrl: string | null;
|
||||
}> {
|
||||
if (!companyId) {
|
||||
return { name: null, brandColor: null, logoAssetId: null, logoUrl: null };
|
||||
return { name: null, logoAssetId: null, logoUrl: null };
|
||||
}
|
||||
const company = await db
|
||||
.select({
|
||||
name: companies.name,
|
||||
brandColor: companies.brandColor,
|
||||
logoAssetId: companyLogos.assetId,
|
||||
})
|
||||
.from(companies)
|
||||
|
|
@ -3238,7 +3234,6 @@ export function accessRoutes(
|
|||
|
||||
return {
|
||||
name: company?.name ?? null,
|
||||
brandColor: company?.brandColor ?? null,
|
||||
logoAssetId: company?.logoAssetId ?? null,
|
||||
logoUrl,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -6,7 +6,11 @@ import type { Db } from "@paperclipai/db";
|
|||
import { ASSET_NAMESPACE_RULE, createAssetImageMetadataSchema } from "@paperclipai/shared";
|
||||
import type { StorageService } from "../storage/types.js";
|
||||
import { assetService, logActivity } from "../services/index.js";
|
||||
import { isAllowedContentType, MAX_ATTACHMENT_BYTES } from "../attachment-types.js";
|
||||
import {
|
||||
formatAttachmentSize,
|
||||
isAllowedContentType,
|
||||
MAX_ATTACHMENT_BYTES,
|
||||
} from "../attachment-types.js";
|
||||
import { assertCompanyAccess, getAccessibleResource, getActorInfo } from "./authz.js";
|
||||
const SVG_CONTENT_TYPE = "image/svg+xml";
|
||||
const ALLOWED_COMPANY_LOGO_CONTENT_TYPES = new Set([
|
||||
|
|
@ -116,7 +120,9 @@ export function assetRoutes(db: Db, storage: StorageService) {
|
|||
} catch (err) {
|
||||
if (err instanceof multer.MulterError) {
|
||||
if (err.code === "LIMIT_FILE_SIZE") {
|
||||
res.status(422).json({ error: `File exceeds ${MAX_ATTACHMENT_BYTES} bytes` });
|
||||
res.status(422).json({
|
||||
error: `File is larger than the ${formatAttachmentSize(MAX_ATTACHMENT_BYTES)} limit`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
res.status(400).json({ error: err.message });
|
||||
|
|
@ -223,7 +229,9 @@ export function assetRoutes(db: Db, storage: StorageService) {
|
|||
} catch (err) {
|
||||
if (err instanceof multer.MulterError) {
|
||||
if (err.code === "LIMIT_FILE_SIZE") {
|
||||
res.status(422).json({ error: `Image exceeds ${MAX_ATTACHMENT_BYTES} bytes` });
|
||||
res.status(422).json({
|
||||
error: `Image is larger than the ${formatAttachmentSize(MAX_ATTACHMENT_BYTES)} limit`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
res.status(400).json({ error: err.message });
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ import {
|
|||
updateDocumentAnnotationThreadSchema,
|
||||
isUuidLike,
|
||||
} from "@paperclipai/shared";
|
||||
import { normalizeContentType } from "../attachment-types.js";
|
||||
import { formatAttachmentSize, MAX_ATTACHMENT_BYTES, normalizeContentType } from "../attachment-types.js";
|
||||
import { badRequest, conflict, forbidden, notFound, unprocessable } from "../errors.js";
|
||||
import { validate } from "../middleware/validate.js";
|
||||
import { instanceSettingsService } from "../services/instance-settings.js";
|
||||
|
|
@ -1276,19 +1276,15 @@ export function caseRoutes(db: Db, storage: StorageService) {
|
|||
await assertCasesEnabled(db);
|
||||
const caseRow = await assertCaseAccess(db, req, req.params.id as string);
|
||||
const actor = getActorInfo(req);
|
||||
const [company] = await db
|
||||
.select({ attachmentMaxBytes: companies.attachmentMaxBytes })
|
||||
.from(companies)
|
||||
.where(eq(companies.id, caseRow.companyId))
|
||||
.limit(1);
|
||||
const maxBytes = company?.attachmentMaxBytes ?? 10 * 1024 * 1024;
|
||||
|
||||
try {
|
||||
await singleFileUpload(req, res, maxBytes);
|
||||
await singleFileUpload(req, res, MAX_ATTACHMENT_BYTES);
|
||||
} catch (err) {
|
||||
if (err instanceof multer.MulterError) {
|
||||
if (err.code === "LIMIT_FILE_SIZE") {
|
||||
throw unprocessable(`Attachment exceeds ${maxBytes} bytes`);
|
||||
throw unprocessable(
|
||||
`Attachment is larger than the ${formatAttachmentSize(MAX_ATTACHMENT_BYTES)} limit`,
|
||||
);
|
||||
}
|
||||
throw badRequest(err.message);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -168,9 +168,10 @@ import {
|
|||
} from "./workspace-command-authz.js";
|
||||
import { shouldWakeAssigneeOnCheckout } from "./issues-checkout-wakeup.js";
|
||||
import {
|
||||
formatAttachmentSize,
|
||||
GENERIC_ATTACHMENT_CONTENT_TYPES,
|
||||
isInlineAttachmentContentType,
|
||||
normalizeIssueAttachmentMaxBytes,
|
||||
MAX_ATTACHMENT_BYTES,
|
||||
normalizeContentType,
|
||||
normalizeUploadAttachmentContentType,
|
||||
SVG_CONTENT_TYPE,
|
||||
|
|
@ -13007,15 +13008,14 @@ export function issueRoutes(
|
|||
if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return;
|
||||
if (!(await assertDeliverableMutationAllowedByRunContext(req, res, issue))) return;
|
||||
|
||||
const company = await companiesSvc.getById(companyId);
|
||||
const attachmentMaxBytes = normalizeIssueAttachmentMaxBytes(company?.attachmentMaxBytes);
|
||||
|
||||
try {
|
||||
await runSingleFileUpload(req, res, attachmentMaxBytes);
|
||||
await runSingleFileUpload(req, res, MAX_ATTACHMENT_BYTES);
|
||||
} catch (err) {
|
||||
if (err instanceof multer.MulterError) {
|
||||
if (err.code === "LIMIT_FILE_SIZE") {
|
||||
res.status(422).json({ error: `Attachment exceeds ${attachmentMaxBytes} bytes` });
|
||||
res.status(422).json({
|
||||
error: `Attachment is larger than the ${formatAttachmentSize(MAX_ATTACHMENT_BYTES)} limit`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
res.status(400).json({ error: err.message });
|
||||
|
|
|
|||
|
|
@ -146,7 +146,6 @@ export function companyService(db: Db) {
|
|||
issueCounter: companies.issueCounter,
|
||||
budgetMonthlyCents: companies.budgetMonthlyCents,
|
||||
spentMonthlyCents: companies.spentMonthlyCents,
|
||||
attachmentMaxBytes: companies.attachmentMaxBytes,
|
||||
defaultResponsibleUserId: companies.defaultResponsibleUserId,
|
||||
requireBoardApprovalForNewAgents: companies.requireBoardApprovalForNewAgents,
|
||||
interactionResolverGovernance: companies.interactionResolverGovernance,
|
||||
|
|
@ -154,7 +153,6 @@ export function companyService(db: Db) {
|
|||
feedbackDataSharingConsentAt: companies.feedbackDataSharingConsentAt,
|
||||
feedbackDataSharingConsentByUserId: companies.feedbackDataSharingConsentByUserId,
|
||||
feedbackDataSharingTermsVersion: companies.feedbackDataSharingTermsVersion,
|
||||
brandColor: companies.brandColor,
|
||||
logoAssetId: companyLogos.assetId,
|
||||
createdAt: companies.createdAt,
|
||||
updatedAt: companies.updatedAt,
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ import {
|
|||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
import { requireOpenCodeModelId } from "@paperclipai/adapter-opencode-local/server";
|
||||
import { findServerAdapter } from "../adapters/index.js";
|
||||
import { normalizeIssueAttachmentMaxBytes } from "../attachment-types.js";
|
||||
import { formatAttachmentSize, MAX_ATTACHMENT_BYTES } from "../attachment-types.js";
|
||||
import { forbidden, notFound, unprocessable } from "../errors.js";
|
||||
import { ghFetch, gitHubApiBase, resolveRawGitHubUrl } from "./github-fetch.js";
|
||||
import type { StorageService } from "../storage/types.js";
|
||||
|
|
@ -2394,7 +2394,6 @@ const YAML_KEY_PRIORITY = [
|
|||
"role",
|
||||
"icon",
|
||||
"capabilities",
|
||||
"brandColor",
|
||||
"logoPath",
|
||||
"adapter",
|
||||
"runtime",
|
||||
|
|
@ -3149,12 +3148,7 @@ function buildManifestFromPackageFiles(
|
|||
path: resolvedCompanyPath,
|
||||
name: companyName,
|
||||
description: asString(companyFrontmatter.description),
|
||||
brandColor: asString(paperclipCompany.brandColor),
|
||||
logoPath: asString(paperclipCompany.logoPath) ?? asString(paperclipCompany.logo),
|
||||
attachmentMaxBytes:
|
||||
typeof paperclipCompany.attachmentMaxBytes === "number" && Number.isFinite(paperclipCompany.attachmentMaxBytes)
|
||||
? Math.max(1, Math.floor(paperclipCompany.attachmentMaxBytes))
|
||||
: null,
|
||||
requireBoardApprovalForNewAgents:
|
||||
typeof paperclipCompany.requireBoardApprovalForNewAgents === "boolean"
|
||||
? paperclipCompany.requireBoardApprovalForNewAgents
|
||||
|
|
@ -4688,9 +4682,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
|||
schema: "paperclip/v1",
|
||||
schemaVersion: BUNDLE_SCHEMA_VERSION,
|
||||
company: stripEmptyValues({
|
||||
brandColor: company.brandColor ?? null,
|
||||
logoPath: companyLogoPath,
|
||||
attachmentMaxBytes: company.attachmentMaxBytes,
|
||||
requireBoardApprovalForNewAgents: company.requireBoardApprovalForNewAgents ? true : undefined,
|
||||
feedbackDataSharingEnabled: company.feedbackDataSharingEnabled ? true : undefined,
|
||||
feedbackDataSharingConsentAt: company.feedbackDataSharingConsentAt?.toISOString() ?? null,
|
||||
|
|
@ -5269,7 +5261,6 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
|||
id: string;
|
||||
name: string;
|
||||
requireBoardApprovalForNewAgents?: boolean | null;
|
||||
attachmentMaxBytes?: number | null;
|
||||
} | null = null;
|
||||
let companyAction: "created" | "updated" | "unchanged" = "unchanged";
|
||||
|
||||
|
|
@ -5301,10 +5292,6 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
|||
const created = await companies.create({
|
||||
name: companyName,
|
||||
description: include.company ? (sourceManifest.company?.description ?? null) : null,
|
||||
brandColor: include.company ? (sourceManifest.company?.brandColor ?? null) : null,
|
||||
attachmentMaxBytes: include.company
|
||||
? (sourceManifest.company?.attachmentMaxBytes ?? undefined)
|
||||
: undefined,
|
||||
requireBoardApprovalForNewAgents: include.company
|
||||
? (sourceManifest.company?.requireBoardApprovalForNewAgents ?? false)
|
||||
: false,
|
||||
|
|
@ -5342,8 +5329,6 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
|||
const updated = await companies.update(targetCompany.id, {
|
||||
name: sourceManifest.company.name,
|
||||
description: sourceManifest.company.description,
|
||||
brandColor: sourceManifest.company.brandColor,
|
||||
attachmentMaxBytes: sourceManifest.company.attachmentMaxBytes ?? undefined,
|
||||
requireBoardApprovalForNewAgents: sourceManifest.company.requireBoardApprovalForNewAgents,
|
||||
feedbackDataSharingEnabled: sourceManifest.company.feedbackDataSharingEnabled,
|
||||
feedbackDataSharingConsentAt: sourceManifest.company.feedbackDataSharingConsentAt
|
||||
|
|
@ -5920,7 +5905,6 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
|||
const parentSlugBySlug = new Map<string, string>();
|
||||
let unarmedMonitorCount = 0;
|
||||
let attachmentsSkippedNoStorage = 0;
|
||||
const attachmentMaxBytes = normalizeIssueAttachmentMaxBytes(targetCompany.attachmentMaxBytes ?? null);
|
||||
|
||||
// Import writes every issue and its children as a single batch instead
|
||||
// of one network round-trip per row. The loop below resolves each
|
||||
|
|
@ -6196,8 +6180,8 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
|||
if (sha256HexOfBytes(body) !== attachmentEntry.sha256) {
|
||||
throw unprocessable(`Attachment blob ${blobPath} does not match its declared sha256; the package is corrupted or was tampered with.`);
|
||||
}
|
||||
if (body.length > attachmentMaxBytes) {
|
||||
warnings.push(`Task ${manifestIssue.slug} attachment ${attachmentLabel} was skipped because it exceeds this board's attachment size limit of ${attachmentMaxBytes} bytes.`);
|
||||
if (body.length > MAX_ATTACHMENT_BYTES) {
|
||||
warnings.push(`Task ${manifestIssue.slug} attachment ${attachmentLabel} was skipped because it exceeds this deployment's attachment size limit of ${formatAttachmentSize(MAX_ATTACHMENT_BYTES)}.`);
|
||||
continue;
|
||||
}
|
||||
let issueCommentId: string | null = null;
|
||||
|
|
|
|||
|
|
@ -718,7 +718,7 @@ PATCH /api/companies/{companyId} — update company fields
|
|||
POST /api/companies/{companyId}/logo — upload logo (multipart, field: "file")
|
||||
```
|
||||
|
||||
**CEO-allowed fields:** `name`, `description`, `brandColor` (hex e.g. `#FF5733` or null), `logoAssetId` (UUID or null).
|
||||
**CEO-allowed fields:** `name`, `description`, `logoAssetId` (UUID or null).
|
||||
|
||||
**Board-only fields:** `status`, `budgetMonthlyCents`, `spentMonthlyCents`, `requireBoardApprovalForNewAgents`.
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ type InviteSummary = {
|
|||
companyId: string | null;
|
||||
companyName?: string | null;
|
||||
companyLogoUrl?: string | null;
|
||||
companyBrandColor?: string | null;
|
||||
inviteType: "company_join" | "bootstrap_ceo";
|
||||
allowedJoinTypes: "human" | "agent" | "both";
|
||||
humanRole?: HumanCompanyRole | null;
|
||||
|
|
|
|||
|
|
@ -101,11 +101,9 @@ export const companiesApi = {
|
|||
| "description"
|
||||
| "status"
|
||||
| "budgetMonthlyCents"
|
||||
| "attachmentMaxBytes"
|
||||
| "requireBoardApprovalForNewAgents"
|
||||
| "interactionResolverGovernance"
|
||||
| "feedbackDataSharingEnabled"
|
||||
| "brandColor"
|
||||
| "logoAssetId"
|
||||
>
|
||||
>,
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ const BAYER_4X4 = [
|
|||
interface CompanyPatternIconProps {
|
||||
companyName: string;
|
||||
logoUrl?: string | null;
|
||||
brandColor?: string | null;
|
||||
className?: string;
|
||||
logoFit?: "cover" | "contain";
|
||||
}
|
||||
|
|
@ -75,22 +74,7 @@ function hslToRgb(h: number, s: number, l: number): [number, number, number] {
|
|||
];
|
||||
}
|
||||
|
||||
function hexToHue(hex: string): number {
|
||||
const r = parseInt(hex.slice(1, 3), 16) / 255;
|
||||
const g = parseInt(hex.slice(3, 5), 16) / 255;
|
||||
const b = parseInt(hex.slice(5, 7), 16) / 255;
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
const d = max - min;
|
||||
if (d === 0) return 0;
|
||||
let h = 0;
|
||||
if (max === r) h = ((g - b) / d) % 6;
|
||||
else if (max === g) h = (b - r) / d + 2;
|
||||
else h = (r - g) / d + 4;
|
||||
return ((h * 60) + 360) % 360;
|
||||
}
|
||||
|
||||
function makeCompanyPatternDataUrl(seed: string, brandColor?: string | null, logicalSize = 22, cellSize = 2): string {
|
||||
function makeCompanyPatternDataUrl(seed: string, logicalSize = 22, cellSize = 2): string {
|
||||
if (typeof document === "undefined") return "";
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
|
|
@ -102,7 +86,7 @@ function makeCompanyPatternDataUrl(seed: string, brandColor?: string | null, log
|
|||
|
||||
const rand = mulberry32(hashString(seed));
|
||||
|
||||
const hue = brandColor ? hexToHue(brandColor) : Math.floor(rand() * 360);
|
||||
const hue = Math.floor(rand() * 360);
|
||||
const [offR, offG, offB] = hslToRgb(
|
||||
hue,
|
||||
54 + Math.floor(rand() * 14),
|
||||
|
|
@ -165,7 +149,6 @@ function makeCompanyPatternDataUrl(seed: string, brandColor?: string | null, log
|
|||
export function CompanyPatternIcon({
|
||||
companyName,
|
||||
logoUrl,
|
||||
brandColor,
|
||||
className,
|
||||
logoFit = "cover",
|
||||
}: CompanyPatternIconProps) {
|
||||
|
|
@ -176,8 +159,8 @@ export function CompanyPatternIcon({
|
|||
setImageError(false);
|
||||
}, [logoUrl]);
|
||||
const patternDataUrl = useMemo(
|
||||
() => makeCompanyPatternDataUrl(companyName.trim().toLowerCase(), brandColor),
|
||||
[companyName, brandColor],
|
||||
() => makeCompanyPatternDataUrl(companyName.trim().toLowerCase()),
|
||||
[companyName],
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -27,7 +27,6 @@ const companyState = vi.hoisted(() => ({
|
|||
id: "company-1",
|
||||
name: "Paperclip",
|
||||
status: "active",
|
||||
brandColor: "#123456",
|
||||
issuePrefix: "PAP",
|
||||
},
|
||||
],
|
||||
|
|
@ -36,7 +35,6 @@ const companyState = vi.hoisted(() => ({
|
|||
id: "company-1",
|
||||
name: "Paperclip",
|
||||
status: "active",
|
||||
brandColor: "#123456",
|
||||
issuePrefix: "PAP",
|
||||
},
|
||||
}));
|
||||
|
|
@ -1472,7 +1470,6 @@ describe("NewIssueDialog", () => {
|
|||
id: "company-1",
|
||||
name: "Acme Labs",
|
||||
status: "active",
|
||||
brandColor: "#123456",
|
||||
issuePrefix: "OPS",
|
||||
},
|
||||
];
|
||||
|
|
@ -1480,7 +1477,6 @@ describe("NewIssueDialog", () => {
|
|||
id: "company-1",
|
||||
name: "Acme Labs",
|
||||
status: "active",
|
||||
brandColor: "#123456",
|
||||
issuePrefix: "OPS",
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { memo, useState, useEffect, useRef, useCallback, useMemo, type ChangeEvent, type CSSProperties, type DragEvent, type RefObject } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import type { AgentEnvConfig, EnvBinding, IssueWorkMode } from "@paperclipai/shared";
|
||||
import { pickTextColorForSolidBg } from "@/lib/color-contrast";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useAdapterCapabilities } from "../adapters/use-adapter-capabilities";
|
||||
|
|
@ -1418,19 +1417,8 @@ export function NewIssueDialog() {
|
|||
<Popover open={companyOpen} onOpenChange={setCompanyOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
"px-1.5 py-0.5 rounded text-xs font-semibold cursor-pointer hover:opacity-80 transition-opacity",
|
||||
!dialogCompany?.brandColor && "bg-muted",
|
||||
)}
|
||||
className="px-1.5 py-0.5 rounded bg-muted text-xs font-semibold cursor-pointer hover:opacity-80 transition-opacity"
|
||||
disabled={isSubIssueMode}
|
||||
style={
|
||||
dialogCompany?.brandColor
|
||||
? {
|
||||
backgroundColor: dialogCompany.brandColor,
|
||||
color: pickTextColorForSolidBg(dialogCompany.brandColor),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{dialogCompany?.issuePrefix ?? ""}
|
||||
</button>
|
||||
|
|
@ -1448,20 +1436,7 @@ export function NewIssueDialog() {
|
|||
setCompanyOpen(false);
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"px-1 py-0.5 rounded text-(length:--text-nano) font-semibold leading-none",
|
||||
!c.brandColor && "bg-muted",
|
||||
)}
|
||||
style={
|
||||
c.brandColor
|
||||
? {
|
||||
backgroundColor: c.brandColor,
|
||||
color: pickTextColorForSolidBg(c.brandColor),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<span className="px-1 py-0.5 rounded bg-muted text-(length:--text-nano) font-semibold leading-none">
|
||||
{c.issuePrefix}
|
||||
</span>
|
||||
<span className="truncate">{c.name}</span>
|
||||
|
|
|
|||
|
|
@ -71,21 +71,18 @@ vi.mock("@/context/CompanyContext", () => ({
|
|||
id: "company-1",
|
||||
issuePrefix: "PAP",
|
||||
name: "Acme Labs",
|
||||
brandColor: "#3366ff",
|
||||
status: "active",
|
||||
},
|
||||
{
|
||||
id: "company-2",
|
||||
issuePrefix: "STR",
|
||||
name: "Strata",
|
||||
brandColor: "#36a269",
|
||||
status: "active",
|
||||
},
|
||||
{
|
||||
id: "company-3",
|
||||
issuePrefix: "ANA",
|
||||
name: "Anachronist Wiki",
|
||||
brandColor: "#a36a21",
|
||||
status: "active",
|
||||
},
|
||||
],
|
||||
|
|
@ -93,7 +90,6 @@ vi.mock("@/context/CompanyContext", () => ({
|
|||
id: "company-1",
|
||||
issuePrefix: "PAP",
|
||||
name: "Acme Labs",
|
||||
brandColor: "#3366ff",
|
||||
logoUrl: "/api/assets/logo-asset-1/content",
|
||||
status: "active",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -60,7 +60,6 @@ function WorkspaceIcon({ company }: { company: Company }) {
|
|||
<CompanyPatternIcon
|
||||
companyName={company.name}
|
||||
logoUrl={company.logoUrl}
|
||||
brandColor={company.brandColor}
|
||||
className={WORKSPACE_ICON_CLASS}
|
||||
/>
|
||||
);
|
||||
|
|
@ -92,7 +91,6 @@ function CurrentStackIcon({
|
|||
<CompanyPatternIcon
|
||||
companyName={displayName}
|
||||
logoUrl={company?.logoUrl}
|
||||
brandColor={company?.brandColor}
|
||||
className={WORKSPACE_ICON_CLASS}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -54,7 +54,6 @@ function makeCompany(id: string): Company {
|
|||
issueCounter: 1,
|
||||
budgetMonthlyCents: 0,
|
||||
spentMonthlyCents: 0,
|
||||
attachmentMaxBytes: 10 * 1024 * 1024,
|
||||
defaultResponsibleUserId: null,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
interactionResolverGovernance: {},
|
||||
|
|
@ -62,7 +61,6 @@ function makeCompany(id: string): Company {
|
|||
feedbackDataSharingConsentAt: null,
|
||||
feedbackDataSharingConsentByUserId: null,
|
||||
feedbackDataSharingTermsVersion: null,
|
||||
brandColor: null,
|
||||
logoAssetId: null,
|
||||
logoUrl: null,
|
||||
createdAt: new Date(),
|
||||
|
|
|
|||
|
|
@ -1991,13 +1991,13 @@ span.paperclip-mention-chip[data-mention-kind="external-object"] {
|
|||
NOT converted to tokens — each also carries an inline
|
||||
`token-extraction: allowlisted` comment at the site):
|
||||
- pages/CompanyEnvironments.tsx — xterm.js terminal theme config; functional JS values, third-party.
|
||||
- pages/CompanySettings.tsx — <input type="color"> value; functional form control, not a rendered value.
|
||||
- pages/CompanySettings.tsx — <input type="color"> value; functional form control, not a rendered value. REMOVED: the company brand-color setting was deleted, so the control no longer exists.
|
||||
- components/issue-properties/IssueProperties.tsx (newLabelColor) — color-picker seed persisted into label-create payload.
|
||||
- pages/CompanySkills.tsx (DISCOVERY_ACCENTS) — persisted/compared skill.color JS data, not just rendered.
|
||||
- components/IssueColumns.tsx (accentColor fallback) — also feeds pickTextColorForPillBg() contrast math.
|
||||
- components/CompanyPatternIcon.tsx — canvas fillStyle computed at runtime from numeric props, not a static literal.
|
||||
- pages/InviteUxLab.tsx (brandColor prop, x2) — demo/showcase-only prop feeding CompanyPatternIcon's hexToHue() color math, not a rendered CSS value. REMOVED: the brandColor prop was deleted, so the props no longer exist.
|
||||
- components/FileViewerSheet.tsx — half-migrated var(--paperclip-code-highlight-*, fallback) pattern at the time of Batch 1; RESOLVED in Batch 4 via *-resolved wrapper tokens (see the Batch 4 MISC token block below) — no longer allowlisted.
|
||||
- pages/InviteUxLab.tsx (brandColor prop, x2) — demo/showcase-only prop feeding CompanyPatternIcon's hexToHue() color math, not a rendered CSS value.
|
||||
*/
|
||||
:root {
|
||||
--hex-959596: #959596; /* Muted feed actor/verb/title text (ActivityFeed.tsx, FeedCard.tsx) — PRIOR-ART-flagged gap cluster, no existing token match. */
|
||||
|
|
@ -2436,12 +2436,10 @@ span.paperclip-mention-chip[data-mention-kind="external-object"] {
|
|||
allowlisted concern. Paths are matched by the gate script as a substring
|
||||
of the file being checked (repo-relative, POSIX-style).
|
||||
* allow ui/src/pages/CompanyEnvironments.tsx — xterm.js terminal theme config (background/foreground/cursor/cursorAccent/selectionBackground hex, fontSize: 12); functional third-party option object, not a rendered CSS value
|
||||
* allow ui/src/pages/CompanySettings.tsx — <input type="color"> value; the DOM color-picker control requires a real hex string
|
||||
* allow ui/src/components/issue-properties/IssueProperties.tsx — newLabelColor color-picker seed state persisted into the label-create payload sent to the backend; a var() string would corrupt that payload
|
||||
* allow ui/src/pages/CompanySkills.tsx — DISCOVERY_ACCENTS palette array feeds skill.color, persisted/compared JS data (SkillCreateDraft), not just a rendered value; also fontSize: Math.round(size * 0.42), computed at runtime from a prop, not a static literal
|
||||
* allow ui/src/components/IssueColumns.tsx — accentColor fallback also feeds pickTextColorForPillBg() contrast math (lib/color-contrast.ts), which needs a real hex string to compute luminance
|
||||
* allow ui/src/components/CompanyPatternIcon.tsx — canvas 2D fillStyle built from a runtime-computed template literal, not a static literal at all
|
||||
* allow ui/src/pages/InviteUxLab.tsx — brandColor prop (x2) feeds CompanyPatternIcon's hexToHue() color math via the same canvas-fill code path; demo/showcase-only prop, not a rendered CSS value
|
||||
* allow ui/src/components/ui/scroll-area.tsx — rounded-[inherit] is a CSS keyword, not a literal value; nothing to extract
|
||||
* allow ui/src/components/ui/dialog.tsx — tw-animate-css plugin utilities (zoom-in-[0.97], zoom-out-[0.97], slide-in-from-top-[1%], slide-out-to-top-[1%], animate-in, animate-out, fade-in-0, fade-out-0) are dead/no-op classes today (the tw-animate-css plugin is not installed and no matching @utility exists in this file; confirmed via grep of the built storybook-static CSS — none of these class names emit any rule); nothing to tokenize without visually changing a currently-inert class
|
||||
* allow ui/src/components/ui/alert-dialog.tsx — same tw-animate-css dead-class situation as dialog.tsx (zoom-out-[0.97], zoom-in-[0.97])
|
||||
|
|
@ -2468,15 +2466,14 @@ span.paperclip-mention-chip[data-mention-kind="external-object"] {
|
|||
* allow ui/src/components/IssueProperties.test.tsx — mock label/project color fixtures
|
||||
* allow ui/src/components/MarkdownBody.test.tsx — mock project color fed through a mention-href builder
|
||||
* allow ui/src/components/MarkdownEditor.test.tsx — mock projectColor mention fixture
|
||||
* allow ui/src/components/NewIssueDialog.test.tsx — mock brandColor/label color fixtures
|
||||
* allow ui/src/components/NewIssueDialog.test.tsx — mock label color fixtures
|
||||
* allow ui/src/components/ProjectTile.test.tsx — mock project color prop
|
||||
* allow ui/src/components/RoutineRunVariablesDialog.test.tsx — mock label color fixture
|
||||
* allow ui/src/components/SidebarCompanyMenu.test.tsx — mock brandColor company fixtures
|
||||
* allow ui/src/components/SidebarProjects.test.tsx — mock project color fixture
|
||||
* allow ui/src/components/SidebarStarredProjects.test.tsx — mock project color fixture
|
||||
* allow ui/src/pages/CompanyEnvironments.test.tsx — xterm.js theme-mock assertion values (mirrors the allowlisted xterm.js config in the CompanyEnvironments.tsx source above)
|
||||
* allow ui/src/pages/ExecutionWorkspaceDetail.test.tsx — mock project color fixture
|
||||
* allow ui/src/pages/InviteLanding.test.tsx — mock companyBrandColor fixtures
|
||||
* allow ui/src/pages/InviteLanding.test.tsx — "#NNNN" pull-request references inside explanatory comments, not color literals
|
||||
* allow ui/src/pages/IssueDetail.test.tsx — mock project color fixture
|
||||
* allow ui/src/pages/ProjectDetail.test.tsx — mock project color fixture
|
||||
* allow ui/src/pages/ProjectWorkspaceDetail.test.tsx — mock project color fixture
|
||||
|
|
|
|||
|
|
@ -81,21 +81,6 @@ export const READABLE_TEXT_DARK = "#111827";
|
|||
const TEXT_LIGHT = READABLE_TEXT_LIGHT;
|
||||
const TEXT_DARK = READABLE_TEXT_DARK;
|
||||
|
||||
/**
|
||||
* Pick a readable text color for a solid background.
|
||||
* Uses WCAG contrast ratios to choose between light and dark text.
|
||||
*/
|
||||
export function pickTextColorForSolidBg(hexColor: string): string {
|
||||
const rgb = hexToRgb(hexColor);
|
||||
if (!rgb) return TEXT_LIGHT;
|
||||
const bgLum = relativeLuminance(rgb.r, rgb.g, rgb.b);
|
||||
const whiteLum = relativeLuminance(248, 250, 252);
|
||||
const blackLum = relativeLuminance(17, 24, 39);
|
||||
return contrastRatio(bgLum, whiteLum) >= contrastRatio(bgLum, blackLum)
|
||||
? TEXT_LIGHT
|
||||
: TEXT_DARK;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick a readable text color for a semi-transparent pill background.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -181,7 +181,6 @@ describe("CompanyEnvironments", () => {
|
|||
id: "company-1",
|
||||
name: "Paperclip",
|
||||
description: null,
|
||||
brandColor: null,
|
||||
logoUrl: null,
|
||||
issuePrefix: "PAP",
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
import { ChangeEvent, useEffect, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
DEFAULT_COMPANY_ATTACHMENT_MAX_BYTES,
|
||||
MAX_COMPANY_ATTACHMENT_MAX_BYTES,
|
||||
type InteractionResolverGovernance,
|
||||
type IssueThreadInteractionKind,
|
||||
} from "@paperclipai/shared";
|
||||
|
|
@ -27,10 +25,6 @@ import {
|
|||
} from "../components/agent-config-primitives";
|
||||
import { InstanceGeneralSettings } from "./InstanceGeneralSettings";
|
||||
|
||||
const BYTES_PER_MIB = 1024 * 1024;
|
||||
const DEFAULT_COMPANY_ATTACHMENT_MAX_MIB = DEFAULT_COMPANY_ATTACHMENT_MAX_BYTES / BYTES_PER_MIB;
|
||||
const MAX_COMPANY_ATTACHMENT_MAX_MIB = MAX_COMPANY_ATTACHMENT_MAX_BYTES / BYTES_PER_MIB;
|
||||
|
||||
export function CompanySettings() {
|
||||
const {
|
||||
companies,
|
||||
|
|
@ -46,8 +40,6 @@ export function CompanySettings() {
|
|||
// General settings local state
|
||||
const [companyName, setCompanyName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [brandColor, setBrandColor] = useState("");
|
||||
const [attachmentMaxMiB, setAttachmentMaxMiB] = useState(String(DEFAULT_COMPANY_ATTACHMENT_MAX_MIB));
|
||||
const [logoUrl, setLogoUrl] = useState("");
|
||||
const [logoUploadError, setLogoUploadError] = useState<string | null>(null);
|
||||
const [governance, setGovernance] = useState<InteractionResolverGovernance>({});
|
||||
|
|
@ -57,31 +49,19 @@ export function CompanySettings() {
|
|||
if (!selectedCompany) return;
|
||||
setCompanyName(selectedCompany.name);
|
||||
setDescription(selectedCompany.description ?? "");
|
||||
setBrandColor(selectedCompany.brandColor ?? "");
|
||||
setAttachmentMaxMiB(String(Math.round((selectedCompany.attachmentMaxBytes ?? DEFAULT_COMPANY_ATTACHMENT_MAX_BYTES) / BYTES_PER_MIB)));
|
||||
setLogoUrl(selectedCompany.logoUrl ?? "");
|
||||
setGovernance(selectedCompany.interactionResolverGovernance ?? {});
|
||||
}, [selectedCompany]);
|
||||
|
||||
const attachmentMaxBytes = Number.parseInt(attachmentMaxMiB, 10) * BYTES_PER_MIB;
|
||||
const attachmentMaxValid =
|
||||
Number.isInteger(attachmentMaxBytes)
|
||||
&& attachmentMaxBytes >= BYTES_PER_MIB
|
||||
&& attachmentMaxBytes <= MAX_COMPANY_ATTACHMENT_MAX_BYTES;
|
||||
|
||||
const generalDirty =
|
||||
!!selectedCompany &&
|
||||
(companyName !== selectedCompany.name ||
|
||||
description !== (selectedCompany.description ?? "") ||
|
||||
brandColor !== (selectedCompany.brandColor ?? "") ||
|
||||
attachmentMaxBytes !== (selectedCompany.attachmentMaxBytes ?? DEFAULT_COMPANY_ATTACHMENT_MAX_BYTES));
|
||||
description !== (selectedCompany.description ?? ""));
|
||||
|
||||
const generalMutation = useMutation({
|
||||
mutationFn: (data: {
|
||||
name: string;
|
||||
description: string | null;
|
||||
brandColor: string | null;
|
||||
attachmentMaxBytes: number;
|
||||
}) => companiesApi.update(selectedCompanyId!, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
|
||||
|
|
@ -192,9 +172,7 @@ export function CompanySettings() {
|
|||
function handleSaveGeneral() {
|
||||
generalMutation.mutate({
|
||||
name: companyName.trim(),
|
||||
description: description.trim() || null,
|
||||
brandColor: brandColor || null,
|
||||
attachmentMaxBytes
|
||||
description: description.trim() || null
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -251,7 +229,6 @@ export function CompanySettings() {
|
|||
<CompanyPatternIcon
|
||||
companyName={companyName || selectedCompany.name}
|
||||
logoUrl={logoUrl || null}
|
||||
brandColor={brandColor || null}
|
||||
className="rounded-(--rad-14)"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -297,66 +274,6 @@ export function CompanySettings() {
|
|||
)}
|
||||
</div>
|
||||
</Field>
|
||||
<Field
|
||||
label="Brand color"
|
||||
hint="Sets the hue for the organization icon. Leave empty for auto-generated color."
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* token-extraction: allowlisted — <input type="color"> value must be a real hex string, not a var() reference. */}
|
||||
<input
|
||||
type="color"
|
||||
value={brandColor || "#6366f1"}
|
||||
onChange={(e) => setBrandColor(e.target.value)}
|
||||
className="h-8 w-8 cursor-pointer rounded border border-border bg-transparent p-0"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={brandColor}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
if (v === "" || /^#[0-9a-fA-F]{0,6}$/.test(v)) {
|
||||
setBrandColor(v);
|
||||
}
|
||||
}}
|
||||
placeholder="Auto"
|
||||
className="w-28 rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm font-mono outline-none"
|
||||
/>
|
||||
{brandColor && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setBrandColor("")}
|
||||
className="text-xs text-muted-foreground"
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Field>
|
||||
<Field
|
||||
label="Attachment size limit"
|
||||
hint={`Accepted range: 1-${MAX_COMPANY_ATTACHMENT_MAX_MIB} MiB.`}
|
||||
>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={MAX_COMPANY_ATTACHMENT_MAX_MIB}
|
||||
step={1}
|
||||
value={attachmentMaxMiB}
|
||||
onChange={(e) => setAttachmentMaxMiB(e.target.value)}
|
||||
className="w-28 rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">MiB</span>
|
||||
</div>
|
||||
{!attachmentMaxValid && (
|
||||
<span className="text-xs text-destructive">
|
||||
Enter a whole number from 1 to {MAX_COMPANY_ATTACHMENT_MAX_MIB}.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -368,7 +285,7 @@ export function CompanySettings() {
|
|||
<Button
|
||||
size="sm"
|
||||
onClick={handleSaveGeneral}
|
||||
disabled={generalMutation.isPending || !companyName.trim() || !attachmentMaxValid}
|
||||
disabled={generalMutation.isPending || !companyName.trim()}
|
||||
>
|
||||
{generalMutation.isPending ? "Saving..." : "Save changes"}
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -105,7 +105,6 @@ describe("InviteLandingPage", () => {
|
|||
companyId: "company-1",
|
||||
companyName: "Acme Robotics",
|
||||
companyLogoUrl: "/api/invites/pcp_invite_test/logo",
|
||||
companyBrandColor: "#114488",
|
||||
inviteType: "company_join",
|
||||
allowedJoinTypes: "both",
|
||||
humanRole: "operator",
|
||||
|
|
@ -578,7 +577,6 @@ describe("InviteLandingPage", () => {
|
|||
companyId: "company-1",
|
||||
companyName: "Acme Robotics",
|
||||
companyLogoUrl: "/api/invites/pcp_invite_test/logo",
|
||||
companyBrandColor: "#114488",
|
||||
inviteType: "company_join",
|
||||
allowedJoinTypes: "both",
|
||||
humanRole: "operator",
|
||||
|
|
@ -645,7 +643,6 @@ describe("InviteLandingPage", () => {
|
|||
companyId: "company-1",
|
||||
companyName: "Acme Robotics",
|
||||
companyLogoUrl: "/api/invites/pcp_invite_test/logo",
|
||||
companyBrandColor: "#114488",
|
||||
inviteType: "company_join",
|
||||
allowedJoinTypes: "human",
|
||||
humanRole: "operator",
|
||||
|
|
|
|||
|
|
@ -123,7 +123,6 @@ function isApprovedHumanJoinPayload(payload: unknown, showsAgentForm: boolean) {
|
|||
type AwaitingJoinApprovalPanelProps = {
|
||||
companyDisplayName: string;
|
||||
companyLogoUrl: string | null;
|
||||
companyBrandColor: string | null;
|
||||
invitedByUserName: string | null;
|
||||
claimSecret?: string | null;
|
||||
claimApiKeyPath?: string | null;
|
||||
|
|
@ -133,19 +132,16 @@ type AwaitingJoinApprovalPanelProps = {
|
|||
function InviteCompanyLogo({
|
||||
companyDisplayName,
|
||||
companyLogoUrl,
|
||||
companyBrandColor,
|
||||
className,
|
||||
}: {
|
||||
companyDisplayName: string;
|
||||
companyLogoUrl: string | null;
|
||||
companyBrandColor: string | null;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<CompanyPatternIcon
|
||||
companyName={companyDisplayName}
|
||||
logoUrl={companyLogoUrl}
|
||||
brandColor={companyBrandColor}
|
||||
logoFit="contain"
|
||||
className={className}
|
||||
/>
|
||||
|
|
@ -155,7 +151,6 @@ function InviteCompanyLogo({
|
|||
function AwaitingJoinApprovalPanel({
|
||||
companyDisplayName,
|
||||
companyLogoUrl,
|
||||
companyBrandColor,
|
||||
invitedByUserName,
|
||||
claimSecret = null,
|
||||
claimApiKeyPath = null,
|
||||
|
|
@ -170,7 +165,6 @@ function AwaitingJoinApprovalPanel({
|
|||
<InviteCompanyLogo
|
||||
companyDisplayName={companyDisplayName}
|
||||
companyLogoUrl={companyLogoUrl}
|
||||
companyBrandColor={companyBrandColor}
|
||||
className="h-12 w-12 border border-zinc-800 rounded-none"
|
||||
/>
|
||||
<h1 className="text-lg font-semibold">Request to join {companyDisplayName}</h1>
|
||||
|
|
@ -297,7 +291,6 @@ export function InviteLandingPage() {
|
|||
const companyName = invite?.companyName?.trim() || null;
|
||||
const companyDisplayName = companyName || "this Paperclip company";
|
||||
const companyLogoUrl = invite?.companyLogoUrl?.trim() || null;
|
||||
const companyBrandColor = invite?.companyBrandColor?.trim() || null;
|
||||
const invitedByUserName = invite?.invitedByUserName?.trim() || null;
|
||||
const inviteMessage = invite?.inviteMessage?.trim() || null;
|
||||
const requestedHumanRole = formatHumanRole(invite?.humanRole);
|
||||
|
|
@ -475,7 +468,6 @@ export function InviteLandingPage() {
|
|||
<AwaitingJoinApprovalPanel
|
||||
companyDisplayName={companyDisplayName}
|
||||
companyLogoUrl={companyLogoUrl}
|
||||
companyBrandColor={companyBrandColor}
|
||||
invitedByUserName={invitedByUserName}
|
||||
/>
|
||||
);
|
||||
|
|
@ -530,7 +522,6 @@ export function InviteLandingPage() {
|
|||
<InviteCompanyLogo
|
||||
companyDisplayName={companyDisplayName}
|
||||
companyLogoUrl={companyLogoUrl}
|
||||
companyBrandColor={companyBrandColor}
|
||||
className="h-12 w-12 border border-zinc-800 rounded-none"
|
||||
/>
|
||||
<h1 className="text-lg font-semibold">You joined the organization</h1>
|
||||
|
|
@ -546,7 +537,6 @@ export function InviteLandingPage() {
|
|||
<AwaitingJoinApprovalPanel
|
||||
companyDisplayName={companyDisplayName}
|
||||
companyLogoUrl={companyLogoUrl}
|
||||
companyBrandColor={companyBrandColor}
|
||||
invitedByUserName={invitedByUserName}
|
||||
claimSecret={claimSecret}
|
||||
claimApiKeyPath={claimApiKeyPath}
|
||||
|
|
@ -565,7 +555,6 @@ export function InviteLandingPage() {
|
|||
<InviteCompanyLogo
|
||||
companyDisplayName={companyDisplayName}
|
||||
companyLogoUrl={companyLogoUrl}
|
||||
companyBrandColor={companyBrandColor}
|
||||
className="h-16 w-16 rounded-none border border-zinc-800"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
|
|
|
|||
|
|
@ -196,12 +196,10 @@ function InviteSummaryPanel({
|
|||
}) {
|
||||
return (
|
||||
<>
|
||||
{/* token-extraction: allowlisted — brandColor feeds CompanyPatternIcon's hexToHue() color math via a canvas fill; demo/showcase-only prop, not a rendered CSS value. */}
|
||||
<div className="flex items-start gap-4">
|
||||
<CompanyPatternIcon
|
||||
companyName="Acme Robotics"
|
||||
logoUrl="/api/invites/pcp_invite_test/logo"
|
||||
brandColor="#114488"
|
||||
className="h-16 w-16 rounded-none border border-zinc-800"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
|
|
@ -413,7 +411,6 @@ function InviteResultPreview({
|
|||
<CompanyPatternIcon
|
||||
companyName="Acme Robotics"
|
||||
logoUrl="/api/invites/pcp_invite_test/logo"
|
||||
brandColor="#114488"
|
||||
className="h-12 w-12 rounded-none border border-zinc-800"
|
||||
/>
|
||||
<h3 className="text-lg font-semibold">{title}</h3>
|
||||
|
|
|
|||
|
|
@ -42,7 +42,6 @@ export const storybookCompanies: Company[] = [
|
|||
issueCounter: 1641,
|
||||
budgetMonthlyCents: 250_000,
|
||||
spentMonthlyCents: 67_500,
|
||||
attachmentMaxBytes: 10 * 1024 * 1024,
|
||||
defaultResponsibleUserId: "user-board",
|
||||
requireBoardApprovalForNewAgents: true,
|
||||
interactionResolverGovernance: {},
|
||||
|
|
@ -50,7 +49,6 @@ export const storybookCompanies: Company[] = [
|
|||
feedbackDataSharingConsentAt: null,
|
||||
feedbackDataSharingConsentByUserId: null,
|
||||
feedbackDataSharingTermsVersion: null,
|
||||
brandColor: "#0f766e",
|
||||
logoAssetId: null,
|
||||
logoUrl: null,
|
||||
createdAt: new Date("2026-04-01T09:00:00.000Z"),
|
||||
|
|
@ -67,7 +65,6 @@ export const storybookCompanies: Company[] = [
|
|||
issueCounter: 88,
|
||||
budgetMonthlyCents: 180_000,
|
||||
spentMonthlyCents: 39_500,
|
||||
attachmentMaxBytes: 10 * 1024 * 1024,
|
||||
defaultResponsibleUserId: "user-board",
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
interactionResolverGovernance: {},
|
||||
|
|
@ -75,7 +72,6 @@ export const storybookCompanies: Company[] = [
|
|||
feedbackDataSharingConsentAt: null,
|
||||
feedbackDataSharingConsentByUserId: null,
|
||||
feedbackDataSharingTermsVersion: null,
|
||||
brandColor: "#4f46e5",
|
||||
logoAssetId: null,
|
||||
logoUrl: null,
|
||||
createdAt: new Date("2026-04-03T09:00:00.000Z"),
|
||||
|
|
@ -92,7 +88,6 @@ export const storybookCompanies: Company[] = [
|
|||
issueCounter: 204,
|
||||
budgetMonthlyCents: 90_000,
|
||||
spentMonthlyCents: 91_200,
|
||||
attachmentMaxBytes: 10 * 1024 * 1024,
|
||||
defaultResponsibleUserId: "user-board",
|
||||
requireBoardApprovalForNewAgents: true,
|
||||
interactionResolverGovernance: {},
|
||||
|
|
@ -100,7 +95,6 @@ export const storybookCompanies: Company[] = [
|
|||
feedbackDataSharingConsentAt: null,
|
||||
feedbackDataSharingConsentByUserId: null,
|
||||
feedbackDataSharingTermsVersion: null,
|
||||
brandColor: "#c2410c",
|
||||
logoAssetId: null,
|
||||
logoUrl: null,
|
||||
createdAt: new Date("2026-04-05T09:00:00.000Z"),
|
||||
|
|
|
|||
|
|
@ -556,10 +556,10 @@ function SwipeToArchiveDemo({ disabled = false }: { disabled?: boolean }) {
|
|||
|
||||
function CompanyPatternIconMatrix() {
|
||||
const companies = [
|
||||
{ name: "Paperclip Storybook", color: "#0f766e" },
|
||||
{ name: "Research Bureau", color: "#2563eb" },
|
||||
{ name: "Launch Ops", color: "#c2410c" },
|
||||
{ name: "Atlas Finance", color: "#7c3aed" },
|
||||
"Paperclip Storybook",
|
||||
"Research Bureau",
|
||||
"Launch Ops",
|
||||
"Atlas Finance",
|
||||
];
|
||||
const sizes = ["h-8 w-8 text-xs", "h-11 w-11 text-base", "h-16 w-16 text-xl", "h-24 w-24 text-3xl"];
|
||||
|
||||
|
|
@ -567,20 +567,15 @@ function CompanyPatternIconMatrix() {
|
|||
<StoryShell>
|
||||
<Section eyebrow="CompanyPatternIcon" title="Generated company pattern icons by size">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{companies.map((company) => (
|
||||
<Card key={company.name} className="shadow-none">
|
||||
{companies.map((companyName) => (
|
||||
<Card key={companyName} className="shadow-none">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{company.name}</CardTitle>
|
||||
<CardDescription>{company.color}</CardDescription>
|
||||
<CardTitle className="text-base">{companyName}</CardTitle>
|
||||
<CardDescription>Hue derived from the company name</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-wrap items-end gap-4">
|
||||
{sizes.map((size) => (
|
||||
<CompanyPatternIcon
|
||||
key={size}
|
||||
companyName={company.name}
|
||||
brandColor={company.color}
|
||||
className={size}
|
||||
/>
|
||||
<CompanyPatternIcon key={size} companyName={companyName} className={size} />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
|
|||
Loading…
Reference in New Issue