Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
62 commits
Select commit Hold shift + click to select a range
2e8400b
feat(msgr): 크루 브리지 — 조직 채널 멘션·DM을 크루 턴으로(디스크 큐·서버 커서·멱등 답글·결재 카드·위임 미…
beyondworks Sep 3, 2026
dc078f5
test(msgr): 브리지 격리 E2E — 로컬 Supabase 스택 + 가짜 Anthropic SSE 러너로 RLS·Re…
beyondworks Sep 3, 2026
bcd76cb
fix(msgr): 분리 검수 반영 — 답글 insert 실패 시 잡 종결(무한 재실행 차단), journal 재귀·위임 7…
beyondworks Sep 3, 2026
57ec4ff
feat(messenger): 별도 메신저 앱 스캐폴드(P1-S3) — Vite+React+supabase-js 직결, 로그…
beyondworks Sep 3, 2026
12e9e00
chore(messenger): 마크다운 목록 간격(pre-wrap 이중 개행) 수정 + 로컬 Supabase 스택 기동 스…
beyondworks Sep 3, 2026
37714ce
chore(messenger): 채널 메시지 10s 폴링 폴백(Realtime 단절 대비) + 개발 콘솔 구독 상태 + 데모…
beyondworks Sep 3, 2026
ce0e41d
style(messenger): 아르고 디자인 시스템으로 통일 — globals.css·ThemeProvider(기본 그래파…
beyondworks Sep 3, 2026
229d08a
feat(theme): 리넨(linen) 테마 가족 — 웜 그레이지·차콜 레일·옐로 --mark 토큰 분리, ThemePro…
beyondworks Sep 3, 2026
16c65f1
feat(messenger): linen v2 컴포넌트 — 타임라인 척추·사람 원/크루 타일·2단 다크 독·결재 슬립(소유자…
beyondworks Sep 3, 2026
271144f
docs(messenger): linen 디자인 시안 v1·v2(HTML 5장·토큰·시스템 CSS)와 채점표 README —…
beyondworks Sep 3, 2026
c20d227
fix(theme,messenger): 분리 검수 반영 — 레일 스코프 --bg·상태색(H1) + 불변식 테스트, 결재 소유…
beyondworks Sep 3, 2026
02d2dfc
chore(messenger): v1 시안 파일 삭제(v2가 정본), design/shots는 gitignore — 유건 지시
beyondworks Sep 3, 2026
4463f34
fix(messenger): 멘션 팝업이 독의 overflow에 잘리던 결함 — 독 overflow 개방, 좌우 정렬은 측정…
beyondworks Sep 3, 2026
dbd2e28
fix(messenger): 멘션 팝업·멤버 툴팁의 역할 라벨 한글화(role.* 사전)
beyondworks Sep 3, 2026
6c309f1
style(messenger): 조직 선택을 알약+메뉴로, 언어·테마·계정을 설정 페이지로 이동(레일 풋터는 톱니·로그아웃만…
beyondworks Sep 3, 2026
abfcf06
fix(runners): Anthropic 호환 러너(openrouter·glm·kimi·grok) SDK 턴 격리 — CL…
beyondworks Sep 3, 2026
1e8ada5
feat(messenger): 크루 시트 — 소유자·실행 위치·접속 + '누가 시킬 수 있나'(전원/지정 멤버/소유자만, 소…
beyondworks Sep 3, 2026
464bb87
feat(settings): 팀 메신저에 크루 등록 카드(F1-1) — 조직 선택·크루별 등록/해제·허용 범위(조직 전원/지…
beyondworks Sep 3, 2026
5c8adc6
feat(messenger): 채널 시트(F1-3)·1:1 대화(F1-2) — 이름·주제 편집, 크루 기억 스위치, 비공개 …
beyondworks Sep 3, 2026
cc30327
feat(messenger): 첨부 25MB 클라이언트 검사·올리는 중 표시(F1-4), 좌석/플랜 표시와 좌석 한도 친절 …
beyondworks Sep 3, 2026
7087470
fix(messenger,bridge,schema): F1 분리 검수 반영 — 시트가 15초마다 닫히던 tick 의존 분리(…
beyondworks Sep 3, 2026
08c0b6e
test(msgr): F1 2R 조건 반영 — crewChannels DM 한정 행동 핀·실패 로그, DM 멤버 정책 드릴(…
beyondworks Sep 3, 2026
1ce7d41
feat(msgr): H-0 조직 정책 — msgr_org_policies(허용 범위·크루 기억 기본값+잠금), 서버 게이트…
beyondworks Sep 3, 2026
5767ecb
feat(msgr): H-1 결재권 규칙·위험 등급 — risk 컬럼(잠김)·정책 approval_high_by·msgr_c…
beyondworks Sep 3, 2026
30211f0
feat(msgr): H-2 허용 판정 서버 이관 — msgr_can_instruct RPC·크루 답글 서버 게이트, 결재권…
beyondworks Sep 3, 2026
be76a1f
feat(msgr): I-1+H-3 회사 크루 판별·등급 표시 — msgr_orgs.service_user_id·msgr_c…
beyondworks Sep 3, 2026
81f0068
feat(msgr): I-2+I-3 — 아르고 카드 조직 정책 요약·파견=개인 크루 안내, 채널 personal_crews …
beyondworks Sep 3, 2026
84dc7e3
feat(msgr): G-1 조직 문서 — msgr_org_docs(전사/채널 범위·폴더 3종 경로·버전·감사)·편집권 함수…
beyondworks Sep 3, 2026
c6a65d2
feat(msgr): G-2 조직 문서 로컬 미러 — 브리지가 서버 문서를 vault/org/<slug>/<path> 읽기 …
beyondworks Sep 3, 2026
160a9db
style(messenger): 시각 QA 1차 — 네이티브 대화상자 0(새 채널·새 조직 인라인 폼, 보관 2단계 확인, …
beyondworks Sep 3, 2026
3b336ad
style(messenger): 레일을 채널 중심으로 재구성 — 채널·1:1 세로 목록, 크루·멤버 목록 제거, 상단 참여 …
beyondworks Sep 3, 2026
af265f2
fix(messenger): 스크롤 QA — 스레드 바닥 고정(ResizeObserver·위로 올리면 유지·채널 전환 시 바…
beyondworks Sep 3, 2026
09c26b9
feat(msgr): G-3 조직 규칙 주입 — 미러 rules/를 전사>채널 순으로 크루 턴 시스템 프롬프트에 항상 첨부(…
beyondworks Sep 3, 2026
81d9337
feat(msgr): G-4 조직 문서 제안 결재 — propose_org_doc 도구(메신저 턴), 미러 kind·payl…
beyondworks Sep 3, 2026
f51d188
feat(messenger): F2 조직 운영 — 관리자 조직 카드(이름·멤버 역할/제거 2단계·초대 만들기/취소·감사 50…
beyondworks Sep 3, 2026
d35be8f
feat(msgr): J-1 역할 정식화 — 채널 관리자(admin_user_ids·msgr_can_manage_channe…
beyondworks Sep 3, 2026
26431d1
fix(msgr): 정책 결재권자 피커 한 줄 JSX 안 줄 주석이 닫는 괄호를 삼켜 빌드 실패 → 블록 주석
beyondworks Sep 3, 2026
646ddb4
feat(msgr): I-4 상주 노드 부트스트랩 — 노드용 연결 코드(for_node) 수락 = 서비스 계정 자동 지정, …
beyondworks Sep 3, 2026
08e747b
feat(msgr): I-5 채널·조직에서 회사 크루 만들기 — 요청 행(msgr_crew_requests)·정책 게이트(c…
beyondworks Sep 3, 2026
51a2c05
feat(msgr): J-2 소유권 이전 제안→수락·승계 관리자·결제 문제 읽기 전용 — 트리거 재정의(직접 이전 거절·NU…
beyondworks Sep 3, 2026
ca68900
feat(msgr): J-3 도메인 자동 가입 — 소유자 본인 도메인만 등록(공개 메일 거절·소문자 정규화·감사), msgr…
beyondworks Sep 3, 2026
a7a5a80
feat(msgr): J-4 게스트 — 비공개 채널 한정·기간 한정 게스트 링크(채널 관리자도 발급), 수락 시 채널 자동 …
beyondworks Sep 3, 2026
fdbe992
feat(msgr): J-5 조직 삭제 30일 유예·복구 — 소유자 이름 입력 2단계 삭제, msgr_restore_org/…
beyondworks Sep 3, 2026
a2ecdc3
fix(msgr): I-4~J-5 분리 검수 반영 — 크루 요청 컬럼 잠금·완료 크루 검증(CRITICAL), 채널 멤버 소…
beyondworks Sep 3, 2026
fc79f1b
feat(msgr): 채널 패널 재구성(UX 1/3) — "이 채널의 사람과 크루"가 먼저, 버튼 4개를 '+ 추가' 메뉴 …
beyondworks Sep 4, 2026
f57cefe
feat(msgr): 설정 재구성(UX 2/3) — 탭(멤버·조직·크루와 서버·기록·내 계정), 질문형 크루 규칙 3개+고급…
beyondworks Sep 4, 2026
b11dc55
feat(msgr): 회사 크루 AI 드롭다운(UX 3/3) — 서버가 하트비트에 쓸 수 있는 러너·모델 목록(node_in…
beyondworks Sep 4, 2026
dfac351
fix(msgr): 유건 실검수 반영 — 레일 채널·1:1 행 '…' 메뉴(채널 설정·나가기·보관, 대화 나가기), 게스트 …
beyondworks Sep 4, 2026
8f20983
fix(msgr): 채널 나가기가 목록에서 사라지게 — 만든 사람 열람 예외를 "아직 사람 멤버가 없을 때(생성 직후)"로 …
beyondworks Sep 4, 2026
462927b
style(msgr): 설정·문서 페이지는 대화의 720px 읽기 레인을 쓰지 않는다 — 데스크톱 폭(설정 200px 탭+1…
beyondworks Sep 4, 2026
cd61ef0
feat(msgr): 활동 페이지 — 아르고 "기억"처럼 조직→채널→사람·크루·문서 트리 + 같은 룩의 관계 그래프 + 고른…
beyondworks Sep 4, 2026
bbda25b
feat(msgr): 활동 페이지 전체 화면 분할·가로 다중 탭·옵시디언식 원형 노드
beyondworks Sep 4, 2026
c57e1f6
fix(msgr): 활동 트리 리마운트 제거·그래프 러스트 포인트색·탭 메뉴(모두 닫기 등)
beyondworks Sep 4, 2026
53e87b4
feat(msgr): 활동 그래프 3D — 옵시디언식 구 배치·궤도 회전·원형 노드(의존성 0)
beyondworks Sep 4, 2026
0c1bbda
fix(msgr): 대상 탭 로컬 그래프 — 집중 노드+이웃만 기하학적 고리로(전체 축소판 겹침 해소)
beyondworks Sep 4, 2026
a56289d
feat(msgr): 활동 페이지 창 분할 — 그래프 노드 클릭은 옆 창에 열기(아르고 기억 페이지와 같은 모양)
beyondworks Sep 4, 2026
657d16f
feat(msgr): 활동 페이지 → 기억 페이지 — 조직 문서(규칙집·용어집·프로젝트)가 주 내용, 활동 기록은 접힘
beyondworks Sep 4, 2026
ef0fe4d
fix(msgr): 기억 탭 줄 — 탭만 가로 스크롤, 동작 버튼은 한 줄 고정(창 많을 때 글자 깨짐 해소)
beyondworks Sep 4, 2026
a4e8179
style(msgr): 기억 페이지 한 판 배경·흑백 노드·대상 탭 그래프 제거
beyondworks Sep 4, 2026
fea97cb
fix(msgr): 레일 행 메뉴 확인 항목 2단 — 긴 문장이 버튼 안에서 흘러 균형이 깨지던 것
beyondworks Sep 4, 2026
c529661
feat(msgr): 기억 페이지 베이지 판·실제로 먹는 줌·미니멀 동적 그래프
beyondworks Sep 4, 2026
564a74f
fix(msgr): 분리 검수(2026-09-05) 반영 — 생성자 재진입 봉합·node_info 가드·창 상태 순수화·그래…
beyondworks Sep 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,6 @@ dist-server/
!/docs/selfhost.md
!/docs/privacy-sync.md
/PRODUCT-SPEC.md

# 메신저 디자인 시안 스크린샷 — 로컬 실측 산출물(유건 지시 2026-09-03: 커밋 금지)
apps/messenger/design/shots/
94 changes: 94 additions & 0 deletions app/api/companies/[ws]/msgr/route.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// 팀 메신저 — 이 회사 크루를 조직에 등록/해제/조회. 서버가 아니라 **이 기기의 기기 세션(크루 소유자 JWT)**으로
// Supabase msgr_crews에 쓴다(RLS: owner_user_id = auth.uid()). 새 공개 API를 뚫지 않는다 — 조직·채널·메시지는 메신저 앱이 직접 다룬다.
// 등록이 하나라도 있으면 company.json.msgr.enabled=true → 게이트웨이 매니저가 브리지(폴러)·드레인 워커를 켠다(src/gateway.mjs).
import { guardCompany, csrfDenied, authError, requestLang } from '../../../../auth.mjs';
import { apiError } from '../../../../apimsg.mjs';
import { sessionClient } from '../../../../../src/gateway/msgr.mjs';
import { listAgents } from '../../../../../src/hub.mjs';
import { loadCompany, updateCompany } from '../../../../../src/workspace.mjs';

const ALLOW = new Set(['all', 'list', 'owner']);
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const upstream = (where, e, lang) => { console.error(`[argo] msgr ${where}:`, e?.message ?? e); return apiError('msgr_upstream', lang); }; // PG 원문은 화면이 아니라 로그로

async function myRegistrations(c, ws) {
const { data, error } = await c.client.from('msgr_crews')
.select('id, org_id, slug, display_name, hosting, status, allow, allow_users, last_seen_at, msgr_orgs(name, slug)')
.eq('owner_user_id', c.uid).eq('ws_id', ws);
if (error) throw new Error(error.message);
return data ?? [];
}
async function myOrgs(c) {
const { data, error } = await c.client.from('msgr_org_members').select('org_id, role, msgr_orgs(id, name, slug)').eq('user_id', c.uid).is('removed_at', null); // 본인 행만(멤버 select 정책은 조직 전원 행을 준다 — 검수 MEDIUM-2)
if (error) throw new Error(error.message);
const orgs = (data ?? []).filter((m) => m.msgr_orgs).map((m) => ({ id: m.org_id, name: m.msgr_orgs.name, slug: m.msgr_orgs.slug, role: m.role }));
if (!orgs.length) return orgs;
// 조직별 멤버(허용 범위 '지정 멤버' 선택지) — 본인 제외, 표시명·역할만
const { data: mems, error: e2 } = await c.client.from('msgr_org_members').select('org_id, user_id, role, display_name').in('org_id', orgs.map((o) => o.id)).is('removed_at', null);
if (e2) throw new Error(e2.message);
for (const o of orgs) o.members = (mems ?? []).filter((m) => m.org_id === o.id && m.user_id !== c.uid).map((m) => ({ id: m.user_id, name: m.display_name || m.user_id.slice(0, 8), role: m.role }));
// 조직 정책(H-0): 허용 범위가 잠겼으면 카드가 선택지를 잠그고 정책값을 보여준다(서버 트리거 msgr_crew_policy_gate가 최종)
const { data: pols } = await c.client.from('msgr_org_policies').select('org_id, allow_default, allow_locked, crew_memory_default, crew_memory_locked, approval_high_by').in('org_id', orgs.map((o) => o.id));
for (const o of orgs) o.policy = (pols ?? []).find((p) => p.org_id === o.id) ?? null;
return orgs;
}
async function syncEnabled(ws, c) {
let regs;
try { regs = await myRegistrations(c, ws); } catch (e) { console.error('[argo] msgr 등록 조회 실패 — enabled 유지:', e.message); return []; } // 일시 오류로 브리지를 끄지 않는다
const enabled = regs.some((r) => r.status === 'active');
const company = await loadCompany(ws);
if (!!company.msgr?.enabled !== enabled) await updateCompany(ws, { msgr: { ...(company.msgr ?? {}), enabled } });
return regs;
}

export async function GET(_req, { params }) {
const { ws } = await params;
const denied = await guardCompany(ws); if (denied) return denied;
const c = await sessionClient().catch(() => null);
if (!c) return Response.json({ signedIn: false, orgs: [], crews: [] });
try {
const [orgs, crews] = await Promise.all([myOrgs(c), myRegistrations(c, ws)]);
return Response.json({ signedIn: true, uid: c.uid, orgs, crews });
} catch (e) { return upstream('GET', e, await requestLang()); }
}

/** 등록 { orgId, slug, allow?, allowUsers? } — 크루 카드(agents/<slug>.md)가 있어야 한다. 이미 있으면 allow만 갱신. */
export async function POST(req, { params }) {
const { ws } = await params;
const denied = await guardCompany(ws); if (denied) return denied;
const cs = await csrfDenied(req); if (cs) return cs;
const lang = await requestLang();
// 기본 'owner' — 조직 정책 테이블(부록 H-0) 전까지는 가장 좁게 시작하고 크루 시트/카드에서 연다(유건 결정 2026-09-03)
const { orgId, slug, allow = 'owner', allowUsers = [] } = await req.json().catch(() => ({}));
const users = Array.isArray(allowUsers) ? allowUsers : [];
if (!UUID.test(String(orgId ?? '')) || !slug || !ALLOW.has(allow) || !users.every((u) => UUID.test(String(u)))) return apiError('msgr_bad_request', lang);
const agent = (await listAgents(ws)).find((a) => a.slug === slug);
if (!agent) return apiError('msgr_crew_not_found', lang);
const c = await sessionClient().catch(() => null);
if (!c) return authError('auth_required', lang);
const row = {
org_id: orgId, owner_user_id: c.uid, ws_id: ws, slug, display_name: agent.name || slug, role_text: agent.role || null,
hosting: process.env.ARGO_TENANT_OWNER ? 'resident' : 'local', status: 'active', allow,
allow_users: users.slice(0, 200),
};
const { data, error } = await c.client.from('msgr_crews').upsert(row, { onConflict: 'org_id,owner_user_id,ws_id,slug' }).select('id').single();
if (error) return upstream('POST', error, lang);
const crews = await syncEnabled(ws, c);
return Response.json({ ok: true, id: data.id, crews });
}

/** 해제 { orgId, slug } — 행 삭제(감사는 서버 트리거 몫). 마지막 등록이 사라지면 브리지도 꺼진다. */
export async function DELETE(req, { params }) {
const { ws } = await params;
const denied = await guardCompany(ws); if (denied) return denied;
const cs = await csrfDenied(req); if (cs) return cs;
const lang = await requestLang();
const { orgId, slug } = await req.json().catch(() => ({}));
if (!UUID.test(String(orgId ?? '')) || !slug) return apiError('msgr_bad_request', lang);
const c = await sessionClient().catch(() => null);
if (!c) return authError('auth_required', lang);
const { error } = await c.client.from('msgr_crews').delete().eq('org_id', orgId).eq('owner_user_id', c.uid).eq('ws_id', ws).eq('slug', slug);
if (error) return upstream('DELETE', error, lang);
const crews = await syncEnabled(ws, c);
return Response.json({ ok: true, crews });
}
4 changes: 4 additions & 0 deletions app/apimsg.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ export const API_MSG = {
// 회의실(app/api/companies/[ws]/room·sessions) — 크루 발언 중 새 회의·전환·마치기 거절(코어 assertRoomIdle의 ROOM_BUSY).
// 화면은 errorCode로 사전(room.busyGate)을 다시 그리고, 이 문구는 API 소비자·로그용 표시 언어 본문(#393 DELETE 문구 계승).
room_busy: { status: 409, ko: '발언이 진행 중입니다 — 끝난 뒤 다시 시도해 주세요.', en: 'A crew is still speaking — try again after it finishes.' },
// 팀 메신저 크루 등록(app/api/companies/[ws]/msgr)
msgr_bad_request: { status: 400, ko: '조직 id·크루·허용 범위(all|list|owner)를 확인해 주세요', en: 'Check the organization id, crew, and allow scope (all|list|owner)' },
msgr_crew_not_found: { status: 404, ko: '크루가 없습니다', en: 'Crew not found' },
msgr_upstream: { status: 502, ko: '조직 서버 응답 오류 — 잠시 후 다시 시도해 주세요', en: 'Organization server error — please try again shortly' },
};

/** 기능 라우트 공통 오류 응답. lang은 ko|en(그 외 값·미지정은 ko). 미등록 코드는 throw —
Expand Down
114 changes: 112 additions & 2 deletions app/c/[ws]/settings/page.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ function Settings({ params }) {
<ConnectionCard ws={ws} kind="slack" title={t('activity.slack')}
help={t('settings.conn.slackHelp')}
agents={data?.agents ?? []} />
<MsgrCard ws={ws} agents={data?.agents ?? []} />
<ConnectorsCard ws={ws} />
<SyncCard ws={ws} />
<E2eeCard />
Expand Down Expand Up @@ -340,6 +341,9 @@ const THEME_SWATCHES = {
graphite: ['#f0f0f0', '#202020', '#1a1a1a'], // 자동 — 밝은 판·어두운 판을 함께 보여 시스템 따라감을 드러낸다
'graphite-light': ['#f0f0f0', '#ffffff', '#1a1a1a'],
'graphite-dark': ['#202020', '#252525', '#ededed'],
linen: ['#e9e6df', '#1f1e1b', '#e8e400'], // 자동 — 그레이지 판·차콜 판 + 옐로 마크
'linen-light': ['#e9e6df', '#fbfaf7', '#1f1e1b'],
'linen-dark': ['#1f1e1b', '#2a2926', '#ececea'],
argo: ['#e3e5d6', '#e9ebdd', '#22241c'],
calm: ['#eff1f4', '#f8f9fb', '#5a6b8c'],
'calm-dark': ['#1b1e24', '#22262e', '#8098bd'],
Expand Down Expand Up @@ -370,9 +374,9 @@ const THEME_SWATCHES = {
};

// 아르고 시그니처 = 라이트/다크/시스템 3-모드. 나머지 테마는 "다른 스킨"으로 분리(모드 토글과 중복 제거).
// 기본 패밀리 2종(그래파이트=기본, 아르고=시그니처) × 모드 3종(시스템/라이트/다크). 패밀리 코드: 시스템 자동은
// 기본 패밀리 3종(그래파이트=기본, 아르고=시그니처, 리넨=메신저 기본) × 모드 3종(시스템/라이트/다크). 패밀리 코드: 시스템 자동은
// 접미 없음, 고정은 -light/-dark. 다른 스킨(목록)은 패밀리 밖이라 모드 세그먼트가 적용되지 않는다.
const FAMILIES = [['graphite', 'settings.family.graphite'], ['argo', 'settings.family.argo']];
const FAMILIES = [['graphite', 'settings.family.graphite'], ['argo', 'settings.family.argo'], ['linen', 'settings.family.linen']];
const MODE_SUFFIX = [['', 'settings.mode.system'], ['-light', 'settings.mode.light'], ['-dark', 'settings.mode.dark']];
const FAMILY_CODES = FAMILIES.flatMap(([f]) => MODE_SUFFIX.map(([sfx]) => `${f}${sfx}`));
const ARGO_CODES = FAMILY_CODES;
Expand Down Expand Up @@ -734,6 +738,112 @@ function TrashCard({ ws }) {
/** 로그인만으로 붙는 외부 서비스(커넥터) — 카탈로그 × 이 회사 연결 상태.
"연결"은 브라우저 동의 창을 열고 **끝나기를 기다리지 않는다**(사용자가 구글에서 로그인하는 동안
응답이 막히면 화면이 죽은 것처럼 보인다). 완료는 목록을 다시 읽어 상태로 관측한다 — 코어와 같은 계약. */
/** 팀 메신저 — 이 회사 크루를 조직에 등록(파견)/해제하고 허용 범위를 고른다. 조직·채널·메시지는 메신저 앱이 다룬다.
데이터는 /api/companies/[ws]/msgr(기기 세션 JWT로 Supabase msgr_crews에 씀). 기본 허용 범위는 '나만'(부록 H — 정책 테이블 전까지 가장 좁게). */
function MsgrCard({ ws, agents }) {
const { t } = useLang();
const [st, setSt] = useState(null); // { signedIn, orgs:[{id,name,role,members}], crews:[등록 행] }
const [orgId, setOrgId] = useState('');
const [busy, setBusy] = useState('');
const [err, setErr] = useState('');
const load = useCallback(() => api(`/api/companies/${ws}/msgr`)
.then((d) => { setSt(d); setOrgId((cur) => cur && d.orgs?.some((o) => o.id === cur) ? cur : (d.orgs?.[0]?.id ?? '')); })
.catch(() => { setSt({ signedIn: false, orgs: [], crews: [] }); setErr(t('settings.msgr.err.load')); }), [ws, t]);
useEffect(() => { load(); }, [load]);
const org = st?.orgs?.find((o) => o.id === orgId);
const regOf = (slug) => st?.crews?.find((r) => r.org_id === orgId && r.slug === slug && r.status === 'active');
const bridgeOn = !!st?.crews?.some((r) => r.status === 'active');
async function register(slug, allow, allowUsers) {
if (busy) return; setBusy(slug); setErr('');
try { await api(`/api/companies/${ws}/msgr`, { orgId, slug, allow, allowUsers }); await load(); }
catch (e) { setErr(String(e.message)); } finally { setBusy(''); }
}
async function unregister(slug) {
if (busy) return; setBusy(slug); setErr('');
try {
const r = await fetch(`/api/companies/${ws}/msgr`, { method: 'DELETE', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ orgId, slug }) });
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || `${r.status}`);
await load();
} catch (e) { setErr(String(e.message)); } finally { setBusy(''); }
}
return (
<div className="card" style={{ padding: 18, display: 'flex', flexDirection: 'column', gap: 10 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, flexWrap: 'wrap' }}>
<strong style={{ fontSize: 14, whiteSpace: 'nowrap' }}>{t('settings.msgr.title')}</strong>
{st?.signedIn && <span className="chip" style={{ fontSize: 10 }} title={bridgeOn ? t('settings.msgr.bridge.on') : t('settings.msgr.bridge.off')}>{bridgeOn ? t('settings.msgr.bridge.onShort') : t('settings.msgr.bridge.offShort')}</span>}
</div>
<p style={{ fontSize: 12, color: 'var(--fg-2)', margin: 0, lineHeight: 1.7 }}>{t('settings.msgr.help')}</p>
{st === null && <Skeleton h={44} />}
{st && !st.signedIn && <p style={{ fontSize: 12, color: 'var(--fg-3)', margin: 0 }}>{t('settings.msgr.notSignedIn')}</p>}
{st?.signedIn && !st.orgs?.length && <p style={{ fontSize: 12, color: 'var(--fg-3)', margin: 0 }}>{t('settings.msgr.noOrg')}</p>}
{st?.signedIn && !!st.orgs?.length && (<>
<label style={{ display: 'flex', alignItems: 'center', gap: 10, fontSize: 12.5 }}>
<span className="microlabel">{t('settings.msgr.org')}</span>
<select className="input" value={orgId} onChange={(e) => setOrgId(e.target.value)} style={{ height: 30, fontSize: 12.5 }}>
{st.orgs.map((o) => <option key={o.id} value={o.id}>{o.name} · {t(`role.${o.role}`)}</option>)}
</select>
</label>
{org?.policy && (
<p style={{ fontSize: 11.5, color: 'var(--fg-3)', margin: 0, lineHeight: 1.5 }}>
<span className="microlabel" style={{ marginRight: 6 }}>{t('settings.msgr.policy')}</span>
{t('settings.msgr.policy.summary', { allow: t(`settings.msgr.allow.${org.policy.allow_default}`) + (org.policy.allow_locked ? t('settings.msgr.policy.locked') : ''), approver: t(`settings.msgr.policy.approver.${org.policy.approval_high_by ?? 'admin'}`), memory: (org.policy.crew_memory_default === false ? t('settings.msgr.policy.memory.off') : t('settings.msgr.policy.memory.on')) + (org.policy.crew_memory_locked ? t('settings.msgr.policy.locked') : '') })}
</p>
)}
<p style={{ fontSize: 11.5, color: 'var(--fg-3)', margin: 0, lineHeight: 1.5 }}>{t('settings.msgr.tierNote')}</p>
{!agents.length && <p style={{ fontSize: 12, color: 'var(--fg-3)', margin: 0 }}>{t('settings.msgr.noCrews')}</p>}
{agents.map((a) => {
const reg = regOf(a.slug);
const allow = reg?.allow ?? 'owner';
const picked = reg?.allow_users ?? [];
return (
<div key={a.slug} style={{ display: 'flex', flexDirection: 'column', gap: 8, padding: '8px 0', borderTop: '1px solid var(--border-soft)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
<div style={{ flex: 1, minWidth: 180 }}>
<span style={{ fontSize: 13, fontWeight: 600 }}>{a.name}</span>
<span style={{ fontSize: 11.5, color: 'var(--fg-3)', marginLeft: 8 }}>{a.role}</span>
<span className="chip" style={{ fontSize: 10, marginLeft: 8 }}>{reg ? t('settings.msgr.registered') : t('settings.msgr.notRegistered')}</span>
</div>
{reg ? (
<button type="button" className="btn sm" disabled={busy === a.slug} onClick={() => unregister(a.slug)}>{busy === a.slug ? <Spinner size={12} /> : t('settings.msgr.unregister')}</button>
) : (
<button type="button" className="btn sm btn-primary" disabled={busy === a.slug} onClick={() => register(a.slug, org?.policy?.allow_locked ? org.policy.allow_default : 'owner', [])}>{busy === a.slug ? <Spinner size={12} /> : t('settings.msgr.register')}</button>
)}
</div>
{reg && (
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
<span className="microlabel">{t('settings.msgr.allow')}</span>
<div role="radiogroup" aria-label={t('settings.msgr.allow')} style={{ display: 'inline-flex', gap: 3, background: 'var(--card-2)', border: '1px solid var(--border)', borderRadius: 999, padding: 3 }}>
{['all', 'list', 'owner'].map((v) => (
<button key={v} type="button" role="radio" aria-checked={allow === v} disabled={busy === a.slug || !!org?.policy?.allow_locked} onClick={() => register(a.slug, v, v === 'list' ? picked : [])}
style={{ cursor: 'pointer', border: 0, borderRadius: 999, padding: '4px 12px', fontSize: 12, fontWeight: 600, background: allow === v ? 'var(--primary)' : 'transparent', color: allow === v ? 'var(--primary-fg)' : 'var(--fg-2)' }}>
{t(`settings.msgr.allow.${v}`)}
</button>
))}
</div>
{!!org?.policy?.allow_locked && <span style={{ fontSize: 11.5, color: 'var(--fg-3)' }}>{t('settings.msgr.allow.locked')}</span>}
{allow === 'list' && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, alignItems: 'center' }}>
<span className="microlabel">{t('settings.msgr.allow.pick')}</span>
{!org?.members?.length && <span style={{ fontSize: 11.5, color: 'var(--fg-3)' }}>{t('settings.msgr.allow.noMembers')}</span>}
{org?.members?.map((m) => {
const on = picked.includes(m.id);
return <button key={m.id} type="button" className="chip" aria-pressed={on} disabled={busy === a.slug} onClick={() => register(a.slug, 'list', on ? picked.filter((x) => x !== m.id) : [...picked, m.id])}
style={{ cursor: 'pointer', textTransform: 'none', letterSpacing: 0, background: on ? 'var(--primary)' : undefined, color: on ? 'var(--primary-fg)' : undefined }}>{m.name}</button>;
})}
</div>
)}
</div>
)}
</div>
);
})}
<p style={{ fontSize: 11, color: 'var(--fg-3)', margin: 0, lineHeight: 1.6 }}>{t('settings.msgr.policyNote')}</p>
</>)}
{err && <p style={{ fontSize: 11.5, color: 'var(--danger)', margin: 0 }}>{err}</p>}
</div>
);
}

function ConnectorsCard({ ws }) {
const { t, lang } = useLang();
const [rows, setRows] = useState(null);
Expand Down
Loading
Loading