@rubytech/create-maxy 0.3.7 → 0.3.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/package.json
CHANGED
|
@@ -2,10 +2,27 @@ import { NextResponse } from 'next/server'
|
|
|
2
2
|
import { registerSession } from '../../../lib/claude-agent'
|
|
3
3
|
import { readFileSync, readdirSync, existsSync } from 'node:fs'
|
|
4
4
|
import { resolve } from 'node:path'
|
|
5
|
+
import { createHash } from 'node:crypto'
|
|
5
6
|
|
|
6
|
-
const
|
|
7
|
+
const PERSISTENT_DIR = resolve(process.env.HOME ?? '/root', '.maxy')
|
|
8
|
+
const PIN_FILE = resolve(PERSISTENT_DIR, '.admin-pin')
|
|
7
9
|
const ACCOUNTS_DIR = resolve(process.cwd(), '../platform/config/accounts')
|
|
8
10
|
|
|
11
|
+
function hashPin(pin: string): string {
|
|
12
|
+
return createHash('sha256').update(pin).digest('hex')
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function getStoredPinHash(): string | null {
|
|
16
|
+
if (existsSync(PIN_FILE)) {
|
|
17
|
+
return readFileSync(PIN_FILE, 'utf-8').trim()
|
|
18
|
+
}
|
|
19
|
+
// Fallback to env var (legacy)
|
|
20
|
+
if (process.env.ADMIN_PIN) {
|
|
21
|
+
return hashPin(process.env.ADMIN_PIN)
|
|
22
|
+
}
|
|
23
|
+
return null
|
|
24
|
+
}
|
|
25
|
+
|
|
9
26
|
function getDefaultAccountId(): string | null {
|
|
10
27
|
if (!existsSync(ACCOUNTS_DIR)) return null
|
|
11
28
|
const entries = readdirSync(ACCOUNTS_DIR, { withFileTypes: true })
|
|
@@ -25,9 +42,11 @@ function getDefaultAccountId(): string | null {
|
|
|
25
42
|
* Body: { pin: string }
|
|
26
43
|
*/
|
|
27
44
|
export async function POST(req: Request) {
|
|
28
|
-
|
|
45
|
+
const storedHash = getStoredPinHash()
|
|
46
|
+
|
|
47
|
+
if (!storedHash) {
|
|
29
48
|
return NextResponse.json(
|
|
30
|
-
{ error: '
|
|
49
|
+
{ error: 'PIN not configured. Complete onboarding first.' },
|
|
31
50
|
{ status: 503 },
|
|
32
51
|
)
|
|
33
52
|
}
|
|
@@ -39,14 +58,11 @@ export async function POST(req: Request) {
|
|
|
39
58
|
return NextResponse.json({ error: 'Invalid request' }, { status: 400 })
|
|
40
59
|
}
|
|
41
60
|
|
|
42
|
-
if (!body.pin || body.pin !==
|
|
61
|
+
if (!body.pin || hashPin(body.pin) !== storedHash) {
|
|
43
62
|
return NextResponse.json({ error: 'Invalid PIN' }, { status: 401 })
|
|
44
63
|
}
|
|
45
64
|
|
|
46
|
-
const accountId = getDefaultAccountId()
|
|
47
|
-
if (!accountId) {
|
|
48
|
-
return NextResponse.json({ error: 'No account configured' }, { status: 503 })
|
|
49
|
-
}
|
|
65
|
+
const accountId = getDefaultAccountId() ?? 'default'
|
|
50
66
|
|
|
51
67
|
const sessionKey = crypto.randomUUID()
|
|
52
68
|
registerSession(sessionKey, 'admin', accountId)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import { existsSync } from 'node:fs'
|
|
3
|
+
import { resolve } from 'node:path'
|
|
4
|
+
|
|
5
|
+
const INSTALL_DIR = resolve(process.cwd(), '..')
|
|
6
|
+
const PIN_FILE = resolve(INSTALL_DIR, '.maxy', '.admin-pin')
|
|
7
|
+
const HOME_PIN_FILE = resolve(process.env.HOME ?? '/root', '.maxy', '.admin-pin')
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* GET /api/health
|
|
11
|
+
* Returns platform state for the onboarding flow.
|
|
12
|
+
*/
|
|
13
|
+
export async function GET() {
|
|
14
|
+
const pinConfigured = existsSync(PIN_FILE) || existsSync(HOME_PIN_FILE) || !!process.env.ADMIN_PIN
|
|
15
|
+
|
|
16
|
+
// TODO: check Claude Code auth status
|
|
17
|
+
const claudeAuthenticated = false
|
|
18
|
+
|
|
19
|
+
return NextResponse.json({
|
|
20
|
+
pin_configured: pinConfigured,
|
|
21
|
+
claude_authenticated: claudeAuthenticated,
|
|
22
|
+
})
|
|
23
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import { existsSync, writeFileSync, mkdirSync, readFileSync } from 'node:fs'
|
|
3
|
+
import { resolve } from 'node:path'
|
|
4
|
+
import { createHash } from 'node:crypto'
|
|
5
|
+
|
|
6
|
+
const PERSISTENT_DIR = resolve(process.env.HOME ?? '/root', '.maxy')
|
|
7
|
+
const PIN_FILE = resolve(PERSISTENT_DIR, '.admin-pin')
|
|
8
|
+
|
|
9
|
+
function hashPin(pin: string): string {
|
|
10
|
+
return createHash('sha256').update(pin).digest('hex')
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* POST /api/onboarding/set-pin
|
|
15
|
+
* Sets the admin PIN. Only works when no PIN is configured.
|
|
16
|
+
* Body: { pin: string }
|
|
17
|
+
*/
|
|
18
|
+
export async function POST(req: Request) {
|
|
19
|
+
if (existsSync(PIN_FILE)) {
|
|
20
|
+
return NextResponse.json(
|
|
21
|
+
{ error: 'PIN is already configured.' },
|
|
22
|
+
{ status: 409 },
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let body: { pin: string }
|
|
27
|
+
try {
|
|
28
|
+
body = await req.json()
|
|
29
|
+
} catch {
|
|
30
|
+
return NextResponse.json({ error: 'Invalid request' }, { status: 400 })
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (!body.pin || body.pin.length < 4) {
|
|
34
|
+
return NextResponse.json(
|
|
35
|
+
{ error: 'PIN must be at least 4 characters.' },
|
|
36
|
+
{ status: 400 },
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
mkdirSync(PERSISTENT_DIR, { recursive: true })
|
|
41
|
+
writeFileSync(PIN_FILE, hashPin(body.pin), { mode: 0o600 })
|
|
42
|
+
|
|
43
|
+
return NextResponse.json({ ok: true })
|
|
44
|
+
}
|
|
@@ -1340,11 +1340,42 @@ a:hover {
|
|
|
1340
1340
|
|
|
1341
1341
|
.admin-pin-form form {
|
|
1342
1342
|
display: flex;
|
|
1343
|
+
flex-direction: column;
|
|
1343
1344
|
gap: 8px;
|
|
1344
1345
|
width: 100%;
|
|
1345
1346
|
max-width: 300px;
|
|
1346
1347
|
}
|
|
1347
1348
|
|
|
1349
|
+
.pin-input-row {
|
|
1350
|
+
display: flex;
|
|
1351
|
+
gap: 8px;
|
|
1352
|
+
align-items: center;
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
.pin-input-row .chat-input {
|
|
1356
|
+
flex: 1;
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
.pin-toggle {
|
|
1360
|
+
background: none;
|
|
1361
|
+
border: 1px solid var(--border-strong);
|
|
1362
|
+
border-radius: 50%;
|
|
1363
|
+
width: 40px;
|
|
1364
|
+
height: 40px;
|
|
1365
|
+
cursor: pointer;
|
|
1366
|
+
font-size: 16px;
|
|
1367
|
+
color: var(--text-secondary);
|
|
1368
|
+
flex-shrink: 0;
|
|
1369
|
+
display: flex;
|
|
1370
|
+
align-items: center;
|
|
1371
|
+
justify-content: center;
|
|
1372
|
+
transition: border-color 0.15s;
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
.pin-toggle:hover {
|
|
1376
|
+
border-color: var(--sage);
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1348
1379
|
.admin-pin-error {
|
|
1349
1380
|
color: #c44;
|
|
1350
1381
|
font-size: 14px;
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
import { useState, useRef, useEffect, FormEvent } from 'react'
|
|
4
4
|
import { ActivityEvent, type AdminEvent } from './admin/components/ActivityEvent'
|
|
5
5
|
|
|
6
|
+
type AppState = 'loading' | 'set-pin' | 'enter-pin' | 'chat'
|
|
7
|
+
|
|
6
8
|
interface Message {
|
|
7
9
|
role: 'admin' | 'maxy'
|
|
8
10
|
content?: string
|
|
@@ -10,9 +12,11 @@ interface Message {
|
|
|
10
12
|
}
|
|
11
13
|
|
|
12
14
|
export default function AdminPage() {
|
|
13
|
-
const [
|
|
15
|
+
const [appState, setAppState] = useState<AppState>('loading')
|
|
14
16
|
const [pin, setPin] = useState('')
|
|
17
|
+
const [confirmPin, setConfirmPin] = useState('')
|
|
15
18
|
const [pinError, setPinError] = useState('')
|
|
19
|
+
const [showPin, setShowPin] = useState(false)
|
|
16
20
|
const [sessionKey, setSessionKey] = useState<string | null>(null)
|
|
17
21
|
const [messages, setMessages] = useState<Message[]>([])
|
|
18
22
|
const [input, setInput] = useState('')
|
|
@@ -25,22 +29,80 @@ export default function AdminPage() {
|
|
|
25
29
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
|
26
30
|
}, [messages])
|
|
27
31
|
|
|
32
|
+
// Check platform state on mount
|
|
28
33
|
useEffect(() => {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
34
|
+
async function checkHealth() {
|
|
35
|
+
try {
|
|
36
|
+
const res = await fetch('/api/health')
|
|
37
|
+
if (!res.ok) {
|
|
38
|
+
setAppState('set-pin')
|
|
39
|
+
return
|
|
40
|
+
}
|
|
41
|
+
const health = await res.json()
|
|
42
|
+
setAppState(health.pin_configured ? 'enter-pin' : 'set-pin')
|
|
43
|
+
} catch {
|
|
44
|
+
setAppState('set-pin')
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
checkHealth()
|
|
48
|
+
}, [])
|
|
49
|
+
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
if (appState === 'set-pin' || appState === 'enter-pin') {
|
|
52
|
+
setTimeout(() => pinInputRef.current?.focus(), 100)
|
|
53
|
+
}
|
|
54
|
+
if (appState === 'chat') {
|
|
55
|
+
setTimeout(() => inputRef.current?.focus(), 100)
|
|
56
|
+
}
|
|
57
|
+
}, [appState])
|
|
32
58
|
|
|
33
|
-
async function
|
|
59
|
+
async function handleSetPin(e: FormEvent) {
|
|
34
60
|
e.preventDefault()
|
|
35
61
|
setPinError('')
|
|
36
62
|
|
|
63
|
+
if (pin.length < 4) {
|
|
64
|
+
setPinError('PIN must be at least 4 characters.')
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
if (pin !== confirmPin) {
|
|
68
|
+
setPinError('PINs do not match.')
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
|
|
37
72
|
try {
|
|
38
|
-
const res = await fetch('/api/
|
|
73
|
+
const res = await fetch('/api/onboarding/set-pin', {
|
|
39
74
|
method: 'POST',
|
|
40
75
|
headers: { 'Content-Type': 'application/json' },
|
|
41
76
|
body: JSON.stringify({ pin }),
|
|
42
77
|
})
|
|
43
78
|
|
|
79
|
+
if (!res.ok) {
|
|
80
|
+
const data = await res.json().catch(() => ({}))
|
|
81
|
+
setPinError(data.error || 'Failed to set PIN.')
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// PIN set — now log in with it
|
|
86
|
+
await doLogin(pin)
|
|
87
|
+
} catch {
|
|
88
|
+
setPinError('Could not connect.')
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function handleLogin(e: FormEvent) {
|
|
93
|
+
e.preventDefault()
|
|
94
|
+
setPinError('')
|
|
95
|
+
await doLogin(pin)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function doLogin(pinValue: string) {
|
|
99
|
+
try {
|
|
100
|
+
const res = await fetch('/api/admin/session', {
|
|
101
|
+
method: 'POST',
|
|
102
|
+
headers: { 'Content-Type': 'application/json' },
|
|
103
|
+
body: JSON.stringify({ pin: pinValue }),
|
|
104
|
+
})
|
|
105
|
+
|
|
44
106
|
if (!res.ok) {
|
|
45
107
|
const data = await res.json().catch(() => ({}))
|
|
46
108
|
setPinError(data.error || 'Invalid PIN')
|
|
@@ -49,7 +111,9 @@ export default function AdminPage() {
|
|
|
49
111
|
|
|
50
112
|
const data = await res.json()
|
|
51
113
|
setSessionKey(data.session_key)
|
|
52
|
-
|
|
114
|
+
setPin('')
|
|
115
|
+
setConfirmPin('')
|
|
116
|
+
setAppState('chat')
|
|
53
117
|
} catch {
|
|
54
118
|
setPinError('Could not connect.')
|
|
55
119
|
}
|
|
@@ -97,16 +161,10 @@ export default function AdminPage() {
|
|
|
97
161
|
if (payload === '[DONE]') continue
|
|
98
162
|
|
|
99
163
|
let parsed: { type: string; [key: string]: unknown }
|
|
100
|
-
try {
|
|
101
|
-
parsed = JSON.parse(payload)
|
|
102
|
-
} catch {
|
|
103
|
-
continue
|
|
104
|
-
}
|
|
105
|
-
|
|
164
|
+
try { parsed = JSON.parse(payload) } catch { continue }
|
|
106
165
|
if (parsed.type === 'done' || parsed.type === 'session_init') continue
|
|
107
166
|
|
|
108
167
|
const event = parsed as AdminEvent
|
|
109
|
-
|
|
110
168
|
setMessages(prev => {
|
|
111
169
|
const updated = [...prev]
|
|
112
170
|
const msg = updated[messageIndex]
|
|
@@ -136,12 +194,66 @@ export default function AdminPage() {
|
|
|
136
194
|
}
|
|
137
195
|
}
|
|
138
196
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
197
|
+
// --- Loading ---
|
|
198
|
+
if (appState === 'loading') {
|
|
199
|
+
return (
|
|
200
|
+
<div className="chat-page admin-page">
|
|
201
|
+
<header className="chat-header">
|
|
202
|
+
<img src="/brand/maxy-black.png" alt="Maxy" className="chat-logo" />
|
|
203
|
+
</header>
|
|
204
|
+
</div>
|
|
205
|
+
)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// --- Set PIN (first boot) ---
|
|
209
|
+
if (appState === 'set-pin') {
|
|
210
|
+
return (
|
|
211
|
+
<div className="chat-page admin-page">
|
|
212
|
+
<header className="chat-header">
|
|
213
|
+
<img src="/brand/maxy-black.png" alt="Maxy" className="chat-logo" />
|
|
214
|
+
<h1 className="chat-tagline">Welcome to Maxy</h1>
|
|
215
|
+
<p className="chat-intro">Choose a PIN to secure your admin access.</p>
|
|
216
|
+
</header>
|
|
217
|
+
<div className="admin-pin-form">
|
|
218
|
+
<form onSubmit={handleSetPin}>
|
|
219
|
+
<div className="pin-input-row">
|
|
220
|
+
<input
|
|
221
|
+
ref={pinInputRef}
|
|
222
|
+
type={showPin ? 'text' : 'password'}
|
|
223
|
+
value={pin}
|
|
224
|
+
onChange={e => setPin(e.target.value)}
|
|
225
|
+
placeholder="Choose a PIN"
|
|
226
|
+
className="chat-input"
|
|
227
|
+
autoFocus
|
|
228
|
+
/>
|
|
229
|
+
<button type="button" className="pin-toggle" onClick={() => setShowPin(!showPin)} aria-label={showPin ? 'Hide' : 'Show'}>
|
|
230
|
+
{showPin ? '◉' : '○'}
|
|
231
|
+
</button>
|
|
232
|
+
</div>
|
|
233
|
+
<div className="pin-input-row">
|
|
234
|
+
<input
|
|
235
|
+
type={showPin ? 'text' : 'password'}
|
|
236
|
+
value={confirmPin}
|
|
237
|
+
onChange={e => setConfirmPin(e.target.value)}
|
|
238
|
+
placeholder="Confirm PIN"
|
|
239
|
+
className="chat-input"
|
|
240
|
+
/>
|
|
241
|
+
<button type="submit" className="chat-send" disabled={!pin || !confirmPin} aria-label="Set PIN">
|
|
242
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
243
|
+
<line x1="5" y1="12" x2="19" y2="12" />
|
|
244
|
+
<polyline points="12 5 19 12 12 19" />
|
|
245
|
+
</svg>
|
|
246
|
+
</button>
|
|
247
|
+
</div>
|
|
248
|
+
</form>
|
|
249
|
+
{pinError && <p className="admin-pin-error">{pinError}</p>}
|
|
250
|
+
</div>
|
|
251
|
+
</div>
|
|
252
|
+
)
|
|
142
253
|
}
|
|
143
254
|
|
|
144
|
-
|
|
255
|
+
// --- Enter PIN (returning user) ---
|
|
256
|
+
if (appState === 'enter-pin') {
|
|
145
257
|
return (
|
|
146
258
|
<div className="chat-page admin-page">
|
|
147
259
|
<header className="chat-header">
|
|
@@ -150,22 +262,27 @@ export default function AdminPage() {
|
|
|
150
262
|
<p className="chat-intro">Convenience as standard.</p>
|
|
151
263
|
</header>
|
|
152
264
|
<div className="admin-pin-form">
|
|
153
|
-
<form onSubmit={
|
|
154
|
-
<input
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
<
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
265
|
+
<form onSubmit={handleLogin}>
|
|
266
|
+
<div className="pin-input-row">
|
|
267
|
+
<input
|
|
268
|
+
ref={pinInputRef}
|
|
269
|
+
type={showPin ? 'text' : 'password'}
|
|
270
|
+
value={pin}
|
|
271
|
+
onChange={e => setPin(e.target.value)}
|
|
272
|
+
placeholder="Enter PIN"
|
|
273
|
+
className="chat-input"
|
|
274
|
+
autoFocus
|
|
275
|
+
/>
|
|
276
|
+
<button type="button" className="pin-toggle" onClick={() => setShowPin(!showPin)} aria-label={showPin ? 'Hide' : 'Show'}>
|
|
277
|
+
{showPin ? '◉' : '○'}
|
|
278
|
+
</button>
|
|
279
|
+
<button type="submit" className="chat-send" disabled={!pin}>
|
|
280
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
281
|
+
<line x1="5" y1="12" x2="19" y2="12" />
|
|
282
|
+
<polyline points="12 5 19 12 12 19" />
|
|
283
|
+
</svg>
|
|
284
|
+
</button>
|
|
285
|
+
</div>
|
|
169
286
|
</form>
|
|
170
287
|
{pinError && <p className="admin-pin-error">{pinError}</p>}
|
|
171
288
|
</div>
|
|
@@ -173,6 +290,7 @@ export default function AdminPage() {
|
|
|
173
290
|
)
|
|
174
291
|
}
|
|
175
292
|
|
|
293
|
+
// --- Chat ---
|
|
176
294
|
return (
|
|
177
295
|
<div className="chat-page admin-page">
|
|
178
296
|
<header className="chat-header">
|
|
@@ -206,7 +324,7 @@ export default function AdminPage() {
|
|
|
206
324
|
</div>
|
|
207
325
|
|
|
208
326
|
<div className="chat-input-area">
|
|
209
|
-
<form className="chat-form" onSubmit={
|
|
327
|
+
<form className="chat-form" onSubmit={(e) => { e.preventDefault(); sendMessage(input.trim()) }}>
|
|
210
328
|
<input
|
|
211
329
|
ref={inputRef}
|
|
212
330
|
className="chat-input"
|