Open-source release of MAESTRO, an agent orchestration platform that runs LLM-driven tasks through sandboxed tools, with a web UI. Apache-2.0. See README.md and docs/ (getting-started, configuration, architecture).
314 lines
16 KiB
JavaScript
314 lines
16 KiB
JavaScript
// UsersPage — left list of users + center profile/role editor
|
|
// Data model derives from ui/src/pages/UsersPage.tsx.
|
|
|
|
const ROLE_TONE = {
|
|
admin: { bg: '#ede9fe', fg: '#5b21b6' },
|
|
operator: { bg: '#dbeafe', fg: '#1d4ed8' },
|
|
viewer: { bg: '#e2e8f0', fg: '#475569' },
|
|
};
|
|
|
|
const USER_STATUS_TONE = {
|
|
pending: { bg: '#fef9c3', fg: '#854d0e', label: '承認待ち' },
|
|
active: { bg: '#dcfce7', fg: '#166534', label: 'アクティブ' },
|
|
disabled: { bg: '#e2e8f0', fg: '#475569', label: '無効' },
|
|
};
|
|
|
|
function UserAvatar({ name, size = 32 }) {
|
|
const initial = (name || '?').charAt(0).toUpperCase();
|
|
// simple deterministic hue from name
|
|
let hue = 0; for (const c of (name || '')) hue = (hue * 31 + c.charCodeAt(0)) % 360;
|
|
return (
|
|
<div style={{
|
|
width: size, height: size, borderRadius: 9999,
|
|
background: `hsl(${hue} 60% 92%)`, color: `hsl(${hue} 50% 35%)`,
|
|
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
|
fontSize: size * 0.45, fontWeight: 800, flexShrink: 0,
|
|
}}>{initial}</div>
|
|
);
|
|
}
|
|
|
|
function UserListItem({ user, active, onClick }) {
|
|
return (
|
|
<button onClick={onClick} style={{
|
|
width: '100%', textAlign: 'left', padding: '10px 12px', borderRadius: 12,
|
|
border: '1px solid ' + (active ? '#3b82f6' : '#e2e8f0'),
|
|
background: active ? '#eff6ff' : '#fff',
|
|
cursor: 'pointer', transition: 'background .15s', fontFamily: 'inherit',
|
|
display: 'flex', alignItems: 'center', gap: 10,
|
|
}}>
|
|
<UserAvatar name={user.name || user.email} size={36} />
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, minWidth: 0 }}>
|
|
<div style={{
|
|
flex: 1, minWidth: 0,
|
|
fontSize: 13, fontWeight: 700, color: '#0f172a',
|
|
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
|
}}>{user.name || '(未設定)'}</div>
|
|
{user.status === 'pending' && (
|
|
<span style={{
|
|
fontSize: 10, fontWeight: 700, color: '#854d0e', background: '#fef9c3',
|
|
padding: '1px 6px', borderRadius: 4, flexShrink: 0,
|
|
}}>承認</span>
|
|
)}
|
|
</div>
|
|
<div style={{ fontSize: 11, color: '#64748b', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
{user.email}
|
|
</div>
|
|
<div style={{ marginTop: 2, display: 'flex', alignItems: 'center', gap: 6 }}>
|
|
<span style={{
|
|
fontSize: 10, fontWeight: 700, padding: '1px 6px', borderRadius: 4,
|
|
background: ROLE_TONE[user.role]?.bg, color: ROLE_TONE[user.role]?.fg,
|
|
}}>{user.role}</span>
|
|
<span style={{ fontSize: 10, color: '#94a3b8' }}>· {user.taskCount} タスク</span>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function UserListPane({ users, activeId, onSelect, filter, setFilter, search, setSearch, onOpenInvite }) {
|
|
const filtered = users.filter(u => {
|
|
if (filter !== 'all' && u.role !== filter && u.status !== filter) return false;
|
|
if (search && !((u.name || '') + u.email).toLowerCase().includes(search.toLowerCase())) return false;
|
|
return true;
|
|
});
|
|
const counts = {
|
|
all: users.length,
|
|
admin: users.filter(u => u.role === 'admin').length,
|
|
operator: users.filter(u => u.role === 'operator').length,
|
|
viewer: users.filter(u => u.role === 'viewer').length,
|
|
pending: users.filter(u => u.status === 'pending').length,
|
|
};
|
|
const chipStyle = (on) => ({
|
|
flexShrink: 0, padding: '6px 10px', borderRadius: 9999,
|
|
fontSize: 11, fontWeight: 700, whiteSpace: 'nowrap', cursor: 'pointer',
|
|
border: '1px solid ' + (on ? '#2563eb' : '#e2e8f0'),
|
|
background: on ? '#eff6ff' : '#fff',
|
|
color: on ? '#1d4ed8' : '#64748b', fontFamily: 'inherit',
|
|
});
|
|
return (
|
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
|
<button onClick={onOpenInvite} style={{
|
|
width: '100%', padding: '10px 14px', marginBottom: 10, background: '#2563eb',
|
|
color: '#fff', borderRadius: 12, fontSize: 13, fontWeight: 700, border: 'none',
|
|
cursor: 'pointer', fontFamily: 'inherit', display: 'inline-flex', alignItems: 'center',
|
|
justifyContent: 'center', gap: 6, boxShadow: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
|
|
}}>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"><path d="M12 5v14M5 12h14"/></svg>
|
|
ユーザーを招待
|
|
</button>
|
|
<div style={{
|
|
display: 'flex', alignItems: 'center', gap: 10, fontSize: 11,
|
|
color: '#64748b', padding: '0 2px 10px',
|
|
}}>
|
|
<span><b style={{ color: '#334155', fontWeight: 700 }}>{counts.all}</b> 人</span>
|
|
{counts.pending > 0 && <><span style={{ color: '#cbd5e1' }}>·</span>
|
|
<span><b style={{ color: '#d97706', fontWeight: 700 }}>{counts.pending}</b> 承認待ち</span></>}
|
|
</div>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, paddingBottom: 12, borderBottom: '1px solid #e2e8f0' }}>
|
|
<div style={{
|
|
display: 'flex', alignItems: 'center', gap: 8, background: '#fff', border: '1px solid #e2e8f0',
|
|
borderRadius: 12, padding: '6px 12px', boxShadow: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
|
|
}}>
|
|
<IconSearch width={14} height={14} style={{ color: '#94a3b8', flexShrink: 0 }} />
|
|
<input value={search} onChange={(e) => setSearch(e.target.value)} placeholder="名前・メールで検索..."
|
|
style={{ flex: 1, border: 'none', outline: 'none', background: 'transparent', fontSize: 13, fontFamily: 'inherit', color: '#0f172a', minWidth: 0 }} />
|
|
</div>
|
|
<div style={{ display: 'flex', gap: 6, overflowX: 'auto', paddingBottom: 4 }}>
|
|
<button style={chipStyle(filter === 'all')} onClick={() => setFilter('all')}>All <span style={{ color: '#94a3b8', marginLeft: 2 }}>{counts.all}</span></button>
|
|
<button style={chipStyle(filter === 'admin')} onClick={() => setFilter('admin')}>Admin <span style={{ color: '#94a3b8', marginLeft: 2 }}>{counts.admin}</span></button>
|
|
<button style={chipStyle(filter === 'operator')} onClick={() => setFilter('operator')}>Operator <span style={{ color: '#94a3b8', marginLeft: 2 }}>{counts.operator}</span></button>
|
|
<button style={chipStyle(filter === 'viewer')} onClick={() => setFilter('viewer')}>Viewer <span style={{ color: '#94a3b8', marginLeft: 2 }}>{counts.viewer}</span></button>
|
|
{counts.pending > 0 && <button style={chipStyle(filter === 'pending')} onClick={() => setFilter('pending')}>承認待ち <span style={{ color: '#94a3b8', marginLeft: 2 }}>{counts.pending}</span></button>}
|
|
</div>
|
|
</div>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginTop: 8, overflowY: 'auto', flex: 1, minHeight: 0 }}>
|
|
{filtered.map(u => <UserListItem key={u.id} user={u} active={activeId === u.id} onClick={() => onSelect(u.id)} />)}
|
|
{filtered.length === 0 && (
|
|
(search || filter !== 'all') ? (
|
|
<EmptyState
|
|
compact
|
|
icon={<IconSearch width={18} height={18} />}
|
|
title="該当するユーザーがいません"
|
|
hint="検索やフィルタを変えてみてください。"
|
|
action={
|
|
<button onClick={() => { setSearch(''); setFilter('all'); }} style={{
|
|
padding: '6px 12px', borderRadius: 8, fontSize: 12, fontWeight: 700,
|
|
background: '#fff', border: '1px solid #e2e8f0', color: '#334155',
|
|
cursor: 'pointer', fontFamily: 'inherit',
|
|
}}>フィルタをクリア</button>
|
|
}
|
|
/>
|
|
) : (
|
|
<EmptyState
|
|
compact
|
|
icon={<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 00-3-3.87M16 3.13a4 4 0 010 7.75"/></svg>}
|
|
title="ユーザーがいません"
|
|
hint="右上の「ユーザーを招待」から追加できます。"
|
|
/>
|
|
)
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function UserDetail({ user, onPatch, onDelete, onApprove }) {
|
|
if (!user) {
|
|
return (
|
|
<div style={{ padding: 40, display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%' }}>
|
|
<EmptyState
|
|
icon={<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="8" r="4"/><path d="M4 21v-1a8 8 0 0116 0v1"/></svg>}
|
|
title="ユーザーを選択してください"
|
|
hint="左のリストから表示・編集したいユーザーを開きます。"
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
const statusTone = USER_STATUS_TONE[user.status] || USER_STATUS_TONE.active;
|
|
return (
|
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
|
{/* Header */}
|
|
<div style={{
|
|
flexShrink: 0, padding: '14px 20px', borderBottom: '1px solid #e2e8f0', background: '#fff',
|
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12,
|
|
}}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, minWidth: 0 }}>
|
|
<UserAvatar name={user.name || user.email} size={40} />
|
|
<div style={{ minWidth: 0 }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
<div style={{ fontSize: 15, fontWeight: 700, color: '#0f172a' }}>{user.name || '(未設定)'}</div>
|
|
<span style={{
|
|
fontSize: 10, fontWeight: 700, padding: '2px 8px', borderRadius: 9999,
|
|
background: statusTone.bg, color: statusTone.fg,
|
|
}}>{statusTone.label}</span>
|
|
</div>
|
|
<div style={{ fontSize: 12, color: '#64748b' }}>{user.email}</div>
|
|
</div>
|
|
</div>
|
|
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
|
|
{user.status === 'pending' && (
|
|
<button onClick={() => onApprove(user.id)} style={{
|
|
padding: '6px 12px', background: '#16a34a', border: 'none', color: '#fff',
|
|
borderRadius: 8, fontSize: 12, fontWeight: 700, cursor: 'pointer', fontFamily: 'inherit',
|
|
}}>承認</button>
|
|
)}
|
|
<button onClick={() => onDelete(user.id)} style={{
|
|
padding: '6px 12px', background: '#fff', border: '1px solid #fecaca', color: '#dc2626',
|
|
borderRadius: 8, fontSize: 12, fontWeight: 700, cursor: 'pointer', fontFamily: 'inherit',
|
|
}}>削除</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Body */}
|
|
<div style={{ flex: 1, overflowY: 'auto', padding: '20px 24px', background: '#f8fafc' }}>
|
|
<div style={{ maxWidth: 640, margin: '0 auto' }}>
|
|
{/* Summary strip */}
|
|
<div style={{ display: 'flex', gap: 10, marginBottom: 20, flexWrap: 'wrap' }}>
|
|
<StatChip label="タスク数" value={user.taskCount} />
|
|
<StatChip label="最終ログイン" value={user.lastLogin || '—'} />
|
|
<StatChip label="登録日" value={user.createdAt || '—'} />
|
|
</div>
|
|
|
|
{/* Role & permissions */}
|
|
<div style={{
|
|
background: '#fff', border: '1px solid #e2e8f0', borderRadius: 16, padding: 20,
|
|
boxShadow: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
|
|
}}>
|
|
<div style={{ fontSize: 11, fontWeight: 700, color: '#64748b', letterSpacing: '.08em', textTransform: 'uppercase', marginBottom: 14 }}>
|
|
ロールと権限
|
|
</div>
|
|
{[
|
|
{ id: 'admin', label: 'Admin', desc: '全ての設定変更・ユーザー管理・システム操作' },
|
|
{ id: 'operator', label: 'Operator', desc: 'タスク作成・実行・スケジュール管理' },
|
|
{ id: 'viewer', label: 'Viewer', desc: '閲覧のみ。タスクの作成・変更不可' },
|
|
].map(r => (
|
|
<button key={r.id} onClick={() => onPatch(user.id, { role: r.id })} style={{
|
|
width: '100%', textAlign: 'left', padding: '12px 14px', borderRadius: 10,
|
|
border: '1px solid ' + (user.role === r.id ? '#2563eb' : '#e2e8f0'),
|
|
background: user.role === r.id ? '#eff6ff' : '#fff',
|
|
cursor: 'pointer', fontFamily: 'inherit', marginBottom: 8,
|
|
display: 'flex', alignItems: 'center', gap: 12,
|
|
}}>
|
|
<span style={{
|
|
width: 18, height: 18, borderRadius: 9999, flexShrink: 0,
|
|
border: '2px solid ' + (user.role === r.id ? '#2563eb' : '#cbd5e1'),
|
|
background: user.role === r.id ? '#2563eb' : '#fff',
|
|
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
|
}}>
|
|
{user.role === r.id && <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><path d="M5 13l4 4L19 7"/></svg>}
|
|
</span>
|
|
<div style={{ flex: 1 }}>
|
|
<div style={{ fontSize: 13, fontWeight: 700, color: '#0f172a' }}>{r.label}</div>
|
|
<div style={{ fontSize: 11, color: '#64748b', marginTop: 2 }}>{r.desc}</div>
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Profile */}
|
|
<div style={{
|
|
background: '#fff', border: '1px solid #e2e8f0', borderRadius: 16, padding: 20,
|
|
marginTop: 16, boxShadow: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
|
|
}}>
|
|
<div style={{ fontSize: 11, fontWeight: 700, color: '#64748b', letterSpacing: '.08em', textTransform: 'uppercase', marginBottom: 14 }}>
|
|
プロフィール
|
|
</div>
|
|
<FormRow label="表示名">
|
|
<TextInput value={user.name || ''} onChange={e => onPatch(user.id, { name: e.target.value })} />
|
|
</FormRow>
|
|
<FormRow label="メールアドレス">
|
|
<TextInput value={user.email} readOnly style={{ background: '#f8fafc', color: '#64748b' }} />
|
|
</FormRow>
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
|
<FormRow label="タイムゾーン">
|
|
<SelectInput value={user.timezone || 'Asia/Tokyo'} onChange={e => onPatch(user.id, { timezone: e.target.value })}>
|
|
<option value="Asia/Tokyo">Asia/Tokyo</option>
|
|
<option value="UTC">UTC</option>
|
|
<option value="America/Los_Angeles">America/Los_Angeles</option>
|
|
<option value="Europe/London">Europe/London</option>
|
|
</SelectInput>
|
|
</FormRow>
|
|
<FormRow label="デフォルトピース">
|
|
<SelectInput value={user.defaultPiece || 'auto'} onChange={e => onPatch(user.id, { defaultPiece: e.target.value })}>
|
|
<option value="auto">auto</option>
|
|
<option value="chat">chat</option>
|
|
<option value="research">research</option>
|
|
</SelectInput>
|
|
</FormRow>
|
|
</div>
|
|
</div>
|
|
|
|
<div style={{ height: 40 }} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function UsersPage({ users, activeId, setActiveId, onPatch, onDelete, onApprove, onOpenInvite }) {
|
|
const [filter, setFilter] = React.useState('all');
|
|
const [search, setSearch] = React.useState('');
|
|
const active = users.find(u => u.id === activeId) || users[0];
|
|
return (
|
|
<div style={{
|
|
flex: 1, minHeight: 0, display: 'grid',
|
|
gridTemplateColumns: '320px 1fr',
|
|
background: '#f1f5f9', gap: 1,
|
|
}}>
|
|
<div style={{ background: '#fff', padding: 12, minWidth: 0, display: 'flex', flexDirection: 'column' }}>
|
|
<UserListPane
|
|
users={users} activeId={active?.id} onSelect={setActiveId}
|
|
filter={filter} setFilter={setFilter} search={search} setSearch={setSearch}
|
|
onOpenInvite={onOpenInvite}
|
|
/>
|
|
</div>
|
|
<div style={{ background: '#fff', minWidth: 0 }}>
|
|
<UserDetail user={active} onPatch={onPatch} onDelete={onDelete} onApprove={onApprove} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
window.UsersPage = UsersPage;
|