mop-agent 0.1.6 → 0.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -5
- package/apps/web/app/api/setup/status/route.ts +9 -1
- package/apps/web/app/assistant/layout.tsx +15 -2
- package/apps/web/app/assistant/page.tsx +75 -109
- package/apps/web/app/brain/[projectId]/page.tsx +1 -1
- package/apps/web/app/brain/graph/page.tsx +1 -1
- package/apps/web/app/brain/layout.tsx +15 -2
- package/apps/web/app/brain/page.tsx +10 -9
- package/apps/web/app/chat/[projectId]/page.tsx +1 -1
- package/apps/web/app/chat/layout.tsx +15 -2
- package/apps/web/app/globals.css +401 -2
- package/apps/web/app/page.tsx +3 -0
- package/apps/web/app/settings/layout.tsx +15 -3
- package/apps/web/app/settings/page.tsx +189 -35
- package/apps/web/app/setup/page.tsx +52 -6
- package/apps/web/app/team/layout.tsx +5 -2
- package/apps/web/app/team/page.tsx +3 -84
- package/apps/web/components/AppShell.tsx +110 -0
- package/apps/web/lib/page-auth.ts +11 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +2 -1
|
@@ -1,63 +1,217 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
|
|
2
|
+
|
|
3
|
+
import type { CSSProperties } from "react";
|
|
3
4
|
import { useEffect, useState } from "react";
|
|
4
5
|
|
|
6
|
+
type Section = "providers" | "users";
|
|
5
7
|
type Masked = { configured: boolean; provider?: string; model?: string | null; keyHint?: string };
|
|
8
|
+
type Member = { id: string; email: string; name: string; role: string };
|
|
9
|
+
type Invite = { email: string; role: string; expiresAt: number; usedAt: number | null };
|
|
6
10
|
|
|
7
11
|
export default function SettingsPage() {
|
|
12
|
+
const [section, setSection] = useState<Section>("providers");
|
|
8
13
|
const [config, setConfig] = useState<Masked>({ configured: false });
|
|
9
14
|
const [env, setEnv] = useState<{ anthropic: boolean; openrouter: boolean }>({ anthropic: false, openrouter: false });
|
|
10
15
|
const [provider, setProvider] = useState<"anthropic" | "openrouter">("openrouter");
|
|
11
16
|
const [apiKey, setApiKey] = useState("");
|
|
12
17
|
const [model, setModel] = useState("");
|
|
13
|
-
const [
|
|
18
|
+
const [providerMsg, setProviderMsg] = useState("");
|
|
19
|
+
const [members, setMembers] = useState<Member[]>([]);
|
|
20
|
+
const [invites, setInvites] = useState<Invite[]>([]);
|
|
21
|
+
const [email, setEmail] = useState("");
|
|
22
|
+
const [role, setRole] = useState<"member" | "owner">("member");
|
|
23
|
+
const [userMsg, setUserMsg] = useState("");
|
|
24
|
+
|
|
25
|
+
function loadProvider() {
|
|
26
|
+
fetch("/api/providers").then((r) => r.json()).then((data) => {
|
|
27
|
+
setConfig(data.config ?? { configured: false });
|
|
28
|
+
setEnv(data.env ?? { anthropic: false, openrouter: false });
|
|
29
|
+
}).catch(() => {});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function loadUsers() {
|
|
33
|
+
fetch("/api/members").then((r) => (r.ok ? r.json() : { members: [] })).then((data) => setMembers(data.members ?? [])).catch(() => {});
|
|
34
|
+
fetch("/api/invites").then((r) => (r.ok ? r.json() : { invites: [] })).then((data) => setInvites(data.invites ?? [])).catch(() => {});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
const requested = new URLSearchParams(window.location.search).get("section");
|
|
39
|
+
if (requested === "users") setSection("users");
|
|
40
|
+
loadProvider();
|
|
41
|
+
loadUsers();
|
|
42
|
+
}, []);
|
|
14
43
|
|
|
15
|
-
function
|
|
16
|
-
|
|
44
|
+
function chooseSection(next: Section) {
|
|
45
|
+
setSection(next);
|
|
46
|
+
const url = next === "providers" ? "/settings" : "/settings?section=users";
|
|
47
|
+
window.history.replaceState(null, "", url);
|
|
17
48
|
}
|
|
18
|
-
useEffect(load, []);
|
|
19
49
|
|
|
20
|
-
async function
|
|
50
|
+
async function saveProvider(e: React.FormEvent) {
|
|
21
51
|
e.preventDefault();
|
|
22
|
-
|
|
23
|
-
const
|
|
52
|
+
setProviderMsg("Saving…");
|
|
53
|
+
const response = await fetch("/api/providers", {
|
|
24
54
|
method: "POST",
|
|
25
55
|
headers: { "content-type": "application/json" },
|
|
26
56
|
body: JSON.stringify({ provider, apiKey, model: model || undefined }),
|
|
27
57
|
});
|
|
28
|
-
const
|
|
29
|
-
|
|
58
|
+
const data = await response.json();
|
|
59
|
+
setProviderMsg(response.ok ? "Provider saved. The API key is encrypted." : `Unable to save: ${data.error}`);
|
|
30
60
|
setApiKey("");
|
|
31
|
-
if (
|
|
61
|
+
if (response.ok) setConfig(data.config);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function invite(e: React.FormEvent) {
|
|
65
|
+
e.preventDefault();
|
|
66
|
+
setUserMsg("Creating invite…");
|
|
67
|
+
const response = await fetch("/api/invites", {
|
|
68
|
+
method: "POST",
|
|
69
|
+
headers: { "content-type": "application/json" },
|
|
70
|
+
body: JSON.stringify({ email, role }),
|
|
71
|
+
});
|
|
72
|
+
setUserMsg(response.ok ? `Invite created for ${email}.` : `Unable to invite (error ${response.status}).`);
|
|
73
|
+
if (response.ok) setEmail("");
|
|
74
|
+
loadUsers();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function revoke(inviteEmail: string) {
|
|
78
|
+
await fetch(`/api/invites?email=${encodeURIComponent(inviteEmail)}`, { method: "DELETE" });
|
|
79
|
+
loadUsers();
|
|
32
80
|
}
|
|
33
81
|
|
|
34
82
|
return (
|
|
35
|
-
<
|
|
36
|
-
<
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
? <>Active: <strong>{config.provider}</strong>{config.model ? ` · ${config.model}` : ""} · key {config.keyHint}</>
|
|
42
|
-
: "No provider key saved yet — chat uses the offline echo provider."}
|
|
43
|
-
<div style={{ fontSize: 12, opacity: 0.6, marginTop: 6 }}>
|
|
44
|
-
env keys: anthropic {env.anthropic ? "✓" : "—"} · openrouter {env.openrouter ? "✓" : "—"} (DB config overrides env)
|
|
83
|
+
<div className="mop-page">
|
|
84
|
+
<header className="mop-page-heading">
|
|
85
|
+
<div>
|
|
86
|
+
<p className="mop-page-kicker">ADMIN CONTROL</p>
|
|
87
|
+
<h1>Settings</h1>
|
|
88
|
+
<p>Configure system-wide AI providers and user access.</p>
|
|
45
89
|
</div>
|
|
46
|
-
|
|
90
|
+
<span style={adminBadge}>ADMIN ONLY</span>
|
|
91
|
+
</header>
|
|
92
|
+
|
|
93
|
+
<div className="mop-settings-grid">
|
|
94
|
+
<aside className="mop-settings-nav mop-panel" aria-label="Settings sections">
|
|
95
|
+
<button className={section === "providers" ? "is-active" : ""} onClick={() => chooseSection("providers")}>
|
|
96
|
+
<span>◇</span><strong>Providers</strong>
|
|
97
|
+
</button>
|
|
98
|
+
<button className={section === "users" ? "is-active" : ""} onClick={() => chooseSection("users")}>
|
|
99
|
+
<span>♙</span><strong>Users</strong>
|
|
100
|
+
</button>
|
|
101
|
+
</aside>
|
|
102
|
+
|
|
103
|
+
<section className="mop-settings-content mop-panel">
|
|
104
|
+
{section === "providers" ? (
|
|
105
|
+
<>
|
|
106
|
+
<div style={sectionHeading}>
|
|
107
|
+
<div>
|
|
108
|
+
<p className="mop-page-kicker">AI CONNECTION</p>
|
|
109
|
+
<h2 style={titleStyle}>Providers</h2>
|
|
110
|
+
</div>
|
|
111
|
+
<span style={{ ...statusBadge, color: config.configured ? "#2d4a3e" : "#742220" }}>
|
|
112
|
+
{config.configured ? "● CONNECTED" : "● OFFLINE DEMO"}
|
|
113
|
+
</span>
|
|
114
|
+
</div>
|
|
47
115
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
116
|
+
<div style={summaryCard}>
|
|
117
|
+
{config.configured ? (
|
|
118
|
+
<><strong>{config.provider}</strong>{config.model ? ` · ${config.model}` : ""}<span style={muted}> · key {config.keyHint}</span></>
|
|
119
|
+
) : (
|
|
120
|
+
<>No provider key saved. Assistant currently uses the offline echo provider.</>
|
|
121
|
+
)}
|
|
122
|
+
<div style={{ ...muted, marginTop: 7, fontSize: 12 }}>
|
|
123
|
+
Environment: Anthropic {env.anthropic ? "available" : "not set"} · OpenRouter {env.openrouter ? "available" : "not set"}
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
|
|
127
|
+
<form onSubmit={saveProvider} style={formGrid}>
|
|
128
|
+
<label style={labelStyle}>Provider
|
|
129
|
+
<select value={provider} onChange={(e) => setProvider(e.target.value as "anthropic" | "openrouter")} style={inputStyle}>
|
|
130
|
+
<option value="openrouter">OpenRouter</option>
|
|
131
|
+
<option value="anthropic">Anthropic</option>
|
|
132
|
+
</select>
|
|
133
|
+
</label>
|
|
134
|
+
<label style={labelStyle}>API key
|
|
135
|
+
<input placeholder="Paste a new API key" type="password" value={apiKey} onChange={(e) => setApiKey(e.target.value)} required style={inputStyle} />
|
|
136
|
+
</label>
|
|
137
|
+
<label style={labelStyle}>Model
|
|
138
|
+
<input placeholder={provider === "anthropic" ? "claude-sonnet-4-6" : "anthropic/claude-sonnet-4.6"} value={model} onChange={(e) => setModel(e.target.value)} style={inputStyle} />
|
|
139
|
+
</label>
|
|
140
|
+
<button type="submit" style={primaryButton}>SAVE PROVIDER</button>
|
|
141
|
+
</form>
|
|
142
|
+
{providerMsg && <p style={messageStyle}>{providerMsg}</p>}
|
|
143
|
+
</>
|
|
144
|
+
) : (
|
|
145
|
+
<>
|
|
146
|
+
<div style={sectionHeading}>
|
|
147
|
+
<div>
|
|
148
|
+
<p className="mop-page-kicker">ACCESS CONTROL</p>
|
|
149
|
+
<h2 style={titleStyle}>Users</h2>
|
|
150
|
+
</div>
|
|
151
|
+
<span style={statusBadge}>{members.length} ACCOUNTS</span>
|
|
152
|
+
</div>
|
|
153
|
+
|
|
154
|
+
<div style={{ overflowX: "auto" }}>
|
|
155
|
+
<table style={tableStyle}>
|
|
156
|
+
<thead><tr><th>User</th><th>Email</th><th>Role</th></tr></thead>
|
|
157
|
+
<tbody>
|
|
158
|
+
{members.map((member) => (
|
|
159
|
+
<tr key={member.id}>
|
|
160
|
+
<td><span style={miniAvatar}>{(member.name || member.email).slice(0, 1).toUpperCase()}</span>{member.name || "Unnamed"}</td>
|
|
161
|
+
<td>{member.email}</td>
|
|
162
|
+
<td><span style={member.role === "owner" ? ownerRole : memberRole}>{member.role === "owner" ? "ADMIN" : "MEMBER"}</span></td>
|
|
163
|
+
</tr>
|
|
164
|
+
))}
|
|
165
|
+
</tbody>
|
|
166
|
+
</table>
|
|
167
|
+
</div>
|
|
168
|
+
|
|
169
|
+
<div style={invitePanel}>
|
|
170
|
+
<h3 style={{ margin: 0, fontSize: 15 }}>Invite a user</h3>
|
|
171
|
+
<form onSubmit={invite} style={{ display: "grid", gridTemplateColumns: "minmax(180px,1fr) 130px auto", gap: 9, marginTop: 13 }} className="mop-user-invite-form">
|
|
172
|
+
<input placeholder="user@example.com" type="email" required value={email} onChange={(e) => setEmail(e.target.value)} style={inputStyle} />
|
|
173
|
+
<select value={role} onChange={(e) => setRole(e.target.value as "member" | "owner")} style={inputStyle}>
|
|
174
|
+
<option value="member">Member</option>
|
|
175
|
+
<option value="owner">Admin</option>
|
|
176
|
+
</select>
|
|
177
|
+
<button type="submit" style={primaryButton}>CREATE INVITE</button>
|
|
178
|
+
</form>
|
|
179
|
+
{userMsg && <p style={messageStyle}>{userMsg}</p>}
|
|
180
|
+
</div>
|
|
181
|
+
|
|
182
|
+
<h3 style={{ margin: "26px 0 10px", fontSize: 14 }}>Pending invites</h3>
|
|
183
|
+
<div style={{ display: "grid", gap: 7 }}>
|
|
184
|
+
{invites.filter((item) => !item.usedAt).map((item) => (
|
|
185
|
+
<div key={item.email} style={inviteRow}>
|
|
186
|
+
<span><strong>{item.email}</strong><small style={{ ...muted, marginLeft: 8 }}>{item.role === "owner" ? "admin" : "member"}</small></span>
|
|
187
|
+
<button onClick={() => revoke(item.email)} style={secondaryButton}>REVOKE</button>
|
|
188
|
+
</div>
|
|
189
|
+
))}
|
|
190
|
+
{invites.filter((item) => !item.usedAt).length === 0 && <p style={muted}>No pending invites.</p>}
|
|
191
|
+
</div>
|
|
192
|
+
</>
|
|
193
|
+
)}
|
|
194
|
+
</section>
|
|
195
|
+
</div>
|
|
196
|
+
</div>
|
|
59
197
|
);
|
|
60
198
|
}
|
|
61
199
|
|
|
62
|
-
const
|
|
63
|
-
const
|
|
200
|
+
const adminBadge: CSSProperties = { padding: "7px 10px", color: "#fef9e1", background: "#742220", fontFamily: '"SFMono-Regular", Consolas, monospace', fontSize: 10, fontWeight: 900, letterSpacing: ".12em" };
|
|
201
|
+
const sectionHeading: CSSProperties = { display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12, marginBottom: 20, paddingBottom: 15, borderBottom: "1px solid rgba(45,74,62,.24)" };
|
|
202
|
+
const titleStyle: CSSProperties = { margin: 0, fontFamily: '"SFMono-Regular", Consolas, monospace', fontSize: 22 };
|
|
203
|
+
const statusBadge: CSSProperties = { padding: "6px 8px", border: "1px solid rgba(45,74,62,.32)", color: "#2d4a3e", fontFamily: '"SFMono-Regular", Consolas, monospace', fontSize: 9, fontWeight: 900, letterSpacing: ".1em" };
|
|
204
|
+
const summaryCard: CSSProperties = { padding: 15, border: "1px solid rgba(45,74,62,.24)", background: "rgba(254,249,225,.72)", lineHeight: 1.5 };
|
|
205
|
+
const muted: CSSProperties = { color: "rgba(45,74,62,.58)" };
|
|
206
|
+
const formGrid: CSSProperties = { display: "grid", gap: 13, marginTop: 20 };
|
|
207
|
+
const labelStyle: CSSProperties = { display: "grid", gap: 6, color: "#742220", fontFamily: '"SFMono-Regular", Consolas, monospace', fontSize: 11, fontWeight: 800, letterSpacing: ".07em", textTransform: "uppercase" };
|
|
208
|
+
const inputStyle: CSSProperties = { width: "100%", minHeight: 40, padding: "9px 11px", border: "1px solid rgba(45,74,62,.4)", background: "#fffdf2", color: "#2d4a3e" };
|
|
209
|
+
const primaryButton: CSSProperties = { minHeight: 40, padding: "9px 15px", border: "1px solid #742220", background: "#742220", color: "#fef9e1", fontFamily: '"SFMono-Regular", Consolas, monospace', fontSize: 10, fontWeight: 900, cursor: "pointer" };
|
|
210
|
+
const secondaryButton: CSSProperties = { padding: "6px 10px", border: "1px solid #742220", background: "transparent", color: "#742220", fontSize: 9, fontWeight: 900, cursor: "pointer" };
|
|
211
|
+
const messageStyle: CSSProperties = { padding: "9px 11px", borderLeft: "3px solid #742220", background: "rgba(116,34,32,.06)", fontSize: 13 };
|
|
212
|
+
const tableStyle: CSSProperties = { width: "100%", borderCollapse: "collapse", fontSize: 13 };
|
|
213
|
+
const miniAvatar: CSSProperties = { width: 28, height: 28, display: "inline-grid", placeItems: "center", marginRight: 9, background: "#2d4a3e", color: "#fef9e1", fontWeight: 900 };
|
|
214
|
+
const ownerRole: CSSProperties = { padding: "4px 7px", background: "#742220", color: "#fef9e1", fontSize: 9, fontWeight: 900 };
|
|
215
|
+
const memberRole: CSSProperties = { ...ownerRole, color: "#2d4a3e", background: "rgba(45,74,62,.12)" };
|
|
216
|
+
const invitePanel: CSSProperties = { marginTop: 26, padding: 16, border: "1px solid rgba(45,74,62,.27)", background: "rgba(254,249,225,.55)" };
|
|
217
|
+
const inviteRow: CSSProperties = { display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12, padding: 10, border: "1px solid rgba(45,74,62,.22)" };
|
|
@@ -3,6 +3,30 @@
|
|
|
3
3
|
import { useEffect, useState } from "react";
|
|
4
4
|
import { signIn, signUp } from "@/lib/auth-client";
|
|
5
5
|
|
|
6
|
+
type SetupStatus = { ownerExists: boolean; authenticated: boolean };
|
|
7
|
+
|
|
8
|
+
async function getSetupStatus(): Promise<SetupStatus> {
|
|
9
|
+
const response = await fetch(`/api/setup/status?t=${Date.now()}`, {
|
|
10
|
+
cache: "no-store",
|
|
11
|
+
credentials: "include",
|
|
12
|
+
headers: { "cache-control": "no-cache" },
|
|
13
|
+
});
|
|
14
|
+
if (!response.ok) throw new Error(`setup status ${response.status}`);
|
|
15
|
+
return response.json() as Promise<SetupStatus>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function waitForSession(): Promise<boolean> {
|
|
19
|
+
for (let attempt = 0; attempt < 5; attempt += 1) {
|
|
20
|
+
try {
|
|
21
|
+
if ((await getSetupStatus()).authenticated) return true;
|
|
22
|
+
} catch {
|
|
23
|
+
// A short deployment/network gap should not strand the form in busy mode.
|
|
24
|
+
}
|
|
25
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
26
|
+
}
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
|
|
6
30
|
export default function SetupPage() {
|
|
7
31
|
const [ownerExists, setOwnerExists] = useState<boolean | null>(null);
|
|
8
32
|
const [email, setEmail] = useState("");
|
|
@@ -13,11 +37,10 @@ export default function SetupPage() {
|
|
|
13
37
|
const [mode, setMode] = useState<"signup" | "signin">("signup");
|
|
14
38
|
|
|
15
39
|
useEffect(() => {
|
|
16
|
-
|
|
17
|
-
.then((r) => r.json() as Promise<{ ownerExists: boolean; authenticated: boolean }>)
|
|
40
|
+
getSetupStatus()
|
|
18
41
|
.then((status) => {
|
|
19
42
|
if (status.authenticated) {
|
|
20
|
-
window.location.replace(
|
|
43
|
+
window.location.replace(`/assistant?auth=${Date.now()}`);
|
|
21
44
|
return;
|
|
22
45
|
}
|
|
23
46
|
setOwnerExists(status.ownerExists);
|
|
@@ -38,8 +61,26 @@ export default function SetupPage() {
|
|
|
38
61
|
setBusy(false);
|
|
39
62
|
return;
|
|
40
63
|
}
|
|
41
|
-
//
|
|
42
|
-
|
|
64
|
+
// Verify the cookie before entering a server-protected route. If the
|
|
65
|
+
// signup response did not establish it, explicitly sign in once.
|
|
66
|
+
if (!(await waitForSession())) {
|
|
67
|
+
const login = await signIn.email({ email, password });
|
|
68
|
+
if (login.error) {
|
|
69
|
+
setMode("signin");
|
|
70
|
+
setOwnerExists(true);
|
|
71
|
+
setMsg(login.error.message ?? "Admin created, but sign in failed. Please sign in below.");
|
|
72
|
+
setBusy(false);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (await waitForSession()) {
|
|
77
|
+
window.location.replace(`/assistant?auth=${Date.now()}`);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
setMode("signin");
|
|
81
|
+
setOwnerExists(true);
|
|
82
|
+
setMsg("Admin created, but the session cookie was not accepted. Please sign in again.");
|
|
83
|
+
setBusy(false);
|
|
43
84
|
return;
|
|
44
85
|
}
|
|
45
86
|
|
|
@@ -49,7 +90,12 @@ export default function SetupPage() {
|
|
|
49
90
|
setBusy(false);
|
|
50
91
|
return;
|
|
51
92
|
}
|
|
52
|
-
|
|
93
|
+
if (await waitForSession()) {
|
|
94
|
+
window.location.replace(`/assistant?auth=${Date.now()}`);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
setMsg("Sign in succeeded, but the session cookie was not accepted. Check that you are using the configured HTTPS domain.");
|
|
98
|
+
setBusy(false);
|
|
53
99
|
}
|
|
54
100
|
|
|
55
101
|
const loading = ownerExists === null && !msg;
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import type { ReactNode } from "react";
|
|
2
|
-
import {
|
|
2
|
+
import { requireOwnerPage } from "@/lib/page-auth";
|
|
3
|
+
|
|
4
|
+
export const dynamic = "force-dynamic";
|
|
5
|
+
export const revalidate = 0;
|
|
3
6
|
|
|
4
7
|
export default async function TeamLayout({ children }: { children: ReactNode }) {
|
|
5
|
-
await
|
|
8
|
+
await requireOwnerPage();
|
|
6
9
|
return children;
|
|
7
10
|
}
|
|
@@ -1,86 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
/** Team — your role, members, and (owner) invite management. */
|
|
3
|
-
import { useEffect, useState } from "react";
|
|
1
|
+
import { redirect } from "next/navigation";
|
|
4
2
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
type Invite = { email: string; role: string; expiresAt: number; usedAt: number | null };
|
|
8
|
-
|
|
9
|
-
export default function TeamPage() {
|
|
10
|
-
const [me, setMe] = useState<Me | null>(null);
|
|
11
|
-
const [members, setMembers] = useState<Member[]>([]);
|
|
12
|
-
const [invites, setInvites] = useState<Invite[]>([]);
|
|
13
|
-
const [email, setEmail] = useState("");
|
|
14
|
-
const [role, setRole] = useState<"member" | "owner">("member");
|
|
15
|
-
const [msg, setMsg] = useState("");
|
|
16
|
-
|
|
17
|
-
const isOwner = me?.role === "owner";
|
|
18
|
-
|
|
19
|
-
function load() {
|
|
20
|
-
fetch("/api/me").then((r) => r.json()).then(setMe).catch(() => {});
|
|
21
|
-
fetch("/api/members").then((r) => (r.ok ? r.json() : { members: [] })).then((d) => setMembers(d.members ?? [])).catch(() => {});
|
|
22
|
-
fetch("/api/invites").then((r) => (r.ok ? r.json() : { invites: [] })).then((d) => setInvites(d.invites ?? [])).catch(() => {});
|
|
23
|
-
}
|
|
24
|
-
useEffect(load, []);
|
|
25
|
-
|
|
26
|
-
async function invite(e: React.FormEvent) {
|
|
27
|
-
e.preventDefault();
|
|
28
|
-
setMsg("…");
|
|
29
|
-
const r = await fetch("/api/invites", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ email, role }) });
|
|
30
|
-
setMsg(r.ok ? `✅ invited ${email}` : `error ${r.status}`);
|
|
31
|
-
setEmail("");
|
|
32
|
-
load();
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
async function revoke(em: string) {
|
|
36
|
-
await fetch(`/api/invites?email=${encodeURIComponent(em)}`, { method: "DELETE" });
|
|
37
|
-
load();
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
return (
|
|
41
|
-
<main style={{ maxWidth: 640, margin: "0 auto", padding: "40px 24px" }}>
|
|
42
|
-
<a href="/brain" style={{ color: "#742220" }}>← Brain</a>
|
|
43
|
-
<h1 style={{ fontSize: 24 }}>👥 Team</h1>
|
|
44
|
-
<p style={{ opacity: 0.7 }}>You are <strong>{me?.user.email}</strong> · role <strong>{me?.role}</strong></p>
|
|
45
|
-
|
|
46
|
-
<h2 style={{ fontSize: 16, marginTop: 20 }}>Members ({members.length})</h2>
|
|
47
|
-
<ul style={{ listStyle: "none", padding: 0 }}>
|
|
48
|
-
{members.map((m) => (
|
|
49
|
-
<li key={m.id} style={row}>{m.role === "owner" ? "👑" : "👤"} {m.email} <span style={{ opacity: 0.5 }}>· {m.role}</span></li>
|
|
50
|
-
))}
|
|
51
|
-
{!isOwner && members.length === 0 && <p style={{ opacity: 0.5 }}>Owner-only view.</p>}
|
|
52
|
-
</ul>
|
|
53
|
-
|
|
54
|
-
{isOwner && (
|
|
55
|
-
<>
|
|
56
|
-
<h2 style={{ fontSize: 16, marginTop: 20 }}>Invite a member</h2>
|
|
57
|
-
<form onSubmit={invite} style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
|
58
|
-
<input placeholder="email" type="email" required value={email} onChange={(e) => setEmail(e.target.value)} style={inp} />
|
|
59
|
-
<select value={role} onChange={(e) => setRole(e.target.value as "member" | "owner")} style={inp}>
|
|
60
|
-
<option value="member">member</option>
|
|
61
|
-
<option value="owner">owner</option>
|
|
62
|
-
</select>
|
|
63
|
-
<button type="submit" style={btn}>Invite</button>
|
|
64
|
-
</form>
|
|
65
|
-
{msg && <p style={{ opacity: 0.8 }}>{msg}</p>}
|
|
66
|
-
|
|
67
|
-
<h2 style={{ fontSize: 16, marginTop: 20 }}>Pending invites</h2>
|
|
68
|
-
<ul style={{ listStyle: "none", padding: 0 }}>
|
|
69
|
-
{invites.filter((i) => !i.usedAt).map((i) => (
|
|
70
|
-
<li key={i.email} style={row}>
|
|
71
|
-
✉️ {i.email} <span style={{ opacity: 0.5 }}>· {i.role}</span>
|
|
72
|
-
<button onClick={() => revoke(i.email)} style={{ float: "right", ...btn, background: "#742220", borderColor: "#742220", padding: "2px 10px" }}>revoke</button>
|
|
73
|
-
</li>
|
|
74
|
-
))}
|
|
75
|
-
{invites.filter((i) => !i.usedAt).length === 0 && <p style={{ opacity: 0.5 }}>None.</p>}
|
|
76
|
-
</ul>
|
|
77
|
-
<p style={{ opacity: 0.55, fontSize: 13 }}>Invited people sign up at <code>/setup</code> with that exact email.</p>
|
|
78
|
-
</>
|
|
79
|
-
)}
|
|
80
|
-
</main>
|
|
81
|
-
);
|
|
3
|
+
export default function LegacyTeamPage() {
|
|
4
|
+
redirect("/settings?section=users");
|
|
82
5
|
}
|
|
83
|
-
|
|
84
|
-
const row: React.CSSProperties = { border: "1px solid rgba(45,74,62,.28)", borderRadius: 8, padding: "8px 12px", marginBottom: 6, background: "#fffdf2" };
|
|
85
|
-
const inp: React.CSSProperties = { padding: "8px 10px", borderRadius: 8, border: "1px solid rgba(45,74,62,.32)", background: "#fffdf2", color: "#2d4a3e" };
|
|
86
|
-
const btn: React.CSSProperties = { padding: "8px 14px", borderRadius: 8, border: "1px solid #742220", background: "#742220", color: "#fef9e1", cursor: "pointer" };
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { ReactNode } from "react";
|
|
4
|
+
import { usePathname } from "next/navigation";
|
|
5
|
+
import { useState } from "react";
|
|
6
|
+
import { signOut } from "@/lib/auth-client";
|
|
7
|
+
|
|
8
|
+
export type AppViewer = {
|
|
9
|
+
name: string;
|
|
10
|
+
email: string;
|
|
11
|
+
role: "owner" | "member";
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
function pageTitle(pathname: string): string {
|
|
15
|
+
if (pathname === "/assistant") return "Assistant";
|
|
16
|
+
if (pathname === "/brain/graph") return "Knowledge Graph";
|
|
17
|
+
if (pathname.startsWith("/brain/")) return "Project Brain";
|
|
18
|
+
if (pathname.startsWith("/brain")) return "Brain";
|
|
19
|
+
if (pathname.startsWith("/chat/")) return "Project Chat";
|
|
20
|
+
if (pathname.startsWith("/settings")) return "Settings";
|
|
21
|
+
return "MOP-AGENT";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function AppShell({ viewer, children }: { viewer: AppViewer; children: ReactNode }) {
|
|
25
|
+
const pathname = usePathname();
|
|
26
|
+
const [menuOpen, setMenuOpen] = useState(false);
|
|
27
|
+
const isAdmin = viewer.role === "owner";
|
|
28
|
+
const title = pageTitle(pathname);
|
|
29
|
+
|
|
30
|
+
async function logout() {
|
|
31
|
+
await signOut();
|
|
32
|
+
window.location.replace("/setup");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const nav = [
|
|
36
|
+
{ href: "/assistant", label: "Assistant", icon: "✦", active: pathname.startsWith("/assistant") || pathname.startsWith("/chat/") },
|
|
37
|
+
{ href: "/brain", label: "Brain", icon: "◉", active: pathname.startsWith("/brain") },
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<div className="mop-app-frame">
|
|
42
|
+
<header className="mop-app-topbar">
|
|
43
|
+
<a className="mop-app-brand" href="/assistant" aria-label="MOP-AGENT home">
|
|
44
|
+
<img src="/icon.svg" alt="" />
|
|
45
|
+
<span>MOP-AGENT</span>
|
|
46
|
+
</a>
|
|
47
|
+
<div className="mop-app-topbar-main">
|
|
48
|
+
<button
|
|
49
|
+
className="mop-menu-toggle"
|
|
50
|
+
type="button"
|
|
51
|
+
aria-label="Toggle navigation"
|
|
52
|
+
aria-expanded={menuOpen}
|
|
53
|
+
onClick={() => setMenuOpen((open) => !open)}
|
|
54
|
+
>
|
|
55
|
+
☰
|
|
56
|
+
</button>
|
|
57
|
+
<div className="mop-topbar-title">
|
|
58
|
+
<span className="mop-live-dot" />
|
|
59
|
+
<strong>{title}</strong>
|
|
60
|
+
</div>
|
|
61
|
+
<div className="mop-topbar-center">MOP MEMORYCORE</div>
|
|
62
|
+
<div className="mop-topbar-meta">
|
|
63
|
+
<span>{isAdmin ? "ADMIN" : "MEMBER"}</span>
|
|
64
|
+
<span className="mop-version">v0.1.8</span>
|
|
65
|
+
</div>
|
|
66
|
+
</div>
|
|
67
|
+
</header>
|
|
68
|
+
|
|
69
|
+
{menuOpen && <button className="mop-sidebar-scrim" aria-label="Close navigation" onClick={() => setMenuOpen(false)} />}
|
|
70
|
+
|
|
71
|
+
<aside className={`mop-app-sidebar${menuOpen ? " is-open" : ""}`}>
|
|
72
|
+
<div className="mop-nav-section">
|
|
73
|
+
<p>WORKSPACE</p>
|
|
74
|
+
<nav>
|
|
75
|
+
{nav.map((item) => (
|
|
76
|
+
<a key={item.href} href={item.href} className={item.active ? "is-active" : ""} onClick={() => setMenuOpen(false)}>
|
|
77
|
+
<span className="mop-nav-icon">{item.icon}</span>
|
|
78
|
+
<span>{item.label}</span>
|
|
79
|
+
</a>
|
|
80
|
+
))}
|
|
81
|
+
</nav>
|
|
82
|
+
</div>
|
|
83
|
+
|
|
84
|
+
{isAdmin && (
|
|
85
|
+
<div className="mop-nav-section">
|
|
86
|
+
<p>ADMIN</p>
|
|
87
|
+
<nav>
|
|
88
|
+
<a href="/settings" className={pathname.startsWith("/settings") ? "is-active" : ""} onClick={() => setMenuOpen(false)}>
|
|
89
|
+
<span className="mop-nav-icon">⚙</span>
|
|
90
|
+
<span>Settings</span>
|
|
91
|
+
</a>
|
|
92
|
+
</nav>
|
|
93
|
+
</div>
|
|
94
|
+
)}
|
|
95
|
+
|
|
96
|
+
<div className="mop-sidebar-spacer" />
|
|
97
|
+
<button className="mop-account-card" type="button" onClick={logout} title="Sign out">
|
|
98
|
+
<span className="mop-account-avatar">{viewer.name.slice(0, 1).toUpperCase()}</span>
|
|
99
|
+
<span className="mop-account-copy">
|
|
100
|
+
<strong>{viewer.name}</strong>
|
|
101
|
+
<small>{isAdmin ? "Administrator" : "Member"}</small>
|
|
102
|
+
</span>
|
|
103
|
+
<span aria-hidden="true">↪</span>
|
|
104
|
+
</button>
|
|
105
|
+
</aside>
|
|
106
|
+
|
|
107
|
+
<main className="mop-app-main">{children}</main>
|
|
108
|
+
</div>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
@@ -1,12 +1,22 @@
|
|
|
1
|
-
import { auth, ownerExists } from "@/lib/auth";
|
|
1
|
+
import { auth, getRole, ownerExists } from "@/lib/auth";
|
|
2
2
|
import { headers } from "next/headers";
|
|
3
3
|
import { redirect } from "next/navigation";
|
|
4
|
+
import { unstable_noStore as noStore } from "next/cache";
|
|
4
5
|
|
|
5
6
|
/** Server-side guard for authenticated application pages. */
|
|
6
7
|
export async function requirePageSession() {
|
|
8
|
+
// Auth redirects are cookie-specific and must never enter Next's route cache.
|
|
9
|
+
noStore();
|
|
7
10
|
if (!ownerExists()) redirect("/setup");
|
|
8
11
|
|
|
9
12
|
const session = await auth.api.getSession({ headers: await headers() });
|
|
10
13
|
if (!session) redirect("/setup");
|
|
11
14
|
return session;
|
|
12
15
|
}
|
|
16
|
+
|
|
17
|
+
/** Server-side guard for pages that expose installation-wide administration. */
|
|
18
|
+
export async function requireOwnerPage() {
|
|
19
|
+
const session = await requirePageSession();
|
|
20
|
+
if (getRole(session.user.id) !== "owner") redirect("/assistant");
|
|
21
|
+
return session;
|
|
22
|
+
}
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mop-agent",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "mop-agent",
|
|
9
|
-
"version": "0.1.
|
|
9
|
+
"version": "0.1.8",
|
|
10
10
|
"license": "UNLICENSED",
|
|
11
11
|
"workspaces": [
|
|
12
12
|
"packages/*",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mop-agent",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
4
4
|
"description": "Self-hosted AI assistant with persistent cross-project memory, installed with npx mop-agent.",
|
|
5
5
|
"author": "BURHANDEV ENTERPRISE",
|
|
6
6
|
"license": "UNLICENSED",
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
"files": [
|
|
34
34
|
"apps/web/app",
|
|
35
35
|
"apps/web/bin",
|
|
36
|
+
"apps/web/components",
|
|
36
37
|
"apps/web/lib",
|
|
37
38
|
"apps/web/scripts",
|
|
38
39
|
"apps/web/.env.example",
|