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.
@@ -1,63 +1,206 @@
1
1
  "use client";
2
- /** Provider settings — plug an AI key (stored encrypted). */
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 [msg, setMsg] = useState("");
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
- function load() {
16
- fetch("/api/providers").then((r) => r.json()).then((d) => { setConfig(d.config); setEnv(d.env); }).catch(() => {});
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 save(e: React.FormEvent) {
49
+ async function saveProvider(e: React.FormEvent) {
21
50
  e.preventDefault();
22
- setMsg("…");
23
- const r = await fetch("/api/providers", {
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 d = await r.json();
29
- setMsg(r.ok ? " saved (key encrypted)" : `error: ${d.error}`);
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 (r.ok) setConfig(d.config);
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
- <main style={{ maxWidth: 520, margin: "0 auto", padding: "48px 24px" }}>
36
- <a href="/assistant" style={{ color: "#742220" }}>← Assistant</a>
37
- <h1 style={{ fontSize: 24 }}>⚙️ Provider settings</h1>
38
-
39
- <div style={{ border: "1px solid rgba(45,74,62,.28)", borderRadius: 8, padding: 14, margin: "12px 0", opacity: 0.9, background: "#fffdf2" }}>
40
- {config.configured
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
- </div>
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
- <form onSubmit={save} style={{ display: "grid", gap: 12 }}>
49
- <select value={provider} onChange={(e) => setProvider(e.target.value as "anthropic" | "openrouter")} style={inp}>
50
- <option value="openrouter">OpenRouter (any model)</option>
51
- <option value="anthropic">Anthropic</option>
52
- </select>
53
- <input placeholder="API key" type="password" value={apiKey} onChange={(e) => setApiKey(e.target.value)} required style={inp} />
54
- <input placeholder={provider === "anthropic" ? "model (e.g. claude-sonnet-4-6)" : "model (e.g. anthropic/claude-sonnet-4.6)"} value={model} onChange={(e) => setModel(e.target.value)} style={inp} />
55
- <button type="submit" style={btn}>Save</button>
56
- </form>
57
- {msg && <p style={{ marginTop: 14, opacity: 0.85 }}>{msg}</p>}
58
- </main>
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 inp: React.CSSProperties = { padding: "10px 12px", borderRadius: 8, border: "1px solid rgba(45,74,62,.32)", background: "#fffdf2", color: "#2d4a3e" };
63
- const btn: React.CSSProperties = { padding: "10px 12px", borderRadius: 8, border: "1px solid #742220", background: "#742220", color: "#fef9e1", cursor: "pointer" };
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 { 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" };
@@ -1,10 +1,10 @@
1
1
  import type { ReactNode } from "react";
2
- import { requirePageSession } from "@/lib/page-auth";
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 requirePageSession();
8
+ await requireOwnerPage();
9
9
  return children;
10
10
  }
@@ -1,86 +1,5 @@
1
- "use client";
2
- /** Team — your role, members, and (owner) invite management. */
3
- import { useEffect, useState } from "react";
1
+ import { redirect } from "next/navigation";
4
2
 
5
- type Me = { user: { email: string; name: string }; role: string };
6
- type Member = { id: string; email: string; name: string; role: string };
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" };