Skip to content

Commit d6e2349

Browse files
committed
Merge branch 'main' of https://github.com/MeshJS/governance
2 parents de94f29 + 37eb905 commit d6e2349

File tree

30 files changed

+2147
-754
lines changed

30 files changed

+2147
-754
lines changed
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
import React, { useCallback, useEffect, useState } from 'react';
2+
import { Modal } from '@/components/ui/Modal';
3+
import styles from './ProjectEditorModal.module.css';
4+
import { formatAddressShort } from '@/utils/address';
5+
import type { ProjectRecord } from '@/types/projects';
6+
7+
export type EditorsModalProps = {
8+
isOpen: boolean;
9+
project: ProjectRecord | null;
10+
canSubmit: boolean;
11+
onClose: () => void;
12+
};
13+
14+
export function EditorsModal({ isOpen, project, canSubmit, onClose }: EditorsModalProps) {
15+
const [error, setError] = useState<string | null>(null);
16+
const [roles, setRoles] = useState<{ id: string; role: 'admin' | 'editor'; principal_type: 'wallet' | 'nft_policy'; wallet_payment_address?: string | null; stake_address?: string | null; policy_id?: string | null }[]>([]);
17+
const [newWallet, setNewWallet] = useState('');
18+
const [newPolicy, setNewPolicy] = useState('');
19+
const [newRole, setNewRole] = useState<'admin' | 'editor'>('editor');
20+
const [ownerWallet, setOwnerWallet] = useState('');
21+
const [ownerPolicyCsv, setOwnerPolicyCsv] = useState('');
22+
23+
useEffect(() => {
24+
if (!project?.id || !isOpen) {
25+
setRoles([]);
26+
setNewWallet('');
27+
setNewPolicy('');
28+
setNewRole('editor');
29+
setOwnerWallet('');
30+
setOwnerPolicyCsv('');
31+
setError(null);
32+
return;
33+
}
34+
(async () => {
35+
try {
36+
const rolesResp = await fetch(`/api/projects/roles?project_id=${encodeURIComponent(project.id)}`);
37+
if (rolesResp.ok) {
38+
const data: { roles?: { id: string; role: 'admin' | 'editor'; principal_type: 'wallet' | 'nft_policy'; wallet_payment_address?: string | null; stake_address?: string | null; policy_id?: string | null }[] } = await rolesResp.json();
39+
setRoles((data?.roles ?? []));
40+
} else {
41+
setRoles([]);
42+
}
43+
setOwnerPolicyCsv((project.owner_nft_policy_ids ?? []).join(', '));
44+
} catch {
45+
setRoles([]);
46+
}
47+
})();
48+
}, [project?.id, isOpen, project?.owner_nft_policy_ids]);
49+
50+
const addWalletRole = useCallback(async () => {
51+
if (!project?.id || !newWallet.trim()) return;
52+
try {
53+
const resp = await fetch('/api/projects/roles', {
54+
method: 'POST',
55+
headers: { 'Content-Type': 'application/json' },
56+
body: JSON.stringify({ project_id: project.id, role: newRole, principal_type: 'wallet', wallet_address: newWallet.trim() }),
57+
});
58+
const data: { role?: { id: string; role: 'admin' | 'editor'; principal_type: 'wallet' | 'nft_policy'; wallet_payment_address?: string | null; stake_address?: string | null; policy_id?: string | null }; error?: string } = await resp.json().catch(() => ({}) as { role?: { id: string; role: 'admin' | 'editor'; principal_type: 'wallet' | 'nft_policy'; wallet_payment_address?: string | null; stake_address?: string | null; policy_id?: string | null }; error?: string });
59+
if (!resp.ok) throw new Error(data?.error || 'Failed to add wallet role');
60+
setNewWallet('');
61+
if (data.role) setRoles((prev) => prev.concat(data.role as { id: string; role: 'admin' | 'editor'; principal_type: 'wallet' | 'nft_policy'; wallet_payment_address?: string | null; stake_address?: string | null; policy_id?: string | null }));
62+
} catch (e) {
63+
setError(e instanceof Error ? e.message : 'Failed to add wallet role');
64+
}
65+
}, [project?.id, newWallet, newRole]);
66+
67+
const addPolicyRole = useCallback(async () => {
68+
if (!project?.id || !newPolicy.trim()) return;
69+
try {
70+
const resp = await fetch('/api/projects/roles', {
71+
method: 'POST',
72+
headers: { 'Content-Type': 'application/json' },
73+
body: JSON.stringify({ project_id: project.id, role: newRole, principal_type: 'nft_policy', policy_id: newPolicy.trim() }),
74+
});
75+
const data: { role?: { id: string; role: 'admin' | 'editor'; principal_type: 'wallet' | 'nft_policy'; wallet_payment_address?: string | null; stake_address?: string | null; policy_id?: string | null }; error?: string } = await resp.json().catch(() => ({}) as { role?: { id: string; role: 'admin' | 'editor'; principal_type: 'wallet' | 'nft_policy'; wallet_payment_address?: string | null; stake_address?: string | null; policy_id?: string | null }; error?: string });
76+
if (!resp.ok) throw new Error(data?.error || 'Failed to add policy role');
77+
setNewPolicy('');
78+
if (data.role) setRoles((prev) => prev.concat(data.role as { id: string; role: 'admin' | 'editor'; principal_type: 'wallet' | 'nft_policy'; wallet_payment_address?: string | null; stake_address?: string | null; policy_id?: string | null }));
79+
} catch (e) {
80+
setError(e instanceof Error ? e.message : 'Failed to add policy role');
81+
}
82+
}, [project?.id, newPolicy, newRole]);
83+
84+
const removeRole = useCallback(async (r: { role: 'admin' | 'editor'; principal_type: 'wallet' | 'nft_policy'; wallet_payment_address?: string | null; stake_address?: string | null; policy_id?: string | null }) => {
85+
if (!project?.id) return;
86+
try {
87+
const params = new URLSearchParams({ project_id: project.id, role: r.role, principal_type: r.principal_type });
88+
if (r.principal_type === 'wallet') {
89+
params.set('wallet_address', r.stake_address || r.wallet_payment_address || '');
90+
} else {
91+
params.set('policy_id', r.policy_id || '');
92+
}
93+
const resp = await fetch(`/api/projects/roles?${params.toString()}`, { method: 'DELETE' });
94+
if (!resp.ok && resp.status !== 204) {
95+
const data = await resp.json().catch(() => ({}));
96+
throw new Error((data as { error?: string })?.error || 'Failed to remove role');
97+
}
98+
setRoles((prev) => prev.filter((x) => {
99+
const sameRole = x.role === r.role && x.principal_type === r.principal_type;
100+
if (!sameRole) return true;
101+
if (r.principal_type === 'wallet') {
102+
const match = (x.stake_address && r.stake_address && x.stake_address === r.stake_address)
103+
|| (x.wallet_payment_address && r.wallet_payment_address && x.wallet_payment_address === r.wallet_payment_address);
104+
return !match;
105+
} else {
106+
return !(x.policy_id && r.policy_id && x.policy_id === r.policy_id);
107+
}
108+
}));
109+
} catch (e) {
110+
setError(e instanceof Error ? e.message : 'Failed to remove role');
111+
}
112+
}, [project?.id]);
113+
114+
const transferOwner = useCallback(async () => {
115+
if (!project?.id || !ownerWallet.trim()) return;
116+
try {
117+
const resp = await fetch('/api/projects/owner', {
118+
method: 'POST',
119+
headers: { 'Content-Type': 'application/json' },
120+
body: JSON.stringify({ project_id: project.id, new_owner_address: ownerWallet.trim() }),
121+
});
122+
const data = await resp.json().catch(() => ({}));
123+
if (!resp.ok) throw new Error((data as { error?: string })?.error || 'Failed to transfer owner');
124+
setOwnerWallet('');
125+
} catch (e) {
126+
setError(e instanceof Error ? e.message : 'Failed to transfer owner');
127+
}
128+
}, [project?.id, ownerWallet]);
129+
130+
const saveOwnerPolicy = useCallback(async () => {
131+
if (!project?.id) return;
132+
try {
133+
const resp = await fetch('/api/projects', {
134+
method: 'PUT',
135+
headers: { 'Content-Type': 'application/json' },
136+
body: JSON.stringify({ id: project.id, owner_nft_policy_ids: ownerPolicyCsv.split(',').map((s) => s.trim()).filter(Boolean) }),
137+
});
138+
const data = await resp.json().catch(() => ({}));
139+
if (!resp.ok) throw new Error((data as { error?: string })?.error || 'Failed to save owner policy');
140+
} catch (e) {
141+
setError(e instanceof Error ? e.message : 'Failed to save owner policy');
142+
}
143+
}, [project?.id, ownerPolicyCsv]);
144+
145+
return (
146+
<Modal isOpen={isOpen} title={project ? `Editors · ${project.name}` : 'Editors'} onClose={onClose}>
147+
{error && <div className={styles.error}>{error}</div>}
148+
{!canSubmit && (
149+
<div className={styles.muted}>Connect and verify a wallet to manage editors.</div>
150+
)}
151+
{project && (
152+
<div className={styles.grid} style={{ gridColumn: '1 / -1' }}>
153+
<div className={styles.field} style={{ gridColumn: '1 / -1' }}>
154+
<span>Add role by wallet (owner manages):</span>
155+
<div style={{ display: 'flex', gap: 8 }}>
156+
<select value={newRole} onChange={(e) => setNewRole(e.target.value as 'admin' | 'editor')}>
157+
<option value="editor">editor</option>
158+
<option value="admin">admin</option>
159+
</select>
160+
<input value={newWallet} onChange={(e) => setNewWallet(e.target.value)} placeholder="addr... or stake..." />
161+
</div>
162+
<div className={styles.actions}>
163+
<button type="button" className={styles.secondary} onClick={addWalletRole} disabled={!canSubmit}>Add Wallet Role</button>
164+
</div>
165+
</div>
166+
<div className={styles.field} style={{ gridColumn: '1 / -1' }}>
167+
<span>Add role by NFT policy ID:</span>
168+
<div style={{ display: 'flex', gap: 8 }}>
169+
<select value={newRole} onChange={(e) => setNewRole(e.target.value as 'admin' | 'editor')}>
170+
<option value="editor">editor</option>
171+
<option value="admin">admin</option>
172+
</select>
173+
<input value={newPolicy} onChange={(e) => setNewPolicy(e.target.value)} placeholder="policy id (hex)" />
174+
</div>
175+
<div className={styles.actions}>
176+
<button type="button" className={styles.secondary} onClick={addPolicyRole} disabled={!canSubmit}>Add Policy Role</button>
177+
</div>
178+
</div>
179+
<div className={styles.field} style={{ gridColumn: '1 / -1' }}>
180+
<span>Current roles</span>
181+
{roles.length === 0 ? (
182+
<div className={styles.muted}>None</div>
183+
) : (
184+
<ul>
185+
{roles.map((r) => (
186+
<li key={`${r.id}`}>
187+
{r.role} · {r.principal_type === 'wallet' ? formatAddressShort(r.stake_address || r.wallet_payment_address || '') : (r.policy_id || '')}
188+
<button className={styles.secondary} onClick={() => removeRole(r)} disabled={!canSubmit}>Remove</button>
189+
</li>
190+
))}
191+
</ul>
192+
)}
193+
</div>
194+
<div className={styles.field} style={{ gridColumn: '1 / -1' }}>
195+
<span>Transfer ownership to wallet</span>
196+
<input value={ownerWallet} onChange={(e) => setOwnerWallet(e.target.value)} placeholder="addr... or stake..." />
197+
<div className={styles.actions}>
198+
<button type="button" className={styles.secondary} onClick={transferOwner} disabled={!canSubmit}>Transfer Owner</button>
199+
</div>
200+
</div>
201+
<div className={styles.field} style={{ gridColumn: '1 / -1' }}>
202+
<span>Owner NFT policy IDs (comma-separated)</span>
203+
<input value={ownerPolicyCsv} onChange={(e) => setOwnerPolicyCsv(e.target.value)} placeholder="policy ids (hex, comma-separated)" />
204+
<div className={styles.actions}>
205+
<button type="button" className={styles.secondary} onClick={saveOwnerPolicy} disabled={!canSubmit}>Save Owner Policy</button>
206+
</div>
207+
</div>
208+
</div>
209+
)}
210+
</Modal>
211+
);
212+
}
213+
214+
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
.form {
2+
display: flex;
3+
flex-direction: column;
4+
gap: 16px;
5+
}
6+
7+
.grid {
8+
display: grid;
9+
grid-template-columns: repeat(2, minmax(0, 1fr));
10+
gap: 12px 16px;
11+
}
12+
13+
.field {
14+
display: flex;
15+
flex-direction: column;
16+
gap: 6px;
17+
}
18+
19+
.field>span {
20+
font-size: 12px;
21+
color: #999;
22+
}
23+
24+
.field>input,
25+
.field>textarea,
26+
.field>select {
27+
border: 1px solid #333;
28+
background: #111;
29+
color: #eee;
30+
border-radius: 6px;
31+
padding: 8px 10px;
32+
}
33+
34+
.checkbox {
35+
display: flex;
36+
align-items: center;
37+
gap: 8px;
38+
}
39+
40+
.actions {
41+
display: flex;
42+
gap: 8px;
43+
}
44+
45+
.primary {
46+
background: #2563eb;
47+
color: white;
48+
border: none;
49+
border-radius: 6px;
50+
padding: 8px 12px;
51+
cursor: pointer;
52+
}
53+
54+
.secondary {
55+
background: transparent;
56+
color: #eee;
57+
border: 1px solid #444;
58+
border-radius: 6px;
59+
padding: 8px 12px;
60+
cursor: pointer;
61+
}
62+
63+
.error {
64+
color: #ff6b6b;
65+
font-size: 14px;
66+
}
67+
68+
.muted {
69+
color: #aaa;
70+
}
71+
72+
.sectionTitle {
73+
margin: 0 0 8px 0;
74+
font-size: 18px;
75+
}
76+
77+
@media (max-width: 720px) {
78+
.grid {
79+
grid-template-columns: 1fr;
80+
}
81+
}

0 commit comments

Comments
 (0)