10 Commits

Author SHA1 Message Date
maaz519 c4254749a4 fix(messaging-ui): stop Start-chat/Create buttons stretching full width
The new-conversation + channel-create forms are flex columns, so the submit
button stretched to full width and read as thin. Give it align-self:flex-start
+ a 38px height so it sizes to its label like a normal button.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 00:15:21 +05:30
maaz519 4faf05fee9 feat(messaging-ui): start direct messages + groups (Slack-style people picker)
Add a 'New message' + on the Direct messages section that opens a people picker
(NewConversation): pick one person -> DM, two or more -> group with an optional
name, via the adapter's existing openThread({participantIds, membership, subject}).
Expose directory() on MessagingAdapter (org people list); MockAdapter implements
it + dedupes 1:1 DMs. 72 tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 00:15:21 +05:30
maaz519 9882177c59 fix(messaging-ui): portal compose modal to <body> so it renders above host chrome
A host wrapper like .view{position:relative;z-index:1} creates a stacking context
that traps the modal's fixed overlay below a sibling sticky header, no matter its
z-index. Render the modal through a ModalPortal (createPortal to document.body) so
it escapes the host stacking context + overflow entirely; the portal root copies
the SDK theme tokens from the live surface (synchronously, no unstyled paint) and
carries dark defaults as a fallback. Adds react-dom peer dep. 67 tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 00:15:21 +05:30
maaz519 f16d7986c7 fix(migration): regenerate provider_credentials via prisma engine, not hand-written SQL
Replace the hand-authored migration with one produced by `prisma migrate diff`
(offline, engine-generated) and verified by replaying the full 24-migration chain
into a throwaway Postgres DB. Same DDL, but generated through Prisma tooling so the
migration history stays consistent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 00:15:21 +05:30
maaz519 ddd628ab6f feat(capability): per-scope BYO provider credentials + Twilio SMS egress
IIOS becomes the tenant's integration/credential hub for anything it sends.
New IiosProviderCredential (one row per scope+providerType) seals the secret
with AES-256-GCM under IIOS_CRED_KEY (secret-crypto.ts); only non-secret
displayHints are ever read back. ProviderCredentialService upsert/resolve/status;
TwilioSmsProvider resolves the caller's own creds per scope at send time and
POSTs to Twilio (fail-closed NOT_CONFIGURED when unset). Registered over the
SMS sandbox only when the platform key + store are present. PUT/GET
/v1/providers/:type/credentials (scope-fenced, masked reads). 16 new unit tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 00:15:21 +05:30
maaz519 2aa2893973 feat(messaging-ui): attachments in inbox mail — reply + compose upload
InboxAdapter gains uploadAttachment + attachment params on mailReply/
composeInternal/composeExternal. MailReader reply and ComposeModal grow an
attach affordance (paperclip + pending chips); mail history renders sent
attachments. MockInboxAdapter implements upload. 67 tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 00:15:21 +05:30
maaz519 2f24a95ef4 feat(messaging-ui): Inbox domain — unified work items + mail (contract + UI + mock)
Second domain in the SDK, mirroring messaging's adapter/provider/hooks/components:
- InboxAdapter (listInbox unified feed + transition + mailHistory/mailReply +
  optional directory/composeInternal/composeExternal), InboxProvider/useInboxAdapter
- hooks: useInbox (filter + transition), useMailThread (history + reply), useCompose
- <Inbox> (filter tabs, unified list, detail pane), <MailReader> (HTML in a
  sandboxed iframe + reply), compose modal (in-app / email); themeable styles
- MockInboxAdapter (seeded mentions/needs-reply/alert + folded mail threads +
  directory) shipped from ./adapters/mock-inbox
- 6 inbox tests (mock unify/transition/reply + render open/reply/compose); 64 green

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 00:15:21 +05:30
maaz519 d02cd152de fix(messaging-ui): reaction picker opens downward, no longer clips
The emoji picker used bottom:100% and was clipped by the message list's
overflow when reacting to a message near the top (and could spill past the
right edge). Open it downward + edge-anchored (right for others' messages,
left for my own) with width:max-content so all emojis stay visible.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 00:15:21 +05:30
maaz519 fa93173b1f feat(messaging-ui): Slack-style threaded-replies side panel
Replies (messages with parentInteractionId) now open in a right-hand ThreadPane
instead of inline quoting:
- extracted a shared Composer (draft + @mention autocomplete + attach) and
  MessageItem (bubble + mentions + attachment + reactions + reply affordance)
- Thread shows top-level messages only; a message with replies gets a
  '💬 N replies' link, and hovering shows 💬 'Reply in thread'
- ThreadPane renders the root + its replies + a composer that posts back with
  parentInteractionId; Messenger becomes 3-column (list | thread | pane),
  pane closes on conversation switch
- no contract/adapter/backend change (parentInteractionId was already wired)
- thread-pane render test (open → reply → parent shows the count); 58 green

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 00:15:21 +05:30
maaz519 7a0fb2ca97 feat(messaging-ui): reaction picker + attachments in the Thread
- useMessages exposes upload(); Thread gains a hover reaction picker (react to
  a message) and an attach button (upload → stage → send), plus inline image /
  file rendering of message attachments — all capability-degrading (hidden when
  the adapter lacks react/upload)
- themeable styles for the picker, attach button, staged chip, and attachments
- 57 tests still green

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 00:15:21 +05:30
38 changed files with 2528 additions and 112 deletions
BIN
View File
Binary file not shown.
+7 -1
View File
@@ -18,6 +18,10 @@
"types": "./dist/adapters/kernel-client.d.ts", "types": "./dist/adapters/kernel-client.d.ts",
"import": "./dist/adapters/kernel-client.js" "import": "./dist/adapters/kernel-client.js"
}, },
"./adapters/mock-inbox": {
"types": "./dist/adapters/mock-inbox.d.ts",
"import": "./dist/adapters/mock-inbox.js"
},
"./conformance": { "./conformance": {
"types": "./dist/conformance.d.ts", "types": "./dist/conformance.d.ts",
"import": "./dist/conformance.js" "import": "./dist/conformance.js"
@@ -33,8 +37,9 @@
"test": "vitest run" "test": "vitest run"
}, },
"peerDependencies": { "peerDependencies": {
"@insignia/iios-kernel-client": "*",
"react": ">=18", "react": ">=18",
"@insignia/iios-kernel-client": "*" "react-dom": ">=18"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"@insignia/iios-kernel-client": { "@insignia/iios-kernel-client": {
@@ -46,6 +51,7 @@
"@testing-library/dom": "^10.4.0", "@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.1.0", "@testing-library/react": "^16.1.0",
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"jsdom": "^26.0.0", "jsdom": "^26.0.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
@@ -61,6 +61,10 @@ export interface MessagingAdapter {
* Absent => the composer offers no autocomplete (you can still type @text). */ * Absent => the composer offers no autocomplete (you can still type @text). */
listMembers?(threadId: string): Promise<Person[]>; listMembers?(threadId: string): Promise<Person[]>;
/** The people you can start a conversation with (org directory). Drives the "New message"
* people picker. Absent => the UI hides DM/group creation (you can still open channels). */
directory?(): Promise<Person[]>;
// ── Channels (optional capability) ────────────────────────────── // ── Channels (optional capability) ──────────────────────────────
// A channel is just a third membership beyond dm/group: a discoverable, joinable room. // A channel is just a third membership beyond dm/group: a discoverable, joinable room.
// Implement all four to enable the channels UI; absent => the UI hides channels entirely. // Implement all four to enable the channels UI; absent => the UI hides channels entirely.
@@ -0,0 +1,109 @@
import type { InboxAdapter } from '../inbox/adapter';
import type { InboxItem, InboxState, MailAttachment, MailMessage, MailPerson } from '../inbox/types';
const PEOPLE: MailPerson[] = [
{ id: 'pp_sofia', name: 'Sofia Ramirez', kind: 'staff' },
{ id: 'pp_dan', name: 'Dan Whitaker', kind: 'staff' },
{ id: 'cust_acme', name: 'Acme Roofing (Client)', kind: 'customer' },
];
interface MockMail {
subject: string;
messages: MailMessage[];
}
/**
* In-memory inbox for demos/tests. State is PER INSTANCE. Seeds a few work items (mention,
* needs-reply, alert) plus mail threads that fold into the Open view as MAIL rows.
*/
export class MockInboxAdapter implements InboxAdapter {
private seq = 100;
private readonly items: InboxItem[];
private readonly threads = new Map<string, MockMail>();
/** threadIds that surface as standalone MAIL rows (in addition to work items). */
private readonly mailFold = ['mt_welcome', 'mt_invoice'];
constructor(private readonly now: () => string = () => new Date().toISOString()) {
const at = this.now();
this.items = [
{ id: 'in_1', kind: 'MENTION', state: 'OPEN', title: 'Sofia mentioned you', summary: '@you — can you confirm the Henderson scope?', priority: 'HIGH', threadId: 'mt_mention', createdAt: at },
{ id: 'in_2', kind: 'NEEDS_REPLY', state: 'OPEN', title: 'Reply needed — Storm response', summary: 'Dan: crew rolling out at 7', priority: 'MEDIUM', threadId: 'mt_storm', createdAt: at },
{ id: 'in_3', kind: 'SYSTEM_ALERT', state: 'OPEN', title: 'Ticket TK-204 updated', summary: 'Customer replied on the roof-leak case.', priority: 'LOW', createdAt: at },
];
this.threads.set('mt_mention', {
subject: 'Henderson scope',
messages: [{ id: 'm1', actorId: 'pp_sofia', kind: 'EMAIL', at, html: '<p>Can you confirm the <b>Henderson</b> scope by EOD?</p>', text: 'Can you confirm the Henderson scope by EOD?', attachment: null }],
});
this.threads.set('mt_storm', {
subject: 'Storm response — East side',
messages: [{ id: 'm2', actorId: 'pp_dan', kind: 'EMAIL', at, html: '<p>Crew is rolling out at 7. Confirm the Henderson job?</p>', text: 'Crew rolling out at 7. Confirm the Henderson job?', attachment: null }],
});
this.threads.set('mt_welcome', {
subject: 'Welcome to the Founders Club',
messages: [{ id: 'm3', actorId: 'system', kind: 'EMAIL', at, html: '<p>Thanks for joining the <b>Founders Club</b>. Set up your account to get started.</p>', text: 'Thanks for joining the Founders Club.', attachment: null }],
});
this.threads.set('mt_invoice', {
subject: 'Invoice #1042 — Acme Roofing',
messages: [{ id: 'm4', actorId: 'cust_acme', kind: 'EMAIL', at, html: '<p>Attached is invoice <b>#1042</b> for the East-side job.</p>', text: 'Attached is invoice #1042 for the East-side job.', attachment: null }],
});
}
async listInbox(state?: InboxState): Promise<InboxItem[]> {
const showMail = !state || state === 'OPEN';
const work = this.items.filter((i) => (state ? i.state === state : true));
const mail: InboxItem[] = showMail
? this.mailFold.map((tid) => {
const t = this.threads.get(tid)!;
const last = t.messages[t.messages.length - 1];
return {
id: `mail:${tid}`,
kind: 'MAIL',
state: 'OPEN' as InboxState,
title: t.subject,
...(last?.text ? { summary: last.text } : {}),
priority: 'LOW',
threadId: tid,
createdAt: last?.at ?? this.now(),
};
})
: [];
return [...mail, ...work];
}
async transition(id: string, state: InboxState): Promise<void> {
const item = this.items.find((i) => i.id === id);
if (item) item.state = state;
}
async mailHistory(threadId: string): Promise<MailMessage[]> {
return [...(this.threads.get(threadId)?.messages ?? [])];
}
async mailReply(threadId: string, content: string, attachment?: MailAttachment): Promise<void> {
const t = this.threads.get(threadId);
if (t) t.messages = [...t.messages, { id: `r_${this.seq++}`, actorId: 'me', kind: 'MESSAGE', at: this.now(), html: null, text: content || null, attachment: attachment ?? null }];
}
async uploadAttachment(file: File): Promise<MailAttachment> {
return { contentRef: `mock/${this.seq++}`, mimeType: file.type || 'application/octet-stream', sizeBytes: file.size, filename: file.name };
}
async directory(): Promise<MailPerson[]> {
return [...PEOPLE];
}
async composeInternal(recipientUserId: string, subject: string, text: string, attachments?: MailAttachment[]): Promise<void> {
const threadId = `mt_${this.seq++}`;
this.threads.set(threadId, { subject, messages: [{ id: `m_${this.seq++}`, actorId: 'me', kind: 'EMAIL', at: this.now(), html: `<p>${text}</p>`, text, attachment: attachments?.[0] ?? null }] });
this.mailFold.unshift(threadId);
void recipientUserId;
}
async composeExternal(target: string, subject: string, text: string, attachments?: MailAttachment[]): Promise<void> {
// A mock external send has no in-app thread — no-op beyond acknowledging.
void target;
void subject;
void text;
void attachments;
}
}
@@ -159,7 +159,20 @@ export class MockAdapter implements MessagingAdapter {
}); });
} }
async directory(): Promise<Person[]> {
return MOCK_PEOPLE.map((p) => ({ ...p }));
}
async openThread(p: { participantIds: string[]; membership?: Membership; subject?: string }): Promise<{ threadId: string }> { async openThread(p: { participantIds: string[]; membership?: Membership; subject?: string }): Promise<{ threadId: string }> {
// A DM to someone you already have reuses the existing 1:1 thread (dedupe, like the live door).
if ((p.membership ?? (p.participantIds.length === 1 ? 'dm' : 'group')) === 'dm' && p.participantIds.length === 1) {
const target = p.participantIds[0];
for (const [id, t] of this.threads) {
if (t.membership === 'dm' && t.participants.length === 2 && t.participants.includes(ME) && t.participants.includes(target)) {
return { threadId: id };
}
}
}
const membership = p.membership ?? (p.participantIds.length === 1 ? 'dm' : 'group'); const membership = p.membership ?? (p.participantIds.length === 1 ? 'dm' : 'group');
const threadId = `th_mock_${this.seq++}`; const threadId = `th_mock_${this.seq++}`;
this.threads.set(threadId, { this.threads.set(threadId, {
@@ -0,0 +1,147 @@
import { useMemo, useRef, useState, type ChangeEvent, type FormEvent, type KeyboardEvent } from 'react';
import {
SPECIAL_MENTIONS,
insertMention,
resolveMentions,
trailingMentionQuery,
} from '../mentions';
import type { Attachment, Person, SendOpts } from '../types';
interface Suggestion {
key: string;
label: string;
insert: string;
}
/**
* The message input: draft, @mention autocomplete, and attachment staging. Shared by the main
* Thread and the ThreadPane (which passes a parentInteractionId so a reply lands in the thread).
*/
export function Composer({
members,
canUpload,
upload,
onSend,
onTyping,
parentInteractionId,
placeholder = 'Type a message… @ to mention',
}: {
members: Person[];
canUpload: boolean;
upload: (file: File) => Promise<Attachment>;
onSend: (text: string, opts?: SendOpts) => Promise<void>;
onTyping?: () => void;
parentInteractionId?: string;
placeholder?: string;
}) {
const [draft, setDraft] = useState('');
const [sending, setSending] = useState(false);
const [staged, setStaged] = useState<Attachment | null>(null);
const [uploading, setUploading] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
const query = trailingMentionQuery(draft);
const suggestions = useMemo<Suggestion[]>(() => {
if (query === null) return [];
const q = query.toLowerCase();
const specials = SPECIAL_MENTIONS.filter((s) => s.startsWith(q)).map((s) => ({ key: `@${s}`, label: `@${s}`, insert: s }));
const people = members.filter((m) => m.name.toLowerCase().includes(q)).map((m) => ({ key: m.id, label: m.name, insert: m.name }));
return [...specials, ...people].slice(0, 6);
}, [query, members]);
const showSuggest = query !== null && suggestions.length > 0;
function pick(insert: string): void {
setDraft((d) => insertMention(d, insert));
}
async function onPickFile(e: ChangeEvent<HTMLInputElement>): Promise<void> {
const file = e.target.files?.[0];
e.target.value = '';
if (!file) return;
setUploading(true);
try {
setStaged(await upload(file));
} catch {
/* host surfaces upload errors */
} finally {
setUploading(false);
}
}
async function submit(e?: FormEvent): Promise<void> {
e?.preventDefault();
const text = draft.trim();
if ((!text && !staged) || sending) return;
const mentions = resolveMentions(text, members);
const att = staged;
setDraft('');
setStaged(null);
setSending(true);
try {
await onSend(text, {
...(mentions.length ? { mentions } : {}),
...(att ? { attachment: att } : {}),
...(parentInteractionId ? { parentInteractionId } : {}),
});
} catch {
setStaged(att);
} finally {
setSending(false);
}
}
function onKeyDown(e: KeyboardEvent<HTMLInputElement>): void {
if (showSuggest && e.key === 'Enter') {
e.preventDefault();
pick(suggestions[0]!.insert);
}
}
return (
<form className="miu-composer" onSubmit={submit}>
{showSuggest ? (
<ul className="miu-suggest" role="listbox" aria-label="Mention suggestions">
{suggestions.map((s) => (
<li key={s.key}>
<button type="button" role="option" aria-selected="false" className="miu-suggest-item" onClick={() => pick(s.insert)}>
{s.label}
</button>
</li>
))}
</ul>
) : null}
{staged ? (
<div className="miu-staged">
📎 {staged.name}
<button type="button" className="miu-staged-x" onClick={() => setStaged(null)} aria-label="Remove attachment">
</button>
</div>
) : null}
<div className="miu-composer-row">
{canUpload ? (
<>
<input ref={fileRef} type="file" className="miu-file-input" onChange={onPickFile} aria-label="Attach a file" />
<button type="button" className="miu-attach-btn" title="Attach a file" disabled={uploading} onClick={() => fileRef.current?.click()}>
{uploading ? '…' : '📎'}
</button>
</>
) : null}
<input
className="miu-input"
value={draft}
placeholder={placeholder}
aria-label="Message"
onChange={(e) => {
setDraft(e.target.value);
onTyping?.();
}}
onKeyDown={onKeyDown}
/>
<button type="submit" className="miu-send" disabled={(!draft.trim() && !staged) || sending}>
Send
</button>
</div>
</form>
);
}
@@ -0,0 +1,107 @@
import { useState } from 'react';
import { highlightMentions } from '../mentions';
import type { Attachment } from '../types';
import type { UiMessage } from '../hooks/use-messages';
const REACTION_EMOJIS = ['👍', '❤️', '😂', '🎉', '👀'];
const isImage = (mime: string): boolean => mime.startsWith('image/');
function AttachmentView({ att }: { att: Attachment }) {
if (isImage(att.mime)) {
return (
<a href={att.url} target="_blank" rel="noreferrer" className="miu-att-img-link">
<img src={att.url} alt={att.name} className="miu-att-img" />
</a>
);
}
return (
<a href={att.url} target="_blank" rel="noreferrer" className="miu-att-file">
📎 {att.name}
</a>
);
}
/** One rendered message: bubble (with @mention highlighting), attachment, reactions, seen tick,
* and — in the main thread only — a thread/reply affordance. */
export function MessageItem({
message,
memberNames,
canReact,
onReact,
seen,
replyCount,
onOpenThread,
}: {
message: UiMessage;
memberNames: string[];
canReact: boolean;
onReact: (messageId: string, emoji: string) => void;
seen: boolean;
/** Present only in the main thread (not inside the pane). undefined => no thread affordance. */
replyCount?: number;
onOpenThread?: (messageId: string) => void;
}) {
const [pickerOpen, setPickerOpen] = useState(false);
const m = message;
return (
<div className={`miu-msg${message.mine ? ' is-mine' : ''}${message.pending ? ' is-pending' : ''}`}>
<div className="miu-bubble-row">
<div className="miu-bubble">
{m.text ? highlightMentions(m.text, memberNames) : null}
{m.attachment ? <AttachmentView att={m.attachment} /> : null}
</div>
<div className="miu-msg-actions">
{canReact ? (
<div className="miu-react-wrap">
<button type="button" className="miu-react-btn" title="React" onClick={() => setPickerOpen((p) => !p)}>
🙂
</button>
{pickerOpen ? (
<div className="miu-react-picker">
{REACTION_EMOJIS.map((e) => (
<button
key={e}
type="button"
className="miu-react-emoji"
onClick={() => {
onReact(m.id, e);
setPickerOpen(false);
}}
>
{e}
</button>
))}
</div>
) : null}
</div>
) : null}
{onOpenThread ? (
<button type="button" className="miu-react-btn" title="Reply in thread" onClick={() => onOpenThread(m.id)}>
💬
</button>
) : null}
</div>
</div>
{m.reactions && m.reactions.length > 0 ? (
<div className="miu-reactions">
{m.reactions.map((r) => (
<button key={r.emoji} type="button" className={`miu-reaction${r.mine ? ' is-mine' : ''}`} onClick={() => canReact && onReact(m.id, r.emoji)}>
{r.emoji} {r.count}
</button>
))}
</div>
) : null}
{replyCount !== undefined && replyCount > 0 && onOpenThread ? (
<button type="button" className="miu-thread-link" onClick={() => onOpenThread(m.id)}>
💬 {replyCount} {replyCount === 1 ? 'reply' : 'replies'}
</button>
) : null}
{message.mine && seen ? <span className="miu-seen">Seen</span> : null}
</div>
);
}
@@ -3,7 +3,9 @@ import { useConversations } from '../hooks/use-conversations';
import { useAdapter } from '../provider'; import { useAdapter } from '../provider';
import { ConversationList } from './conversation-list'; import { ConversationList } from './conversation-list';
import { ChannelBrowser } from './channel-browser'; import { ChannelBrowser } from './channel-browser';
import { NewConversation } from './new-conversation';
import { Thread } from './thread'; import { Thread } from './thread';
import { ThreadPane } from './thread-pane';
/** /**
* The drop-in messenger: sectioned conversation list (Channels / Direct messages) + open thread, * The drop-in messenger: sectioned conversation list (Channels / Direct messages) + open thread,
@@ -14,24 +16,41 @@ export function Messenger() {
const { conversations, loading, error, refetch } = useConversations(); const { conversations, loading, error, refetch } = useConversations();
const adapter = useAdapter(); const adapter = useAdapter();
const channelsSupported = typeof adapter.browseChannels === 'function'; const channelsSupported = typeof adapter.browseChannels === 'function';
const directorySupported = typeof adapter.directory === 'function';
const [selected, setSelected] = useState<string | null>(null); const [selected, setSelected] = useState<string | null>(null);
const [browsing, setBrowsing] = useState(false); const [browsing, setBrowsing] = useState(false);
const [composing, setComposing] = useState(false); // "New message" people picker open
const [activeRoot, setActiveRoot] = useState<string | null>(null); // open thread pane's root message
useEffect(() => { useEffect(() => {
if (browsing) return; if (browsing || composing) return;
if (selected && conversations.some((c) => c.threadId === selected)) return; if (selected && conversations.some((c) => c.threadId === selected)) return;
setSelected(conversations[0]?.threadId ?? null); setSelected(conversations[0]?.threadId ?? null);
}, [conversations, selected, browsing]); }, [conversations, selected, browsing, composing]);
// Switching conversations (or into browse/compose) closes any open thread pane.
useEffect(() => setActiveRoot(null), [selected, browsing, composing]);
const channels = useMemo(() => conversations.filter((c) => c.membership === 'channel'), [conversations]); const channels = useMemo(() => conversations.filter((c) => c.membership === 'channel'), [conversations]);
const dms = useMemo(() => conversations.filter((c) => c.membership !== 'channel'), [conversations]); const dms = useMemo(() => conversations.filter((c) => c.membership !== 'channel'), [conversations]);
function pick(threadId: string): void { function pick(threadId: string): void {
setBrowsing(false); setBrowsing(false);
setComposing(false);
setSelected(threadId); setSelected(threadId);
} }
function openBrowse(): void {
setComposing(false);
setBrowsing(true);
}
function openCompose(): void {
setBrowsing(false);
setComposing(true);
}
return ( return (
<div className="miu-messenger"> <div className="miu-messenger">
<aside className="miu-sidebar"> <aside className="miu-sidebar">
@@ -42,12 +61,12 @@ export function Messenger() {
<div className="miu-section"> <div className="miu-section">
<div className="miu-section-head"> <div className="miu-section-head">
<span>Channels</span> <span>Channels</span>
<button type="button" className="miu-section-add" title="Browse channels" onClick={() => setBrowsing(true)}> <button type="button" className="miu-section-add" title="Browse channels" onClick={openBrowse}>
</button> </button>
</div> </div>
{channels.length > 0 ? ( {channels.length > 0 ? (
<ConversationList conversations={channels} selectedId={browsing ? null : selected} onSelect={pick} /> <ConversationList conversations={channels} selectedId={browsing || composing ? null : selected} onSelect={pick} />
) : ( ) : (
<div className="miu-empty miu-empty-sm">Browse to join a channel.</div> <div className="miu-empty miu-empty-sm">Browse to join a channel.</div>
)} )}
@@ -55,12 +74,17 @@ export function Messenger() {
) : null} ) : null}
<div className="miu-section"> <div className="miu-section">
{channelsSupported ? ( {channelsSupported || directorySupported ? (
<div className="miu-section-head"> <div className="miu-section-head">
<span>Direct messages</span> <span>Direct messages</span>
{directorySupported ? (
<button type="button" className="miu-section-add" title="New message" onClick={openCompose}>
</button>
) : null}
</div> </div>
) : null} ) : null}
<ConversationList conversations={dms} selectedId={browsing ? null : selected} onSelect={pick} /> <ConversationList conversations={dms} selectedId={browsing || composing ? null : selected} onSelect={pick} />
</div> </div>
</aside> </aside>
@@ -72,10 +96,21 @@ export function Messenger() {
pick(threadId); pick(threadId);
}} }}
/> />
) : composing ? (
<NewConversation
onCreated={(threadId) => {
refetch();
pick(threadId);
}}
/>
) : ( ) : (
<Thread threadId={selected} /> <Thread threadId={selected} activeRootId={activeRoot} onOpenThread={setActiveRoot} />
)} )}
</section> </section>
{!browsing && !composing && selected && activeRoot ? (
<ThreadPane threadId={selected} rootId={activeRoot} onClose={() => setActiveRoot(null)} />
) : null}
</div> </div>
); );
} }
@@ -0,0 +1,35 @@
import { useState, type ReactNode, type CSSProperties } from 'react';
import { createPortal } from 'react-dom';
// The SDK theme tokens. A body-portaled node is outside the `.miu-messenger`/`.miu-inbox`
// subtree that defines these, so we copy their resolved values onto the portal root.
const THEME_VARS = [
'--miu-bg', '--miu-panel', '--miu-panel-2', '--miu-border',
'--miu-text', '--miu-muted', '--miu-accent', '--miu-accent-text', '--miu-radius',
] as const;
function copyThemeVars(): Record<string, string> {
if (typeof document === 'undefined') return {};
const src = document.querySelector('.miu-messenger, .miu-inbox');
if (!src) return {};
const cs = getComputedStyle(src);
const out: Record<string, string> = {};
for (const v of THEME_VARS) {
const val = cs.getPropertyValue(v).trim();
if (val) out[v] = val;
}
return out;
}
/**
* Render an overlay into document.body so it escapes the host's stacking context and overflow.
* A host wrapper like `.view { position: relative; z-index: 1 }` traps a `position: fixed` overlay
* BELOW a sibling sticky header no matter how high its z-index — the only robust fix is to leave
* that stacking context entirely. Theme tokens are copied from the live surface (captured once on
* mount, synchronously, so there is no unstyled first paint) and re-applied on the portal root.
*/
export function ModalPortal({ children }: { children: ReactNode }) {
const [vars] = useState<Record<string, string>>(copyThemeVars);
if (typeof document === 'undefined') return null;
return createPortal(<div className="miu-portal" style={vars as CSSProperties}>{children}</div>, document.body);
}
@@ -0,0 +1,66 @@
import { describe, it, expect } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { MessagingProvider } from '../provider';
import { MockAdapter } from '../adapters/mock';
import { Messenger } from './messenger';
function mount(adapter = new MockAdapter()) {
render(
<MessagingProvider adapter={adapter}>
<Messenger />
</MessagingProvider>,
);
return adapter;
}
describe('MockAdapter directory + openThread', () => {
it('directory lists people you can message', async () => {
const a = new MockAdapter();
const people = await a.directory();
expect(people.some((p) => p.name === 'Dan Whitaker')).toBe(true);
});
it('one participant opens a dm; two+ open a group with a subject', async () => {
const a = new MockAdapter();
const dm = await a.openThread({ participantIds: ['pp_dan'], membership: 'dm' });
const list = await a.listConversations();
expect(list.find((c) => c.threadId === dm.threadId)?.membership).toBe('dm');
const group = await a.openThread({ participantIds: ['pp_dan', 'pp_priya'], membership: 'group', subject: 'Roof crew' });
const g = (await a.listConversations()).find((c) => c.threadId === group.threadId);
expect(g?.membership).toBe('group');
expect(g?.title).toBe('Roof crew');
});
it('opening a dm with the same person reuses the existing thread', async () => {
const a = new MockAdapter();
const first = await a.openThread({ participantIds: ['pp_priya'], membership: 'dm' });
const second = await a.openThread({ participantIds: ['pp_priya'], membership: 'dm' });
expect(second.threadId).toBe(first.threadId);
});
});
describe('<Messenger /> new conversation flow', () => {
it('opens the picker from the Direct messages +, and starting a chat leaves the picker', async () => {
mount();
// Open the "New message" picker.
fireEvent.click(await screen.findByTitle('New message'));
expect(await screen.findByText('New message')).toBeTruthy();
// Pick a person and start a DM.
fireEvent.click(await screen.findByText('Dan Whitaker'));
fireEvent.click(screen.getByText('Start chat'));
// The picker closes (we're back in a thread view — the picker heading is gone).
await waitFor(() => expect(screen.queryByText('Start chat')).toBeNull());
});
it('selecting two people switches the action to group create', async () => {
mount();
fireEvent.click(await screen.findByTitle('New message'));
fireEvent.click(await screen.findByText('Dan Whitaker'));
fireEvent.click(await screen.findByText('Priya Nair'));
expect(screen.getByText(/Create group \(2\)/)).toBeTruthy();
expect(screen.getByLabelText('Group name')).toBeTruthy();
});
});
@@ -0,0 +1,97 @@
import { useEffect, useState } from 'react';
import { useAdapter } from '../provider';
import type { Person } from '../types';
/**
* Start a direct message or a group — Slack-style. Pick people from the org directory: one selected
* opens a DM (deduped by the adapter), two or more create a group with an optional name. Shown in
* the main pane like the channel browser.
*/
export function NewConversation({ onCreated }: { onCreated?: (threadId: string) => void }) {
const adapter = useAdapter();
const [people, setPeople] = useState<Person[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [q, setQ] = useState('');
const [selected, setSelected] = useState<string[]>([]);
const [name, setName] = useState('');
const [busy, setBusy] = useState(false);
useEffect(() => {
if (!adapter.directory) {
setLoading(false);
return;
}
let alive = true;
adapter
.directory()
.then((p) => {
if (alive) {
setPeople(p);
setError(null);
}
})
.catch((e: unknown) => {
if (alive) setError(e instanceof Error ? e.message : String(e));
})
.finally(() => {
if (alive) setLoading(false);
});
return () => {
alive = false;
};
}, [adapter]);
const filtered = people.filter((p) => p.name.toLowerCase().includes(q.trim().toLowerCase()));
const isGroup = selected.length > 1;
const canStart = selected.length >= 1 && !busy;
function toggle(id: string): void {
setSelected((s) => (s.includes(id) ? s.filter((x) => x !== id) : [...s, id]));
}
async function start(): Promise<void> {
if (!canStart) return;
setBusy(true);
setError(null);
try {
const res = await adapter.openThread({
participantIds: selected,
membership: isGroup ? 'group' : 'dm',
...(isGroup && name.trim() ? { subject: name.trim() } : {}),
});
onCreated?.(res.threadId);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
}
return (
<div className="miu-browser">
<div className="miu-browser-head">New message</div>
<div className="miu-newconv">
<input className="miu-input" value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search people…" aria-label="Search people" />
{isGroup ? (
<input className="miu-input" value={name} onChange={(e) => setName(e.target.value)} placeholder="Group name (optional)" aria-label="Group name" />
) : null}
{error ? <div className="miu-empty miu-error">{error}</div> : null}
<div className="miu-browser-list">
{loading && people.length === 0 ? <div className="miu-empty">Loading</div> : null}
{!loading && filtered.length === 0 ? <div className="miu-empty">No people found.</div> : null}
{filtered.map((p) => (
<label key={p.id} className={`miu-person-row${selected.includes(p.id) ? ' is-active' : ''}`}>
<input type="checkbox" checked={selected.includes(p.id)} onChange={() => toggle(p.id)} />
<span className="miu-browser-name">{p.name}</span>
<span className="miu-pill">{p.kind}</span>
</label>
))}
</div>
<button type="button" className="miu-send" onClick={() => void start()} disabled={!canStart}>
{busy ? 'Starting…' : isGroup ? `Create group (${selected.length})` : 'Start chat'}
</button>
</div>
</div>
);
}
@@ -0,0 +1,36 @@
import { describe, it, expect } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { MessagingProvider } from '../provider';
import { Messenger } from './messenger';
import { MockAdapter } from '../adapters/mock';
function mount() {
return render(
<MessagingProvider adapter={new MockAdapter()}>
<Messenger />
</MessagingProvider>,
);
}
describe('Slack-style thread pane', () => {
it('opens a thread on 💬, posts a reply into it, and shows the reply count on the parent', async () => {
mount();
// Wait for the auto-selected thread's message to render WITH its actions (not just the sidebar
// preview), then open the thread pane via the message's reply affordance (💬).
const replyButtons = await screen.findAllByTitle('Reply in thread');
fireEvent.click(replyButtons[0]!);
expect(await screen.findByText('Thread')).toBeTruthy(); // pane header
// The pane's composer (placeholder "Reply…") — send a threaded reply.
const replyInput = screen.getByPlaceholderText('Reply…') as HTMLInputElement;
fireEvent.change(replyInput, { target: { value: 'on it' } });
// The pane has its own Send; grab the last one (pane is rendered after the main composer).
const sends = screen.getAllByText('Send');
fireEvent.click(sends[sends.length - 1]!);
// The reply shows in the pane (replies are hidden from the main thread, so this is unique)...
await waitFor(() => expect(screen.getByText('on it')).toBeTruthy());
// ...and the parent now advertises the reply count as a thread-link button in the main thread.
await waitFor(() => expect(screen.getByRole('button', { name: /1 reply/ })).toBeTruthy());
});
});
@@ -0,0 +1,60 @@
import { useMemo } from 'react';
import { useMessages } from '../hooks/use-messages';
import { useMembers } from '../hooks/use-members';
import { Composer } from './composer';
import { MessageItem } from './message-item';
/**
* The Slack-style thread side panel: the root message + its replies + a composer that posts back
* into the thread (parentInteractionId = root). Uses its own useMessages on the same thread; the
* shared adapter subscription keeps it and the main view in sync.
*/
export function ThreadPane({
threadId,
rootId,
onClose,
}: {
threadId: string;
rootId: string;
onClose: () => void;
}) {
const { messages, send, react, upload, seenIds, sendTyping, canReact, canUpload } = useMessages(threadId);
const members = useMembers(threadId);
const memberNames = useMemo(() => members.map((m) => m.name), [members]);
const root = useMemo(() => messages.find((m) => m.id === rootId), [messages, rootId]);
const replies = useMemo(() => messages.filter((m) => m.parentInteractionId === rootId), [messages, rootId]);
return (
<aside className="miu-pane">
<header className="miu-pane-head">
<span>Thread</span>
<button type="button" className="miu-pane-close" onClick={onClose} aria-label="Close thread">
</button>
</header>
<div className="miu-messages miu-pane-messages">
{root ? (
<MessageItem message={root} memberNames={memberNames} canReact={canReact} onReact={(id, e) => void react(id, e)} seen={seenIds.has(root.id)} />
) : (
<div className="miu-empty">Message not found.</div>
)}
<div className="miu-pane-divider">{replies.length} {replies.length === 1 ? 'reply' : 'replies'}</div>
{replies.map((m) => (
<MessageItem key={m.id} message={m} memberNames={memberNames} canReact={canReact} onReact={(id, e) => void react(id, e)} seen={seenIds.has(m.id)} />
))}
</div>
<Composer
members={members}
canUpload={canUpload}
upload={upload}
onSend={send}
onTyping={sendTyping}
parentInteractionId={rootId}
placeholder="Reply…"
/>
</aside>
);
}
@@ -1,124 +1,61 @@
import { useMemo, useState, type FormEvent, type KeyboardEvent } from 'react'; import { useMemo } from 'react';
import { useMessages } from '../hooks/use-messages'; import { useMessages } from '../hooks/use-messages';
import { useMembers } from '../hooks/use-members'; import { useMembers } from '../hooks/use-members';
import { import { Composer } from './composer';
SPECIAL_MENTIONS, import { MessageItem } from './message-item';
highlightMentions,
insertMention,
resolveMentions,
trailingMentionQuery,
} from '../mentions';
interface Suggestion {
key: string;
label: string;
insert: string;
}
/** /**
* One conversation: message list + composer, driven by useMessages. Adds @mention autocomplete * The main conversation view: top-level messages + composer. Replies (messages with a
* (from the thread's members) and highlights mentions in message bodies. Transport-agnostic. * parentInteractionId) are hidden here and live in the ThreadPane — a message with replies shows a
* "N replies" link that opens it. @mentions, reactions, and attachments all work.
*/ */
export function Thread({ threadId }: { threadId: string | null }) { export function Thread({
const { messages, loading, error, send, typingUserIds, seenIds, sendTyping } = useMessages(threadId); threadId,
activeRootId,
onOpenThread,
}: {
threadId: string | null;
activeRootId?: string | null;
onOpenThread?: (rootId: string) => void;
}) {
const { messages, loading, error, send, react, upload, typingUserIds, seenIds, sendTyping, canReact, canUpload } = useMessages(threadId);
const members = useMembers(threadId); const members = useMembers(threadId);
const [draft, setDraft] = useState('');
const [sending, setSending] = useState(false);
const memberNames = useMemo(() => members.map((m) => m.name), [members]); const memberNames = useMemo(() => members.map((m) => m.name), [members]);
const query = trailingMentionQuery(draft);
const suggestions = useMemo<Suggestion[]>(() => {
if (query === null) return [];
const q = query.toLowerCase();
const specials = SPECIAL_MENTIONS.filter((s) => s.startsWith(q)).map((s) => ({ key: `@${s}`, label: `@${s}`, insert: s }));
const people = members.filter((m) => m.name.toLowerCase().includes(q)).map((m) => ({ key: m.id, label: m.name, insert: m.name }));
return [...specials, ...people].slice(0, 6);
}, [query, members]);
const showSuggest = query !== null && suggestions.length > 0;
function pick(insert: string): void { const topLevel = useMemo(() => messages.filter((m) => !m.parentInteractionId), [messages]);
setDraft((d) => insertMention(d, insert)); const replyCount = useMemo(() => {
} const counts = new Map<string, number>();
for (const m of messages) if (m.parentInteractionId) counts.set(m.parentInteractionId, (counts.get(m.parentInteractionId) ?? 0) + 1);
async function submit(e?: FormEvent): Promise<void> { return counts;
e?.preventDefault(); }, [messages]);
const text = draft.trim();
if (!text || sending) return;
const mentions = resolveMentions(text, members);
setDraft('');
setSending(true);
try {
await send(text, mentions.length ? { mentions } : undefined);
} catch {
// surfaced via the hook's error
} finally {
setSending(false);
}
}
function onKeyDown(e: KeyboardEvent<HTMLInputElement>): void {
if (showSuggest && e.key === 'Enter') {
e.preventDefault();
pick(suggestions[0]!.insert);
}
}
if (!threadId) { if (!threadId) {
return <div className="miu-empty miu-thread-empty">Select a conversation.</div>; return <div className="miu-empty miu-thread-empty">Select a conversation.</div>;
} }
return ( return (
<div className="miu-thread"> <div className={`miu-thread${activeRootId ? ' has-pane' : ''}`}>
<div className="miu-messages"> <div className="miu-messages">
{loading && messages.length === 0 ? <div className="miu-empty">Loading</div> : null} {loading && messages.length === 0 ? <div className="miu-empty">Loading</div> : null}
{error ? <div className="miu-empty miu-error">{error}</div> : null} {error ? <div className="miu-empty miu-error">{error}</div> : null}
{messages.map((m) => ( {topLevel.map((m) => (
<div key={m.id} className={`miu-msg${m.mine ? ' is-mine' : ''}${m.pending ? ' is-pending' : ''}`}> <MessageItem
<div className="miu-bubble">{highlightMentions(m.text, memberNames)}</div> key={m.id}
{m.reactions && m.reactions.length > 0 ? ( message={m}
<div className="miu-reactions"> memberNames={memberNames}
{m.reactions.map((r) => ( canReact={canReact}
<span key={r.emoji} className={`miu-reaction${r.mine ? ' is-mine' : ''}`}> onReact={(id, emoji) => void react(id, emoji)}
{r.emoji} {r.count} seen={seenIds.has(m.id)}
</span> replyCount={replyCount.get(m.id) ?? 0}
))} {...(onOpenThread ? { onOpenThread } : {})}
</div> />
) : null}
{m.mine && seenIds.has(m.id) ? <span className="miu-seen">Seen</span> : null}
</div>
))} ))}
{typingUserIds.length > 0 ? ( {typingUserIds.length > 0 ? (
<div className="miu-typing">{typingUserIds.length === 1 ? 'typing…' : 'several people are typing…'}</div> <div className="miu-typing">{typingUserIds.length === 1 ? 'typing…' : 'several people are typing…'}</div>
) : null} ) : null}
</div> </div>
<form className="miu-composer" onSubmit={submit}> <Composer members={members} canUpload={canUpload} upload={upload} onSend={send} onTyping={sendTyping} />
{showSuggest ? (
<ul className="miu-suggest" role="listbox" aria-label="Mention suggestions">
{suggestions.map((s) => (
<li key={s.key}>
<button type="button" role="option" aria-selected="false" className="miu-suggest-item" onClick={() => pick(s.insert)}>
{s.label}
</button>
</li>
))}
</ul>
) : null}
<input
className="miu-input"
value={draft}
placeholder="Type a message… @ to mention"
aria-label="Message"
onChange={(e) => {
setDraft(e.target.value);
sendTyping();
}}
onKeyDown={onKeyDown}
/>
<button type="submit" className="miu-send" disabled={!draft.trim() || sending}>
Send
</button>
</form>
</div> </div>
); );
} }
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useAdapter } from '../provider'; import { useAdapter } from '../provider';
import { isOwnMessage } from '../types'; import { isOwnMessage } from '../types';
import type { Message, SendOpts } from '../types'; import type { Attachment, Message, SendOpts } from '../types';
const TYPING_TTL_MS = 3500; const TYPING_TTL_MS = 3500;
@@ -15,6 +15,7 @@ export interface MessagesState {
error: string | null; error: string | null;
send: (content: string, opts?: SendOpts) => Promise<void>; send: (content: string, opts?: SendOpts) => Promise<void>;
react: (messageId: string, emoji: string) => Promise<void>; react: (messageId: string, emoji: string) => Promise<void>;
upload: (file: File) => Promise<Attachment>;
typingUserIds: string[]; typingUserIds: string[];
seenIds: Set<string>; seenIds: Set<string>;
sendTyping: () => void; sendTyping: () => void;
@@ -147,6 +148,14 @@ export function useMessages(threadId: string | null): MessagesState {
[adapter, threadId], [adapter, threadId],
); );
const upload = useCallback(
async (file: File): Promise<Attachment> => {
if (!adapter.upload) throw new Error('uploads are not supported by this adapter');
return adapter.upload(file);
},
[adapter],
);
const sendTyping = useCallback(() => { const sendTyping = useCallback(() => {
if (threadId) adapter.sendTyping(threadId); if (threadId) adapter.sendTyping(threadId);
}, [adapter, threadId]); }, [adapter, threadId]);
@@ -196,6 +205,7 @@ export function useMessages(threadId: string | null): MessagesState {
error, error,
send, send,
react, react,
upload,
typingUserIds, typingUserIds,
seenIds: seenMine, seenIds: seenMine,
sendTyping, sendTyping,
@@ -0,0 +1,32 @@
import type { MailAttachment, InboxItem, InboxState, MailMessage, MailPerson } from './types';
/**
* The inbox seam. A host implements this; the SDK renders it. Mirrors MessagingAdapter's philosophy:
* `listInbox` returns the UNIFIED feed (work items + mail folded in) so the folding logic lives in
* one place (the adapter, which knows both sources). Compose methods are optional and degrade.
*/
export interface InboxAdapter {
/** The unified inbox: work items + mail threads as rows, filtered by state (mail shows in OPEN). */
listInbox(state?: InboxState): Promise<InboxItem[]>;
/** Transition a work item's state (Done/Snooze/Archive…). Mail rows are not transitioned. */
transition(id: string, state: InboxState): Promise<void>;
/** Messages of one mail thread (HTML + text parts) for the reader. */
mailHistory(threadId: string): Promise<MailMessage[]>;
/** Reply into an existing mail thread, optionally with one attachment. */
mailReply(threadId: string, content: string, attachment?: MailAttachment): Promise<void>;
// ── Attachments (optional) — absent hides the attach affordance ──
/** Upload a file to storage, returning a reference to send with a reply/compose. */
uploadAttachment?(file: File): Promise<MailAttachment>;
// ── Compose (optional) — absent hides the "New message" affordance ──
/** People you can compose an in-app message to. */
directory?(): Promise<MailPerson[]>;
/** App-to-app mail (no SMTP) to a registered user's in-app inbox. */
composeInternal?(recipientUserId: string, subject: string, text: string, attachments?: MailAttachment[]): Promise<void>;
/** External email (SMTP) to an address. */
composeExternal?(target: string, subject: string, text: string, attachments?: MailAttachment[]): Promise<void>;
}
@@ -0,0 +1,174 @@
import { useCallback, useEffect, useState } from 'react';
import { useInboxAdapter } from './provider';
import type { InboxItem, InboxState, MailAttachment, MailMessage, MailPerson } from './types';
export interface InboxData {
items: InboxItem[];
loading: boolean;
error: string | null;
transition: (id: string, state: InboxState) => Promise<void>;
refetch: () => void;
}
/** The unified inbox for a given filter state. Refetches when the filter changes. */
export function useInbox(state?: InboxState): InboxData {
const adapter = useInboxAdapter();
const [items, setItems] = useState<InboxItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [nonce, setNonce] = useState(0);
useEffect(() => {
let alive = true;
setLoading(true);
adapter
.listInbox(state)
.then((list) => {
if (!alive) return;
setItems(list);
setError(null);
})
.catch((e: unknown) => {
if (!alive) return;
setError(e instanceof Error ? e.message : String(e));
setItems([]);
})
.finally(() => {
if (alive) setLoading(false);
});
return () => {
alive = false;
};
}, [adapter, state, nonce]);
const refetch = useCallback(() => setNonce((n) => n + 1), []);
const transition = useCallback(
async (id: string, next: InboxState) => {
await adapter.transition(id, next);
setNonce((n) => n + 1);
},
[adapter],
);
return { items, loading, error, transition, refetch };
}
export interface MailThreadState {
messages: MailMessage[];
loading: boolean;
error: string | null;
reply: (content: string, attachment?: MailAttachment) => Promise<void>;
canAttach: boolean;
upload: (file: File) => Promise<MailAttachment>;
refetch: () => void;
}
/** One mail thread: history + reply. */
export function useMailThread(threadId: string | null): MailThreadState {
const adapter = useInboxAdapter();
const [messages, setMessages] = useState<MailMessage[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [nonce, setNonce] = useState(0);
useEffect(() => {
if (!threadId) {
setMessages([]);
setLoading(false);
return;
}
let alive = true;
setLoading(true);
adapter
.mailHistory(threadId)
.then((m) => {
if (!alive) return;
setMessages(m);
setError(null);
})
.catch((e: unknown) => {
if (alive) setError(e instanceof Error ? e.message : String(e));
})
.finally(() => {
if (alive) setLoading(false);
});
return () => {
alive = false;
};
}, [adapter, threadId, nonce]);
const refetch = useCallback(() => setNonce((n) => n + 1), []);
const reply = useCallback(
async (content: string, attachment?: MailAttachment) => {
if (!threadId) return;
await adapter.mailReply(threadId, content, attachment);
setNonce((n) => n + 1);
},
[adapter, threadId],
);
const upload = useCallback(
async (file: File) => {
if (!adapter.uploadAttachment) throw new Error('attachments are not supported by this adapter');
return adapter.uploadAttachment(file);
},
[adapter],
);
return { messages, loading, error, reply, canAttach: typeof adapter.uploadAttachment === 'function', upload, refetch };
}
export interface ComposeState {
supported: boolean;
directory: MailPerson[];
sendInternal: (recipientUserId: string, subject: string, text: string, attachments?: MailAttachment[]) => Promise<void>;
sendExternal: (target: string, subject: string, text: string, attachments?: MailAttachment[]) => Promise<void>;
canAttach: boolean;
upload: (file: File) => Promise<MailAttachment>;
}
/** Compose a new message — in-app (to a person) or external (to an email). */
export function useCompose(): ComposeState {
const adapter = useInboxAdapter();
const supported = typeof adapter.composeInternal === 'function';
const [directory, setDirectory] = useState<MailPerson[]>([]);
useEffect(() => {
if (!adapter.directory) return;
let alive = true;
adapter
.directory()
.then((d) => {
if (alive) setDirectory(d);
})
.catch(() => {
if (alive) setDirectory([]);
});
return () => {
alive = false;
};
}, [adapter]);
const sendInternal = useCallback(
async (recipientUserId: string, subject: string, text: string, attachments?: MailAttachment[]) => {
if (!adapter.composeInternal) throw new Error('compose is not supported by this adapter');
await adapter.composeInternal(recipientUserId, subject, text, attachments);
},
[adapter],
);
const sendExternal = useCallback(
async (target: string, subject: string, text: string, attachments?: MailAttachment[]) => {
if (!adapter.composeExternal) throw new Error('compose is not supported by this adapter');
await adapter.composeExternal(target, subject, text, attachments);
},
[adapter],
);
const upload = useCallback(
async (file: File) => {
if (!adapter.uploadAttachment) throw new Error('attachments are not supported by this adapter');
return adapter.uploadAttachment(file);
},
[adapter],
);
return { supported, directory, sendInternal, sendExternal, canAttach: typeof adapter.uploadAttachment === 'function', upload };
}
@@ -0,0 +1,97 @@
import { describe, it, expect } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { InboxProvider } from './provider';
import { Inbox } from './inbox';
import { MockInboxAdapter } from '../adapters/mock-inbox';
function mount() {
return render(
<InboxProvider adapter={new MockInboxAdapter()}>
<Inbox />
</InboxProvider>,
);
}
describe('mock inbox adapter', () => {
it('unifies work items + mail in the Open view; other states drop mail', async () => {
const a = new MockInboxAdapter();
const open = await a.listInbox('OPEN');
expect(open.some((i) => i.kind === 'MAIL')).toBe(true);
expect(open.some((i) => i.kind === 'MENTION')).toBe(true);
const done = await a.listInbox('DONE');
expect(done.some((i) => i.kind === 'MAIL')).toBe(false);
});
it('transition moves a work item out of Open', async () => {
const a = new MockInboxAdapter();
await a.transition('in_3', 'DONE');
expect((await a.listInbox('OPEN')).some((i) => i.id === 'in_3')).toBe(false);
expect((await a.listInbox('DONE')).some((i) => i.id === 'in_3')).toBe(true);
});
it('mail reply appends to the thread', async () => {
const a = new MockInboxAdapter();
await a.mailReply('mt_welcome', 'thanks!');
expect((await a.mailHistory('mt_welcome')).some((m) => m.text === 'thanks!')).toBe(true);
});
it('reply carries an uploaded attachment', async () => {
const a = new MockInboxAdapter();
const ref = await a.uploadAttachment(new File(['x'], 'plan.pdf', { type: 'application/pdf' }));
expect(ref).toMatchObject({ filename: 'plan.pdf', mimeType: 'application/pdf' });
await a.mailReply('mt_welcome', '', ref);
const last = (await a.mailHistory('mt_welcome')).at(-1)!;
expect(last.attachment?.filename).toBe('plan.pdf');
});
it('composeInternal carries a first attachment onto the new thread', async () => {
const a = new MockInboxAdapter();
const ref = await a.uploadAttachment(new File(['x'], 'quote.png', { type: 'image/png' }));
await a.composeInternal('pp_sofia', 'Quote', 'see attached', [ref]);
const open = await a.listInbox('OPEN');
const row = open.find((i) => i.title === 'Quote')!;
expect((await a.mailHistory(row.threadId!)).at(-1)?.attachment?.filename).toBe('quote.png');
});
});
describe('<Inbox /> (rendered)', () => {
it('lists items and opens a mail thread on click', async () => {
mount();
// A folded mail row is present.
const welcome = await screen.findByText('Welcome to the Founders Club');
fireEvent.click(welcome);
// The reader opens with a reply box.
expect(await screen.findByLabelText('Reply')).toBeTruthy();
});
it('replies into a mail thread', async () => {
mount();
fireEvent.click(await screen.findByText('Welcome to the Founders Club'));
const input = (await screen.findByLabelText('Reply')) as HTMLInputElement;
fireEvent.change(input, { target: { value: 'got it' } });
fireEvent.click(screen.getByText('Reply'));
await waitFor(() => expect(screen.getByText('got it')).toBeTruthy());
});
it('attaches a file into a mail reply', async () => {
mount();
fireEvent.click(await screen.findByText('Welcome to the Founders Club'));
const file = new File(['data'], 'roof.pdf', { type: 'application/pdf' });
fireEvent.change(await screen.findByLabelText('Attach file'), { target: { files: [file] } });
// The pending chip shows the file, then Reply sends it.
await screen.findByText(/roof\.pdf/);
fireEvent.click(screen.getByText('Reply'));
await waitFor(() => expect(screen.getAllByText(/roof\.pdf/).length).toBeGreaterThan(0));
});
it('composes an in-app message and it shows in the inbox', async () => {
mount();
fireEvent.click(await screen.findByText('New message'));
// pick a recipient
fireEvent.click(await screen.findByText('Sofia Ramirez'));
fireEvent.change(screen.getByLabelText('Subject'), { target: { value: 'Quick q' } });
fireEvent.change(screen.getByLabelText('Message body'), { target: { value: 'ping' } });
fireEvent.click(screen.getByText('Send'));
await waitFor(() => expect(screen.getByText('Quick q')).toBeTruthy());
});
});
@@ -0,0 +1,328 @@
import { useEffect, useRef, useState, type FormEvent } from 'react';
import { ModalPortal } from '../components/modal-portal';
import { useCompose, useInbox, useMailThread } from './hooks';
import type { InboxItem, InboxState, MailAttachment, MailPerson } from './types';
const fmtBytes = (n: number): string => {
if (!n) return '';
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`;
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
};
const FILTERS: { value: InboxState; label: string }[] = [
{ value: 'OPEN', label: 'Open' },
{ value: 'SNOOZED', label: 'Snoozed' },
{ value: 'DONE', label: 'Done' },
{ value: 'ARCHIVED', label: 'Archived' },
];
const KIND_LABEL: Record<string, string> = {
MAIL: 'Mail',
MENTION: 'Mention',
NEEDS_REPLY: 'Needs reply',
SYSTEM_ALERT: 'Alert',
SUPPORT_UPDATE: 'Support',
};
const timeOf = (iso?: string): string => {
if (!iso) return '';
const d = new Date(iso);
return Number.isNaN(+d) ? '' : d.toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
};
/** The unified inbox: work items + mail in one list; click a threaded row to read + reply. */
export function Inbox() {
const [filter, setFilter] = useState<InboxState>('OPEN');
const { items, loading, error, transition, refetch } = useInbox(filter);
const compose = useCompose();
const [selectedId, setSelectedId] = useState<string | null>(null);
const [composing, setComposing] = useState(false);
useEffect(() => {
if (selectedId && items.some((i) => i.id === selectedId)) return;
setSelectedId(items[0]?.id ?? null);
}, [items, selectedId]);
const selected = items.find((i) => i.id === selectedId) ?? null;
return (
<div className="miu-inbox">
<div className="miu-inbox-bar">
<div className="miu-inbox-filters">
{FILTERS.map((f) => (
<button key={f.value} type="button" className={`miu-tab${filter === f.value ? ' is-active' : ''}`} onClick={() => setFilter(f.value)}>
{f.label}
</button>
))}
</div>
{compose.supported ? (
<button type="button" className="miu-send" onClick={() => setComposing(true)}>
New message
</button>
) : null}
</div>
<div className="miu-inbox-body">
<aside className="miu-inbox-list">
{loading && items.length === 0 ? <div className="miu-empty">Loading</div> : null}
{error ? <div className="miu-empty miu-error">{error}</div> : null}
{!loading && items.length === 0 ? <div className="miu-empty">Nothing here you&apos;re all caught up 🎉</div> : null}
{items.map((it) => (
<button key={it.id} type="button" className={`miu-inbox-row${it.id === selectedId ? ' is-active' : ''}`} onClick={() => setSelectedId(it.id)}>
<span className="miu-inbox-glyph" aria-hidden="true">{it.kind === 'MAIL' ? '✉' : it.kind === 'MENTION' ? '@' : '•'}</span>
<span className="miu-inbox-main">
<span className="miu-inbox-row-top">
<span className="miu-pill">{KIND_LABEL[it.kind] ?? it.kind}</span>
<span className="miu-inbox-title">{it.title}</span>
</span>
{it.summary ? <span className="miu-inbox-summary">{it.summary}</span> : null}
</span>
{it.state !== 'OPEN' ? <span className="miu-pill">{it.state.toLowerCase()}</span> : null}
</button>
))}
</aside>
<section className="miu-inbox-detail">
{selected ? <Detail item={selected} onTransition={(s) => void transition(selected.id, s)} /> : <div className="miu-empty miu-thread-empty">Select an item to read.</div>}
</section>
</div>
{composing ? <ComposeModal onClose={() => setComposing(false)} onSent={() => { setComposing(false); refetch(); }} /> : null}
</div>
);
}
function Detail({ item, onTransition }: { item: InboxItem; onTransition: (state: InboxState) => void }) {
return (
<div className="miu-detail">
{item.state === 'OPEN' && item.kind !== 'MAIL' ? (
<div className="miu-detail-actions">
<button type="button" className="miu-tab" onClick={() => onTransition('SNOOZED')}>Snooze</button>
<button type="button" className="miu-tab" onClick={() => onTransition('DONE')}>Done</button>
<button type="button" className="miu-tab" onClick={() => onTransition('ARCHIVED')}>Archive</button>
</div>
) : null}
{item.threadId ? (
<MailReader threadId={item.threadId} subject={item.title} />
) : (
<div className="miu-detail-body">
<div className="miu-detail-title">{item.title}</div>
{item.summary ? <div className="miu-detail-summary">{item.summary}</div> : null}
</div>
)}
</div>
);
}
/** Read a mail thread (HTML in a sandboxed iframe) + reply. */
export function MailReader({ threadId, subject }: { threadId: string; subject: string }) {
const { messages, loading, error, reply, canAttach, upload } = useMailThread(threadId);
const [draft, setDraft] = useState('');
const [sending, setSending] = useState(false);
const [pending, setPending] = useState<MailAttachment | null>(null);
const [attaching, setAttaching] = useState(false);
const [attachErr, setAttachErr] = useState<string | null>(null);
const fileRef = useRef<HTMLInputElement>(null);
async function pick(e: React.ChangeEvent<HTMLInputElement>): Promise<void> {
const file = e.target.files?.[0];
e.target.value = '';
if (!file) return;
setAttaching(true);
setAttachErr(null);
try {
setPending(await upload(file));
} catch (err) {
setAttachErr(err instanceof Error ? err.message : String(err));
} finally {
setAttaching(false);
}
}
async function submit(e: FormEvent): Promise<void> {
e.preventDefault();
const text = draft.trim();
if ((!text && !pending) || sending) return;
const att = pending;
setDraft('');
setPending(null);
setSending(true);
try {
await reply(text, att ?? undefined);
} catch {
setDraft(text);
setPending(att);
} finally {
setSending(false);
}
}
return (
<div className="miu-mail">
<header className="miu-mail-head">{subject || '(no subject)'}</header>
<div className="miu-mail-body">
{loading && messages.length === 0 ? <div className="miu-empty">Loading</div> : null}
{error ? <div className="miu-empty miu-error">{error}</div> : null}
{messages.map((m) => (
<article key={m.id} className="miu-mail-msg">
<div className="miu-mail-meta">
<span>{m.kind === 'EMAIL' ? 'Email' : 'Reply'}{m.actorId ? ` · ${m.actorId}` : ''}</span>
<span>{timeOf(m.at)}</span>
</div>
{m.html ? (
<iframe sandbox="" srcDoc={m.html} title="mail body" className="miu-mail-frame" />
) : m.text ? (
<div className="miu-mail-text">{m.text}</div>
) : null}
{m.attachment ? (
<span className="miu-attach-chip">📎 {m.attachment.filename ?? 'attachment'}{m.attachment.sizeBytes ? ` · ${fmtBytes(m.attachment.sizeBytes)}` : ''}</span>
) : null}
</article>
))}
</div>
<form className="miu-composer" onSubmit={submit}>
{attachErr ? <div className="miu-empty miu-error">{attachErr}</div> : null}
{pending ? (
<div className="miu-attach-pending">
<span className="miu-attach-chip">📎 {pending.filename ?? 'attachment'}{pending.sizeBytes ? ` · ${fmtBytes(pending.sizeBytes)}` : ''}</span>
<button type="button" className="miu-attach-x" onClick={() => setPending(null)} aria-label="Remove attachment"></button>
</div>
) : null}
<div className="miu-composer-row">
{canAttach ? (
<>
<input ref={fileRef} type="file" hidden onChange={pick} aria-label="Attach file" />
<button type="button" className="miu-attach-btn" onClick={() => fileRef.current?.click()} disabled={attaching || !!pending} title="Attach a file" aria-label="Attach a file">
{attaching ? '…' : '📎'}
</button>
</>
) : null}
<input className="miu-input" value={draft} onChange={(e) => setDraft(e.target.value)} placeholder="Reply…" aria-label="Reply" />
<button type="submit" className="miu-send" disabled={(!draft.trim() && !pending) || sending}>Reply</button>
</div>
</form>
</div>
);
}
function ComposeModal({ onClose, onSent }: { onClose: () => void; onSent: () => void }) {
const compose = useCompose();
const [mode, setMode] = useState<'internal' | 'external'>('internal');
const [recipient, setRecipient] = useState('');
const [subject, setSubject] = useState('');
const [body, setBody] = useState('');
const [q, setQ] = useState('');
const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(null);
const [attachments, setAttachments] = useState<MailAttachment[]>([]);
const [attaching, setAttaching] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
const filtered = compose.directory.filter((p) => p.name.toLowerCase().includes(q.trim().toLowerCase()));
const canSend = !!recipient && !!subject.trim() && (!!body.trim() || attachments.length > 0) && !busy && !attaching;
async function pick(e: React.ChangeEvent<HTMLInputElement>): Promise<void> {
const file = e.target.files?.[0];
e.target.value = '';
if (!file || attachments.length >= 10) return;
setAttaching(true);
setErr(null);
try {
const ref = await compose.upload(file);
setAttachments((a) => [...a, ref]);
} catch (e2) {
setErr(e2 instanceof Error ? e2.message : String(e2));
} finally {
setAttaching(false);
}
}
async function send(): Promise<void> {
if (!canSend) return;
setBusy(true);
setErr(null);
const atts = attachments.length > 0 ? attachments : undefined;
try {
if (mode === 'internal') await compose.sendInternal(recipient, subject.trim(), body.trim(), atts);
else await compose.sendExternal(recipient.trim(), subject.trim(), body.trim(), atts);
onSent();
} catch (e) {
setErr(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
}
return (
<ModalPortal>
<div className="miu-modal-overlay" onMouseDown={onClose}>
<div className="miu-modal" role="dialog" aria-modal="true" onMouseDown={(e) => e.stopPropagation()}>
<div className="miu-modal-head">
<span>New message</span>
<button type="button" className="miu-pane-close" onClick={onClose} aria-label="Close"></button>
</div>
<div className="miu-modal-body">
<div className="miu-compose-modes">
<button type="button" className={`miu-tab${mode === 'internal' ? ' is-active' : ''}`} onClick={() => { setMode('internal'); setRecipient(''); }}>In-app</button>
<button type="button" className={`miu-tab${mode === 'external' ? ' is-active' : ''}`} onClick={() => { setMode('external'); setRecipient(''); }}>Email</button>
</div>
{mode === 'internal' ? (
<div className="miu-field">
<span className="miu-field-lbl">To (person)</span>
<input className="miu-input" value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search people…" aria-label="Search people" />
<div className="miu-people">
{filtered.length === 0 ? <div className="miu-empty">No people found.</div> : null}
{filtered.map((p: MailPerson) => (
<label key={p.id} className={`miu-person${recipient === p.id ? ' is-active' : ''}`}>
<input type="radio" name="miu-recipient" checked={recipient === p.id} onChange={() => setRecipient(p.id)} />
<span>{p.name}</span>
<span className="miu-pill">{p.kind}</span>
</label>
))}
</div>
</div>
) : (
<div className="miu-field">
<span className="miu-field-lbl">To (email)</span>
<input className="miu-input" value={recipient} onChange={(e) => setRecipient(e.target.value)} placeholder="name@company.com" aria-label="To email" />
</div>
)}
<div className="miu-field">
<span className="miu-field-lbl">Subject</span>
<input className="miu-input" value={subject} onChange={(e) => setSubject(e.target.value)} placeholder="Subject" aria-label="Subject" />
</div>
<div className="miu-field">
<span className="miu-field-lbl">Message</span>
<textarea className="miu-input miu-textarea" value={body} onChange={(e) => setBody(e.target.value)} placeholder="Write your message…" rows={5} aria-label="Message body" />
</div>
{compose.canAttach ? (
<div className="miu-field">
<span className="miu-field-lbl">Attachments</span>
<div className="miu-attach-list">
{attachments.map((a, i) => (
<span key={`${a.contentRef}-${i}`} className="miu-attach-pending">
<span className="miu-attach-chip">📎 {a.filename ?? 'attachment'}{a.sizeBytes ? ` · ${fmtBytes(a.sizeBytes)}` : ''}</span>
<button type="button" className="miu-attach-x" onClick={() => setAttachments((prev) => prev.filter((_, j) => j !== i))} aria-label="Remove attachment"></button>
</span>
))}
<input ref={fileRef} type="file" hidden onChange={pick} aria-label="Attach file" />
<button type="button" className="miu-tab" onClick={() => fileRef.current?.click()} disabled={attaching || attachments.length >= 10}>
{attaching ? 'Uploading…' : '📎 Attach'}
</button>
</div>
</div>
) : null}
{err ? <div className="miu-empty miu-error">{err}</div> : null}
</div>
<div className="miu-modal-foot">
<button type="button" className="miu-tab" onClick={onClose}>Cancel</button>
<button type="button" className="miu-send" onClick={() => void send()} disabled={!canSend}>{busy ? 'Sending…' : 'Send'}</button>
</div>
</div>
</div>
</ModalPortal>
);
}
@@ -0,0 +1,16 @@
import { createContext, useContext, type ReactNode } from 'react';
import type { InboxAdapter } from './adapter';
const InboxContext = createContext<InboxAdapter | null>(null);
export function InboxProvider({ adapter, children }: { adapter: InboxAdapter; children: ReactNode }) {
return <InboxContext.Provider value={adapter}>{children}</InboxContext.Provider>;
}
/** Access the host-injected inbox adapter. Throws outside a provider — a missing provider is a
* wiring bug, and failing loudly beats a confusing null-deref three layers down. */
export function useInboxAdapter(): InboxAdapter {
const adapter = useContext(InboxContext);
if (!adapter) throw new Error('useInboxAdapter must be used within an <InboxProvider>');
return adapter;
}
@@ -0,0 +1,42 @@
// Domain types for the inbox surface (work items + mail). Zero transport imports.
export type InboxState = 'OPEN' | 'SNOOZED' | 'DONE' | 'ARCHIVED' | 'CANCELLED' | 'STALE';
/** A unified inbox row — a work item (mention/needs-reply/alert) OR a mail thread. */
export interface InboxItem {
id: string;
kind: string; // MAIL | MENTION | NEEDS_REPLY | SYSTEM_ALERT | …
state: InboxState;
title: string;
summary?: string;
priority: string;
/** Present when the row opens a conversation/mail thread. */
threadId?: string;
createdAt: string;
}
/** A mail attachment reference (bytes live in storage; the reader resolves a display URL). */
export interface MailAttachment {
contentRef: string;
mimeType: string;
sizeBytes: number;
filename: string | null;
}
/** One message in a mail thread — HTML and/or plain text, optionally an attachment. */
export interface MailMessage {
id: string;
actorId: string | null;
kind: string;
at: string;
html: string | null;
text: string | null;
attachment: MailAttachment | null;
}
/** A person you can compose an in-app message to. */
export interface MailPerson {
id: string;
name: string;
kind: 'staff' | 'customer';
}
+8
View File
@@ -15,6 +15,14 @@ export { Messenger } from './components/messenger';
export { ConversationList } from './components/conversation-list'; export { ConversationList } from './components/conversation-list';
export { ChannelBrowser } from './components/channel-browser'; export { ChannelBrowser } from './components/channel-browser';
export { Thread } from './components/thread'; export { Thread } from './components/thread';
export { ThreadPane } from './components/thread-pane';
// ── Inbox domain (work items + mail) ──
export { InboxProvider, useInboxAdapter } from './inbox/provider';
export { useInbox, useMailThread, useCompose } from './inbox/hooks';
export { Inbox, MailReader } from './inbox/inbox';
export type { InboxAdapter } from './inbox/adapter';
export type { InboxItem, InboxState, MailAttachment, MailMessage, MailPerson } from './inbox/types';
export type { MessagingAdapter } from './adapter'; export type { MessagingAdapter } from './adapter';
export type { ConversationsState } from './hooks/use-conversations'; export type { ConversationsState } from './hooks/use-conversations';
+553
View File
@@ -149,6 +149,86 @@
.miu-msg.is-pending { .miu-msg.is-pending {
opacity: 0.6; opacity: 0.6;
} }
.miu-bubble-row {
display: flex;
align-items: center;
gap: 4px;
}
.miu-msg.is-mine .miu-bubble-row {
flex-direction: row-reverse;
}
.miu-msg-actions {
display: flex;
gap: 2px;
flex-shrink: 0;
}
.miu-thread-link {
align-self: flex-start;
margin-top: 3px;
padding: 3px 9px;
border-radius: 999px;
border: 1px solid var(--miu-border);
background: var(--miu-panel);
color: var(--miu-accent);
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.miu-msg.is-mine .miu-thread-link {
align-self: flex-end;
}
.miu-react-wrap {
position: relative;
flex-shrink: 0;
}
.miu-react-btn {
border: none;
background: none;
cursor: pointer;
opacity: 0;
font-size: 14px;
padding: 2px;
transition: opacity 0.12s;
}
.miu-msg:hover .miu-react-btn {
opacity: 0.6;
}
.miu-react-btn:hover {
opacity: 1;
}
.miu-react-picker {
position: absolute;
/* Open downward + right-aligned so it never clips against the top of the scroll area
(the reported bug) or spills past the right edge. */
top: 100%;
right: 0;
margin-top: 4px;
display: flex;
gap: 2px;
padding: 4px;
border-radius: 999px;
border: 1px solid var(--miu-border);
background: var(--miu-panel);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4);
z-index: 5;
width: max-content;
}
/* On my own (right-aligned) messages, anchor the picker to the left instead so it stays in view. */
.miu-msg.is-mine .miu-react-picker {
right: auto;
left: 0;
}
.miu-react-emoji {
border: none;
background: none;
cursor: pointer;
font-size: 16px;
padding: 2px 4px;
border-radius: 6px;
}
.miu-react-emoji:hover {
background: var(--miu-panel-2);
}
.miu-reactions { .miu-reactions {
display: flex; display: flex;
gap: 4px; gap: 4px;
@@ -160,10 +240,35 @@
border-radius: 999px; border-radius: 999px;
border: 1px solid var(--miu-border); border: 1px solid var(--miu-border);
background: var(--miu-panel-2); background: var(--miu-panel-2);
color: var(--miu-text);
cursor: pointer;
} }
.miu-reaction.is-mine { .miu-reaction.is-mine {
border-color: var(--miu-accent); border-color: var(--miu-accent);
} }
/* attachments */
.miu-att-img-link {
display: inline-block;
margin-top: 4px;
}
.miu-att-img {
max-width: 260px;
max-height: 220px;
border-radius: 8px;
border: 1px solid var(--miu-border);
}
.miu-att-file {
display: inline-block;
margin-top: 4px;
padding: 6px 10px;
border-radius: 8px;
border: 1px solid var(--miu-border);
background: var(--miu-panel-2);
color: var(--miu-text);
text-decoration: none;
font-size: 13px;
}
.miu-seen { .miu-seen {
font-size: 11px; font-size: 11px;
color: var(--miu-muted); color: var(--miu-muted);
@@ -189,10 +294,87 @@
.miu-composer { .miu-composer {
position: relative; position: relative;
display: flex; display: flex;
flex-direction: column;
gap: 8px; gap: 8px;
padding: 12px; padding: 12px;
border-top: 1px solid var(--miu-border); border-top: 1px solid var(--miu-border);
} }
.miu-composer-row {
display: flex;
gap: 8px;
}
.miu-file-input {
display: none;
}
.miu-attach-btn {
flex-shrink: 0;
width: 38px;
border-radius: 10px;
border: 1px solid var(--miu-border);
background: var(--miu-panel);
color: var(--miu-text);
cursor: pointer;
font-size: 16px;
}
.miu-staged {
display: inline-flex;
align-items: center;
gap: 8px;
align-self: flex-start;
padding: 5px 10px;
border-radius: 999px;
border: 1px solid var(--miu-border);
background: var(--miu-panel-2);
font-size: 13px;
}
.miu-staged-x {
border: none;
background: none;
color: var(--miu-muted);
cursor: pointer;
padding: 0;
line-height: 1;
}
/* inbox mail attachments */
.miu-attach-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 999px;
border: 1px solid var(--miu-border);
background: var(--miu-panel-2);
color: var(--miu-text);
font-size: 12.5px;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.miu-mail-msg .miu-attach-chip {
margin-top: 6px;
}
.miu-attach-pending {
display: inline-flex;
align-items: center;
gap: 6px;
}
.miu-attach-x {
border: none;
background: none;
color: var(--miu-muted);
cursor: pointer;
padding: 0 2px;
line-height: 1;
font-size: 12px;
}
.miu-attach-list {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.miu-suggest { .miu-suggest {
position: absolute; position: absolute;
left: 12px; left: 12px;
@@ -318,6 +500,38 @@
gap: 5px; gap: 5px;
cursor: pointer; cursor: pointer;
} }
/* new conversation (DM / group) picker */
.miu-newconv {
display: flex;
flex-direction: column;
gap: 10px;
}
/* Submit buttons in the stacked create/compose forms size to content, not full width. */
.miu-newconv .miu-send,
.miu-channel-create .miu-send {
align-self: flex-start;
height: 38px;
}
.miu-person-row {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border: 1px solid var(--miu-border);
border-radius: 10px;
background: var(--miu-panel);
cursor: pointer;
}
.miu-person-row:hover {
border-color: var(--miu-accent);
}
.miu-person-row.is-active {
border-color: var(--miu-accent);
background: color-mix(in srgb, var(--miu-accent) 10%, var(--miu-panel));
}
.miu-person-row .miu-browser-name {
flex: 1 1 auto;
}
.miu-browser-list { .miu-browser-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -377,6 +591,345 @@
color: var(--miu-muted); color: var(--miu-muted);
} }
/* thread pane (Slack-style) */
.miu-pane {
width: 340px;
flex-shrink: 0;
display: flex;
flex-direction: column;
min-height: 0;
border-left: 1px solid var(--miu-border);
background: var(--miu-panel);
}
.miu-pane-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--miu-border);
font-weight: 700;
}
.miu-pane-close {
border: none;
background: none;
color: var(--miu-muted);
cursor: pointer;
font-size: 15px;
}
.miu-pane-messages {
background: var(--miu-bg);
}
.miu-pane-divider {
font-size: 11.5px;
color: var(--miu-muted);
text-align: center;
margin: 4px 0;
padding-bottom: 4px;
border-bottom: 1px solid var(--miu-border);
}
/* ── Inbox ── */
.miu-inbox {
--miu-bg: #0e0e13;
--miu-panel: #16161d;
--miu-panel-2: #1d1d26;
--miu-border: #262631;
--miu-text: #e9e9ef;
--miu-muted: #9a9aa7;
--miu-accent: #fda913;
--miu-accent-text: #1a1206;
--miu-radius: 12px;
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
color: var(--miu-text);
background: var(--miu-bg);
font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
font-size: 14px;
}
.miu-inbox-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 10px 12px;
border-bottom: 1px solid var(--miu-border);
}
.miu-inbox-filters {
display: flex;
gap: 4px;
}
.miu-tab {
padding: 6px 12px;
border-radius: 999px;
border: 1px solid var(--miu-border);
background: var(--miu-panel);
color: var(--miu-text);
font: inherit;
font-size: 13px;
cursor: pointer;
}
.miu-tab.is-active {
background: var(--miu-accent);
color: var(--miu-accent-text);
border-color: var(--miu-accent);
font-weight: 700;
}
.miu-inbox-body {
flex: 1;
min-height: 0;
display: flex;
}
.miu-inbox-list {
width: 340px;
flex-shrink: 0;
overflow-y: auto;
border-right: 1px solid var(--miu-border);
background: var(--miu-panel);
}
.miu-inbox-row {
display: flex;
align-items: flex-start;
gap: 10px;
width: 100%;
padding: 12px 14px;
border: none;
border-bottom: 1px solid var(--miu-border);
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
font: inherit;
}
.miu-inbox-row:hover,
.miu-inbox-row.is-active {
background: var(--miu-panel-2);
}
.miu-inbox-glyph {
width: 26px;
height: 26px;
flex-shrink: 0;
border-radius: 7px;
display: grid;
place-items: center;
color: var(--miu-muted);
background: var(--miu-panel-2);
font-weight: 700;
}
.miu-inbox-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.miu-inbox-row-top {
display: flex;
align-items: center;
gap: 6px;
}
.miu-pill {
flex-shrink: 0;
font-size: 10.5px;
font-weight: 700;
padding: 1px 7px;
border-radius: 999px;
color: var(--miu-muted);
background: var(--miu-panel-2);
text-transform: capitalize;
}
.miu-inbox-title {
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.miu-inbox-summary {
color: var(--miu-muted);
font-size: 12.5px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.miu-inbox-detail {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
background: var(--miu-bg);
}
.miu-detail {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.miu-detail-actions {
display: flex;
justify-content: flex-end;
gap: 6px;
padding: 10px 14px;
border-bottom: 1px solid var(--miu-border);
}
.miu-detail-body {
padding: 22px;
}
.miu-detail-title {
font-weight: 700;
font-size: 16px;
margin-bottom: 6px;
}
.miu-detail-summary {
color: var(--miu-muted);
line-height: 1.55;
}
/* mail reader */
.miu-mail {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.miu-mail-head {
padding: 12px 18px;
border-bottom: 1px solid var(--miu-border);
font-weight: 700;
font-size: 15px;
}
.miu-mail-body {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.miu-mail-msg {
border: 1px solid var(--miu-border);
border-radius: var(--miu-radius);
background: var(--miu-panel);
overflow: hidden;
}
.miu-mail-meta {
display: flex;
justify-content: space-between;
padding: 7px 12px;
border-bottom: 1px solid var(--miu-border);
font-size: 12px;
color: var(--miu-muted);
}
.miu-mail-frame {
width: 100%;
height: 200px;
border: none;
background: #fff;
}
.miu-mail-text {
padding: 12px;
white-space: pre-wrap;
word-break: break-word;
}
/* compose modal — portaled to <body>, so carry SDK theme-token defaults here (the host's
own token mapping + a synchronous copy on the portal root override these). */
.miu-portal {
--miu-bg: #0e0e13;
--miu-panel: #16161d;
--miu-panel-2: #1d1d26;
--miu-border: #262631;
--miu-text: #e9e9ef;
--miu-muted: #9a9aa7;
--miu-accent: #fda913;
--miu-accent-text: #1a1206;
--miu-radius: 12px;
}
.miu-modal-overlay {
position: fixed;
inset: 0;
z-index: 2147483000;
background: rgba(2, 2, 6, 0.6);
display: grid;
place-items: center;
padding: 20px;
}
.miu-modal {
width: 100%;
max-width: 520px;
max-height: 88vh;
display: flex;
flex-direction: column;
border: 1px solid var(--miu-border);
border-radius: 16px;
background: var(--miu-panel);
color: var(--miu-text);
font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
}
.miu-modal-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 18px;
border-bottom: 1px solid var(--miu-border);
font-weight: 700;
}
.miu-modal-body {
padding: 16px 18px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 12px;
}
.miu-modal-foot {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 14px 18px;
border-top: 1px solid var(--miu-border);
}
.miu-compose-modes {
display: flex;
gap: 6px;
}
.miu-field {
display: flex;
flex-direction: column;
gap: 6px;
}
.miu-field-lbl {
font-size: 12.5px;
font-weight: 600;
color: var(--miu-muted);
}
.miu-textarea {
resize: vertical;
min-height: 90px;
}
.miu-people {
display: flex;
flex-direction: column;
gap: 2px;
max-height: 180px;
overflow-y: auto;
}
.miu-person {
display: flex;
align-items: center;
gap: 10px;
padding: 7px 10px;
border-radius: 10px;
cursor: pointer;
}
.miu-person.is-active {
background: var(--miu-panel-2);
}
.miu-person span:nth-of-type(1) {
flex: 1;
}
/* misc */ /* misc */
.miu-empty { .miu-empty {
padding: 24px; padding: 24px;
+2 -2
View File
@@ -6,10 +6,10 @@ export default defineConfig({
// entry, never reachable from `index`, because it imports vitest, and bundling // entry, never reachable from `index`, because it imports vitest, and bundling
// that into the main barrel would drag a test runner into every consumer's // that into the main barrel would drag a test runner into every consumer's
// production build. // production build.
entry: ['src/index.ts', 'src/adapters/mock.ts', 'src/adapters/kernel-client.ts', 'src/conformance.ts', 'src/styles.css'], entry: ['src/index.ts', 'src/adapters/mock.ts', 'src/adapters/mock-inbox.ts', 'src/adapters/kernel-client.ts', 'src/conformance.ts', 'src/styles.css'],
format: ['esm'], format: ['esm'],
// Types only for the TS entries — styles.css has no .d.ts (and tsc chokes on a .css root file). // Types only for the TS entries — styles.css has no .d.ts (and tsc chokes on a .css root file).
dts: { entry: ['src/index.ts', 'src/adapters/mock.ts', 'src/adapters/kernel-client.ts', 'src/conformance.ts'] }, dts: { entry: ['src/index.ts', 'src/adapters/mock.ts', 'src/adapters/mock-inbox.ts', 'src/adapters/kernel-client.ts', 'src/conformance.ts'] },
clean: true, clean: true,
external: ['react', 'react-dom', 'vitest', '@insignia/iios-kernel-client'], external: ['react', 'react-dom', 'vitest', '@insignia/iios-kernel-client'],
}); });
@@ -0,0 +1,26 @@
-- CreateTable
CREATE TABLE "IiosProviderCredential" (
"id" TEXT NOT NULL,
"scopeId" TEXT NOT NULL,
"providerType" TEXT NOT NULL,
"cipherText" TEXT NOT NULL,
"iv" TEXT NOT NULL,
"authTag" TEXT NOT NULL,
"keyVersion" INTEGER NOT NULL DEFAULT 1,
"displayHints" JSONB,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "IiosProviderCredential_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "IiosProviderCredential_scopeId_idx" ON "IiosProviderCredential"("scopeId");
-- CreateIndex
CREATE UNIQUE INDEX "IiosProviderCredential_scopeId_providerType_key" ON "IiosProviderCredential"("scopeId", "providerType");
-- AddForeignKey
ALTER TABLE "IiosProviderCredential" ADD CONSTRAINT "IiosProviderCredential_scopeId_fkey" FOREIGN KEY ("scopeId") REFERENCES "IiosScope"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -317,10 +317,33 @@ model IiosScope {
callbacks IiosCallbackRequest[] callbacks IiosCallbackRequest[]
notificationSubscriptions IiosNotificationSubscription[] notificationSubscriptions IiosNotificationSubscription[]
messageTemplates IiosMessageTemplate[] messageTemplates IiosMessageTemplate[]
providerCredentials IiosProviderCredential[]
@@index([orgId, appId, tenantId]) @@index([orgId, appId, tenantId])
} }
/// A tenant's own credentials for an egress provider (BYO: Twilio SMS, own SMTP, …).
/// The secret is sealed with AES-256-GCM under the platform key (IIOS_CRED_KEY); only
/// `displayHints` (non-secret, e.g. from-number / SID last-4) is ever read back out.
/// One row per (scope, providerType). Resolved at send time by the channel's provider.
model IiosProviderCredential {
id String @id @default(cuid())
scopeId String
scope IiosScope @relation(fields: [scopeId], references: [id], onDelete: Cascade)
providerType String // TWILIO_SMS | SMTP | WHATSAPP_CLOUD | …
cipherText String // base64(AES-256-GCM ciphertext of the JSON config)
iv String // base64 nonce
authTag String // base64 GCM auth tag
keyVersion Int @default(1)
displayHints Json? // non-secret display fields (fromNumber, sidLast4, …)
enabled Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([scopeId, providerType])
@@index([scopeId])
}
/// A channel-specific handle (phone/email/portal user). NOT identity — stays /// A channel-specific handle (phone/email/portal user). NOT identity — stays
/// UNVERIFIED until MDM resolves it; no silent merge. /// UNVERIFIED until MDM resolves it; no silent merge.
model IiosSourceHandle { model IiosSourceHandle {
@@ -2,15 +2,20 @@ import { Module } from '@nestjs/common';
import { MediaModule } from '../media/media.module'; import { MediaModule } from '../media/media.module';
import { CapabilityProviderRegistry } from './capability.registry'; import { CapabilityProviderRegistry } from './capability.registry';
import { CapabilityBroker } from './capability.broker'; import { CapabilityBroker } from './capability.broker';
import { ProviderCredentialService } from './provider-credential.service';
import { ProviderCredentialController } from './provider-credential.controller';
/** /**
* The governed egress boundary (P9 slice 1). Exports the broker + registry so the * The governed egress boundary (P9 slice 1). Exports the broker + registry so the
* outbound layer routes all sends through policy + obligations + a pluggable * outbound layer routes all sends through policy + obligations + a pluggable
* provider. PLATFORM_PORTS (for the opa gate) comes from the @Global PlatformModule. * provider. PLATFORM_PORTS (for the opa gate) comes from the @Global PlatformModule.
* Also owns per-scope BYO provider credentials (Twilio SMS, …) — sealed at rest and
* resolved by the channel providers at send time.
*/ */
@Module({ @Module({
imports: [MediaModule], imports: [MediaModule],
providers: [CapabilityProviderRegistry, CapabilityBroker], controllers: [ProviderCredentialController],
exports: [CapabilityBroker, CapabilityProviderRegistry], providers: [CapabilityProviderRegistry, CapabilityBroker, ProviderCredentialService],
exports: [CapabilityBroker, CapabilityProviderRegistry, ProviderCredentialService],
}) })
export class CapabilityModule {} export class CapabilityModule {}
@@ -4,6 +4,9 @@ import { SandboxProvider } from './sandbox.provider';
import { HttpProvider } from './http.provider'; import { HttpProvider } from './http.provider';
import { EmailProvider } from './email.provider'; import { EmailProvider } from './email.provider';
import { SmtpProvider, smtpFallbackFromEnv, smtpIdentityFromEnv, storageResolver } from './smtp.provider'; import { SmtpProvider, smtpFallbackFromEnv, smtpIdentityFromEnv, storageResolver } from './smtp.provider';
import { TwilioSmsProvider, type TwilioCreds } from './twilio-sms.provider';
import { credKeyFromEnv } from './secret-crypto';
import { ProviderCredentialService } from './provider-credential.service';
import { STORAGE_PORT, type StoragePort } from '../media/storage.port'; import { STORAGE_PORT, type StoragePort } from '../media/storage.port';
const DEFAULT_CHANNELS = ['WEBHOOK', 'EMAIL', 'SMS', 'WHATSAPP', 'PORTAL']; const DEFAULT_CHANNELS = ['WEBHOOK', 'EMAIL', 'SMS', 'WHATSAPP', 'PORTAL'];
@@ -21,7 +24,10 @@ const DEFAULT_CHANNELS = ['WEBHOOK', 'EMAIL', 'SMS', 'WHATSAPP', 'PORTAL'];
export class CapabilityProviderRegistry { export class CapabilityProviderRegistry {
private readonly byChannel = new Map<string, CapabilityProvider>(); private readonly byChannel = new Map<string, CapabilityProvider>();
constructor(@Optional() @Inject(STORAGE_PORT) private readonly storage?: StoragePort) { constructor(
@Optional() @Inject(STORAGE_PORT) private readonly storage?: StoragePort,
@Optional() private readonly credentials?: ProviderCredentialService,
) {
for (const ch of DEFAULT_CHANNELS) this.register(new SandboxProvider([ch])); for (const ch of DEFAULT_CHANNELS) this.register(new SandboxProvider([ch]));
for (const ch of DEFAULT_CHANNELS) { for (const ch of DEFAULT_CHANNELS) {
const url = process.env[`IIOS_PROVIDER_URL_${ch}`]; const url = process.env[`IIOS_PROVIDER_URL_${ch}`];
@@ -36,6 +42,12 @@ export class CapabilityProviderRegistry {
const resolver = this.storage ? storageResolver(this.storage) : undefined; const resolver = this.storage ? storageResolver(this.storage) : undefined;
this.register(new SmtpProvider(smtp, smtpFallbackFromEnv() ?? undefined, undefined, resolver)); this.register(new SmtpProvider(smtp, smtpFallbackFromEnv() ?? undefined, undefined, resolver));
} }
// BYO SMS via each tenant's own Twilio creds (resolved per scope at send time). Registered
// only when the platform key + credential store are present — else SMS stays on the sandbox.
if (credKeyFromEnv() && this.credentials) {
const creds = this.credentials;
this.register(new TwilioSmsProvider((scopeId) => creds.resolve(scopeId, 'TWILIO_SMS') as Promise<TwilioCreds | null>));
}
} }
register(provider: CapabilityProvider): void { register(provider: CapabilityProvider): void {
@@ -0,0 +1,54 @@
import { BadRequestException, Body, Controller, Get, Headers, Param, Put } from '@nestjs/common';
import { SessionVerifier } from '../platform/session.verifier';
import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver';
import { ProviderCredentialService } from './provider-credential.service';
import { TwilioCredentialsDto } from './provider-credential.dto';
const SUPPORTED = new Set(['TWILIO_SMS']);
/**
* Per-scope BYO integration credentials. The caller's attested session decides the scope, so a
* tenant can only read/write ITS OWN provider credentials. The secret is sealed by the service;
* GET returns a masked status (never the token). be-crm gates this behind tenant-admin policy.
*/
@Controller('v1/providers')
export class ProviderCredentialController {
constructor(
private readonly session: SessionVerifier,
private readonly actors: ActorResolver,
private readonly credentials: ProviderCredentialService,
) {}
@Put(':providerType/credentials')
async put(
@Param('providerType') providerType: string,
@Body() body: TwilioCredentialsDto,
@Headers('authorization') authorization?: string,
) {
this.assertSupported(providerType);
const scope = await this.actors.resolveScope(this.principal(authorization));
await this.credentials.upsert(scope.id, providerType, {
accountSid: body.accountSid,
authToken: body.authToken,
fromNumber: body.fromNumber,
});
return this.credentials.status(scope.id, providerType);
}
@Get(':providerType/credentials')
async get(@Param('providerType') providerType: string, @Headers('authorization') authorization?: string) {
this.assertSupported(providerType);
const scope = await this.actors.resolveScope(this.principal(authorization));
return this.credentials.status(scope.id, providerType);
}
private assertSupported(providerType: string): void {
if (!SUPPORTED.has(providerType)) throw new BadRequestException(`unsupported providerType: ${providerType}`);
}
private principal(authorization?: string): MessagePrincipal {
const token = (authorization ?? '').replace(/^Bearer\s+/i, '');
if (!token) throw new BadRequestException('Authorization bearer token is required');
return this.session.verify(token);
}
}
@@ -0,0 +1,11 @@
import { IsNotEmpty, IsString } from 'class-validator';
/**
* PUT /v1/providers/TWILIO_SMS/credentials — a tenant's own Twilio credentials. Step 1 supports
* TWILIO_SMS only; when a second provider (SMTP, …) is added, switch to a per-type validated body.
*/
export class TwilioCredentialsDto {
@IsString() @IsNotEmpty() accountSid!: string;
@IsString() @IsNotEmpty() authToken!: string;
@IsString() @IsNotEmpty() fromNumber!: string;
}
@@ -0,0 +1,79 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { ProviderCredentialService } from './provider-credential.service';
const KEY_B64 = Buffer.alloc(32, 7).toString('base64');
/** Minimal in-memory stand-in for prisma.iiosProviderCredential (upsert/findUnique). */
function fakePrisma() {
const rows = new Map<string, Record<string, unknown>>();
const k = (scopeId: string, providerType: string) => `${scopeId}::${providerType}`;
return {
_rows: rows,
iiosProviderCredential: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async upsert({ where, create, update }: any) {
const key = k(where.scopeId_providerType.scopeId, where.scopeId_providerType.providerType);
const existing = rows.get(key);
const row = existing ? { ...existing, ...update } : { ...create };
rows.set(key, row);
return row;
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async findUnique({ where }: any) {
return rows.get(k(where.scopeId_providerType.scopeId, where.scopeId_providerType.providerType)) ?? null;
},
},
};
}
function make(prisma: ReturnType<typeof fakePrisma>, env: NodeJS.ProcessEnv): ProviderCredentialService {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const s = new ProviderCredentialService(prisma as any);
s.env = env;
return s;
}
describe('ProviderCredentialService', () => {
let prisma: ReturnType<typeof fakePrisma>;
let svc: ProviderCredentialService;
beforeEach(() => {
prisma = fakePrisma();
svc = make(prisma, { IIOS_CRED_KEY: KEY_B64 } as NodeJS.ProcessEnv);
});
it('seals the secret at rest — plaintext token never stored', async () => {
await svc.upsert('scope_1', 'TWILIO_SMS', { accountSid: 'AC123', authToken: 'tok_secret', fromNumber: '+15550001111' });
const stored = JSON.stringify([...prisma._rows.values()]);
expect(stored).not.toContain('tok_secret');
expect(stored).toContain('cipherText');
});
it('resolves back the exact config it sealed', async () => {
const cfg = { accountSid: 'AC123', authToken: 'tok_secret', fromNumber: '+15550001111' };
await svc.upsert('scope_1', 'TWILIO_SMS', cfg);
expect(await svc.resolve('scope_1', 'TWILIO_SMS')).toEqual(cfg);
});
it('status is masked — reveals hints, never the token', async () => {
await svc.upsert('scope_1', 'TWILIO_SMS', { accountSid: 'AC1234567', authToken: 'tok_secret', fromNumber: '+15550001111' });
const status = await svc.status('scope_1', 'TWILIO_SMS');
expect(status).toMatchObject({ configured: true, enabled: true, hints: { fromNumber: '+15550001111', sidLast4: '4567' } });
expect(JSON.stringify(status)).not.toContain('tok_secret');
});
it('reports not-configured for an unknown scope', async () => {
expect(await svc.status('nope', 'TWILIO_SMS')).toEqual({ configured: false });
expect(await svc.resolve('nope', 'TWILIO_SMS')).toBeNull();
});
it('does not resolve a disabled credential', async () => {
await svc.upsert('scope_1', 'TWILIO_SMS', { accountSid: 'AC123', authToken: 't', fromNumber: '+1' }, { enabled: false });
expect(await svc.resolve('scope_1', 'TWILIO_SMS')).toBeNull();
});
it('throws when the platform key is absent (fail closed, never store plaintext)', async () => {
const noKey = make(prisma, {} as NodeJS.ProcessEnv);
await expect(noKey.upsert('s', 'TWILIO_SMS', { accountSid: 'A', authToken: 't', fromNumber: '+1' })).rejects.toThrow(/IIOS_CRED_KEY/);
});
});
@@ -0,0 +1,70 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { credKeyFromEnv, sealSecret, openSecret, type SealedSecret } from './secret-crypto';
/** A provider config is an opaque JSON bag; each provider knows its own shape. */
export type ProviderConfig = Record<string, unknown>;
export interface CredentialStatus {
configured: boolean;
enabled?: boolean;
hints?: Record<string, unknown>;
}
/** Non-secret display fields surfaced by `status` — NEVER the secret itself. */
function hintsFor(providerType: string, config: ProviderConfig): Record<string, unknown> {
if (providerType === 'TWILIO_SMS') {
const sid = String(config.accountSid ?? '');
return { fromNumber: config.fromNumber ?? null, sidLast4: sid.slice(-4) };
}
return {};
}
/**
* The tenant's BYO integration credentials, one row per (scope, providerType). The secret is
* sealed with AES-256-GCM under the platform key before it touches the DB; only non-secret
* `displayHints` are ever read back. `resolve` decrypts on demand at send time; `status` never
* returns the secret. Fail-closed: no platform key ⇒ upsert throws (we refuse to store plaintext).
*/
@Injectable()
export class ProviderCredentialService {
/** Overridable in tests; defaults to the process env (holds IIOS_CRED_KEY). */
env: NodeJS.ProcessEnv = process.env;
constructor(private readonly prisma: PrismaService) {}
async upsert(scopeId: string, providerType: string, config: ProviderConfig, opts?: { enabled?: boolean }): Promise<void> {
const key = credKeyFromEnv(this.env);
if (!key) throw new BadRequestException('IIOS_CRED_KEY is not configured — cannot store integration credentials');
const sealed = sealSecret(JSON.stringify(config), key);
const enabled = opts?.enabled ?? true;
const displayHints = hintsFor(providerType, config) as Prisma.InputJsonValue;
await this.prisma.iiosProviderCredential.upsert({
where: { scopeId_providerType: { scopeId, providerType } },
create: { scopeId, providerType, ...sealed, displayHints, enabled },
update: { ...sealed, displayHints, enabled },
});
}
/** Decrypt the config for send-time use. Null when unconfigured, disabled, or no key. */
async resolve(scopeId: string, providerType: string): Promise<ProviderConfig | null> {
const row = await this.prisma.iiosProviderCredential.findUnique({
where: { scopeId_providerType: { scopeId, providerType } },
});
if (!row || !row.enabled) return null;
const key = credKeyFromEnv(this.env);
if (!key) return null;
const sealed: SealedSecret = { cipherText: row.cipherText, iv: row.iv, authTag: row.authTag, keyVersion: row.keyVersion };
return JSON.parse(openSecret(sealed, key)) as ProviderConfig;
}
/** Masked view for the admin UI — configured + hints, never the secret. */
async status(scopeId: string, providerType: string): Promise<CredentialStatus> {
const row = await this.prisma.iiosProviderCredential.findUnique({
where: { scopeId_providerType: { scopeId, providerType } },
});
if (!row) return { configured: false };
return { configured: true, enabled: row.enabled, hints: (row.displayHints ?? {}) as Record<string, unknown> };
}
}
@@ -0,0 +1,38 @@
import { describe, it, expect } from 'vitest';
import { credKeyFromEnv, sealSecret, openSecret, SECRET_KEY_VERSION } from './secret-crypto';
const KEY = Buffer.alloc(32, 7); // deterministic 32-byte key for tests
describe('secret-crypto', () => {
it('round-trips a secret through seal → open', () => {
const sealed = sealSecret('sk_live_abc123', KEY);
expect(sealed.keyVersion).toBe(SECRET_KEY_VERSION);
expect(sealed.cipherText).not.toContain('sk_live_abc123');
expect(openSecret(sealed, KEY)).toBe('sk_live_abc123');
});
it('produces a distinct ciphertext each time (random IV)', () => {
const a = sealSecret('same', KEY);
const b = sealSecret('same', KEY);
expect(a.cipherText).not.toBe(b.cipherText);
expect(a.iv).not.toBe(b.iv);
});
it('fails to open with the wrong key', () => {
const sealed = sealSecret('top-secret', KEY);
expect(() => openSecret(sealed, Buffer.alloc(32, 9))).toThrow();
});
it('fails to open if the ciphertext is tampered (GCM auth)', () => {
const sealed = sealSecret('top-secret', KEY);
const bad = { ...sealed, cipherText: Buffer.from('deadbeef', 'hex').toString('base64') };
expect(() => openSecret(bad, KEY)).toThrow();
});
it('credKeyFromEnv returns null when unset and rejects a wrong-length key', () => {
expect(credKeyFromEnv({})).toBeNull();
expect(() => credKeyFromEnv({ IIOS_CRED_KEY: Buffer.alloc(16).toString('base64') })).toThrow(/32 bytes/);
const key = credKeyFromEnv({ IIOS_CRED_KEY: KEY.toString('base64') });
expect(key?.length).toBe(32);
});
});
@@ -0,0 +1,46 @@
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
/**
* Envelope for a tenant secret sealed with AES-256-GCM. Stored as base64 columns on
* IiosProviderCredential; the plaintext (a provider config JSON string) never touches disk.
*/
export interface SealedSecret {
cipherText: string;
iv: string;
authTag: string;
keyVersion: number;
}
/** Bumped only when the platform master key rotates; lets old rows decrypt under an old key. */
export const SECRET_KEY_VERSION = 1;
/**
* Resolve the 32-byte platform master key from `IIOS_CRED_KEY` (base64). Returns null when
* unset (so egress providers that need it stay unregistered / fail closed) but THROWS on a
* present-but-malformed key — a wrong-length key is an operator error, not a "disabled" state.
*/
export function credKeyFromEnv(env: NodeJS.ProcessEnv = process.env): Buffer | null {
const raw = env.IIOS_CRED_KEY;
if (!raw) return null;
const key = Buffer.from(raw, 'base64');
if (key.length !== 32) throw new Error('IIOS_CRED_KEY must decode to 32 bytes (base64-encoded 256-bit key)');
return key;
}
export function sealSecret(plaintext: string, key: Buffer): SealedSecret {
const iv = randomBytes(12);
const cipher = createCipheriv('aes-256-gcm', key, iv);
const enc = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
return {
cipherText: enc.toString('base64'),
iv: iv.toString('base64'),
authTag: cipher.getAuthTag().toString('base64'),
keyVersion: SECRET_KEY_VERSION,
};
}
export function openSecret(sealed: SealedSecret, key: Buffer): string {
const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(sealed.iv, 'base64'));
decipher.setAuthTag(Buffer.from(sealed.authTag, 'base64'));
return Buffer.concat([decipher.update(Buffer.from(sealed.cipherText, 'base64')), decipher.final()]).toString('utf8');
}
@@ -0,0 +1,67 @@
import { describe, it, expect, vi } from 'vitest';
import type { CapabilityRequest } from '@insignia/iios-contracts';
import { TwilioSmsProvider, type TwilioCreds } from './twilio-sms.provider';
const CREDS: TwilioCreds = { accountSid: 'AC123', authToken: 'tok_secret', fromNumber: '+15550001111' };
const req = (over: Partial<CapabilityRequest> = {}): CapabilityRequest => ({
capability: 'channel.send',
channelType: 'SMS',
scopeId: 'scope_1',
target: '+15557654321',
payload: { text: 'hello world' },
idempotencyKey: 'idem-1',
...over,
});
describe('TwilioSmsProvider', () => {
it('resolves the scope creds and POSTs to Twilio, returning SENT with the message SID', async () => {
const http = vi.fn().mockResolvedValue({ ok: true, status: 201, json: async () => ({ sid: 'SM999' }), text: async () => '' });
const resolve = vi.fn().mockResolvedValue(CREDS);
const p = new TwilioSmsProvider(resolve, http);
const res = await p.send(req());
expect(resolve).toHaveBeenCalledWith('scope_1');
const [url, init] = http.mock.calls[0];
expect(url).toBe('https://api.twilio.com/2010-04-01/Accounts/AC123/Messages.json');
expect(init.method).toBe('POST');
expect(init.headers.authorization).toBe(`Basic ${Buffer.from('AC123:tok_secret').toString('base64')}`);
const body = new URLSearchParams(init.body as string);
expect(body.get('To')).toBe('+15557654321');
expect(body.get('From')).toBe('+15550001111');
expect(body.get('Body')).toBe('hello world');
expect(res).toMatchObject({ outcome: 'SENT', providerRef: 'SM999' });
});
it('fails closed as NOT_CONFIGURED when the scope has no creds', async () => {
const http = vi.fn();
const p = new TwilioSmsProvider(async () => null, http);
const res = await p.send(req());
expect(res.outcome).toBe('FAILED');
expect(res.errorCode).toBe('NOT_CONFIGURED');
expect(http).not.toHaveBeenCalled();
});
it('fails closed as NOT_CONFIGURED when the request has no scope', async () => {
const p = new TwilioSmsProvider(async () => CREDS, vi.fn());
const res = await p.send(req({ scopeId: undefined }));
expect(res.outcome).toBe('FAILED');
expect(res.errorCode).toBe('NOT_CONFIGURED');
});
it('surfaces a Twilio API error as FAILED (never throws)', async () => {
const http = vi.fn().mockResolvedValue({ ok: false, status: 401, json: async () => ({ code: 20003, message: 'Authenticate' }), text: async () => 'Authenticate' });
const p = new TwilioSmsProvider(async () => CREDS, http);
const res = await p.send(req());
expect(res.outcome).toBe('FAILED');
expect(res.errorCode).toBe('TWILIO_20003');
});
it('surfaces a transport throw as FAILED', async () => {
const http = vi.fn().mockRejectedValue(new Error('network down'));
const p = new TwilioSmsProvider(async () => CREDS, http);
const res = await p.send(req());
expect(res.outcome).toBe('FAILED');
});
});
@@ -0,0 +1,70 @@
import type { CapabilityProvider, CapabilityRequest, ProviderResult } from '@insignia/iios-contracts';
/** The decrypted Twilio config a tenant configures (BYO credentials). */
export interface TwilioCreds {
accountSid: string;
authToken: string;
fromNumber: string;
}
/** Resolves a scope's Twilio creds (decrypting on demand); null when unconfigured/disabled. */
export type TwilioCredResolver = (scopeId: string) => Promise<TwilioCreds | null>;
/** Injectable HTTP sender so tests don't hit the network; prod uses fetch. */
export type HttpSender = (url: string, init: { method: string; headers: Record<string, string>; body: string }) => Promise<{
ok: boolean;
status: number;
json: () => Promise<unknown>;
text: () => Promise<string>;
}>;
const defaultSender: HttpSender = (url, init) => fetch(url, init) as unknown as ReturnType<HttpSender>;
/**
* SMS egress via Twilio, using the CALLER'S OWN credentials (resolved per scope at send time).
* Fail-closed: no scope or no configured creds ⇒ FAILED/NOT_CONFIGURED, nothing sent. A Twilio
* API error is surfaced as FAILED (never thrown), per the provider doctrine.
*/
export class TwilioSmsProvider implements CapabilityProvider {
readonly name = 'twilio-sms';
readonly channelTypes = ['SMS'];
readonly capabilities = { canSend: true, maxPayloadBytes: 1600 };
constructor(
private readonly resolve: TwilioCredResolver,
private readonly http: HttpSender = defaultSender,
) {}
async send(req: CapabilityRequest): Promise<ProviderResult> {
const started = Date.now();
if (!req.scopeId) return { providerRef: 'unconfigured', outcome: 'FAILED', errorCode: 'NOT_CONFIGURED' };
const creds = await this.resolve(req.scopeId).catch(() => null);
if (!creds) return { providerRef: 'unconfigured', outcome: 'FAILED', errorCode: 'NOT_CONFIGURED' };
const body = new URLSearchParams({
To: req.target,
From: creds.fromNumber,
Body: String(req.payload.text ?? req.payload.body ?? ''),
}).toString();
try {
const res = await this.http(`https://api.twilio.com/2010-04-01/Accounts/${creds.accountSid}/Messages.json`, {
method: 'POST',
headers: {
authorization: `Basic ${Buffer.from(`${creds.accountSid}:${creds.authToken}`).toString('base64')}`,
'content-type': 'application/x-www-form-urlencoded',
},
body,
});
const latencyMs = Date.now() - started;
const payload = (await res.json().catch(() => ({}))) as { sid?: string; code?: number; message?: string };
if (!res.ok) {
return { providerRef: `twilio-${res.status}`, outcome: 'FAILED', errorCode: payload.code ? `TWILIO_${payload.code}` : `HTTP_${res.status}`, latencyMs };
}
return { providerRef: payload.sid ?? 'twilio-sent', outcome: 'SENT', latencyMs };
} catch (err) {
return { providerRef: 'twilio-error', outcome: 'FAILED', errorCode: (err as Error).message.slice(0, 60), latencyMs: Date.now() - started };
}
}
}
+3
View File
@@ -340,6 +340,9 @@ importers:
'@types/react': '@types/react':
specifier: ^19.0.0 specifier: ^19.0.0
version: 19.2.17 version: 19.2.17
'@types/react-dom':
specifier: ^19.0.0
version: 19.2.3(@types/react@19.2.17)
jsdom: jsdom:
specifier: ^26.0.0 specifier: ^26.0.0
version: 26.1.0 version: 26.1.0