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 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.8` contains the corrected VPS
9
- > installer, first-run Admin/Assistant flow, and shared retro application shell.
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` — updates only MOP-AGENT, migrates/builds, and restarts it.
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, `/` redirects to **Create Admin account**.
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. Returning users are shown the login form.
69
- 4. Successful setup/login opens the main **Assistant**. It can be used before
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
- 5. Add OpenRouter or Anthropic under **Providers** for full model responses.
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 + roles (owner). */
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
- Promise.all([
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&nbsp;
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", overflow: "hidden", background: "#2d4a3e", border: "2px solid #742220", boxShadow: "5px 5px 0 rgba(45,74,62,.18)" };
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" };
@@ -411,11 +411,12 @@ button {
411
411
 
412
412
  .mop-settings-grid {
413
413
  display: grid;
414
- grid-template-columns: 210px minmax(0, 1fr);
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 [section, setSection] = useState<Section>("providers");
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 [invites, setInvites] = useState<Invite[]>([]);
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 invite(e: React.FormEvent) {
56
+ async function createUser(e: React.FormEvent) {
65
57
  e.preventDefault();
66
- setUserMsg("Creating invite…");
67
- const response = await fetch("/api/invites", {
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
- 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" });
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={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">
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}>CREATE INVITE</button>
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 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)" };
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 { useEffect, useState } from "react";
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
- // A short deployment/network gap should not strand the form in busy mode.
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
- if (mode === "signup") {
58
- const res = await signUp.email({ email, password, name: name.trim() || email });
59
- if (res.error) {
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
- const res = await signIn.email({ email, password });
88
- if (res.error) {
89
- setMsg(res.error.message ?? "Sign in failed.");
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
- setMsg("Sign in succeeded, but the session cookie was not accepted. Check that you are using the configured HTTPS domain.");
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}>{mode === "signup" ? "FIRST-RUN SETUP" : "WELCOME BACK"}</p>
112
- <h2 style={{ fontSize: 27, margin: "8px 0" }}>
113
- {loading ? "Checking server…" : mode === "signup" ? "Create Admin account" : "Sign in to MOP-AGENT"}
114
- </h2>
115
- <p style={{ color: "#8c9bb0", lineHeight: 1.55, marginTop: 0 }}>
116
- {mode === "signup"
117
- ? "This first account controls providers, team access, projects, and system memory."
118
- : "Use your Admin or invited team account."}
119
- </p>
120
-
121
- {!loading && (
122
- <form onSubmit={submit} style={{ display: "grid", gap: 14, marginTop: 28 }}>
123
- {mode === "signup" && (
124
- <label style={label}>Display name
125
- <input placeholder="Admin" autoComplete="name" value={name} onChange={(e) => setName(e.target.value)} style={inputStyle} />
126
- </label>
127
- )}
128
- <label style={label}>Email
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("/setup");
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
- <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>
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&nbsp;
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
- <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>
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("/setup");
13
+ if (!session) redirect("/login");
14
14
  return session;
15
15
  }
16
16
 
@@ -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
- console.log(c("green", "\n✓ updated, rebuilt, and service verified\n"));
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
  }
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "mop-agent",
3
- "version": "0.1.8",
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.8",
9
+ "version": "0.1.10",
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.8",
3
+ "version": "0.1.10",
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",