mop-agent 0.1.7 → 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 +14 -10
- package/apps/web/app/api/members/route.ts +39 -1
- package/apps/web/app/assistant/layout.tsx +12 -2
- package/apps/web/app/assistant/page.tsx +75 -109
- package/apps/web/app/brain/[projectId]/page.tsx +1 -1
- package/apps/web/app/brain/graph/page.tsx +1 -1
- package/apps/web/app/brain/layout.tsx +12 -2
- package/apps/web/app/brain/page.tsx +10 -9
- package/apps/web/app/chat/[projectId]/page.tsx +1 -1
- package/apps/web/app/chat/layout.tsx +12 -2
- package/apps/web/app/globals.css +401 -2
- package/apps/web/app/login/layout.tsx +16 -0
- package/apps/web/app/login/page.tsx +83 -0
- package/apps/web/app/settings/layout.tsx +12 -3
- package/apps/web/app/settings/page.tsx +178 -35
- package/apps/web/app/setup/layout.tsx +15 -0
- package/apps/web/app/setup/page.tsx +28 -91
- package/apps/web/app/team/layout.tsx +2 -2
- package/apps/web/app/team/page.tsx +3 -84
- package/apps/web/components/AppShell.tsx +110 -0
- package/apps/web/lib/page-auth.ts +9 -2
- package/installer/mop-agent.mjs +62 -3
- package/npm-shrinkwrap.json +2 -2
- package/package.json +2 -1
|
@@ -1,63 +1,206 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
|
|
2
|
+
|
|
3
|
+
import type { CSSProperties } from "react";
|
|
3
4
|
import { useEffect, useState } from "react";
|
|
4
5
|
|
|
6
|
+
type Section = "providers" | "users";
|
|
5
7
|
type Masked = { configured: boolean; provider?: string; model?: string | null; keyHint?: string };
|
|
8
|
+
type Member = { id: string; email: string; name: string; role: string };
|
|
6
9
|
|
|
7
10
|
export default function SettingsPage() {
|
|
11
|
+
const [section, setSection] = useState<Section>("providers");
|
|
8
12
|
const [config, setConfig] = useState<Masked>({ configured: false });
|
|
9
13
|
const [env, setEnv] = useState<{ anthropic: boolean; openrouter: boolean }>({ anthropic: false, openrouter: false });
|
|
10
14
|
const [provider, setProvider] = useState<"anthropic" | "openrouter">("openrouter");
|
|
11
15
|
const [apiKey, setApiKey] = useState("");
|
|
12
16
|
const [model, setModel] = useState("");
|
|
13
|
-
const [
|
|
17
|
+
const [providerMsg, setProviderMsg] = useState("");
|
|
18
|
+
const [members, setMembers] = useState<Member[]>([]);
|
|
19
|
+
const [userName, setUserName] = useState("");
|
|
20
|
+
const [email, setEmail] = useState("");
|
|
21
|
+
const [password, setPassword] = useState("");
|
|
22
|
+
const [role, setRole] = useState<"member" | "owner">("member");
|
|
23
|
+
const [userMsg, setUserMsg] = useState("");
|
|
24
|
+
|
|
25
|
+
function loadProvider() {
|
|
26
|
+
fetch("/api/providers").then((r) => r.json()).then((data) => {
|
|
27
|
+
setConfig(data.config ?? { configured: false });
|
|
28
|
+
setEnv(data.env ?? { anthropic: false, openrouter: false });
|
|
29
|
+
}).catch(() => {});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function loadUsers() {
|
|
33
|
+
fetch("/api/members").then((r) => (r.ok ? r.json() : { members: [] })).then((data) => setMembers(data.members ?? [])).catch(() => {});
|
|
34
|
+
}
|
|
14
35
|
|
|
15
|
-
|
|
16
|
-
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
const requested = new URLSearchParams(window.location.search).get("section");
|
|
38
|
+
if (requested === "users") setSection("users");
|
|
39
|
+
loadProvider();
|
|
40
|
+
loadUsers();
|
|
41
|
+
}, []);
|
|
42
|
+
|
|
43
|
+
function chooseSection(next: Section) {
|
|
44
|
+
setSection(next);
|
|
45
|
+
const url = next === "providers" ? "/settings" : "/settings?section=users";
|
|
46
|
+
window.history.replaceState(null, "", url);
|
|
17
47
|
}
|
|
18
|
-
useEffect(load, []);
|
|
19
48
|
|
|
20
|
-
async function
|
|
49
|
+
async function saveProvider(e: React.FormEvent) {
|
|
21
50
|
e.preventDefault();
|
|
22
|
-
|
|
23
|
-
const
|
|
51
|
+
setProviderMsg("Saving…");
|
|
52
|
+
const response = await fetch("/api/providers", {
|
|
24
53
|
method: "POST",
|
|
25
54
|
headers: { "content-type": "application/json" },
|
|
26
55
|
body: JSON.stringify({ provider, apiKey, model: model || undefined }),
|
|
27
56
|
});
|
|
28
|
-
const
|
|
29
|
-
|
|
57
|
+
const data = await response.json();
|
|
58
|
+
setProviderMsg(response.ok ? "Provider saved. The API key is encrypted." : `Unable to save: ${data.error}`);
|
|
30
59
|
setApiKey("");
|
|
31
|
-
if (
|
|
60
|
+
if (response.ok) setConfig(data.config);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function createUser(e: React.FormEvent) {
|
|
64
|
+
e.preventDefault();
|
|
65
|
+
setUserMsg("Creating user…");
|
|
66
|
+
const response = await fetch("/api/members", {
|
|
67
|
+
method: "POST",
|
|
68
|
+
headers: { "content-type": "application/json" },
|
|
69
|
+
body: JSON.stringify({ name: userName, email, password, role }),
|
|
70
|
+
});
|
|
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
|
+
}
|
|
78
|
+
loadUsers();
|
|
32
79
|
}
|
|
33
80
|
|
|
34
81
|
return (
|
|
35
|
-
<
|
|
36
|
-
<
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
? <>Active: <strong>{config.provider}</strong>{config.model ? ` · ${config.model}` : ""} · key {config.keyHint}</>
|
|
42
|
-
: "No provider key saved yet — chat uses the offline echo provider."}
|
|
43
|
-
<div style={{ fontSize: 12, opacity: 0.6, marginTop: 6 }}>
|
|
44
|
-
env keys: anthropic {env.anthropic ? "✓" : "—"} · openrouter {env.openrouter ? "✓" : "—"} (DB config overrides env)
|
|
82
|
+
<div className="mop-page">
|
|
83
|
+
<header className="mop-page-heading">
|
|
84
|
+
<div>
|
|
85
|
+
<p className="mop-page-kicker">ADMIN CONTROL</p>
|
|
86
|
+
<h1>Settings</h1>
|
|
87
|
+
<p>Configure system-wide AI providers and user access.</p>
|
|
45
88
|
</div>
|
|
46
|
-
|
|
89
|
+
<span style={adminBadge}>ADMIN ONLY</span>
|
|
90
|
+
</header>
|
|
91
|
+
|
|
92
|
+
<div className="mop-settings-grid">
|
|
93
|
+
<aside className="mop-settings-nav mop-panel" aria-label="Settings sections">
|
|
94
|
+
<button className={section === "providers" ? "is-active" : ""} onClick={() => chooseSection("providers")}>
|
|
95
|
+
<span>◇</span><strong>Providers</strong>
|
|
96
|
+
</button>
|
|
97
|
+
<button className={section === "users" ? "is-active" : ""} onClick={() => chooseSection("users")}>
|
|
98
|
+
<span>♙</span><strong>Users</strong>
|
|
99
|
+
</button>
|
|
100
|
+
</aside>
|
|
101
|
+
|
|
102
|
+
<section className="mop-settings-content mop-panel">
|
|
103
|
+
{section === "providers" ? (
|
|
104
|
+
<>
|
|
105
|
+
<div style={sectionHeading}>
|
|
106
|
+
<div>
|
|
107
|
+
<p className="mop-page-kicker">AI CONNECTION</p>
|
|
108
|
+
<h2 style={titleStyle}>Providers</h2>
|
|
109
|
+
</div>
|
|
110
|
+
<span style={{ ...statusBadge, color: config.configured ? "#2d4a3e" : "#742220" }}>
|
|
111
|
+
{config.configured ? "● CONNECTED" : "● OFFLINE DEMO"}
|
|
112
|
+
</span>
|
|
113
|
+
</div>
|
|
47
114
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
115
|
+
<div style={summaryCard}>
|
|
116
|
+
{config.configured ? (
|
|
117
|
+
<><strong>{config.provider}</strong>{config.model ? ` · ${config.model}` : ""}<span style={muted}> · key {config.keyHint}</span></>
|
|
118
|
+
) : (
|
|
119
|
+
<>No provider key saved. Assistant currently uses the offline echo provider.</>
|
|
120
|
+
)}
|
|
121
|
+
<div style={{ ...muted, marginTop: 7, fontSize: 12 }}>
|
|
122
|
+
Environment: Anthropic {env.anthropic ? "available" : "not set"} · OpenRouter {env.openrouter ? "available" : "not set"}
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
|
|
126
|
+
<form onSubmit={saveProvider} style={formGrid}>
|
|
127
|
+
<label style={labelStyle}>Provider
|
|
128
|
+
<select value={provider} onChange={(e) => setProvider(e.target.value as "anthropic" | "openrouter")} style={inputStyle}>
|
|
129
|
+
<option value="openrouter">OpenRouter</option>
|
|
130
|
+
<option value="anthropic">Anthropic</option>
|
|
131
|
+
</select>
|
|
132
|
+
</label>
|
|
133
|
+
<label style={labelStyle}>API key
|
|
134
|
+
<input placeholder="Paste a new API key" type="password" value={apiKey} onChange={(e) => setApiKey(e.target.value)} required style={inputStyle} />
|
|
135
|
+
</label>
|
|
136
|
+
<label style={labelStyle}>Model
|
|
137
|
+
<input placeholder={provider === "anthropic" ? "claude-sonnet-4-6" : "anthropic/claude-sonnet-4.6"} value={model} onChange={(e) => setModel(e.target.value)} style={inputStyle} />
|
|
138
|
+
</label>
|
|
139
|
+
<button type="submit" style={primaryButton}>SAVE PROVIDER</button>
|
|
140
|
+
</form>
|
|
141
|
+
{providerMsg && <p style={messageStyle}>{providerMsg}</p>}
|
|
142
|
+
</>
|
|
143
|
+
) : (
|
|
144
|
+
<>
|
|
145
|
+
<div style={sectionHeading}>
|
|
146
|
+
<div>
|
|
147
|
+
<p className="mop-page-kicker">ACCESS CONTROL</p>
|
|
148
|
+
<h2 style={titleStyle}>Users</h2>
|
|
149
|
+
</div>
|
|
150
|
+
<span style={statusBadge}>{members.length} ACCOUNTS</span>
|
|
151
|
+
</div>
|
|
152
|
+
|
|
153
|
+
<div style={{ overflowX: "auto" }}>
|
|
154
|
+
<table style={tableStyle}>
|
|
155
|
+
<thead><tr><th>User</th><th>Email</th><th>Role</th></tr></thead>
|
|
156
|
+
<tbody>
|
|
157
|
+
{members.map((member) => (
|
|
158
|
+
<tr key={member.id}>
|
|
159
|
+
<td><span style={miniAvatar}>{(member.name || member.email).slice(0, 1).toUpperCase()}</span>{member.name || "Unnamed"}</td>
|
|
160
|
+
<td>{member.email}</td>
|
|
161
|
+
<td><span style={member.role === "owner" ? ownerRole : memberRole}>{member.role === "owner" ? "ADMIN" : "MEMBER"}</span></td>
|
|
162
|
+
</tr>
|
|
163
|
+
))}
|
|
164
|
+
</tbody>
|
|
165
|
+
</table>
|
|
166
|
+
</div>
|
|
167
|
+
|
|
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} />
|
|
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} />
|
|
175
|
+
<select value={role} onChange={(e) => setRole(e.target.value as "member" | "owner")} style={inputStyle}>
|
|
176
|
+
<option value="member">Member</option>
|
|
177
|
+
<option value="owner">Admin</option>
|
|
178
|
+
</select>
|
|
179
|
+
<button type="submit" style={primaryButton}>ADD USER</button>
|
|
180
|
+
</form>
|
|
181
|
+
{userMsg && <p style={messageStyle}>{userMsg}</p>}
|
|
182
|
+
</div>
|
|
183
|
+
</>
|
|
184
|
+
)}
|
|
185
|
+
</section>
|
|
186
|
+
</div>
|
|
187
|
+
</div>
|
|
59
188
|
);
|
|
60
189
|
}
|
|
61
190
|
|
|
62
|
-
const
|
|
63
|
-
const
|
|
191
|
+
const adminBadge: CSSProperties = { padding: "7px 10px", color: "#fef9e1", background: "#742220", fontFamily: '"SFMono-Regular", Consolas, monospace', fontSize: 10, fontWeight: 900, letterSpacing: ".12em" };
|
|
192
|
+
const sectionHeading: CSSProperties = { display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12, marginBottom: 20, paddingBottom: 15, borderBottom: "1px solid rgba(45,74,62,.24)" };
|
|
193
|
+
const titleStyle: CSSProperties = { margin: 0, fontFamily: '"SFMono-Regular", Consolas, monospace', fontSize: 22 };
|
|
194
|
+
const statusBadge: CSSProperties = { padding: "6px 8px", border: "1px solid rgba(45,74,62,.32)", color: "#2d4a3e", fontFamily: '"SFMono-Regular", Consolas, monospace', fontSize: 9, fontWeight: 900, letterSpacing: ".1em" };
|
|
195
|
+
const summaryCard: CSSProperties = { padding: 15, border: "1px solid rgba(45,74,62,.24)", background: "rgba(254,249,225,.72)", lineHeight: 1.5 };
|
|
196
|
+
const muted: CSSProperties = { color: "rgba(45,74,62,.58)" };
|
|
197
|
+
const formGrid: CSSProperties = { display: "grid", gap: 13, marginTop: 20 };
|
|
198
|
+
const labelStyle: CSSProperties = { display: "grid", gap: 6, color: "#742220", fontFamily: '"SFMono-Regular", Consolas, monospace', fontSize: 11, fontWeight: 800, letterSpacing: ".07em", textTransform: "uppercase" };
|
|
199
|
+
const inputStyle: CSSProperties = { width: "100%", minHeight: 40, padding: "9px 11px", border: "1px solid rgba(45,74,62,.4)", background: "#fffdf2", color: "#2d4a3e" };
|
|
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" };
|
|
201
|
+
const messageStyle: CSSProperties = { padding: "9px 11px", borderLeft: "3px solid #742220", background: "rgba(116,34,32,.06)", fontSize: 13 };
|
|
202
|
+
const tableStyle: CSSProperties = { width: "100%", borderCollapse: "collapse", fontSize: 13 };
|
|
203
|
+
const miniAvatar: CSSProperties = { width: 28, height: 28, display: "inline-grid", placeItems: "center", marginRight: 9, background: "#2d4a3e", color: "#fef9e1", fontWeight: 900 };
|
|
204
|
+
const ownerRole: CSSProperties = { padding: "4px 7px", background: "#742220", color: "#fef9e1", fontSize: 9, fontWeight: 900 };
|
|
205
|
+
const memberRole: CSSProperties = { ...ownerRole, color: "#2d4a3e", background: "rgba(45,74,62,.12)" };
|
|
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" };
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import type { ReactNode } from "react";
|
|
2
|
-
import {
|
|
2
|
+
import { requireOwnerPage } from "@/lib/page-auth";
|
|
3
3
|
|
|
4
4
|
export const dynamic = "force-dynamic";
|
|
5
5
|
export const revalidate = 0;
|
|
6
6
|
|
|
7
7
|
export default async function TeamLayout({ children }: { children: ReactNode }) {
|
|
8
|
-
await
|
|
8
|
+
await requireOwnerPage();
|
|
9
9
|
return children;
|
|
10
10
|
}
|
|
@@ -1,86 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
/** Team — your role, members, and (owner) invite management. */
|
|
3
|
-
import { useEffect, useState } from "react";
|
|
1
|
+
import { redirect } from "next/navigation";
|
|
4
2
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
type Invite = { email: string; role: string; expiresAt: number; usedAt: number | null };
|
|
8
|
-
|
|
9
|
-
export default function TeamPage() {
|
|
10
|
-
const [me, setMe] = useState<Me | null>(null);
|
|
11
|
-
const [members, setMembers] = useState<Member[]>([]);
|
|
12
|
-
const [invites, setInvites] = useState<Invite[]>([]);
|
|
13
|
-
const [email, setEmail] = useState("");
|
|
14
|
-
const [role, setRole] = useState<"member" | "owner">("member");
|
|
15
|
-
const [msg, setMsg] = useState("");
|
|
16
|
-
|
|
17
|
-
const isOwner = me?.role === "owner";
|
|
18
|
-
|
|
19
|
-
function load() {
|
|
20
|
-
fetch("/api/me").then((r) => r.json()).then(setMe).catch(() => {});
|
|
21
|
-
fetch("/api/members").then((r) => (r.ok ? r.json() : { members: [] })).then((d) => setMembers(d.members ?? [])).catch(() => {});
|
|
22
|
-
fetch("/api/invites").then((r) => (r.ok ? r.json() : { invites: [] })).then((d) => setInvites(d.invites ?? [])).catch(() => {});
|
|
23
|
-
}
|
|
24
|
-
useEffect(load, []);
|
|
25
|
-
|
|
26
|
-
async function invite(e: React.FormEvent) {
|
|
27
|
-
e.preventDefault();
|
|
28
|
-
setMsg("…");
|
|
29
|
-
const r = await fetch("/api/invites", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ email, role }) });
|
|
30
|
-
setMsg(r.ok ? `✅ invited ${email}` : `error ${r.status}`);
|
|
31
|
-
setEmail("");
|
|
32
|
-
load();
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
async function revoke(em: string) {
|
|
36
|
-
await fetch(`/api/invites?email=${encodeURIComponent(em)}`, { method: "DELETE" });
|
|
37
|
-
load();
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
return (
|
|
41
|
-
<main style={{ maxWidth: 640, margin: "0 auto", padding: "40px 24px" }}>
|
|
42
|
-
<a href="/brain" style={{ color: "#742220" }}>← Brain</a>
|
|
43
|
-
<h1 style={{ fontSize: 24 }}>👥 Team</h1>
|
|
44
|
-
<p style={{ opacity: 0.7 }}>You are <strong>{me?.user.email}</strong> · role <strong>{me?.role}</strong></p>
|
|
45
|
-
|
|
46
|
-
<h2 style={{ fontSize: 16, marginTop: 20 }}>Members ({members.length})</h2>
|
|
47
|
-
<ul style={{ listStyle: "none", padding: 0 }}>
|
|
48
|
-
{members.map((m) => (
|
|
49
|
-
<li key={m.id} style={row}>{m.role === "owner" ? "👑" : "👤"} {m.email} <span style={{ opacity: 0.5 }}>· {m.role}</span></li>
|
|
50
|
-
))}
|
|
51
|
-
{!isOwner && members.length === 0 && <p style={{ opacity: 0.5 }}>Owner-only view.</p>}
|
|
52
|
-
</ul>
|
|
53
|
-
|
|
54
|
-
{isOwner && (
|
|
55
|
-
<>
|
|
56
|
-
<h2 style={{ fontSize: 16, marginTop: 20 }}>Invite a member</h2>
|
|
57
|
-
<form onSubmit={invite} style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
|
58
|
-
<input placeholder="email" type="email" required value={email} onChange={(e) => setEmail(e.target.value)} style={inp} />
|
|
59
|
-
<select value={role} onChange={(e) => setRole(e.target.value as "member" | "owner")} style={inp}>
|
|
60
|
-
<option value="member">member</option>
|
|
61
|
-
<option value="owner">owner</option>
|
|
62
|
-
</select>
|
|
63
|
-
<button type="submit" style={btn}>Invite</button>
|
|
64
|
-
</form>
|
|
65
|
-
{msg && <p style={{ opacity: 0.8 }}>{msg}</p>}
|
|
66
|
-
|
|
67
|
-
<h2 style={{ fontSize: 16, marginTop: 20 }}>Pending invites</h2>
|
|
68
|
-
<ul style={{ listStyle: "none", padding: 0 }}>
|
|
69
|
-
{invites.filter((i) => !i.usedAt).map((i) => (
|
|
70
|
-
<li key={i.email} style={row}>
|
|
71
|
-
✉️ {i.email} <span style={{ opacity: 0.5 }}>· {i.role}</span>
|
|
72
|
-
<button onClick={() => revoke(i.email)} style={{ float: "right", ...btn, background: "#742220", borderColor: "#742220", padding: "2px 10px" }}>revoke</button>
|
|
73
|
-
</li>
|
|
74
|
-
))}
|
|
75
|
-
{invites.filter((i) => !i.usedAt).length === 0 && <p style={{ opacity: 0.5 }}>None.</p>}
|
|
76
|
-
</ul>
|
|
77
|
-
<p style={{ opacity: 0.55, fontSize: 13 }}>Invited people sign up at <code>/setup</code> with that exact email.</p>
|
|
78
|
-
</>
|
|
79
|
-
)}
|
|
80
|
-
</main>
|
|
81
|
-
);
|
|
3
|
+
export default function LegacyTeamPage() {
|
|
4
|
+
redirect("/settings?section=users");
|
|
82
5
|
}
|
|
83
|
-
|
|
84
|
-
const row: React.CSSProperties = { border: "1px solid rgba(45,74,62,.28)", borderRadius: 8, padding: "8px 12px", marginBottom: 6, background: "#fffdf2" };
|
|
85
|
-
const inp: React.CSSProperties = { padding: "8px 10px", borderRadius: 8, border: "1px solid rgba(45,74,62,.32)", background: "#fffdf2", color: "#2d4a3e" };
|
|
86
|
-
const btn: React.CSSProperties = { padding: "8px 14px", borderRadius: 8, border: "1px solid #742220", background: "#742220", color: "#fef9e1", cursor: "pointer" };
|