mop-agent 0.1.8 → 0.1.10
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 +11 -7
- package/apps/web/app/api/members/route.ts +39 -1
- package/apps/web/app/assistant/page.tsx +5 -29
- package/apps/web/app/globals.css +63 -1
- package/apps/web/app/login/layout.tsx +16 -0
- package/apps/web/app/login/page.tsx +83 -0
- package/apps/web/app/settings/page.tsx +23 -50
- package/apps/web/app/setup/layout.tsx +15 -0
- package/apps/web/app/setup/page.tsx +28 -91
- package/apps/web/components/AppShell.tsx +166 -69
- package/apps/web/lib/page-auth.ts +1 -1
- package/installer/mop-agent.mjs +62 -3
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -5,8 +5,8 @@ through MOP-FLOW. It stores project memory, performs semantic recall and
|
|
|
5
5
|
consolidation, serves grounded chat, and can request approved actions from a
|
|
6
6
|
linked FLOW node.
|
|
7
7
|
|
|
8
|
-
> **Release status:** npm package `mop-agent@0.1.
|
|
9
|
-
> installer,
|
|
8
|
+
> **Release status:** npm package `mop-agent@0.1.10` contains the corrected VPS
|
|
9
|
+
> installer, one-time Admin setup/login flow, and shared retro application shell.
|
|
10
10
|
> The canonical installation command is exactly `npx mop-agent`.
|
|
11
11
|
|
|
12
12
|
## Current status
|
|
@@ -55,7 +55,8 @@ The first run copies the npm-packaged runtime from the temporary npx cache into
|
|
|
55
55
|
|
|
56
56
|
- `Install` — installs nginx/Certbot and immediately continues through the
|
|
57
57
|
complete domain, SQLite, HTTPS, and systemd setup.
|
|
58
|
-
- `Update` —
|
|
58
|
+
- `Update` — migrates/builds/restarts MOP-AGENT, restores the installer-owned
|
|
59
|
+
nginx vhost, reloads nginx, and verifies both local and domain proxy health.
|
|
59
60
|
- `Status` — reports service health and filesystem locations.
|
|
60
61
|
- `Delete` — removes the service and nginx configuration while preserving data
|
|
61
62
|
unless purge is explicitly requested.
|
|
@@ -63,12 +64,15 @@ The first run copies the npm-packaged runtime from the temporary npx cache into
|
|
|
63
64
|
After installation, open the configured URL in a browser. The application flow
|
|
64
65
|
is intentionally separate from the server installer:
|
|
65
66
|
|
|
66
|
-
1. On a fresh database,
|
|
67
|
+
1. On a fresh database, `/setup` shows **Create Admin account** once.
|
|
67
68
|
2. Creating the first Admin also signs that account in.
|
|
68
|
-
3.
|
|
69
|
-
|
|
69
|
+
3. After an Admin exists, `/setup` always redirects to `/login` when signed out
|
|
70
|
+
or `/assistant` when already signed in.
|
|
71
|
+
4. Admin creates ready-to-login accounts under **Settings → Users**; there is
|
|
72
|
+
no public invited-account signup link.
|
|
73
|
+
5. Successful setup/login opens the main **Assistant**. It can be used before
|
|
70
74
|
any project is linked; Brain is the optional memory/project control surface.
|
|
71
|
-
|
|
75
|
+
6. Add OpenRouter or Anthropic under **Settings → Providers** for full model responses.
|
|
72
76
|
Until then, the built-in offline echo provider confirms the chat pipeline.
|
|
73
77
|
|
|
74
78
|
During `setup`, choose one deployment mode:
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
/** GET /api/members — list users
|
|
1
|
+
/** GET/POST /api/members — list users or create a login directly (owner). */
|
|
2
|
+
import { auth } from "@/lib/auth";
|
|
2
3
|
import { getSqlite } from "@/lib/db/client";
|
|
3
4
|
import { requireRole } from "@/lib/authz";
|
|
4
5
|
|
|
@@ -14,3 +15,40 @@ export async function GET(req: Request): Promise<Response> {
|
|
|
14
15
|
.all();
|
|
15
16
|
return Response.json({ members });
|
|
16
17
|
}
|
|
18
|
+
|
|
19
|
+
export async function POST(req: Request): Promise<Response> {
|
|
20
|
+
const access = await requireRole(req, ["owner"]);
|
|
21
|
+
if (!access.ok) return access.response;
|
|
22
|
+
|
|
23
|
+
const body = (await req.json()) as { name?: string; email?: string; password?: string; role?: "member" | "owner" };
|
|
24
|
+
const name = body.name?.trim();
|
|
25
|
+
const email = body.email?.trim().toLowerCase();
|
|
26
|
+
const password = body.password ?? "";
|
|
27
|
+
const role = body.role === "owner" ? "owner" : "member";
|
|
28
|
+
if (!name || !email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
|
29
|
+
return Response.json({ error: "invalid_name_or_email" }, { status: 400 });
|
|
30
|
+
}
|
|
31
|
+
if (password.length < 8) return Response.json({ error: "password_too_short" }, { status: 400 });
|
|
32
|
+
|
|
33
|
+
const sqlite = getSqlite();
|
|
34
|
+
const exists = sqlite.prepare("SELECT 1 FROM user WHERE lower(email) = lower(?) LIMIT 1").get(email);
|
|
35
|
+
if (exists) return Response.json({ error: "user_already_exists" }, { status: 409 });
|
|
36
|
+
|
|
37
|
+
const now = Date.now();
|
|
38
|
+
sqlite.prepare(
|
|
39
|
+
`INSERT INTO invite(email, role, expires_at, used_at, invited_by, created_at)
|
|
40
|
+
VALUES(?, ?, ?, NULL, ?, ?)
|
|
41
|
+
ON CONFLICT(email) DO UPDATE SET role=excluded.role, expires_at=excluded.expires_at,
|
|
42
|
+
used_at=NULL, invited_by=excluded.invited_by`,
|
|
43
|
+
).run(email, role, now + 10 * 60_000, access.userId, now);
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
await auth.api.signUpEmail({ body: { name, email, password } });
|
|
47
|
+
} catch (error) {
|
|
48
|
+
sqlite.prepare("DELETE FROM invite WHERE email = ? AND used_at IS NULL").run(email);
|
|
49
|
+
const message = error instanceof Error ? error.message : "create_user_failed";
|
|
50
|
+
return Response.json({ error: message }, { status: 400 });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return Response.json({ ok: true, user: { name, email, role } }, { status: 201 });
|
|
54
|
+
}
|
|
@@ -2,16 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
import type { CSSProperties } from "react";
|
|
4
4
|
import { useEffect, useRef, useState } from "react";
|
|
5
|
+
import { useMemoryCore } from "@/components/AppShell";
|
|
5
6
|
|
|
6
7
|
type Turn = { role: "user" | "assistant"; content: string };
|
|
7
|
-
type Project = { id: string; name: string; status: string };
|
|
8
|
-
type ProviderState = { configured: boolean; provider?: string; model?: string | null };
|
|
9
8
|
|
|
10
9
|
export default function AssistantPage() {
|
|
10
|
+
const { selectedProject, setSelectedProject, projects, provider } = useMemoryCore();
|
|
11
11
|
const [turns, setTurns] = useState<Turn[]>([]);
|
|
12
|
-
const [projects, setProjects] = useState<Project[]>([]);
|
|
13
|
-
const [selectedProject, setSelectedProject] = useState("");
|
|
14
|
-
const [provider, setProvider] = useState<ProviderState>({ configured: false });
|
|
15
12
|
const [name, setName] = useState("Admin");
|
|
16
13
|
const [input, setInput] = useState("");
|
|
17
14
|
const [busy, setBusy] = useState(false);
|
|
@@ -19,17 +16,12 @@ export default function AssistantPage() {
|
|
|
19
16
|
const endRef = useRef<HTMLDivElement>(null);
|
|
20
17
|
|
|
21
18
|
useEffect(() => {
|
|
22
|
-
|
|
23
|
-
fetch("/api/projects").then((r) => r.json()),
|
|
24
|
-
fetch("/api/providers").then((r) => r.json()),
|
|
25
|
-
fetch("/api/me").then((r) => r.json()),
|
|
26
|
-
]).then(([projectData, providerData, me]) => {
|
|
27
|
-
setProjects(projectData.projects ?? []);
|
|
28
|
-
setProvider(providerData.config ?? { configured: false });
|
|
19
|
+
fetch("/api/me").then((r) => r.json()).then((me) => {
|
|
29
20
|
setName(me.user?.name || me.user?.email || "Admin");
|
|
30
21
|
}).catch(() => {});
|
|
31
22
|
}, []);
|
|
32
23
|
|
|
24
|
+
|
|
33
25
|
async function send(prefill?: string) {
|
|
34
26
|
const message = (prefill ?? input).trim();
|
|
35
27
|
if (!message || busy) return;
|
|
@@ -77,22 +69,6 @@ export default function AssistantPage() {
|
|
|
77
69
|
|
|
78
70
|
return (
|
|
79
71
|
<section className="mop-assistant-page">
|
|
80
|
-
<div className="mop-assistant-toolbar">
|
|
81
|
-
<div>
|
|
82
|
-
<strong style={{ fontFamily: '"SFMono-Regular", Consolas, monospace', color: "#742220" }}>LIVE ASSISTANT</strong>
|
|
83
|
-
<span style={{ color: "rgba(45,74,62,.62)", marginLeft: 10, fontSize: 12 }}>
|
|
84
|
-
{provider.configured ? `${provider.provider}${provider.model ? ` · ${provider.model}` : ""}` : "offline demo"}
|
|
85
|
-
</span>
|
|
86
|
-
</div>
|
|
87
|
-
<label style={{ color: "#2d4a3e", fontSize: 12 }}>
|
|
88
|
-
MEMORY SCOPE
|
|
89
|
-
<select value={selectedProject} onChange={(e) => setSelectedProject(e.target.value)} style={selectStyle}>
|
|
90
|
-
<option value="">All memory</option>
|
|
91
|
-
{projects.map((project) => <option key={project.id} value={project.id}>{project.name}</option>)}
|
|
92
|
-
</select>
|
|
93
|
-
</label>
|
|
94
|
-
</div>
|
|
95
|
-
|
|
96
72
|
<div className="mop-assistant-conversation">
|
|
97
73
|
{turns.length === 0 ? (
|
|
98
74
|
<div className="mop-assistant-welcome">
|
|
@@ -152,7 +128,7 @@ export default function AssistantPage() {
|
|
|
152
128
|
}
|
|
153
129
|
|
|
154
130
|
const selectStyle: CSSProperties = { color: "#2d4a3e", border: "1px solid rgba(45,74,62,.42)", padding: "6px 8px", background: "#fffdf2" };
|
|
155
|
-
const assistantLogo: CSSProperties = { width: 86, height: 86, display: "grid", placeItems: "center"
|
|
131
|
+
const assistantLogo: CSSProperties = { width: 86, height: 86, display: "grid", placeItems: "center" };
|
|
156
132
|
const promptGrid: CSSProperties = { width: "min(100%, 650px)", display: "grid", gridTemplateColumns: "repeat(2,minmax(0,1fr))", gap: 10, marginTop: 28 };
|
|
157
133
|
const promptCard: CSSProperties = { display: "flex", justifyContent: "space-between", padding: "14px 15px", border: "1px solid rgba(45,74,62,.38)", borderBottomWidth: 3, background: "#fffdf2", color: "#2d4a3e", cursor: "pointer", textAlign: "left" };
|
|
158
134
|
const botAvatar: CSSProperties = { width: 32, height: 32, display: "grid", placeItems: "center", background: "#742220", color: "#fef9e1" };
|
package/apps/web/app/globals.css
CHANGED
|
@@ -411,11 +411,12 @@ button {
|
|
|
411
411
|
|
|
412
412
|
.mop-settings-grid {
|
|
413
413
|
display: grid;
|
|
414
|
-
grid-template-columns:
|
|
414
|
+
grid-template-columns: 1fr;
|
|
415
415
|
gap: 18px;
|
|
416
416
|
align-items: start;
|
|
417
417
|
}
|
|
418
418
|
|
|
419
|
+
|
|
419
420
|
.mop-settings-nav { padding: 9px; }
|
|
420
421
|
.mop-settings-nav button {
|
|
421
422
|
width: 100%;
|
|
@@ -504,4 +505,65 @@ button {
|
|
|
504
505
|
.mop-settings-nav { display: flex; gap: 7px; }
|
|
505
506
|
.mop-settings-nav button { justify-content: center; }
|
|
506
507
|
.mop-user-invite-form { grid-template-columns: 1fr !important; }
|
|
508
|
+
.mop-settings-sidebar {
|
|
509
|
+
position: fixed;
|
|
510
|
+
top: 62px;
|
|
511
|
+
left: 0;
|
|
512
|
+
bottom: 0;
|
|
513
|
+
width: min(82vw, 280px);
|
|
514
|
+
height: auto;
|
|
515
|
+
transform: translateX(-105%);
|
|
516
|
+
transition: transform 160ms steps(4, end);
|
|
517
|
+
display: flex !important;
|
|
518
|
+
flex-direction: column !important;
|
|
519
|
+
}
|
|
520
|
+
.mop-settings-sidebar.is-open {
|
|
521
|
+
transform: translateX(0);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
.mop-settings-sidebar {
|
|
526
|
+
grid-area: sidebar;
|
|
527
|
+
position: sticky;
|
|
528
|
+
top: 70px;
|
|
529
|
+
height: calc(100vh - 70px);
|
|
530
|
+
z-index: 40;
|
|
531
|
+
display: flex;
|
|
532
|
+
flex-direction: column;
|
|
533
|
+
overflow-y: auto;
|
|
534
|
+
padding: 15px 9px 12px;
|
|
535
|
+
border-right: 2px solid rgba(45, 74, 62, .46);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
.mop-back-workspace-btn {
|
|
539
|
+
display: flex;
|
|
540
|
+
align-items: center;
|
|
541
|
+
justify-content: center;
|
|
542
|
+
gap: 8px;
|
|
543
|
+
width: 100%;
|
|
544
|
+
min-height: 40px;
|
|
545
|
+
margin-bottom: 9px;
|
|
546
|
+
padding: 9px 12px;
|
|
547
|
+
border: 1px solid var(--mop-red);
|
|
548
|
+
background: var(--mop-red);
|
|
549
|
+
color: var(--mop-cream);
|
|
550
|
+
font-family: "SFMono-Regular", Consolas, monospace;
|
|
551
|
+
font-size: 11px;
|
|
552
|
+
font-weight: 900;
|
|
553
|
+
text-decoration: none;
|
|
554
|
+
cursor: pointer;
|
|
555
|
+
transition: transform 80ms steps(2, end), box-shadow 80ms steps(2, end);
|
|
556
|
+
box-shadow: 2px 2px 0 rgba(45, 74, 62, .17);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
.mop-back-workspace-btn:hover {
|
|
560
|
+
transform: translate(-1px, -1px);
|
|
561
|
+
box-shadow: 3px 3px 0 rgba(45, 74, 62, .24);
|
|
562
|
+
color: var(--mop-cream);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
.mop-back-workspace-btn:active {
|
|
566
|
+
transform: translate(1px, 1px);
|
|
567
|
+
box-shadow: 0 0 0 rgba(45, 74, 62, 0);
|
|
507
568
|
}
|
|
569
|
+
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { unstable_noStore as noStore } from "next/cache";
|
|
3
|
+
import { headers } from "next/headers";
|
|
4
|
+
import { redirect } from "next/navigation";
|
|
5
|
+
import { auth, ownerExists } from "@/lib/auth";
|
|
6
|
+
|
|
7
|
+
export const dynamic = "force-dynamic";
|
|
8
|
+
export const revalidate = 0;
|
|
9
|
+
|
|
10
|
+
export default async function LoginLayout({ children }: { children: ReactNode }) {
|
|
11
|
+
noStore();
|
|
12
|
+
if (!ownerExists()) redirect("/setup");
|
|
13
|
+
const session = await auth.api.getSession({ headers: await headers() });
|
|
14
|
+
if (session) redirect("/assistant");
|
|
15
|
+
return children;
|
|
16
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { signIn } from "@/lib/auth-client";
|
|
5
|
+
|
|
6
|
+
type SetupStatus = { authenticated: boolean };
|
|
7
|
+
|
|
8
|
+
async function waitForSession(): Promise<boolean> {
|
|
9
|
+
for (let attempt = 0; attempt < 5; attempt += 1) {
|
|
10
|
+
try {
|
|
11
|
+
const response = await fetch(`/api/setup/status?t=${Date.now()}`, { cache: "no-store", credentials: "include" });
|
|
12
|
+
const status = await response.json() as SetupStatus;
|
|
13
|
+
if (status.authenticated) return true;
|
|
14
|
+
} catch {
|
|
15
|
+
// Retry a short proxy/session propagation gap.
|
|
16
|
+
}
|
|
17
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
18
|
+
}
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export default function LoginPage() {
|
|
23
|
+
const [email, setEmail] = useState("");
|
|
24
|
+
const [password, setPassword] = useState("");
|
|
25
|
+
const [msg, setMsg] = useState("");
|
|
26
|
+
const [busy, setBusy] = useState(false);
|
|
27
|
+
|
|
28
|
+
async function submit(e: React.FormEvent) {
|
|
29
|
+
e.preventDefault();
|
|
30
|
+
setBusy(true);
|
|
31
|
+
setMsg("");
|
|
32
|
+
const result = await signIn.email({ email, password });
|
|
33
|
+
if (result.error) {
|
|
34
|
+
setMsg(result.error.message ?? "Sign in failed.");
|
|
35
|
+
setBusy(false);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (await waitForSession()) {
|
|
39
|
+
window.location.replace(`/assistant?auth=${Date.now()}`);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
setMsg("Sign in succeeded, but the session cookie was not accepted. Use the configured domain and HTTPS address.");
|
|
43
|
+
setBusy(false);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<main className="mop-setup-shell" style={shell}>
|
|
48
|
+
<section className="mop-setup-brand" style={brandPanel}>
|
|
49
|
+
<img src="/icon.svg" alt="MOP-AGENT" style={heroLogo} />
|
|
50
|
+
</section>
|
|
51
|
+
<section className="mop-setup-form" style={formWrap}>
|
|
52
|
+
<div style={formCard}>
|
|
53
|
+
<p style={eyebrow}>WELCOME BACK</p>
|
|
54
|
+
<h1 style={{ fontSize: 27, margin: "8px 0" }}>Sign in to MOP-AGENT</h1>
|
|
55
|
+
<p style={description}>Use the account created for you by the Administrator.</p>
|
|
56
|
+
<form onSubmit={submit} style={{ display: "grid", gap: 14, marginTop: 28 }}>
|
|
57
|
+
<label style={label}>Email
|
|
58
|
+
<input placeholder="you@example.com" type="email" autoComplete="email" required value={email} onChange={(e) => setEmail(e.target.value)} style={inputStyle} />
|
|
59
|
+
</label>
|
|
60
|
+
<label style={label}>Password
|
|
61
|
+
<input placeholder="Your password" type="password" autoComplete="current-password" required value={password} onChange={(e) => setPassword(e.target.value)} style={inputStyle} />
|
|
62
|
+
</label>
|
|
63
|
+
<button type="submit" disabled={busy} style={{ ...buttonStyle, opacity: busy ? 0.65 : 1 }}>
|
|
64
|
+
{busy ? "Signing in…" : "Sign in"}
|
|
65
|
+
</button>
|
|
66
|
+
</form>
|
|
67
|
+
{msg && <p role="alert" style={{ marginTop: 16, color: "#742220" }}>{msg}</p>}
|
|
68
|
+
</div>
|
|
69
|
+
</section>
|
|
70
|
+
</main>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const shell: React.CSSProperties = { minHeight: "100vh", display: "grid", gridTemplateColumns: "minmax(0, 1.25fr) minmax(380px, .75fr)", background: "#fef9e1" };
|
|
75
|
+
const brandPanel: React.CSSProperties = { padding: "clamp(48px, 8vw, 110px)", display: "flex", alignItems: "center", justifyContent: "center", overflow: "hidden", background: "radial-gradient(circle at 50% 45%, #49685c 0, #2d4a3e 58%, #21382f 100%)" };
|
|
76
|
+
const heroLogo: React.CSSProperties = { display: "block", width: "min(72%, 560px)", height: "auto", maxHeight: "72vh", objectFit: "contain", filter: "drop-shadow(0 24px 34px rgba(0,0,0,.28))" };
|
|
77
|
+
const formWrap: React.CSSProperties = { display: "flex", alignItems: "center", justifyContent: "center", padding: 28, background: "#fef9e1", borderLeft: "1px solid #2d4a3e" };
|
|
78
|
+
const formCard: React.CSSProperties = { width: "min(100%, 430px)", padding: "38px 34px", border: "1px solid rgba(45,74,62,.45)", borderRadius: 18, background: "#fffdf2", boxShadow: "0 24px 70px rgba(45,74,62,.14)" };
|
|
79
|
+
const eyebrow: React.CSSProperties = { margin: "20px 0 0", color: "#742220", fontSize: 12, fontWeight: 800, letterSpacing: ".16em" };
|
|
80
|
+
const description: React.CSSProperties = { color: "#7f8da2", lineHeight: 1.55, marginTop: 0 };
|
|
81
|
+
const label: React.CSSProperties = { display: "grid", gap: 7, color: "#2d4a3e", fontSize: 13, fontWeight: 650 };
|
|
82
|
+
const inputStyle: React.CSSProperties = { padding: "12px 13px", borderRadius: 9, border: "1px solid rgba(45,74,62,.42)", outline: "none", background: "#fef9e1", color: "#2d4a3e", fontSize: 15 };
|
|
83
|
+
const buttonStyle: React.CSSProperties = { marginTop: 4, padding: "12px 14px", borderRadius: 9, border: "1px solid #742220", background: "#742220", color: "#fef9e1", fontWeight: 750, fontSize: 15, cursor: "pointer" };
|
|
@@ -2,14 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
import type { CSSProperties } from "react";
|
|
4
4
|
import { useEffect, useState } from "react";
|
|
5
|
+
import { useMemoryCore } from "@/components/AppShell";
|
|
5
6
|
|
|
6
|
-
type Section = "providers" | "users";
|
|
7
7
|
type Masked = { configured: boolean; provider?: string; model?: string | null; keyHint?: string };
|
|
8
8
|
type Member = { id: string; email: string; name: string; role: string };
|
|
9
|
-
type Invite = { email: string; role: string; expiresAt: number; usedAt: number | null };
|
|
10
9
|
|
|
11
10
|
export default function SettingsPage() {
|
|
12
|
-
const
|
|
11
|
+
const { settingsSection: section } = useMemoryCore();
|
|
13
12
|
const [config, setConfig] = useState<Masked>({ configured: false });
|
|
14
13
|
const [env, setEnv] = useState<{ anthropic: boolean; openrouter: boolean }>({ anthropic: false, openrouter: false });
|
|
15
14
|
const [provider, setProvider] = useState<"anthropic" | "openrouter">("openrouter");
|
|
@@ -17,8 +16,9 @@ export default function SettingsPage() {
|
|
|
17
16
|
const [model, setModel] = useState("");
|
|
18
17
|
const [providerMsg, setProviderMsg] = useState("");
|
|
19
18
|
const [members, setMembers] = useState<Member[]>([]);
|
|
20
|
-
const [
|
|
19
|
+
const [userName, setUserName] = useState("");
|
|
21
20
|
const [email, setEmail] = useState("");
|
|
21
|
+
const [password, setPassword] = useState("");
|
|
22
22
|
const [role, setRole] = useState<"member" | "owner">("member");
|
|
23
23
|
const [userMsg, setUserMsg] = useState("");
|
|
24
24
|
|
|
@@ -31,21 +31,13 @@ export default function SettingsPage() {
|
|
|
31
31
|
|
|
32
32
|
function loadUsers() {
|
|
33
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
34
|
}
|
|
36
35
|
|
|
37
36
|
useEffect(() => {
|
|
38
|
-
const requested = new URLSearchParams(window.location.search).get("section");
|
|
39
|
-
if (requested === "users") setSection("users");
|
|
40
37
|
loadProvider();
|
|
41
38
|
loadUsers();
|
|
42
39
|
}, []);
|
|
43
40
|
|
|
44
|
-
function chooseSection(next: Section) {
|
|
45
|
-
setSection(next);
|
|
46
|
-
const url = next === "providers" ? "/settings" : "/settings?section=users";
|
|
47
|
-
window.history.replaceState(null, "", url);
|
|
48
|
-
}
|
|
49
41
|
|
|
50
42
|
async function saveProvider(e: React.FormEvent) {
|
|
51
43
|
e.preventDefault();
|
|
@@ -61,21 +53,21 @@ export default function SettingsPage() {
|
|
|
61
53
|
if (response.ok) setConfig(data.config);
|
|
62
54
|
}
|
|
63
55
|
|
|
64
|
-
async function
|
|
56
|
+
async function createUser(e: React.FormEvent) {
|
|
65
57
|
e.preventDefault();
|
|
66
|
-
setUserMsg("Creating
|
|
67
|
-
const response = await fetch("/api/
|
|
58
|
+
setUserMsg("Creating user…");
|
|
59
|
+
const response = await fetch("/api/members", {
|
|
68
60
|
method: "POST",
|
|
69
61
|
headers: { "content-type": "application/json" },
|
|
70
|
-
body: JSON.stringify({ email, role }),
|
|
62
|
+
body: JSON.stringify({ name: userName, email, password, role }),
|
|
71
63
|
});
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
64
|
+
const data = await response.json();
|
|
65
|
+
setUserMsg(response.ok ? `User ${email} created and ready to sign in.` : `Unable to create user: ${data.error ?? response.status}`);
|
|
66
|
+
if (response.ok) {
|
|
67
|
+
setUserName("");
|
|
68
|
+
setEmail("");
|
|
69
|
+
setPassword("");
|
|
70
|
+
}
|
|
79
71
|
loadUsers();
|
|
80
72
|
}
|
|
81
73
|
|
|
@@ -91,15 +83,6 @@ export default function SettingsPage() {
|
|
|
91
83
|
</header>
|
|
92
84
|
|
|
93
85
|
<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
86
|
<section className="mop-settings-content mop-panel">
|
|
104
87
|
{section === "providers" ? (
|
|
105
88
|
<>
|
|
@@ -166,29 +149,21 @@ export default function SettingsPage() {
|
|
|
166
149
|
</table>
|
|
167
150
|
</div>
|
|
168
151
|
|
|
169
|
-
<div style={
|
|
170
|
-
<h3 style={{ margin: 0, fontSize: 15 }}>
|
|
171
|
-
<
|
|
152
|
+
<div style={userPanel}>
|
|
153
|
+
<h3 style={{ margin: 0, fontSize: 15 }}>Add a user</h3>
|
|
154
|
+
<p style={{ ...muted, margin: "6px 0 0", fontSize: 12 }}>Create the account here, then give the user their email and temporary password.</p>
|
|
155
|
+
<form onSubmit={createUser} style={{ display: "grid", gridTemplateColumns: "minmax(140px,.8fr) minmax(190px,1fr) minmax(150px,.8fr) 110px auto", gap: 9, marginTop: 13 }} className="mop-user-invite-form">
|
|
156
|
+
<input placeholder="Display name" required value={userName} onChange={(e) => setUserName(e.target.value)} style={inputStyle} />
|
|
172
157
|
<input placeholder="user@example.com" type="email" required value={email} onChange={(e) => setEmail(e.target.value)} style={inputStyle} />
|
|
158
|
+
<input placeholder="Temporary password" type="password" minLength={8} required value={password} onChange={(e) => setPassword(e.target.value)} style={inputStyle} />
|
|
173
159
|
<select value={role} onChange={(e) => setRole(e.target.value as "member" | "owner")} style={inputStyle}>
|
|
174
160
|
<option value="member">Member</option>
|
|
175
161
|
<option value="owner">Admin</option>
|
|
176
162
|
</select>
|
|
177
|
-
<button type="submit" style={primaryButton}>
|
|
163
|
+
<button type="submit" style={primaryButton}>ADD USER</button>
|
|
178
164
|
</form>
|
|
179
165
|
{userMsg && <p style={messageStyle}>{userMsg}</p>}
|
|
180
166
|
</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
167
|
</>
|
|
193
168
|
)}
|
|
194
169
|
</section>
|
|
@@ -207,11 +182,9 @@ const formGrid: CSSProperties = { display: "grid", gap: 13, marginTop: 20 };
|
|
|
207
182
|
const labelStyle: CSSProperties = { display: "grid", gap: 6, color: "#742220", fontFamily: '"SFMono-Regular", Consolas, monospace', fontSize: 11, fontWeight: 800, letterSpacing: ".07em", textTransform: "uppercase" };
|
|
208
183
|
const inputStyle: CSSProperties = { width: "100%", minHeight: 40, padding: "9px 11px", border: "1px solid rgba(45,74,62,.4)", background: "#fffdf2", color: "#2d4a3e" };
|
|
209
184
|
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
185
|
const messageStyle: CSSProperties = { padding: "9px 11px", borderLeft: "3px solid #742220", background: "rgba(116,34,32,.06)", fontSize: 13 };
|
|
212
186
|
const tableStyle: CSSProperties = { width: "100%", borderCollapse: "collapse", fontSize: 13 };
|
|
213
187
|
const miniAvatar: CSSProperties = { width: 28, height: 28, display: "inline-grid", placeItems: "center", marginRight: 9, background: "#2d4a3e", color: "#fef9e1", fontWeight: 900 };
|
|
214
188
|
const ownerRole: CSSProperties = { padding: "4px 7px", background: "#742220", color: "#fef9e1", fontSize: 9, fontWeight: 900 };
|
|
215
189
|
const memberRole: CSSProperties = { ...ownerRole, color: "#2d4a3e", background: "rgba(45,74,62,.12)" };
|
|
216
|
-
const
|
|
217
|
-
const inviteRow: CSSProperties = { display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12, padding: 10, border: "1px solid rgba(45,74,62,.22)" };
|
|
190
|
+
const userPanel: CSSProperties = { marginTop: 26, padding: 16, border: "1px solid rgba(45,74,62,.27)", background: "rgba(254,249,225,.55)" };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { unstable_noStore as noStore } from "next/cache";
|
|
3
|
+
import { headers } from "next/headers";
|
|
4
|
+
import { redirect } from "next/navigation";
|
|
5
|
+
import { auth, ownerExists } from "@/lib/auth";
|
|
6
|
+
|
|
7
|
+
export const dynamic = "force-dynamic";
|
|
8
|
+
export const revalidate = 0;
|
|
9
|
+
|
|
10
|
+
export default async function SetupLayout({ children }: { children: ReactNode }) {
|
|
11
|
+
noStore();
|
|
12
|
+
if (!ownerExists()) return children;
|
|
13
|
+
const session = await auth.api.getSession({ headers: await headers() });
|
|
14
|
+
redirect(session ? "/assistant" : "/login");
|
|
15
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { useState } from "react";
|
|
4
4
|
import { signIn, signUp } from "@/lib/auth-client";
|
|
5
5
|
|
|
6
6
|
type SetupStatus = { ownerExists: boolean; authenticated: boolean };
|
|
@@ -20,7 +20,7 @@ async function waitForSession(): Promise<boolean> {
|
|
|
20
20
|
try {
|
|
21
21
|
if ((await getSetupStatus()).authenticated) return true;
|
|
22
22
|
} catch {
|
|
23
|
-
//
|
|
23
|
+
// Allow a short deployment/network gap before falling back to Login.
|
|
24
24
|
}
|
|
25
25
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
26
26
|
}
|
|
@@ -28,78 +28,34 @@ async function waitForSession(): Promise<boolean> {
|
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
export default function SetupPage() {
|
|
31
|
-
const [ownerExists, setOwnerExists] = useState<boolean | null>(null);
|
|
32
31
|
const [email, setEmail] = useState("");
|
|
33
32
|
const [password, setPassword] = useState("");
|
|
34
33
|
const [name, setName] = useState("");
|
|
35
34
|
const [msg, setMsg] = useState("");
|
|
36
35
|
const [busy, setBusy] = useState(false);
|
|
37
|
-
const [mode, setMode] = useState<"signup" | "signin">("signup");
|
|
38
|
-
|
|
39
|
-
useEffect(() => {
|
|
40
|
-
getSetupStatus()
|
|
41
|
-
.then((status) => {
|
|
42
|
-
if (status.authenticated) {
|
|
43
|
-
window.location.replace(`/assistant?auth=${Date.now()}`);
|
|
44
|
-
return;
|
|
45
|
-
}
|
|
46
|
-
setOwnerExists(status.ownerExists);
|
|
47
|
-
setMode(status.ownerExists ? "signin" : "signup");
|
|
48
|
-
})
|
|
49
|
-
.catch(() => setMsg("Unable to reach MOP-AGENT. Please refresh."));
|
|
50
|
-
}, []);
|
|
51
36
|
|
|
52
37
|
async function submit(e: React.FormEvent) {
|
|
53
38
|
e.preventDefault();
|
|
54
39
|
setBusy(true);
|
|
55
40
|
setMsg("");
|
|
56
41
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
setMsg(res.error.message ?? "Account setup failed.");
|
|
61
|
-
setBusy(false);
|
|
62
|
-
return;
|
|
63
|
-
}
|
|
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.");
|
|
42
|
+
const result = await signUp.email({ email, password, name: name.trim() || email });
|
|
43
|
+
if (result.error) {
|
|
44
|
+
setMsg(result.error.message ?? "Admin setup failed.");
|
|
83
45
|
setBusy(false);
|
|
84
46
|
return;
|
|
85
47
|
}
|
|
86
48
|
|
|
87
|
-
|
|
88
|
-
if
|
|
89
|
-
|
|
90
|
-
setBusy(false);
|
|
91
|
-
return;
|
|
92
|
-
}
|
|
49
|
+
// Better Auth normally creates the session during signup. Explicitly sign
|
|
50
|
+
// in once if a proxy/browser did not retain that first response cookie.
|
|
51
|
+
if (!(await waitForSession())) await signIn.email({ email, password });
|
|
93
52
|
if (await waitForSession()) {
|
|
94
53
|
window.location.replace(`/assistant?auth=${Date.now()}`);
|
|
95
54
|
return;
|
|
96
55
|
}
|
|
97
|
-
|
|
98
|
-
setBusy(false);
|
|
56
|
+
window.location.replace("/login?admin-created=1");
|
|
99
57
|
}
|
|
100
58
|
|
|
101
|
-
const loading = ownerExists === null && !msg;
|
|
102
|
-
|
|
103
59
|
return (
|
|
104
60
|
<main className="mop-setup-shell" style={shell}>
|
|
105
61
|
<section className="mop-setup-brand" style={brandPanel}>
|
|
@@ -108,45 +64,26 @@ export default function SetupPage() {
|
|
|
108
64
|
|
|
109
65
|
<section className="mop-setup-form" style={formWrap}>
|
|
110
66
|
<div style={formCard}>
|
|
111
|
-
<p style={eyebrow}>
|
|
112
|
-
<
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
<
|
|
116
|
-
{
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
<
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
<input placeholder="admin@example.com" type="email" autoComplete="email" required value={email} onChange={(e) => setEmail(e.target.value)} style={inputStyle} />
|
|
130
|
-
</label>
|
|
131
|
-
<label style={label}>Password
|
|
132
|
-
<input placeholder="Minimum 8 characters" type="password" autoComplete={mode === "signup" ? "new-password" : "current-password"} required minLength={8} value={password} onChange={(e) => setPassword(e.target.value)} style={inputStyle} />
|
|
133
|
-
</label>
|
|
134
|
-
<button type="submit" disabled={busy} style={{ ...buttonStyle, opacity: busy ? 0.65 : 1 }}>
|
|
135
|
-
{busy ? "Please wait…" : mode === "signup" ? "Create Admin & continue" : "Sign in"}
|
|
136
|
-
</button>
|
|
137
|
-
</form>
|
|
138
|
-
)}
|
|
67
|
+
<p style={eyebrow}>FIRST-RUN SETUP</p>
|
|
68
|
+
<h1 style={{ fontSize: 27, margin: "8px 0" }}>Create Admin account</h1>
|
|
69
|
+
<p style={description}>This one-time account controls providers, users, projects, and system memory.</p>
|
|
70
|
+
|
|
71
|
+
<form onSubmit={submit} style={{ display: "grid", gap: 14, marginTop: 28 }}>
|
|
72
|
+
<label style={label}>Display name
|
|
73
|
+
<input placeholder="Admin" autoComplete="name" value={name} onChange={(e) => setName(e.target.value)} style={inputStyle} />
|
|
74
|
+
</label>
|
|
75
|
+
<label style={label}>Email
|
|
76
|
+
<input placeholder="admin@example.com" type="email" autoComplete="email" required value={email} onChange={(e) => setEmail(e.target.value)} style={inputStyle} />
|
|
77
|
+
</label>
|
|
78
|
+
<label style={label}>Password
|
|
79
|
+
<input placeholder="Minimum 8 characters" type="password" autoComplete="new-password" required minLength={8} value={password} onChange={(e) => setPassword(e.target.value)} style={inputStyle} />
|
|
80
|
+
</label>
|
|
81
|
+
<button type="submit" disabled={busy} style={{ ...buttonStyle, opacity: busy ? 0.65 : 1 }}>
|
|
82
|
+
{busy ? "Creating Admin…" : "Create Admin & continue"}
|
|
83
|
+
</button>
|
|
84
|
+
</form>
|
|
139
85
|
|
|
140
86
|
{msg && <p role="alert" style={{ marginTop: 16, color: "#742220" }}>{msg}</p>}
|
|
141
|
-
|
|
142
|
-
{!loading && (
|
|
143
|
-
<p style={{ marginTop: 22, fontSize: 13, color: "#7f8da2" }}>
|
|
144
|
-
{mode === "signin" ? "Invited team member? " : "Already created an account? "}
|
|
145
|
-
<button type="button" onClick={() => { setMode(mode === "signin" ? "signup" : "signin"); setMsg(""); }} style={textButton}>
|
|
146
|
-
{mode === "signin" ? "Create invited account" : "Sign in"}
|
|
147
|
-
</button>
|
|
148
|
-
</p>
|
|
149
|
-
)}
|
|
150
87
|
</div>
|
|
151
88
|
</section>
|
|
152
89
|
</main>
|
|
@@ -159,7 +96,7 @@ const heroLogo: React.CSSProperties = { display: "block", width: "min(72%, 560px
|
|
|
159
96
|
const formWrap: React.CSSProperties = { display: "flex", alignItems: "center", justifyContent: "center", padding: 28, background: "#fef9e1", borderLeft: "1px solid #2d4a3e" };
|
|
160
97
|
const formCard: React.CSSProperties = { width: "min(100%, 430px)", padding: "38px 34px", border: "1px solid rgba(45,74,62,.45)", borderRadius: 18, background: "#fffdf2", boxShadow: "0 24px 70px rgba(45,74,62,.14)" };
|
|
161
98
|
const eyebrow: React.CSSProperties = { margin: "20px 0 0", color: "#742220", fontSize: 12, fontWeight: 800, letterSpacing: ".16em" };
|
|
99
|
+
const description: React.CSSProperties = { color: "#7f8da2", lineHeight: 1.55, marginTop: 0 };
|
|
162
100
|
const label: React.CSSProperties = { display: "grid", gap: 7, color: "#2d4a3e", fontSize: 13, fontWeight: 650 };
|
|
163
101
|
const inputStyle: React.CSSProperties = { padding: "12px 13px", borderRadius: 9, border: "1px solid rgba(45,74,62,.42)", outline: "none", background: "#fef9e1", color: "#2d4a3e", fontSize: 15 };
|
|
164
102
|
const buttonStyle: React.CSSProperties = { marginTop: 4, padding: "12px 14px", borderRadius: 9, border: "1px solid #742220", background: "#742220", color: "#fef9e1", fontWeight: 750, fontSize: 15, cursor: "pointer" };
|
|
165
|
-
const textButton: React.CSSProperties = { padding: 0, border: 0, background: "none", color: "#742220", cursor: "pointer" };
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import type { ReactNode } from "react";
|
|
4
4
|
import { usePathname } from "next/navigation";
|
|
5
|
-
import { useState } from "react";
|
|
5
|
+
import { useState, useEffect, createContext, useContext } from "react";
|
|
6
6
|
import { signOut } from "@/lib/auth-client";
|
|
7
7
|
|
|
8
8
|
export type AppViewer = {
|
|
@@ -11,6 +11,31 @@ export type AppViewer = {
|
|
|
11
11
|
role: "owner" | "member";
|
|
12
12
|
};
|
|
13
13
|
|
|
14
|
+
export type Project = { id: string; name: string; status: string };
|
|
15
|
+
export type ProviderState = { configured: boolean; provider?: string; model?: string | null };
|
|
16
|
+
|
|
17
|
+
interface MemoryCoreContextType {
|
|
18
|
+
selectedProject: string;
|
|
19
|
+
setSelectedProject: (id: string) => void;
|
|
20
|
+
projects: Project[];
|
|
21
|
+
provider: ProviderState;
|
|
22
|
+
settingsSection: "providers" | "users";
|
|
23
|
+
setSettingsSection: (section: "providers" | "users") => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const MemoryCoreContext = createContext<MemoryCoreContextType | undefined>(undefined);
|
|
27
|
+
|
|
28
|
+
export function useMemoryCore() {
|
|
29
|
+
const context = useContext(MemoryCoreContext);
|
|
30
|
+
if (!context) {
|
|
31
|
+
throw new Error("useMemoryCore must be used within a MemoryCoreProvider");
|
|
32
|
+
}
|
|
33
|
+
return context;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const selectStyle = { color: "#2d4a3e", border: "1px solid rgba(45,74,62,.42)", padding: "6px 8px", background: "#fffdf2" };
|
|
37
|
+
|
|
38
|
+
|
|
14
39
|
function pageTitle(pathname: string): string {
|
|
15
40
|
if (pathname === "/assistant") return "Assistant";
|
|
16
41
|
if (pathname === "/brain/graph") return "Knowledge Graph";
|
|
@@ -27,84 +52,156 @@ export function AppShell({ viewer, children }: { viewer: AppViewer; children: Re
|
|
|
27
52
|
const isAdmin = viewer.role === "owner";
|
|
28
53
|
const title = pageTitle(pathname);
|
|
29
54
|
|
|
55
|
+
const [projects, setProjects] = useState<Project[]>([]);
|
|
56
|
+
const [provider, setProvider] = useState<ProviderState>({ configured: false });
|
|
57
|
+
const [selectedProject, setSelectedProject] = useState("");
|
|
58
|
+
const [settingsSection, setSettingsSection] = useState<"providers" | "users">("providers");
|
|
59
|
+
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
Promise.all([
|
|
62
|
+
fetch("/api/projects").then((r) => r.json()),
|
|
63
|
+
fetch("/api/providers").then((r) => r.json()),
|
|
64
|
+
]).then(([projectData, providerData]) => {
|
|
65
|
+
setProjects(projectData.projects ?? []);
|
|
66
|
+
setProvider(providerData.config ?? { configured: false });
|
|
67
|
+
}).catch(() => {});
|
|
68
|
+
|
|
69
|
+
const requested = new URLSearchParams(window.location.search).get("section");
|
|
70
|
+
if (requested === "users") setSettingsSection("users");
|
|
71
|
+
}, []);
|
|
72
|
+
|
|
30
73
|
async function logout() {
|
|
31
74
|
await signOut();
|
|
32
|
-
window.location.replace("/
|
|
75
|
+
window.location.replace("/login");
|
|
33
76
|
}
|
|
34
77
|
|
|
78
|
+
const selectSection = (sec: "providers" | "users") => {
|
|
79
|
+
setSettingsSection(sec);
|
|
80
|
+
const url = sec === "providers" ? "/settings" : "/settings?section=users";
|
|
81
|
+
window.history.replaceState(null, "", url);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const isSettings = pathname.startsWith("/settings");
|
|
85
|
+
|
|
35
86
|
const nav = [
|
|
36
87
|
{ href: "/assistant", label: "Assistant", icon: "✦", active: pathname.startsWith("/assistant") || pathname.startsWith("/chat/") },
|
|
37
88
|
{ href: "/brain", label: "Brain", icon: "◉", active: pathname.startsWith("/brain") },
|
|
38
89
|
];
|
|
39
90
|
|
|
40
91
|
return (
|
|
41
|
-
<
|
|
42
|
-
<
|
|
43
|
-
<
|
|
44
|
-
<
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
<
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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>
|
|
92
|
+
<MemoryCoreContext.Provider value={{ selectedProject, setSelectedProject, projects, provider, settingsSection, setSettingsSection }}>
|
|
93
|
+
<div className="mop-app-frame">
|
|
94
|
+
<header className="mop-app-topbar">
|
|
95
|
+
<a className="mop-app-brand" href="/assistant" aria-label="MOP-AGENT home">
|
|
96
|
+
<img src="/icon.svg" alt="" />
|
|
97
|
+
<span>MOP-AGENT</span>
|
|
98
|
+
</a>
|
|
99
|
+
<div className="mop-app-topbar-main">
|
|
100
|
+
<button
|
|
101
|
+
className="mop-menu-toggle"
|
|
102
|
+
type="button"
|
|
103
|
+
aria-label="Toggle navigation"
|
|
104
|
+
aria-expanded={menuOpen}
|
|
105
|
+
onClick={() => setMenuOpen((open) => !open)}
|
|
106
|
+
>
|
|
107
|
+
☰
|
|
108
|
+
</button>
|
|
109
|
+
{pathname === "/assistant" ? (
|
|
110
|
+
<div className="mop-assistant-toolbar" style={{ border: 0, padding: 0, margin: 0, background: "transparent", width: "100%", display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
|
111
|
+
<div>
|
|
112
|
+
<strong style={{ fontFamily: '"SFMono-Regular", Consolas, monospace', color: "#742220" }}>LIVE ASSISTANT</strong>
|
|
113
|
+
<span style={{ color: "rgba(45,74,62,.62)", marginLeft: 10, fontSize: 12 }}>
|
|
114
|
+
{provider.configured ? `${provider.provider}${provider.model ? ` · ${provider.model}` : ""}` : "offline demo"}
|
|
115
|
+
</span>
|
|
116
|
+
</div>
|
|
117
|
+
<label style={{ color: "#2d4a3e", fontSize: 12 }}>
|
|
118
|
+
MEMORY SCOPE
|
|
119
|
+
<select value={selectedProject} onChange={(e) => setSelectedProject(e.target.value)} style={selectStyle}>
|
|
120
|
+
<option value="">All memory</option>
|
|
121
|
+
{projects.map((project) => <option key={project.id} value={project.id}>{project.name}</option>)}
|
|
122
|
+
</select>
|
|
123
|
+
</label>
|
|
124
|
+
</div>
|
|
125
|
+
) : (
|
|
126
|
+
<>
|
|
127
|
+
<div className="mop-topbar-title">
|
|
128
|
+
<span className="mop-live-dot" />
|
|
129
|
+
<strong>{title}</strong>
|
|
130
|
+
</div>
|
|
131
|
+
<div className="mop-topbar-center">MOP MEMORYCORE</div>
|
|
132
|
+
<div className="mop-topbar-meta">
|
|
133
|
+
<span>{isAdmin ? "ADMIN" : "MEMBER"}</span>
|
|
134
|
+
<span className="mop-version">v0.1.10</span>
|
|
135
|
+
</div>
|
|
136
|
+
</>
|
|
137
|
+
)}
|
|
93
138
|
</div>
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
<
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
139
|
+
</header>
|
|
140
|
+
|
|
141
|
+
{menuOpen && <button className="mop-sidebar-scrim" aria-label="Close navigation" onClick={() => setMenuOpen(false)} />}
|
|
142
|
+
|
|
143
|
+
<aside className={isSettings
|
|
144
|
+
? `mop-settings-nav mop-panel mop-settings-sidebar${menuOpen ? " is-open" : ""}`
|
|
145
|
+
: `mop-app-sidebar${menuOpen ? " is-open" : ""}`}
|
|
146
|
+
>
|
|
147
|
+
{isSettings ? (
|
|
148
|
+
<>
|
|
149
|
+
<button className={settingsSection === "providers" ? "is-active" : ""} onClick={() => { selectSection("providers"); setMenuOpen(false); }}>
|
|
150
|
+
<span>◇</span><strong>Providers</strong>
|
|
151
|
+
</button>
|
|
152
|
+
<button className={settingsSection === "users" ? "is-active" : ""} onClick={() => { selectSection("users"); setMenuOpen(false); }}>
|
|
153
|
+
<span>♙</span><strong>Users</strong>
|
|
154
|
+
</button>
|
|
155
|
+
</>
|
|
156
|
+
) : (
|
|
157
|
+
<>
|
|
158
|
+
<div className="mop-nav-section">
|
|
159
|
+
<p>WORKSPACE</p>
|
|
160
|
+
<nav>
|
|
161
|
+
{nav.map((item) => (
|
|
162
|
+
<a key={item.href} href={item.href} className={item.active ? "is-active" : ""} onClick={() => setMenuOpen(false)}>
|
|
163
|
+
<span className="mop-nav-icon">{item.icon}</span>
|
|
164
|
+
<span>{item.label}</span>
|
|
165
|
+
</a>
|
|
166
|
+
))}
|
|
167
|
+
</nav>
|
|
168
|
+
</div>
|
|
169
|
+
|
|
170
|
+
{isAdmin && (
|
|
171
|
+
<div className="mop-nav-section">
|
|
172
|
+
<p>ADMIN</p>
|
|
173
|
+
<nav>
|
|
174
|
+
<a href="/settings" className={pathname.startsWith("/settings") ? "is-active" : ""} onClick={() => setMenuOpen(false)}>
|
|
175
|
+
<span className="mop-nav-icon">⚙</span>
|
|
176
|
+
<span>Settings</span>
|
|
177
|
+
</a>
|
|
178
|
+
</nav>
|
|
179
|
+
</div>
|
|
180
|
+
)}
|
|
181
|
+
</>
|
|
182
|
+
)}
|
|
183
|
+
|
|
184
|
+
<div className="mop-sidebar-spacer" />
|
|
185
|
+
|
|
186
|
+
{isSettings && (
|
|
187
|
+
<a href="/assistant" className="mop-back-workspace-btn">
|
|
188
|
+
<span>← BACK TO WORKSPACE</span>
|
|
189
|
+
</a>
|
|
190
|
+
)}
|
|
191
|
+
|
|
192
|
+
<button className="mop-account-card" type="button" onClick={logout} title="Sign out">
|
|
193
|
+
<span className="mop-account-avatar">{viewer.name.slice(0, 1).toUpperCase()}</span>
|
|
194
|
+
<span className="mop-account-copy">
|
|
195
|
+
<strong>{viewer.name}</strong>
|
|
196
|
+
<small>{isAdmin ? "Administrator" : "Member"}</small>
|
|
197
|
+
</span>
|
|
198
|
+
<span aria-hidden="true">↪</span>
|
|
199
|
+
</button>
|
|
200
|
+
</aside>
|
|
201
|
+
|
|
202
|
+
<main className="mop-app-main">{children}</main>
|
|
203
|
+
</div>
|
|
204
|
+
</MemoryCoreContext.Provider>
|
|
109
205
|
);
|
|
110
206
|
}
|
|
207
|
+
|
|
@@ -10,7 +10,7 @@ export async function requirePageSession() {
|
|
|
10
10
|
if (!ownerExists()) redirect("/setup");
|
|
11
11
|
|
|
12
12
|
const session = await auth.api.getSession({ headers: await headers() });
|
|
13
|
-
if (!session) redirect("/
|
|
13
|
+
if (!session) redirect("/login");
|
|
14
14
|
return session;
|
|
15
15
|
}
|
|
16
16
|
|
package/installer/mop-agent.mjs
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { spawnSync } from "node:child_process";
|
|
14
14
|
import { randomBytes } from "node:crypto";
|
|
15
|
-
import { chmodSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
|
|
15
|
+
import { chmodSync, writeFileSync, mkdirSync, existsSync, readFileSync } from "node:fs";
|
|
16
16
|
import { createInterface } from "node:readline/promises";
|
|
17
17
|
import { stdin as input, stdout as output } from "node:process";
|
|
18
18
|
import { dirname, resolve } from "node:path";
|
|
@@ -102,6 +102,57 @@ function q(value) {
|
|
|
102
102
|
return `'${String(value).replaceAll("'", `'"'"'`)}'`;
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
function readRuntimeConfig() {
|
|
106
|
+
const envPath = `${APP_DIR}/apps/web/.env`;
|
|
107
|
+
if (!existsSync(envPath)) throw new Error(`Runtime environment is missing: ${envPath}`);
|
|
108
|
+
const env = {};
|
|
109
|
+
for (const raw of readFileSync(envPath, "utf8").split(/\r?\n/)) {
|
|
110
|
+
const line = raw.trim();
|
|
111
|
+
if (!line || line.startsWith("#")) continue;
|
|
112
|
+
const at = line.indexOf("=");
|
|
113
|
+
if (at < 1) continue;
|
|
114
|
+
env[line.slice(0, at)] = line.slice(at + 1).replace(/^(['"])(.*)\1$/, "$2");
|
|
115
|
+
}
|
|
116
|
+
const port = env.PORT || "3000";
|
|
117
|
+
if (!isValidPort(port)) throw new Error(`Invalid PORT in ${envPath}: ${port}`);
|
|
118
|
+
let publicUrl;
|
|
119
|
+
try {
|
|
120
|
+
publicUrl = new URL(env.BETTER_AUTH_URL || `http://localhost:${port}`);
|
|
121
|
+
} catch {
|
|
122
|
+
throw new Error(`Invalid BETTER_AUTH_URL in ${envPath}`);
|
|
123
|
+
}
|
|
124
|
+
return { env, port, publicUrl };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Restore the installer-owned vhost after every update, including TLS. */
|
|
128
|
+
function reconcileNginx() {
|
|
129
|
+
const os = detectOS();
|
|
130
|
+
const { port, publicUrl } = readRuntimeConfig();
|
|
131
|
+
const domain = publicUrl.hostname;
|
|
132
|
+
if (!isValidDomain(domain) && domain !== "localhost") {
|
|
133
|
+
throw new Error(`Invalid domain in BETTER_AUTH_URL: ${domain}`);
|
|
134
|
+
}
|
|
135
|
+
const nginx = nginxPaths(os.family);
|
|
136
|
+
const certDir = `/etc/letsencrypt/live/${domain}`;
|
|
137
|
+
const hasTls = publicUrl.protocol === "https:" && existsSync(`${certDir}/fullchain.pem`) && existsSync(`${certDir}/privkey.pem`);
|
|
138
|
+
const vhost = hasTls ? renderNginxTlsVhost({ domain, port }) : renderNginxVhost({ domain, port });
|
|
139
|
+
|
|
140
|
+
console.log(c("cyan", "▸ Restore nginx reverse proxy"));
|
|
141
|
+
writeConf(nginx.conf, vhost, { privileged: true });
|
|
142
|
+
runSteps([
|
|
143
|
+
...(nginx.enabled ? [{ label: "Enable nginx vhost", cmd: `ln -sf ${nginx.conf} ${nginx.enabled}` }] : []),
|
|
144
|
+
{ label: "Verify nginx configuration", cmd: "nginx -t" },
|
|
145
|
+
{ label: "Enable + start nginx", cmd: "systemctl enable --now nginx" },
|
|
146
|
+
{ label: "Reload nginx", cmd: "systemctl reload nginx" },
|
|
147
|
+
{ label: "Verify local application", cmd: `curl --fail --silent --show-error --max-time 15 ${q(`http://127.0.0.1:${port}/api/setup/status`)} >/dev/null` },
|
|
148
|
+
{
|
|
149
|
+
label: "Verify domain reverse proxy",
|
|
150
|
+
cmd: `curl --fail --silent --show-error --max-time 15 --resolve ${q(`${domain}:${hasTls ? "443" : "80"}:127.0.0.1`)} ${q(`${hasTls ? "https" : "http"}://${domain}/api/setup/status`)} >/dev/null`,
|
|
151
|
+
},
|
|
152
|
+
], { privileged: true });
|
|
153
|
+
return { domain, port, protocol: hasTls ? "https" : "http" };
|
|
154
|
+
}
|
|
155
|
+
|
|
105
156
|
// ---- commands ----------------------------------------------------------
|
|
106
157
|
|
|
107
158
|
async function cmdInstall() {
|
|
@@ -242,21 +293,29 @@ function cmdUpdate() {
|
|
|
242
293
|
{ label: "Start new service", cmd: "systemctl start mop-agent", privileged: true },
|
|
243
294
|
{ label: "Verify service", cmd: "sleep 2 && systemctl is-active --quiet mop-agent", privileged: true },
|
|
244
295
|
]);
|
|
245
|
-
|
|
296
|
+
const proxy = reconcileNginx();
|
|
297
|
+
console.log(c("green", `\n✓ updated and verified through ${proxy.protocol}://${proxy.domain}\n`));
|
|
246
298
|
}
|
|
247
299
|
|
|
248
300
|
function cmdStatus() {
|
|
249
301
|
banner();
|
|
250
302
|
if (!printInstallLocations()) return;
|
|
303
|
+
let runtime;
|
|
304
|
+
try { runtime = readRuntimeConfig(); } catch { runtime = null; }
|
|
305
|
+
const os = detectOS();
|
|
306
|
+
const nginx = nginxPaths(os.family);
|
|
251
307
|
const checks = [
|
|
252
308
|
["service", "systemctl is-active mop-agent 2>/dev/null || echo inactive"],
|
|
253
309
|
["nginx", "systemctl is-active nginx 2>/dev/null || echo inactive"],
|
|
310
|
+
["nginx conf", `test -f ${q(nginx.conf)} && echo present || echo missing`],
|
|
311
|
+
...(nginx.enabled ? [["nginx link", `test -L ${q(nginx.enabled)} && echo present || echo missing`]] : []),
|
|
254
312
|
[".env", existsSync(`${APP_DIR}/apps/web/.env`) ? "echo present" : "echo missing"],
|
|
313
|
+
...(runtime ? [["local app", `curl --silent --output /dev/null --write-out '%{http_code}' --max-time 5 ${q(`http://127.0.0.1:${runtime.port}/api/setup/status`)} || echo failed`]] : []),
|
|
255
314
|
];
|
|
256
315
|
for (const [label, cmd] of checks) {
|
|
257
316
|
const r = run(cmd, { capture: true });
|
|
258
317
|
const val = DRY ? "(dry-run)" : r.stdout.trim();
|
|
259
|
-
console.log(` ${label.padEnd(10)} ${val === "active" || val === "present" ? c("green", val) : c("yellow", val)}`);
|
|
318
|
+
console.log(` ${label.padEnd(10)} ${val === "active" || val === "present" || val === "200" ? c("green", val) : c("yellow", val)}`);
|
|
260
319
|
}
|
|
261
320
|
console.log("");
|
|
262
321
|
}
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mop-agent",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.10",
|
|
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.10",
|
|
10
10
|
"license": "UNLICENSED",
|
|
11
11
|
"workspaces": [
|
|
12
12
|
"packages/*",
|
package/package.json
CHANGED