mop-agent 0.1.8 → 0.1.9
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/login/layout.tsx +16 -0
- package/apps/web/app/login/page.tsx +83 -0
- package/apps/web/app/settings/page.tsx +21 -32
- package/apps/web/app/setup/layout.tsx +15 -0
- package/apps/web/app/setup/page.tsx +28 -91
- package/apps/web/components/AppShell.tsx +2 -2
- 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.9` 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
|
+
}
|
|
@@ -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" };
|
|
@@ -6,7 +6,6 @@ import { useEffect, useState } from "react";
|
|
|
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
11
|
const [section, setSection] = useState<Section>("providers");
|
|
@@ -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,7 +31,6 @@ 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(() => {
|
|
@@ -61,21 +60,21 @@ export default function SettingsPage() {
|
|
|
61
60
|
if (response.ok) setConfig(data.config);
|
|
62
61
|
}
|
|
63
62
|
|
|
64
|
-
async function
|
|
63
|
+
async function createUser(e: React.FormEvent) {
|
|
65
64
|
e.preventDefault();
|
|
66
|
-
setUserMsg("Creating
|
|
67
|
-
const response = await fetch("/api/
|
|
65
|
+
setUserMsg("Creating user…");
|
|
66
|
+
const response = await fetch("/api/members", {
|
|
68
67
|
method: "POST",
|
|
69
68
|
headers: { "content-type": "application/json" },
|
|
70
|
-
body: JSON.stringify({ email, role }),
|
|
69
|
+
body: JSON.stringify({ name: userName, email, password, role }),
|
|
71
70
|
});
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
71
|
+
const data = await response.json();
|
|
72
|
+
setUserMsg(response.ok ? `User ${email} created and ready to sign in.` : `Unable to create user: ${data.error ?? response.status}`);
|
|
73
|
+
if (response.ok) {
|
|
74
|
+
setUserName("");
|
|
75
|
+
setEmail("");
|
|
76
|
+
setPassword("");
|
|
77
|
+
}
|
|
79
78
|
loadUsers();
|
|
80
79
|
}
|
|
81
80
|
|
|
@@ -166,29 +165,21 @@ export default function SettingsPage() {
|
|
|
166
165
|
</table>
|
|
167
166
|
</div>
|
|
168
167
|
|
|
169
|
-
<div style={
|
|
170
|
-
<h3 style={{ margin: 0, fontSize: 15 }}>
|
|
171
|
-
<
|
|
168
|
+
<div style={userPanel}>
|
|
169
|
+
<h3 style={{ margin: 0, fontSize: 15 }}>Add a user</h3>
|
|
170
|
+
<p style={{ ...muted, margin: "6px 0 0", fontSize: 12 }}>Create the account here, then give the user their email and temporary password.</p>
|
|
171
|
+
<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">
|
|
172
|
+
<input placeholder="Display name" required value={userName} onChange={(e) => setUserName(e.target.value)} style={inputStyle} />
|
|
172
173
|
<input placeholder="user@example.com" type="email" required value={email} onChange={(e) => setEmail(e.target.value)} style={inputStyle} />
|
|
174
|
+
<input placeholder="Temporary password" type="password" minLength={8} required value={password} onChange={(e) => setPassword(e.target.value)} style={inputStyle} />
|
|
173
175
|
<select value={role} onChange={(e) => setRole(e.target.value as "member" | "owner")} style={inputStyle}>
|
|
174
176
|
<option value="member">Member</option>
|
|
175
177
|
<option value="owner">Admin</option>
|
|
176
178
|
</select>
|
|
177
|
-
<button type="submit" style={primaryButton}>
|
|
179
|
+
<button type="submit" style={primaryButton}>ADD USER</button>
|
|
178
180
|
</form>
|
|
179
181
|
{userMsg && <p style={messageStyle}>{userMsg}</p>}
|
|
180
182
|
</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
183
|
</>
|
|
193
184
|
)}
|
|
194
185
|
</section>
|
|
@@ -207,11 +198,9 @@ const formGrid: CSSProperties = { display: "grid", gap: 13, marginTop: 20 };
|
|
|
207
198
|
const labelStyle: CSSProperties = { display: "grid", gap: 6, color: "#742220", fontFamily: '"SFMono-Regular", Consolas, monospace', fontSize: 11, fontWeight: 800, letterSpacing: ".07em", textTransform: "uppercase" };
|
|
208
199
|
const inputStyle: CSSProperties = { width: "100%", minHeight: 40, padding: "9px 11px", border: "1px solid rgba(45,74,62,.4)", background: "#fffdf2", color: "#2d4a3e" };
|
|
209
200
|
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
201
|
const messageStyle: CSSProperties = { padding: "9px 11px", borderLeft: "3px solid #742220", background: "rgba(116,34,32,.06)", fontSize: 13 };
|
|
212
202
|
const tableStyle: CSSProperties = { width: "100%", borderCollapse: "collapse", fontSize: 13 };
|
|
213
203
|
const miniAvatar: CSSProperties = { width: 28, height: 28, display: "inline-grid", placeItems: "center", marginRight: 9, background: "#2d4a3e", color: "#fef9e1", fontWeight: 900 };
|
|
214
204
|
const ownerRole: CSSProperties = { padding: "4px 7px", background: "#742220", color: "#fef9e1", fontSize: 9, fontWeight: 900 };
|
|
215
205
|
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)" };
|
|
206
|
+
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" };
|
|
@@ -29,7 +29,7 @@ export function AppShell({ viewer, children }: { viewer: AppViewer; children: Re
|
|
|
29
29
|
|
|
30
30
|
async function logout() {
|
|
31
31
|
await signOut();
|
|
32
|
-
window.location.replace("/
|
|
32
|
+
window.location.replace("/login");
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
const nav = [
|
|
@@ -61,7 +61,7 @@ export function AppShell({ viewer, children }: { viewer: AppViewer; children: Re
|
|
|
61
61
|
<div className="mop-topbar-center">MOP MEMORYCORE</div>
|
|
62
62
|
<div className="mop-topbar-meta">
|
|
63
63
|
<span>{isAdmin ? "ADMIN" : "MEMBER"}</span>
|
|
64
|
-
<span className="mop-version">v0.1.
|
|
64
|
+
<span className="mop-version">v0.1.9</span>
|
|
65
65
|
</div>
|
|
66
66
|
</div>
|
|
67
67
|
</header>
|
|
@@ -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.9",
|
|
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.9",
|
|
10
10
|
"license": "UNLICENSED",
|
|
11
11
|
"workspaces": [
|
|
12
12
|
"packages/*",
|
package/package.json
CHANGED