@agent-native/core 0.130.1 → 0.130.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +6 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/server/email-template.ts +43 -13
  5. package/corpus/core/src/sharing/actions/share-resource.ts +26 -4
  6. package/corpus/core/src/sharing/index.ts +1 -0
  7. package/corpus/core/src/sharing/registry.ts +23 -0
  8. package/corpus/templates/clips/app/components/editor/editor-toolbar.tsx +3 -1
  9. package/corpus/templates/clips/app/components/library/library-layout.tsx +1 -1
  10. package/corpus/templates/clips/app/components/meetings/day-header.tsx +1 -1
  11. package/corpus/templates/clips/app/components/meetings/transcript-bubbles.tsx +1 -22
  12. package/corpus/templates/clips/app/components/workspace/branding-editor.tsx +26 -1
  13. package/corpus/templates/clips/app/components/workspace/organization-identity-card.tsx +107 -0
  14. package/corpus/templates/clips/app/i18n/en-US.ts +3 -4
  15. package/corpus/templates/clips/app/routes/_app.meetings.$meetingId.tsx +0 -10
  16. package/corpus/templates/clips/app/routes/_app.settings._index.tsx +23 -51
  17. package/corpus/templates/clips/app/routes/share.meeting.$meetingId.tsx +1 -8
  18. package/corpus/templates/clips/changelog/2026-07-29-clearing-all-recording-edits-no-longer-leaves-the-editor-unr.md +6 -0
  19. package/corpus/templates/clips/changelog/2026-07-29-clip-share-emails-now-open-with-who-shared-the-clip-add-a-su.md +6 -0
  20. package/corpus/templates/clips/changelog/2026-07-29-finished-meeting-pills-now-have-a-clear-close-button.md +6 -0
  21. package/corpus/templates/clips/changelog/2026-07-29-meeting-cards-no-longer-flicker-or-disappear-when-hovered-in.md +6 -0
  22. package/corpus/templates/clips/changelog/2026-07-29-organization-name-logo-brand-color-and-default-visibility-no.md +6 -0
  23. package/corpus/templates/clips/changelog/2026-07-29-transcript-lines-on-the-meeting-page-no-longer-jump-to-the-r.md +6 -0
  24. package/corpus/templates/clips/changelog/2026-07-29-when-a-meeting-wraps-up-the-recording-pill-now-shows-a-green.md +6 -0
  25. package/corpus/templates/clips/desktop/src/hooks/useMeetingTranscription.ts +6 -3
  26. package/corpus/templates/clips/desktop/src/overlays/recording-pill.tsx +112 -32
  27. package/corpus/templates/clips/desktop/src/styles.css +42 -2
  28. package/corpus/templates/clips/server/db/index.ts +6 -0
  29. package/corpus/templates/clips/server/lib/share-email-hero.ts +33 -0
  30. package/corpus/templates/clips/shared/clips-ai-prefs.ts +0 -1
  31. package/dist/collab/awareness.d.ts +2 -2
  32. package/dist/collab/awareness.d.ts.map +1 -1
  33. package/dist/collab/struct-routes.d.ts +1 -1
  34. package/dist/file-upload/actions/upload-image.d.ts +1 -1
  35. package/dist/notifications/routes.d.ts +3 -3
  36. package/dist/progress/routes.d.ts +1 -1
  37. package/dist/resources/handlers.d.ts +1 -1
  38. package/dist/secrets/routes.d.ts +9 -9
  39. package/dist/server/email-template.d.ts +4 -0
  40. package/dist/server/email-template.d.ts.map +1 -1
  41. package/dist/server/email-template.js +25 -8
  42. package/dist/server/email-template.js.map +1 -1
  43. package/dist/sharing/actions/share-resource.d.ts.map +1 -1
  44. package/dist/sharing/actions/share-resource.js +23 -4
  45. package/dist/sharing/actions/share-resource.js.map +1 -1
  46. package/dist/sharing/index.d.ts +1 -1
  47. package/dist/sharing/index.d.ts.map +1 -1
  48. package/dist/sharing/index.js.map +1 -1
  49. package/dist/sharing/registry.d.ts +23 -0
  50. package/dist/sharing/registry.d.ts.map +1 -1
  51. package/dist/sharing/registry.js.map +1 -1
  52. package/package.json +1 -1
  53. package/src/server/email-template.ts +43 -13
  54. package/src/sharing/actions/share-resource.ts +26 -4
  55. package/src/sharing/index.ts +1 -0
  56. package/src/sharing/registry.ts +23 -0
  57. package/corpus/templates/clips/app/routes/_app.settings.organization.tsx +0 -232
@@ -1 +1 @@
1
- {"version":3,"file":"registry.js","sourceRoot":"","sources":["../../src/sharing/registry.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAgLH,0EAA0E;AAC1E,6EAA6E;AAC7E,8EAA8E;AAC9E,4EAA4E;AAC5E,2EAA2E;AAC3E,0EAA0E;AAC1E,8EAA8E;AAC9E,+EAA+E;AAC/E,MAAM,YAAY,GAAG,mCAAmC,CAAC;AAEzD,MAAM,cAAc,GAClB,UAAiB,CAAC;AAEpB,SAAS,aAAa;IACpB,OAAO,CACL,OAAO,CAAC,GAAG,CAAC,QAAQ,KAAK,MAAM;QAC/B,OAAO,CAAC,GAAG,CAAC,MAAM,KAAK,MAAM;QAC7B,OAAO,CAAC,GAAG,CAAC,MAAM,KAAK,GAAG,CAC3B,CAAC;AACJ,CAAC;AAED,SAAS,4BAA4B;IACnC,MAAM,KAAK,GAAG,IAAI,KAAK,EAAE,CAAC,KAAK,IAAI,EAAE,CAAC;IACtC,OAAO,oEAAoE,CAAC,IAAI,CAC9E,KAAK,CACN,CAAC;AACJ,CAAC;AAED,SAAS,WAAW;IAClB,IAAI,CAAC,GAAG,cAAc,CAAC,YAAY,CAAC,CAAC;IACrC,IAAI,CAAC,CAAC,EAAE,CAAC;QACP,CAAC,GAAG,IAAI,GAAG,EAAyC,CAAC;QACrD,cAAc,CAAC,YAAY,CAAC,GAAG,CAAC,CAAC;IACnC,CAAC;IACD,OAAO,CAAC,CAAC;AACX,CAAC;AAED,MAAM,UAAU,yBAAyB,CACvC,KAAoC;IAEpC,IAAI,CAAC,aAAa,EAAE,IAAI,4BAA4B,EAAE;QAAE,OAAO;IAC/D,WAAW,EAAE,CAAC,GAAG,CAAC,KAAK,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC;AACvC,CAAC;AAED,MAAM,UAAU,oBAAoB,CAClC,IAAY;IAEZ,OAAO,WAAW,EAAE,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;AACjC,CAAC;AAED,MAAM,UAAU,wBAAwB,CACtC,IAAY;IAEZ,MAAM,GAAG,GAAG,WAAW,EAAE,CAAC;IAC1B,MAAM,KAAK,GAAG,GAAG,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;IAC5B,IAAI,CAAC,KAAK,EAAE,CAAC;QACX,MAAM,IAAI,KAAK,CACb,qCAAqC,IAAI,gDAAgD,CAC1F,CAAC;IACJ,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC;AAED,MAAM,UAAU,sBAAsB;IACpC,OAAO,KAAK,CAAC,IAAI,CAAC,WAAW,EAAE,CAAC,MAAM,EAAE,CAAC,CAAC;AAC5C,CAAC","sourcesContent":["/**\n * Registry of shareable resources.\n *\n * Each template registers its ownable resource(s) once on module load so the\n * framework-level share actions (`share-resource`, `list-resource-shares`,\n * etc.) can dispatch to the correct tables.\n *\n * import { registerShareableResource } from \"@agent-native/core/sharing\";\n * import * as schema from \"./schema.js\";\n *\n * registerShareableResource({\n * type: \"document\",\n * resourceTable: schema.documents,\n * sharesTable: schema.documentShares,\n * displayName: \"Document\",\n * titleColumn: \"title\",\n * });\n */\n\nimport type { UserProfile } from \"../user-profile/shared.js\";\n\nexport interface ShareableResourceRegistration {\n /** Stable identifier used across actions, UI, and analytics. e.g. \"document\". */\n type: string;\n /** Drizzle table for the parent resource (must have ownableColumns()). */\n resourceTable: any;\n /** Drizzle table produced by createSharesTable(). */\n sharesTable: any;\n /** Human-readable singular label shown in the share dialog. */\n displayName: string;\n /**\n * Column on the resource table that holds a human-readable title for\n * display in the share UI. Default: \"title\".\n */\n titleColumn?: string;\n /**\n * Optional app-relative path to this resource. Used by share notifications\n * when the caller does not pass a more specific resourceUrl.\n */\n getResourcePath?: (resource: any) => string | undefined;\n /**\n * Optional resolver for the share-notification email's brand logo. Return an\n * absolute `https://` URL to override the default embedded Agent Native logo\n * (e.g. the sharing org's own logo), or undefined to fall back. Receives the\n * resource row being shared so the template can scope the logo to its org.\n */\n getLogoUrl?: (\n resource: any,\n ) => string | undefined | Promise<string | undefined>;\n /**\n * Optional resolver for the brand name shown beside the logo in the\n * share-notification email header. Return a display name (e.g. the sharing\n * org's name) to override the app name, or undefined to keep the default.\n * Receives the resource row being shared.\n */\n getBrandName?: (\n resource: any,\n ) => string | undefined | Promise<string | undefined>;\n /**\n * Optional resolver for who the share-notification email appears to come\n * from. `fromName` changes only the display name and keeps the configured,\n * domain-verified sending address (e.g. \"Alice via Clips\"); `replyTo` routes\n * replies to the sharer. `ctx.sender` is the account doing the sharing, so the\n * template decides how to present them (name, email, avatar).\n *\n * Do not put a user's address in the sending address itself — SPF/DKIM sign\n * the app's domain, so recipients would bounce or spam-filter it.\n */\n getSender?: (\n resource: any,\n ctx: { sender: UserProfile },\n ) =>\n | { fromName?: string; replyTo?: string }\n | undefined\n | Promise<{ fromName?: string; replyTo?: string } | undefined>;\n /**\n * Optional resolver for a preview block shown above the CTA in the\n * share-notification email — e.g. a recording thumbnail with a play badge.\n * Return trusted HTML (built by template code, dynamic values escaped) that\n * is injected verbatim, or undefined to omit it. `ctx.href` is the resolved\n * link to the resource; `ctx.alt` is its title. The template owns the entire\n * preview markup so the generic share action stays app-agnostic.\n */\n getHeroHtml?: (\n resource: any,\n ctx: { href: string; alt?: string },\n ) => string | undefined | Promise<string | undefined>;\n /**\n * Drizzle DB accessor from the template's server/db/index.ts. Required —\n * the framework-level share actions and access helpers call this to reach\n * the right DB instance (schema is template-specific).\n */\n getDb: () => any;\n /**\n * When `false`, `visibility: \"public\"` is rejected by `set-resource-visibility`,\n * and `accessFilter` / `resolveAccess` treat any stored public row as private\n * (defense in depth — only owner + explicit shares grant access).\n *\n * Use this for resources that execute code or expose privileged data and must\n * never be reachable by a random authenticated user. Extensions set this:\n * extension HTML runs inside an iframe that calls actions / DB / proxied APIs\n * as the *viewer*, so a public extension would be arbitrary code with the\n * viewer's credentials.\n *\n * Default: `true` (matches the historical behavior — most resources can be public).\n */\n allowPublic?: boolean;\n /**\n * Optional role granted by a public-by-link read. Most resources should omit\n * this so public visibility remains viewer-only. Use narrowly for local or\n * otherwise constrained resources where the resource owner intentionally wants\n * unauthenticated link holders to do more than view.\n *\n * When this is a function, it may read arbitrary fields on the resource row\n * (not just the ownership/visibility columns). Because of that,\n * `resolveAccess`/`assertAccess`'s opt-in `{ skipResourceBody: true }`\n * projected load (see `access.ts`) is automatically ignored for this\n * registration and always loads the full row instead — a fixed role string\n * has no such requirement and is compatible with the projection.\n */\n publicAccessRole?:\n | \"viewer\"\n | \"editor\"\n | \"admin\"\n | ((\n resource: any,\n ctx: {\n userEmail?: string;\n orgId?: string;\n authCapability?: string;\n },\n ) =>\n | \"viewer\"\n | \"editor\"\n | \"admin\"\n | Promise<\"viewer\" | \"editor\" | \"admin\">);\n /**\n * When `true`, individual user shares (`principalType: \"user\"`) must target\n * an email that is already a member of the same org as the resource, OR has\n * a pending invitation to that org. Cross-org user shares are rejected.\n *\n * Pair with `allowPublic: false` for resources that need a hard \"this org\n * only\" trust boundary. Extensions set this so a malicious caller can't\n * widen reach by sharing a code-executing extension to an outsider email.\n *\n * Default: `false` (matches the historical behavior — any email can be granted).\n */\n requireOrgMemberForUserShares?: boolean;\n /**\n * Optional per-resource access-context adapter. Most resources should use the\n * request user/org unchanged. Templates with an intentional alternate local\n * identity can normalize here so the generic framework sharing actions and\n * access helpers stay in sync with template-owned actions.\n */\n resolveAccessContext?: (ctx: {\n userEmail?: string;\n orgId?: string;\n authCapability?: string;\n }) => {\n userEmail?: string;\n orgId?: string;\n authCapability?: string;\n };\n /**\n * When true, direct ownership is recognized by owner_email regardless of the\n * caller's active org. Use this only for resource types where the template's\n * own actions already treat owner_email as the cross-org authority and list\n * views add their own org filters.\n *\n * Default: `false`.\n */\n ownerAccessIgnoresOrg?: boolean;\n /**\n * Optional external-agent read handoff. When set, the framework-level\n * `create-agent-resource-link` action can mint a short-lived, read-only\n * `agent_access` URL for this resource. The context endpoint is owned by the\n * template so it can expose the same intentionally shareable shape as the\n * public page, not a generic raw database row.\n */\n agentReadable?:\n | false\n | {\n /** Token scope. Include the app name to avoid cross-app collisions. */\n resourceKind: string;\n /** App-relative JSON endpoint that accepts `id` + `agent_access`. */\n getContextPath: (resource: any) => string | undefined;\n /** Optional override for the page URL. Defaults to getResourcePath. */\n getPagePath?: (resource: any) => string | undefined;\n /** Optional override for the default two-hour token lifetime. */\n ttlSeconds?: number;\n };\n}\n\n// Stash the registry on globalThis so it survives SSR bundle duplication.\n// Vite SSR's `noExternal: /^(?!node:)/` policy means @agent-native/core gets\n// inlined into every server bundle that imports it — and each bundle gets its\n// own module-level state. A plain `new Map()` here would create one Map per\n// bundle, so the template's `registerShareableResource()` (called from the\n// Nitro plugin graph) wouldn't be visible to the framework's auto-mounted\n// share-resource action (loaded via `import(\"../sharing/actions/...js\")` in a\n// different module instance). Using globalThis collapses them back to one Map.\nconst REGISTRY_KEY = \"__agentNativeShareableResources__\";\ntype RegistryStore = Map<string, ShareableResourceRegistration>;\nconst globalRegistry: { [K in typeof REGISTRY_KEY]?: RegistryStore } =\n globalThis as any;\n\nfunction isTestRuntime(): boolean {\n return (\n process.env.NODE_ENV === \"test\" ||\n process.env.VITEST === \"true\" ||\n process.env.VITEST === \"1\"\n );\n}\n\nfunction registrationCameFromTestFile(): boolean {\n const stack = new Error().stack ?? \"\";\n return /[./\\\\](?:[^/\\\\]+[.-])(?:test|spec)\\.[cm]?[jt]sx?(?::\\d+)?(?::\\d+)?/.test(\n stack,\n );\n}\n\nfunction getRegistry(): RegistryStore {\n let r = globalRegistry[REGISTRY_KEY];\n if (!r) {\n r = new Map<string, ShareableResourceRegistration>();\n globalRegistry[REGISTRY_KEY] = r;\n }\n return r;\n}\n\nexport function registerShareableResource(\n entry: ShareableResourceRegistration,\n): void {\n if (!isTestRuntime() && registrationCameFromTestFile()) return;\n getRegistry().set(entry.type, entry);\n}\n\nexport function getShareableResource(\n type: string,\n): ShareableResourceRegistration | undefined {\n return getRegistry().get(type);\n}\n\nexport function requireShareableResource(\n type: string,\n): ShareableResourceRegistration {\n const reg = getRegistry();\n const entry = reg.get(type);\n if (!entry) {\n throw new Error(\n `Unknown shareable resource type: \"${type}\". Did you forget registerShareableResource()?`,\n );\n }\n return entry;\n}\n\nexport function listShareableResources(): ShareableResourceRegistration[] {\n return Array.from(getRegistry().values());\n}\n"]}
1
+ {"version":3,"file":"registry.js","sourceRoot":"","sources":["../../src/sharing/registry.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAuMH,0EAA0E;AAC1E,6EAA6E;AAC7E,8EAA8E;AAC9E,4EAA4E;AAC5E,2EAA2E;AAC3E,0EAA0E;AAC1E,8EAA8E;AAC9E,+EAA+E;AAC/E,MAAM,YAAY,GAAG,mCAAmC,CAAC;AAEzD,MAAM,cAAc,GAClB,UAAiB,CAAC;AAEpB,SAAS,aAAa;IACpB,OAAO,CACL,OAAO,CAAC,GAAG,CAAC,QAAQ,KAAK,MAAM;QAC/B,OAAO,CAAC,GAAG,CAAC,MAAM,KAAK,MAAM;QAC7B,OAAO,CAAC,GAAG,CAAC,MAAM,KAAK,GAAG,CAC3B,CAAC;AACJ,CAAC;AAED,SAAS,4BAA4B;IACnC,MAAM,KAAK,GAAG,IAAI,KAAK,EAAE,CAAC,KAAK,IAAI,EAAE,CAAC;IACtC,OAAO,oEAAoE,CAAC,IAAI,CAC9E,KAAK,CACN,CAAC;AACJ,CAAC;AAED,SAAS,WAAW;IAClB,IAAI,CAAC,GAAG,cAAc,CAAC,YAAY,CAAC,CAAC;IACrC,IAAI,CAAC,CAAC,EAAE,CAAC;QACP,CAAC,GAAG,IAAI,GAAG,EAAyC,CAAC;QACrD,cAAc,CAAC,YAAY,CAAC,GAAG,CAAC,CAAC;IACnC,CAAC;IACD,OAAO,CAAC,CAAC;AACX,CAAC;AAED,MAAM,UAAU,yBAAyB,CACvC,KAAoC;IAEpC,IAAI,CAAC,aAAa,EAAE,IAAI,4BAA4B,EAAE;QAAE,OAAO;IAC/D,WAAW,EAAE,CAAC,GAAG,CAAC,KAAK,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC;AACvC,CAAC;AAED,MAAM,UAAU,oBAAoB,CAClC,IAAY;IAEZ,OAAO,WAAW,EAAE,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;AACjC,CAAC;AAED,MAAM,UAAU,wBAAwB,CACtC,IAAY;IAEZ,MAAM,GAAG,GAAG,WAAW,EAAE,CAAC;IAC1B,MAAM,KAAK,GAAG,GAAG,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;IAC5B,IAAI,CAAC,KAAK,EAAE,CAAC;QACX,MAAM,IAAI,KAAK,CACb,qCAAqC,IAAI,gDAAgD,CAC1F,CAAC;IACJ,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC;AAED,MAAM,UAAU,sBAAsB;IACpC,OAAO,KAAK,CAAC,IAAI,CAAC,WAAW,EAAE,CAAC,MAAM,EAAE,CAAC,CAAC;AAC5C,CAAC","sourcesContent":["/**\n * Registry of shareable resources.\n *\n * Each template registers its ownable resource(s) once on module load so the\n * framework-level share actions (`share-resource`, `list-resource-shares`,\n * etc.) can dispatch to the correct tables.\n *\n * import { registerShareableResource } from \"@agent-native/core/sharing\";\n * import * as schema from \"./schema.js\";\n *\n * registerShareableResource({\n * type: \"document\",\n * resourceTable: schema.documents,\n * sharesTable: schema.documentShares,\n * displayName: \"Document\",\n * titleColumn: \"title\",\n * });\n */\n\nimport type { EmailCta, EmailLinkBlock } from \"../server/email-template.js\";\nimport type { UserProfile } from \"../user-profile/shared.js\";\n\nexport interface ShareEmailExtras {\n /**\n * Replaces the default body paragraphs between the heading and the preview.\n * Pass `[]` to send none. Omit to keep the default copy.\n */\n paragraphs?: string[];\n secondaryCta?: EmailCta;\n linkBlock?: EmailLinkBlock;\n closingParagraphs?: string[];\n}\n\nexport interface ShareableResourceRegistration {\n /** Stable identifier used across actions, UI, and analytics. e.g. \"document\". */\n type: string;\n /** Drizzle table for the parent resource (must have ownableColumns()). */\n resourceTable: any;\n /** Drizzle table produced by createSharesTable(). */\n sharesTable: any;\n /** Human-readable singular label shown in the share dialog. */\n displayName: string;\n /**\n * Column on the resource table that holds a human-readable title for\n * display in the share UI. Default: \"title\".\n */\n titleColumn?: string;\n /**\n * Optional app-relative path to this resource. Used by share notifications\n * when the caller does not pass a more specific resourceUrl.\n */\n getResourcePath?: (resource: any) => string | undefined;\n /**\n * Optional resolver for the share-notification email's brand logo. Return an\n * absolute `https://` URL to override the default embedded Agent Native logo\n * (e.g. the sharing org's own logo), or undefined to fall back. Receives the\n * resource row being shared so the template can scope the logo to its org.\n */\n getLogoUrl?: (\n resource: any,\n ) => string | undefined | Promise<string | undefined>;\n /**\n * Optional resolver for the brand name shown beside the logo in the\n * share-notification email header. Return a display name (e.g. the sharing\n * org's name) to override the app name, or undefined to keep the default.\n * Receives the resource row being shared.\n */\n getBrandName?: (\n resource: any,\n ) => string | undefined | Promise<string | undefined>;\n /**\n * Optional resolver for who the share-notification email appears to come\n * from. `fromName` changes only the display name and keeps the configured,\n * domain-verified sending address (e.g. \"Alice via Clips\"); `replyTo` routes\n * replies to the sharer. `ctx.sender` is the account doing the sharing, so the\n * template decides how to present them (name, email, avatar).\n *\n * Do not put a user's address in the sending address itself — SPF/DKIM sign\n * the app's domain, so recipients would bounce or spam-filter it.\n */\n getSender?: (\n resource: any,\n ctx: { sender: UserProfile },\n ) =>\n | { fromName?: string; replyTo?: string }\n | undefined\n | Promise<{ fromName?: string; replyTo?: string } | undefined>;\n /**\n * Optional resolver for a preview block shown above the CTA in the\n * share-notification email — e.g. a recording thumbnail with a play badge.\n * Return trusted HTML (built by template code, dynamic values escaped) that\n * is injected verbatim, or undefined to omit it. `ctx.href` is the resolved\n * link to the resource; `ctx.alt` is its title. The template owns the entire\n * preview markup so the generic share action stays app-agnostic.\n */\n getHeroHtml?: (\n resource: any,\n ctx: { href: string; alt?: string },\n ) => string | undefined | Promise<string | undefined>;\n /**\n * Optional resolver for app-specific content in the share-notification\n * email: a second button beside the primary CTA, a treated copyable link,\n * and closing paragraphs beneath it. Closing paragraphs are injected\n * verbatim (use `emailLink` / `emailStrong` for inline markup), so escape\n * any dynamic values.\n */\n getShareEmailExtras?: (\n resource: any,\n ctx: { href: string; sender: UserProfile; recipientEmail: string },\n ) => ShareEmailExtras | undefined | Promise<ShareEmailExtras | undefined>;\n /**\n * Drizzle DB accessor from the template's server/db/index.ts. Required —\n * the framework-level share actions and access helpers call this to reach\n * the right DB instance (schema is template-specific).\n */\n getDb: () => any;\n /**\n * When `false`, `visibility: \"public\"` is rejected by `set-resource-visibility`,\n * and `accessFilter` / `resolveAccess` treat any stored public row as private\n * (defense in depth — only owner + explicit shares grant access).\n *\n * Use this for resources that execute code or expose privileged data and must\n * never be reachable by a random authenticated user. Extensions set this:\n * extension HTML runs inside an iframe that calls actions / DB / proxied APIs\n * as the *viewer*, so a public extension would be arbitrary code with the\n * viewer's credentials.\n *\n * Default: `true` (matches the historical behavior — most resources can be public).\n */\n allowPublic?: boolean;\n /**\n * Optional role granted by a public-by-link read. Most resources should omit\n * this so public visibility remains viewer-only. Use narrowly for local or\n * otherwise constrained resources where the resource owner intentionally wants\n * unauthenticated link holders to do more than view.\n *\n * When this is a function, it may read arbitrary fields on the resource row\n * (not just the ownership/visibility columns). Because of that,\n * `resolveAccess`/`assertAccess`'s opt-in `{ skipResourceBody: true }`\n * projected load (see `access.ts`) is automatically ignored for this\n * registration and always loads the full row instead — a fixed role string\n * has no such requirement and is compatible with the projection.\n */\n publicAccessRole?:\n | \"viewer\"\n | \"editor\"\n | \"admin\"\n | ((\n resource: any,\n ctx: {\n userEmail?: string;\n orgId?: string;\n authCapability?: string;\n },\n ) =>\n | \"viewer\"\n | \"editor\"\n | \"admin\"\n | Promise<\"viewer\" | \"editor\" | \"admin\">);\n /**\n * When `true`, individual user shares (`principalType: \"user\"`) must target\n * an email that is already a member of the same org as the resource, OR has\n * a pending invitation to that org. Cross-org user shares are rejected.\n *\n * Pair with `allowPublic: false` for resources that need a hard \"this org\n * only\" trust boundary. Extensions set this so a malicious caller can't\n * widen reach by sharing a code-executing extension to an outsider email.\n *\n * Default: `false` (matches the historical behavior — any email can be granted).\n */\n requireOrgMemberForUserShares?: boolean;\n /**\n * Optional per-resource access-context adapter. Most resources should use the\n * request user/org unchanged. Templates with an intentional alternate local\n * identity can normalize here so the generic framework sharing actions and\n * access helpers stay in sync with template-owned actions.\n */\n resolveAccessContext?: (ctx: {\n userEmail?: string;\n orgId?: string;\n authCapability?: string;\n }) => {\n userEmail?: string;\n orgId?: string;\n authCapability?: string;\n };\n /**\n * When true, direct ownership is recognized by owner_email regardless of the\n * caller's active org. Use this only for resource types where the template's\n * own actions already treat owner_email as the cross-org authority and list\n * views add their own org filters.\n *\n * Default: `false`.\n */\n ownerAccessIgnoresOrg?: boolean;\n /**\n * Optional external-agent read handoff. When set, the framework-level\n * `create-agent-resource-link` action can mint a short-lived, read-only\n * `agent_access` URL for this resource. The context endpoint is owned by the\n * template so it can expose the same intentionally shareable shape as the\n * public page, not a generic raw database row.\n */\n agentReadable?:\n | false\n | {\n /** Token scope. Include the app name to avoid cross-app collisions. */\n resourceKind: string;\n /** App-relative JSON endpoint that accepts `id` + `agent_access`. */\n getContextPath: (resource: any) => string | undefined;\n /** Optional override for the page URL. Defaults to getResourcePath. */\n getPagePath?: (resource: any) => string | undefined;\n /** Optional override for the default two-hour token lifetime. */\n ttlSeconds?: number;\n };\n}\n\n// Stash the registry on globalThis so it survives SSR bundle duplication.\n// Vite SSR's `noExternal: /^(?!node:)/` policy means @agent-native/core gets\n// inlined into every server bundle that imports it — and each bundle gets its\n// own module-level state. A plain `new Map()` here would create one Map per\n// bundle, so the template's `registerShareableResource()` (called from the\n// Nitro plugin graph) wouldn't be visible to the framework's auto-mounted\n// share-resource action (loaded via `import(\"../sharing/actions/...js\")` in a\n// different module instance). Using globalThis collapses them back to one Map.\nconst REGISTRY_KEY = \"__agentNativeShareableResources__\";\ntype RegistryStore = Map<string, ShareableResourceRegistration>;\nconst globalRegistry: { [K in typeof REGISTRY_KEY]?: RegistryStore } =\n globalThis as any;\n\nfunction isTestRuntime(): boolean {\n return (\n process.env.NODE_ENV === \"test\" ||\n process.env.VITEST === \"true\" ||\n process.env.VITEST === \"1\"\n );\n}\n\nfunction registrationCameFromTestFile(): boolean {\n const stack = new Error().stack ?? \"\";\n return /[./\\\\](?:[^/\\\\]+[.-])(?:test|spec)\\.[cm]?[jt]sx?(?::\\d+)?(?::\\d+)?/.test(\n stack,\n );\n}\n\nfunction getRegistry(): RegistryStore {\n let r = globalRegistry[REGISTRY_KEY];\n if (!r) {\n r = new Map<string, ShareableResourceRegistration>();\n globalRegistry[REGISTRY_KEY] = r;\n }\n return r;\n}\n\nexport function registerShareableResource(\n entry: ShareableResourceRegistration,\n): void {\n if (!isTestRuntime() && registrationCameFromTestFile()) return;\n getRegistry().set(entry.type, entry);\n}\n\nexport function getShareableResource(\n type: string,\n): ShareableResourceRegistration | undefined {\n return getRegistry().get(type);\n}\n\nexport function requireShareableResource(\n type: string,\n): ShareableResourceRegistration {\n const reg = getRegistry();\n const entry = reg.get(type);\n if (!entry) {\n throw new Error(\n `Unknown shareable resource type: \"${type}\". Did you forget registerShareableResource()?`,\n );\n }\n return entry;\n}\n\nexport function listShareableResources(): ShareableResourceRegistration[] {\n return Array.from(getRegistry().values());\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-native/core",
3
- "version": "0.130.1",
3
+ "version": "0.130.2",
4
4
  "description": "Framework for agent-native application development — where AI agents and UI share SQL state, actions, and context",
5
5
  "homepage": "https://github.com/BuilderIO/agent-native#readme",
6
6
  "bugs": {
@@ -42,6 +42,8 @@ export interface RenderEmailArgs {
42
42
  paragraphs: string[];
43
43
  /** Primary call-to-action rendered as a real button. */
44
44
  cta?: EmailCta;
45
+ /** Optional second button rendered beside the primary CTA, dark-filled. */
46
+ secondaryCta?: EmailCta;
45
47
  /** A treated, copyable URL shown before or after the CTA. */
46
48
  linkBlock?: EmailLinkBlock;
47
49
  /**
@@ -50,6 +52,8 @@ export interface RenderEmailArgs {
50
52
  * by app/template code (never raw user input), and escape any dynamic values.
51
53
  */
52
54
  heroHtml?: string;
55
+ /** Body paragraphs rendered after the CTA and link block. Escaped-by-caller. */
56
+ closingParagraphs?: string[];
53
57
  /** Small muted text under the CTA (e.g. expiry note). */
54
58
  footer?: string;
55
59
  /** Optional app name shown beside the framework logo. */
@@ -128,12 +132,18 @@ export function renderEmail(args: RenderEmailArgs): RenderedEmail {
128
132
  // dynamic values they interpolate.
129
133
  const heroHtml = args.heroHtml ?? "";
130
134
 
131
- const paragraphsHtml = args.paragraphs
132
- .map(
133
- (p) =>
134
- `<p style="margin:0 0 16px 0; font-size:16px; line-height:1.6; color:#d4d4d8;">${p}</p>`,
135
- )
136
- .join("");
135
+ const renderParagraphs = (paragraphs: string[] | undefined): string =>
136
+ (paragraphs ?? [])
137
+ .map(
138
+ (p) =>
139
+ `<p style="margin:0 0 16px 0; font-size:16px; line-height:1.6; color:#d4d4d8;">${p}</p>`,
140
+ )
141
+ .join("");
142
+
143
+ const paragraphsHtml = renderParagraphs(args.paragraphs);
144
+ const closingParagraphsHtml = args.closingParagraphs?.length
145
+ ? `<div style="margin:28px 0 0 0;">${renderParagraphs(args.closingParagraphs)}</div>`
146
+ : "";
137
147
 
138
148
  const linkBlockHtml = args.linkBlock
139
149
  ? `
@@ -148,16 +158,27 @@ export function renderEmail(args: RenderEmailArgs): RenderedEmail {
148
158
  `
149
159
  : "";
150
160
 
161
+ const ctaButtonCell = (
162
+ cta: EmailCta,
163
+ background: string,
164
+ color: string,
165
+ border: string,
166
+ ): string => `
167
+ <td style="border-radius:10px; background:${background}; border:${border};">
168
+ <a href="${escapeAttr(cta.url)}"
169
+ style="display:inline-block; padding:14px 26px; font-family:'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size:15px; font-weight:600; color:${color}; text-decoration:none; border-radius:10px;">
170
+ ${escapeHtml(cta.label)}
171
+ </a>
172
+ </td>`;
173
+
174
+ const secondaryCtaHtml = args.secondaryCta
175
+ ? `<td style="width:12px;">&nbsp;</td>${ctaButtonCell(args.secondaryCta, "#141417", "#fafafa", "1px solid #3f3f46")}`
176
+ : "";
177
+
151
178
  const ctaHtml = args.cta
152
179
  ? `
153
180
  <table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:24px 0 0 0;">
154
- <tr>
155
- <td style="border-radius:10px; background:${ctaBg};">
156
- <a href="${escapeAttr(args.cta.url)}"
157
- style="display:inline-block; padding:14px 26px; font-family:'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size:15px; font-weight:600; color:${ctaFg}; text-decoration:none; border-radius:10px;">
158
- ${escapeHtml(args.cta.label)}
159
- </a>
160
- </td>
181
+ <tr>${ctaButtonCell(args.cta, ctaBg, ctaFg, "0")}${secondaryCtaHtml}
161
182
  </tr>
162
183
  </table>
163
184
  `
@@ -211,6 +232,7 @@ export function renderEmail(args: RenderEmailArgs): RenderedEmail {
211
232
  ${args.linkBlock?.placement !== "after-cta" ? linkBlockHtml : ""}
212
233
  ${ctaHtml}
213
234
  ${args.linkBlock?.placement === "after-cta" ? linkBlockHtml : ""}
235
+ ${closingParagraphsHtml}
214
236
  ${footerHtml}
215
237
  </td>
216
238
  </tr>
@@ -237,11 +259,19 @@ export function renderEmail(args: RenderEmailArgs): RenderedEmail {
237
259
  textLines.push(`${args.cta.label}: ${args.cta.url}`);
238
260
  textLines.push("");
239
261
  }
262
+ if (args.secondaryCta) {
263
+ textLines.push(`${args.secondaryCta.label}: ${args.secondaryCta.url}`);
264
+ textLines.push("");
265
+ }
240
266
  if (args.linkBlock?.placement === "after-cta") {
241
267
  textLines.push(args.linkBlock.intro);
242
268
  textLines.push(args.linkBlock.url);
243
269
  textLines.push("");
244
270
  }
271
+ for (const p of args.closingParagraphs ?? []) {
272
+ textLines.push(stripTags(p));
273
+ textLines.push("");
274
+ }
245
275
  if (args.footer) {
246
276
  textLines.push(args.footer);
247
277
  }
@@ -4,13 +4,14 @@ import { z } from "zod";
4
4
  import { defineAction } from "../../action.js";
5
5
  import { getDbExec } from "../../db/client.js";
6
6
  import { getAppProductionUrl } from "../../server/app-url.js";
7
- import { renderEmail, emailStrong } from "../../server/email-template.js";
7
+ import { emailStrong, renderEmail } from "../../server/email-template.js";
8
8
  import { sendEmail, isEmailConfigured } from "../../server/email.js";
9
9
  import { invalidateCollabAccessCache } from "../../server/poll.js";
10
10
  import { getRequestUserEmail } from "../../server/request-context.js";
11
11
  import { getUserProfile } from "../../user-profile/store.js";
12
12
  import { assertAccess, ForbiddenError } from "../access.js";
13
13
  import { requireShareableResource } from "../registry.js";
14
+ import type { ShareEmailExtras } from "../registry.js";
14
15
  import {
15
16
  getExtensionShareChangeTargets,
16
17
  notifyExtensionShareChanged,
@@ -320,12 +321,14 @@ export default defineAction({
320
321
  );
321
322
  }
322
323
  }
324
+ const senderProfile = await getUserProfile(actor);
325
+ const senderDisplayName = senderProfile.name?.trim() || actor;
323
326
  let fromName: string | undefined;
324
327
  let replyTo: string | undefined;
325
328
  if (reg.getSender) {
326
329
  try {
327
330
  const sender = await reg.getSender(resource, {
328
- sender: await getUserProfile(actor),
331
+ sender: senderProfile,
329
332
  });
330
333
  fromName = sender?.fromName?.trim() || undefined;
331
334
  replyTo = sender?.replyTo?.trim() || undefined;
@@ -351,18 +354,37 @@ export default defineAction({
351
354
  );
352
355
  }
353
356
  }
357
+ let extras: ShareEmailExtras | undefined;
358
+ if (reg.getShareEmailExtras) {
359
+ try {
360
+ extras =
361
+ (await reg.getShareEmailExtras(resource, {
362
+ href: notificationUrl,
363
+ sender: senderProfile,
364
+ recipientEmail: principalId,
365
+ })) ?? undefined;
366
+ } catch (err) {
367
+ console.error(
368
+ "[share-resource] share email extras resolver failed; sending the plain notification:",
369
+ err,
370
+ );
371
+ }
372
+ }
354
373
  const subject = `${actor} shared "${resourceTitle}" with you on ${appName}`;
355
374
  const { html, text } = renderEmail({
356
375
  brandName,
357
376
  brandLogoUrl,
358
377
  preheader: subject,
359
- heading: "You've been given access",
360
- paragraphs: [
378
+ heading: `${senderDisplayName} shared "${resourceTitle}" with you`,
379
+ paragraphs: extras?.paragraphs ?? [
361
380
  `${emailStrong(actor)} has shared the ${reg.displayName} ${emailStrong(resourceTitle)} with you as a ${emailStrong(args.role)}.`,
362
381
  `Use the button below to open it. If prompted, sign in with ${emailStrong(principalId)}.`,
363
382
  ],
364
383
  heroHtml,
365
384
  cta: { label: `Open ${reg.displayName}`, url: notificationUrl },
385
+ secondaryCta: extras?.secondaryCta,
386
+ linkBlock: extras?.linkBlock,
387
+ closingParagraphs: extras?.closingParagraphs,
366
388
  footer: `You received this because ${actor} granted you ${args.role} access.`,
367
389
  });
368
390
  await sendEmail({
@@ -22,6 +22,7 @@ export {
22
22
  requireShareableResource,
23
23
  listShareableResources,
24
24
  type ShareableResourceRegistration,
25
+ type ShareEmailExtras,
25
26
  } from "./registry.js";
26
27
 
27
28
  export {
@@ -17,8 +17,20 @@
17
17
  * });
18
18
  */
19
19
 
20
+ import type { EmailCta, EmailLinkBlock } from "../server/email-template.js";
20
21
  import type { UserProfile } from "../user-profile/shared.js";
21
22
 
23
+ export interface ShareEmailExtras {
24
+ /**
25
+ * Replaces the default body paragraphs between the heading and the preview.
26
+ * Pass `[]` to send none. Omit to keep the default copy.
27
+ */
28
+ paragraphs?: string[];
29
+ secondaryCta?: EmailCta;
30
+ linkBlock?: EmailLinkBlock;
31
+ closingParagraphs?: string[];
32
+ }
33
+
22
34
  export interface ShareableResourceRegistration {
23
35
  /** Stable identifier used across actions, UI, and analytics. e.g. "document". */
24
36
  type: string;
@@ -85,6 +97,17 @@ export interface ShareableResourceRegistration {
85
97
  resource: any,
86
98
  ctx: { href: string; alt?: string },
87
99
  ) => string | undefined | Promise<string | undefined>;
100
+ /**
101
+ * Optional resolver for app-specific content in the share-notification
102
+ * email: a second button beside the primary CTA, a treated copyable link,
103
+ * and closing paragraphs beneath it. Closing paragraphs are injected
104
+ * verbatim (use `emailLink` / `emailStrong` for inline markup), so escape
105
+ * any dynamic values.
106
+ */
107
+ getShareEmailExtras?: (
108
+ resource: any,
109
+ ctx: { href: string; sender: UserProfile; recipientEmail: string },
110
+ ) => ShareEmailExtras | undefined | Promise<ShareEmailExtras | undefined>;
88
111
  /**
89
112
  * Drizzle DB accessor from the template's server/db/index.ts. Required —
90
113
  * the framework-level share actions and access helpers call this to reach
@@ -1,232 +0,0 @@
1
- import { useActionQuery, useSession } from "@agent-native/core/client/hooks";
2
- import { useT } from "@agent-native/core/client/i18n";
3
- import { IconMailFast, IconUsers } from "@tabler/icons-react";
4
- import { useMemo } from "react";
5
-
6
- import { PageHeader } from "@/components/library/page-header";
7
- import { Badge } from "@/components/ui/badge";
8
- import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
9
- import { Skeleton } from "@/components/ui/skeleton";
10
- import {
11
- Table,
12
- TableBody,
13
- TableCell,
14
- TableHead,
15
- TableHeader,
16
- TableRow,
17
- } from "@/components/ui/table";
18
- import {
19
- BrandingEditor,
20
- type RecordingVisibility,
21
- } from "@/components/workspace/branding-editor";
22
- import { InviteDialog } from "@/components/workspace/invite-dialog";
23
- import {
24
- MembersList,
25
- type MemberRole,
26
- } from "@/components/workspace/members-list";
27
- import enMessages from "@/i18n/en-US";
28
-
29
- export function meta() {
30
- return [{ title: enMessages.organizationSettings.pageTitle }];
31
- }
32
-
33
- interface OrganizationStateResponse {
34
- organization: {
35
- id: string;
36
- name: string;
37
- brandColor: string;
38
- brandLogoUrl: string | null;
39
- defaultVisibility: RecordingVisibility;
40
- ownerEmail?: string;
41
- } | null;
42
- members: {
43
- id: string;
44
- email: string;
45
- role: MemberRole;
46
- joinedAt: string | null;
47
- invitedAt: string | null;
48
- }[];
49
- invites: {
50
- id: string;
51
- email: string;
52
- role: MemberRole;
53
- createdAt: string;
54
- }[];
55
- }
56
-
57
- export default function OrganizationSettingsRoute() {
58
- const t = useT();
59
- const { session } = useSession();
60
- const email = session?.email ?? "";
61
-
62
- const { data, isLoading } = useActionQuery<OrganizationStateResponse>(
63
- "list-organization-state",
64
- undefined,
65
- );
66
-
67
- const organization = data?.organization ?? null;
68
- const members = data?.members ?? [];
69
- const invites = data?.invites ?? [];
70
-
71
- const isOwner = !!(
72
- organization?.ownerEmail && organization.ownerEmail === email
73
- );
74
- const currentRole: MemberRole = useMemo(() => {
75
- const me = members.find((m) => m.email === email);
76
- if (me) return me.role;
77
- if (isOwner) return "admin";
78
- return "member";
79
- }, [members, email, isOwner]);
80
-
81
- const isAdmin = currentRole === "admin" || currentRole === "owner" || isOwner;
82
-
83
- if (isLoading) {
84
- return (
85
- <>
86
- <PageHeader>
87
- <h1 className="text-base font-semibold tracking-tight truncate">
88
- {t("organizationSettings.title")}
89
- </h1>
90
- </PageHeader>
91
- <div className="p-6 max-w-3xl mx-auto space-y-4">
92
- <Skeleton className="h-10 w-64" />
93
- <Skeleton className="h-32 w-full" />
94
- <Skeleton className="h-48 w-full" />
95
- </div>
96
- </>
97
- );
98
- }
99
-
100
- if (!organization) {
101
- return (
102
- <>
103
- <PageHeader>
104
- <h1 className="text-base font-semibold tracking-tight truncate">
105
- {t("organizationSettings.title")}
106
- </h1>
107
- </PageHeader>
108
- <div className="p-6 max-w-2xl mx-auto">
109
- <Card>
110
- <CardContent className="py-12 text-center text-sm text-muted-foreground">
111
- {t("organizationSettings.noOrganization")}
112
- </CardContent>
113
- </Card>
114
- </div>
115
- </>
116
- );
117
- }
118
-
119
- return (
120
- <>
121
- <PageHeader>
122
- <h1 className="text-base font-semibold tracking-tight truncate">
123
- {t("organizationSettings.namedTitle", { name: organization.name })}
124
- </h1>
125
- </PageHeader>
126
- <div className="p-6 max-w-3xl mx-auto space-y-6">
127
- <p className="text-sm text-muted-foreground">
128
- {t("organizationSettings.description")}
129
- </p>
130
-
131
- {isAdmin ? (
132
- <BrandingEditor
133
- organizationId={organization.id}
134
- initialName={organization.name}
135
- initialBrandColor={organization.brandColor}
136
- initialBrandLogoUrl={organization.brandLogoUrl}
137
- initialDefaultVisibility={organization.defaultVisibility}
138
- />
139
- ) : (
140
- <Card>
141
- <CardHeader>
142
- <CardTitle className="text-base">
143
- {t("brandingEditor.title")}
144
- </CardTitle>
145
- </CardHeader>
146
- <CardContent>
147
- <div className="flex items-center gap-3">
148
- <div
149
- className="h-10 w-10 rounded"
150
- style={{ background: organization.brandColor }}
151
- />
152
- <div>
153
- <div className="font-medium">{organization.name}</div>
154
- <div className="text-xs text-muted-foreground">
155
- {t("organizationSettings.adminsOnlyBranding")}
156
- </div>
157
- </div>
158
- </div>
159
- </CardContent>
160
- </Card>
161
- )}
162
-
163
- <Card>
164
- <CardHeader className="flex flex-row items-center justify-between">
165
- <CardTitle className="text-base flex items-center gap-2">
166
- <IconUsers className="size-4 text-primary" />
167
- {t("organizationSettings.members")}
168
- </CardTitle>
169
- {isAdmin ? <InviteDialog organizationId={organization.id} /> : null}
170
- </CardHeader>
171
- <CardContent>
172
- <MembersList
173
- organizationId={organization.id}
174
- members={members}
175
- currentUserEmail={email}
176
- currentUserRole={currentRole}
177
- />
178
- </CardContent>
179
- </Card>
180
-
181
- <Card>
182
- <CardHeader>
183
- <CardTitle className="text-base flex items-center gap-2">
184
- <IconMailFast className="size-4 text-primary" />
185
- {t("organizationSettings.pendingInvites")}
186
- </CardTitle>
187
- </CardHeader>
188
- <CardContent>
189
- {invites.length === 0 ? (
190
- <div className="py-4 text-center text-sm text-muted-foreground">
191
- {t("organizationSettings.noPendingInvites")}
192
- </div>
193
- ) : (
194
- <div className="rounded-md border">
195
- <Table>
196
- <TableHeader>
197
- <TableRow>
198
- <TableHead>{t("organizationSettings.email")}</TableHead>
199
- <TableHead className="w-32">
200
- {t("organizationSettings.role")}
201
- </TableHead>
202
- <TableHead className="w-32">
203
- {t("organizationSettings.sent")}
204
- </TableHead>
205
- </TableRow>
206
- </TableHeader>
207
- <TableBody>
208
- {invites.map((inv) => (
209
- <TableRow key={inv.id}>
210
- <TableCell className="font-medium">
211
- {inv.email}
212
- </TableCell>
213
- <TableCell>
214
- <Badge variant="outline" className="capitalize">
215
- {inv.role.replace("-", " ")}
216
- </Badge>
217
- </TableCell>
218
- <TableCell className="text-muted-foreground text-sm">
219
- {new Date(inv.createdAt).toLocaleDateString()}
220
- </TableCell>
221
- </TableRow>
222
- ))}
223
- </TableBody>
224
- </Table>
225
- </div>
226
- )}
227
- </CardContent>
228
- </Card>
229
- </div>
230
- </>
231
- );
232
- }