@rubytech/create-maxy 0.3.9 → 0.4.1

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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/create-maxy",
3
- "version": "0.3.9",
3
+ "version": "0.4.1",
4
4
  "description": "Install Maxy — your personal AI assistant",
5
5
  "bin": {
6
6
  "create-maxy": "./dist/index.js"
@@ -12,7 +12,7 @@ Everything an end user does must be achievable via the chat interface. No CLI. N
12
12
  - Conversational onboarding (IDENTITY.md placeholder population) is the configuration mechanism
13
13
  - Setup scripts install infrastructure only — product configuration happens via chat
14
14
  - If there is no conversational path to use a feature, the feature is incomplete
15
- - Exception: Phase 0 seed script (dog-food product knowledge). Customer devices onboard conversationally.
15
+ - Product knowledge is ingested as markdown via the admin agent there are no seed scripts.
16
16
 
17
17
  ---
18
18
 
@@ -48,6 +48,17 @@ Infrastructure endpoints (`localhost:7687`, `localhost:11434`) may have defaults
48
48
 
49
49
  ---
50
50
 
51
+ ## No Environment Variables for Credentials
52
+
53
+ Credentials are stored as files in `~/.maxy/` (persistent across upgrades):
54
+ - Neo4j password: `~/.maxy/.neo4j-password` (generated by installer)
55
+ - Admin PIN: `~/.maxy/.admin-pin` (set via web UI on first boot)
56
+ - Claude auth: OAuth flow in the web UI (managed by Claude Code)
57
+
58
+ There is no `.env.local`. MCP servers read the password file directly.
59
+
60
+ ---
61
+
51
62
  ## PRD Is Source of Truth
52
63
 
53
64
  `MAXY-PRD.md` is the single authority for all standards, data models, and architecture. Do not rely on patterns found in existing skill code — they may diverge from the PRD.
@@ -21,12 +21,17 @@ When complete, open `http://maxy.local:19200` from any device on the same networ
21
21
  7. Creates the initial account from templates
22
22
  8. Applies the Neo4j schema
23
23
 
24
- ## Post-install (all via conversation)
24
+ ## Onboarding (all via web UI)
25
25
 
26
- Open `http://maxy.local:19200` from any device on the same network and tell the admin agent what you need:
26
+ Open `http://maxy.local:19200` from any device on the same network. The onboarding flow is:
27
27
 
28
- - "Set my admin PIN" secures admin access
29
- - "Ingest the product knowledge" loads the knowledge base
28
+ 1. **Set PIN** — choose an admin PIN (stored at `~/.maxy/.admin-pin`)
29
+ 2. **Connect Claude** OAuth flow in the web UI (no API keys, no environment variables)
30
+ 3. **Chat** — the admin agent is ready
31
+
32
+ From there, tell the admin agent what you need:
33
+
34
+ - "Ingest the product knowledge" — loads the knowledge base (ingested as markdown, not cypher)
30
35
  - "Set up Telegram" — walks through BotFather bot creation
31
36
  - "Set up Cloudflare Tunnel" — configures remote access on your domain
32
37
  - "Check system status" — verifies all services are running
@@ -16,8 +16,9 @@ Fires at the beginning of each agent session. Loads the agent's IDENTITY.md from
16
16
 
17
17
  Fires before every tool call. Enforces the public agent lockdown.
18
18
 
19
- **Public agent:** Single tool allowed:
19
+ **Public agent:** Two tools allowed:
20
20
  - `memory-search` / `mcp__maxy-memory__memory-search`
21
+ - `skill-read` / `mcp__maxy-admin__skill-read`
21
22
 
22
23
  All other tools are blocked with exit code 2 and an error message. The public agent has zero write surface.
23
24
 
@@ -13,11 +13,11 @@ Graph-backed knowledge retrieval. The backbone of RAG (Retrieval-Augmented Gener
13
13
 
14
14
  **Dependencies:** Neo4j (bolt://localhost:7687), Ollama (http://localhost:11434)
15
15
 
16
- **Environment:**
17
- - `NEO4J_URI`, `NEO4J_USER`, `NEO4J_PASSWORD` Neo4j connection
18
- - `OLLAMA_URL` Ollama API endpoint
19
- - `ACCOUNT_ID` Account scope (default: "maxy")
20
- - `READ_ONLY` When "true", memory-write and memory-reindex are not registered
16
+ **Configuration:**
17
+ - Neo4j password read from `~/.maxy/.neo4j-password` (generated by installer)
18
+ - Ollama endpoint: `http://localhost:11434` (local default)
19
+ - Account ID passed by the runtime per session
20
+ - Read-only mode: when enabled, memory-write and memory-reindex are not registered (used for public agent)
21
21
 
22
22
  ## maxy-contacts
23
23
 
@@ -40,7 +40,8 @@ Multi-channel message delivery. Phase 0: Telegram only.
40
40
  - `message-history` — Query conversation log (Communication nodes in Neo4j).
41
41
 
42
42
  **Dependencies:** Telegram Bot API
43
- **Environment:** `TELEGRAM_PUBLIC_BOT_TOKEN`
43
+
44
+ **Configuration:** Bot token stored in the account config directory (`~/maxy/platform/config/accounts/{uuid}/`), set via conversational onboarding.
44
45
 
45
46
  ## maxy-admin
46
47
 
@@ -52,7 +53,7 @@ System status and configuration reading.
52
53
  - `account-manage` — Read account config (tier, domains, settings).
53
54
  - `logs-read` — Read system, session, or error logs (tail).
54
55
 
55
- **Environment:** `PLATFORM_ROOT` path to platform directory, `ACCOUNT_ID` account UUID
56
+ **Configuration:** Platform root and account ID passed by the runtime per session.
56
57
 
57
58
  ### Cloudflare Tunnel Management
58
59
 
@@ -35,14 +35,14 @@ Neo4j's built-in HNSW vector index is used for semantic search:
35
35
 
36
36
  The `memory-search` tool embeds the query via Ollama, runs vector similarity search against the index, then expands results via 1-hop graph traversal to include related nodes (e.g., Service → PriceSpecification).
37
37
 
38
- ## Seed Data
38
+ ## Credentials
39
39
 
40
- Product knowledge is seeded via Cypher scripts in `platform/neo4j/`:
41
- - `schema.cypher` — constraints, indexes, vector index creation
42
- - `seed-product-knowledge.cypher` — all product data (tiers, features, FAQs, comparisons)
40
+ The Neo4j password is generated by the installer and stored at `~/.maxy/.neo4j-password`. MCP servers read from this file. There are no environment variables for credentials.
43
41
 
44
- Run via `platform/scripts/seed-neo4j.sh`.
42
+ ## Product Knowledge
43
+
44
+ Product knowledge is ingested as markdown via the admin agent using `memory-write`. There are no seed scripts or pre-configured cypher files for product data. The schema (constraints, indexes, vector index) is applied by the installer via `platform/neo4j/schema.cypher`.
45
45
 
46
46
  ## Embedding Pipeline
47
47
 
48
- After seeding, run `memory-reindex` (via the admin agent or directly) to compute embeddings for all nodes. This calls Ollama's `/api/embed` endpoint with `nomic-embed-text` and stores the resulting 768-dimensional vectors on each node.
48
+ When the admin agent writes knowledge via `memory-write`, embeddings are computed automatically. The `memory-reindex` tool rebuilds embeddings for any nodes missing them (e.g., after a bulk import). This calls Ollama's `/api/embed` endpoint with `nomic-embed-text` and stores the resulting 768-dimensional vectors on each node.
@@ -36,9 +36,9 @@ platform/
36
36
 
37
37
  | Agent | Access | Model | Tools | Use |
38
38
  |-------|--------|-------|-------|-----|
39
- | Public | Read-only | claude-haiku-4-5 | memory-search | Sales, product enquiries |
39
+ | Public | Read-only | claude-haiku-4-5 | memory-search, skill-read | Sales, product enquiries |
40
40
  | Admin | Full | claude-opus-4-6 | All MCP tools + Claude Code native tools | Content management, monitoring |
41
41
 
42
42
  ## Security Model
43
43
 
44
- The public agent is locked down via the `pre-tool-use.sh` hook. It can only call `memory-search` — a single read-only tool. All other tool calls are blocked with exit code 2. The public agent has zero write surface; waitlist signups captured in conversation are extracted by the admin cron review job. The admin agent has no restrictions.
44
+ The public agent is locked down via the `pre-tool-use.sh` hook. It can only call `memory-search` and `skill-read` two read-only tools. All other tool calls are blocked with exit code 2. The public agent has zero write surface; waitlist signups captured in conversation are extracted by the admin cron review job. The admin agent has no restrictions.
@@ -10,7 +10,7 @@ Skills are domain-specific behaviour modules that teach agents how to handle spe
10
10
  | `business-assistant` | CRM, scheduling, quoting, invoicing, memory-first operations | Admin agent |
11
11
  | `telegram` | Telegram bot setup guide (BotFather, webhook, admin vs public) | Admin agent |
12
12
  | `cloudflare` | Cloudflare Tunnel setup guide (installation, DNS, domain) | Admin agent |
13
- | `anthropic` | Claude connection setup (OAuth, API key) | Admin agent |
13
+ | `anthropic` | Claude connection setup (OAuth flow via web UI) | Admin agent |
14
14
 
15
15
  ## Skill Format
16
16
 
@@ -2,16 +2,20 @@
2
2
 
3
3
  The web chat is a Next.js 16 application in `maxy/` that serves both the public sales agent and the admin agent.
4
4
 
5
- ## Domain Routing
5
+ ## Routing
6
6
 
7
- | Domain | Route | Page |
7
+ | Access | Route | Page |
8
8
  |--------|-------|------|
9
- | `public.maxy.bot` | `/` | Public chat (SSE streaming, text only) |
10
- | `admin.maxy.bot` | `/admin` | Admin chat (SSE streaming, full activity) |
9
+ | Local (`maxy.local:19200`) | `/` | Admin chat (PIN-gated, SSE streaming, full activity) |
10
+ | Local (`maxy.local:19200`) | `/public` | Public chat (SSE streaming, text only) |
11
+ | `public.maxy.bot` (Cloudflare Tunnel) | `/public` | Public chat |
12
+ | `admin.maxy.bot` (Cloudflare Tunnel) | `/` | Admin chat |
11
13
  | `getmaxy.com` | `/bot` | Marketing landing page |
12
14
  | `maxy.bot` | redirect | → `public.maxy.bot` |
13
15
  | `maxy.chat` | redirect | → `public.maxy.bot` |
14
16
 
17
+ The root page (`/`) is the admin interface. Local access on the same network = admin (with PIN). The public chat is at `/public`, served to the internet only via `public.maxy.bot` through Cloudflare Tunnel.
18
+
15
19
  Routing is handled by Next.js middleware (`maxy/middleware.ts`).
16
20
 
17
21
  ## Public Chat API
@@ -53,7 +57,7 @@ The admin UI (`maxy/app/admin/page.tsx`) renders these as a Claude Code-style ac
53
57
  Both endpoints use the shared `maxy/app/lib/claude-agent.ts` module which invokes `@anthropic-ai/claude-agent-sdk`'s `query()` function with:
54
58
  - Appropriate MCP server configurations
55
59
  - System prompts tailored to agent type
56
- - Tool allowlists (public: 3 tools, admin: all)
60
+ - Tool allowlists (public: 2 tools — memory-search + skill-read, admin: all)
57
61
  - Permission modes (public: dontAsk, admin: acceptEdits)
58
62
  - Model selection (public: haiku-4-5, admin: opus-4-6)
59
63
 
@@ -1,20 +1,27 @@
1
1
  import { NextResponse } from 'next/server'
2
2
  import { existsSync } from 'node:fs'
3
3
  import { resolve } from 'node:path'
4
+ import { execFileSync } from 'node:child_process'
4
5
 
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')
6
+ const PERSISTENT_DIR = resolve(process.env.HOME ?? '/root', '.maxy')
7
+ const PIN_FILE = resolve(PERSISTENT_DIR, '.admin-pin')
8
8
 
9
9
  /**
10
10
  * GET /api/health
11
11
  * Returns platform state for the onboarding flow.
12
12
  */
13
13
  export async function GET() {
14
- const pinConfigured = existsSync(PIN_FILE) || existsSync(HOME_PIN_FILE) || !!process.env.ADMIN_PIN
14
+ const pinConfigured = existsSync(PIN_FILE) || !!process.env.ADMIN_PIN
15
15
 
16
- // TODO: check Claude Code auth status
17
- const claudeAuthenticated = false
16
+ let claudeAuthenticated = false
17
+ try {
18
+ const output = execFileSync('claude', ['auth', 'status', '--json'], {
19
+ encoding: 'utf-8',
20
+ timeout: 5000,
21
+ })
22
+ const status = JSON.parse(output)
23
+ claudeAuthenticated = status.loggedIn === true
24
+ } catch { /* not authenticated */ }
18
25
 
19
26
  return NextResponse.json({
20
27
  pin_configured: pinConfigured,
@@ -0,0 +1,112 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { spawn, execFileSync, type ChildProcess } from 'node:child_process'
3
+
4
+ // Keep the login process alive between requests
5
+ let loginProcess: ChildProcess | null = null
6
+
7
+ /**
8
+ * GET /api/onboarding/claude-auth
9
+ * Check Claude Code auth status.
10
+ */
11
+ export async function GET() {
12
+ try {
13
+ const output = execFileSync('claude', ['auth', 'status', '--json'], {
14
+ encoding: 'utf-8',
15
+ timeout: 10000,
16
+ })
17
+ const status = JSON.parse(output)
18
+ return NextResponse.json({
19
+ authenticated: status.loggedIn === true,
20
+ email: status.email ?? null,
21
+ })
22
+ } catch {
23
+ return NextResponse.json({ authenticated: false, email: null })
24
+ }
25
+ }
26
+
27
+ /**
28
+ * POST /api/onboarding/claude-auth
29
+ * Body: {} — starts the login flow, returns auth URL
30
+ * Body: { code: "..." } — feeds the auth code to the running login process
31
+ */
32
+ export async function POST(req: Request) {
33
+ let body: { code?: string } = {}
34
+ try {
35
+ body = await req.json()
36
+ } catch { /* empty body = start flow */ }
37
+
38
+ // If code is provided, feed it to the running login process
39
+ if (body.code && loginProcess && loginProcess.stdin) {
40
+ loginProcess.stdin.write(body.code + '\n')
41
+ loginProcess.stdin.end()
42
+
43
+ // Wait a moment for the process to complete
44
+ await new Promise((r) => setTimeout(r, 3000))
45
+
46
+ // Check if auth succeeded
47
+ try {
48
+ const output = execFileSync('claude', ['auth', 'status', '--json'], {
49
+ encoding: 'utf-8',
50
+ timeout: 5000,
51
+ })
52
+ const status = JSON.parse(output)
53
+ loginProcess = null
54
+ return NextResponse.json({
55
+ authenticated: status.loggedIn === true,
56
+ email: status.email ?? null,
57
+ })
58
+ } catch {
59
+ loginProcess = null
60
+ return NextResponse.json({ authenticated: false, error: 'Authentication failed. Check the code and try again.' })
61
+ }
62
+ }
63
+
64
+ // Start a new login flow
65
+ if (loginProcess) {
66
+ loginProcess.kill()
67
+ loginProcess = null
68
+ }
69
+
70
+ return new Promise<Response>((resolve) => {
71
+ const child = spawn('claude', ['auth', 'login'], {
72
+ stdio: ['pipe', 'pipe', 'pipe'],
73
+ env: { ...process.env, BROWSER: 'echo' },
74
+ })
75
+
76
+ loginProcess = child
77
+
78
+ let output = ''
79
+ let resolved = false
80
+
81
+ function tryResolve() {
82
+ if (resolved) return
83
+ const match = output.match(/(https:\/\/claude\.ai\/oauth\/authorize[^\s]+)/)
84
+ if (match) {
85
+ resolved = true
86
+ resolve(NextResponse.json({ authUrl: match[1] }))
87
+ }
88
+ }
89
+
90
+ child.stdout?.on('data', (chunk: Buffer) => {
91
+ output += chunk.toString()
92
+ tryResolve()
93
+ })
94
+
95
+ child.stderr?.on('data', (chunk: Buffer) => {
96
+ output += chunk.toString()
97
+ tryResolve()
98
+ })
99
+
100
+ setTimeout(() => {
101
+ if (!resolved) {
102
+ resolved = true
103
+ child.kill()
104
+ loginProcess = null
105
+ resolve(NextResponse.json(
106
+ { error: 'Timed out waiting for auth URL.' },
107
+ { status: 500 },
108
+ ))
109
+ }
110
+ }, 10000)
111
+ })
112
+ }
@@ -1376,6 +1376,45 @@ a:hover {
1376
1376
  border-color: var(--sage);
1377
1377
  }
1378
1378
 
1379
+ .btn-primary {
1380
+ padding: 12px 24px;
1381
+ background: var(--sage);
1382
+ color: white;
1383
+ border: none;
1384
+ border-radius: 24px;
1385
+ font-family: var(--font-body);
1386
+ font-size: 15px;
1387
+ cursor: pointer;
1388
+ transition: background 0.15s;
1389
+ }
1390
+
1391
+ .btn-primary:hover {
1392
+ background: var(--sage-hover);
1393
+ }
1394
+
1395
+ .btn-primary:disabled {
1396
+ opacity: 0.5;
1397
+ cursor: not-allowed;
1398
+ }
1399
+
1400
+ .btn-secondary {
1401
+ padding: 10px 20px;
1402
+ background: none;
1403
+ color: var(--text-secondary);
1404
+ border: 1px solid var(--border-strong);
1405
+ border-radius: 24px;
1406
+ font-family: var(--font-body);
1407
+ font-size: 14px;
1408
+ cursor: pointer;
1409
+ text-decoration: none;
1410
+ text-align: center;
1411
+ transition: border-color 0.15s;
1412
+ }
1413
+
1414
+ .btn-secondary:hover {
1415
+ border-color: var(--sage);
1416
+ }
1417
+
1379
1418
  .admin-pin-error {
1380
1419
  color: #c44;
1381
1420
  font-size: 14px;
@@ -17,14 +17,14 @@ const dmSans = DM_Sans({
17
17
  })
18
18
 
19
19
  export const metadata: Metadata = {
20
- title: 'Maxy — No Stress. Quiet Life.',
20
+ title: 'Maxy — Convenience as standard.',
21
21
  description: 'A personal AI assistant that lives in your home. Your data never leaves.',
22
22
  icons: {
23
23
  icon: '/favicon.ico',
24
24
  apple: '/apple-icon.png',
25
25
  },
26
26
  openGraph: {
27
- title: 'Maxy — No Stress. Quiet Life.',
27
+ title: 'Maxy — Convenience as standard.',
28
28
  description: 'A personal AI assistant that lives in your home. Your data never leaves.',
29
29
  siteName: 'Maxy',
30
30
  type: 'website',
@@ -33,13 +33,13 @@ export const metadata: Metadata = {
33
33
  url: '/og-landscape.png',
34
34
  width: 1200,
35
35
  height: 628,
36
- alt: 'Maxy — No Stress. Quiet Life.',
36
+ alt: 'Maxy — Convenience as standard.',
37
37
  },
38
38
  ],
39
39
  },
40
40
  twitter: {
41
41
  card: 'summary_large_image',
42
- title: 'Maxy — No Stress. Quiet Life.',
42
+ title: 'Maxy — Convenience as standard.',
43
43
  description: 'A personal AI assistant that lives in your home. Your data never leaves.',
44
44
  images: ['/og-landscape.png'],
45
45
  },
@@ -1,9 +1,10 @@
1
1
  'use client'
2
2
 
3
3
  import { useState, useRef, useEffect, FormEvent } from 'react'
4
+ import { Eye, EyeOff } from 'lucide-react'
4
5
  import { ActivityEvent, type AdminEvent } from './admin/components/ActivityEvent'
5
6
 
6
- type AppState = 'loading' | 'set-pin' | 'enter-pin' | 'chat'
7
+ type AppState = 'loading' | 'set-pin' | 'enter-pin' | 'connect-claude' | 'chat'
7
8
 
8
9
  interface Message {
9
10
  role: 'admin' | 'maxy'
@@ -17,6 +18,9 @@ export default function AdminPage() {
17
18
  const [confirmPin, setConfirmPin] = useState('')
18
19
  const [pinError, setPinError] = useState('')
19
20
  const [showPin, setShowPin] = useState(false)
21
+ const [authUrl, setAuthUrl] = useState<string | null>(null)
22
+ const [authCode, setAuthCode] = useState('')
23
+ const [authLoading, setAuthLoading] = useState(false)
20
24
  const [sessionKey, setSessionKey] = useState<string | null>(null)
21
25
  const [messages, setMessages] = useState<Message[]>([])
22
26
  const [input, setInput] = useState('')
@@ -39,7 +43,13 @@ export default function AdminPage() {
39
43
  return
40
44
  }
41
45
  const health = await res.json()
42
- setAppState(health.pin_configured ? 'enter-pin' : 'set-pin')
46
+ if (!health.pin_configured) {
47
+ setAppState('set-pin')
48
+ } else if (!health.claude_authenticated) {
49
+ setAppState('connect-claude')
50
+ } else {
51
+ setAppState('enter-pin')
52
+ }
43
53
  } catch {
44
54
  setAppState('set-pin')
45
55
  }
@@ -82,8 +92,10 @@ export default function AdminPage() {
82
92
  return
83
93
  }
84
94
 
85
- // PIN set — now log in with it
86
- await doLogin(pin)
95
+ // PIN set — move to Claude auth
96
+ setPin('')
97
+ setConfirmPin('')
98
+ setAppState('connect-claude')
87
99
  } catch {
88
100
  setPinError('Could not connect.')
89
101
  }
@@ -227,7 +239,7 @@ export default function AdminPage() {
227
239
  autoFocus
228
240
  />
229
241
  <button type="button" className="pin-toggle" onClick={() => setShowPin(!showPin)} aria-label={showPin ? 'Hide' : 'Show'}>
230
- {showPin ? '◉' : '○'}
242
+ {showPin ? <EyeOff size={18} /> : <Eye size={18} />}
231
243
  </button>
232
244
  </div>
233
245
  <div className="pin-input-row">
@@ -252,6 +264,99 @@ export default function AdminPage() {
252
264
  )
253
265
  }
254
266
 
267
+ // --- Connect Claude ---
268
+ if (appState === 'connect-claude') {
269
+ async function startAuth() {
270
+ setAuthLoading(true)
271
+ setPinError('')
272
+ try {
273
+ const res = await fetch('/api/onboarding/claude-auth', { method: 'POST' })
274
+ const data = await res.json()
275
+ if (data.authUrl) {
276
+ setAuthUrl(data.authUrl)
277
+ window.open(data.authUrl, '_blank')
278
+ } else if (data.error) {
279
+ setPinError(data.error)
280
+ }
281
+ } catch {
282
+ setPinError('Could not start auth flow.')
283
+ }
284
+ setAuthLoading(false)
285
+ }
286
+
287
+ async function submitCode(e: FormEvent) {
288
+ e.preventDefault()
289
+ if (!authCode.trim()) return
290
+ setAuthLoading(true)
291
+ setPinError('')
292
+ try {
293
+ const res = await fetch('/api/onboarding/claude-auth', {
294
+ method: 'POST',
295
+ headers: { 'Content-Type': 'application/json' },
296
+ body: JSON.stringify({ code: authCode.trim() }),
297
+ })
298
+ const data = await res.json()
299
+ if (data.authenticated) {
300
+ setAppState('enter-pin')
301
+ } else {
302
+ setPinError(data.error || 'Authentication failed. Check the code and try again.')
303
+ }
304
+ } catch {
305
+ setPinError('Could not verify code.')
306
+ }
307
+ setAuthLoading(false)
308
+ }
309
+
310
+ return (
311
+ <div className="chat-page admin-page">
312
+ <header className="chat-header">
313
+ <img src="/brand/claude.png" alt="Claude" className="chat-logo" />
314
+ <h1 className="chat-tagline">Connect Claude</h1>
315
+ <p className="chat-intro">Sign in with your Anthropic account to power Maxy.</p>
316
+ </header>
317
+ <div className="admin-pin-form">
318
+ {!authUrl ? (
319
+ <button
320
+ className="btn-primary"
321
+ onClick={startAuth}
322
+ disabled={authLoading}
323
+ >
324
+ {authLoading ? 'Starting...' : 'Sign in with Claude'}
325
+ </button>
326
+ ) : (
327
+ <>
328
+ <p className="chat-intro" style={{ fontSize: '14px', marginTop: 0 }}>
329
+ Sign in on the Anthropic page, then paste the code here.
330
+ </p>
331
+ <form onSubmit={submitCode}>
332
+ <div className="pin-input-row">
333
+ <input
334
+ type="text"
335
+ value={authCode}
336
+ onChange={e => setAuthCode(e.target.value)}
337
+ placeholder="Paste auth code"
338
+ className="chat-input"
339
+ autoFocus
340
+ />
341
+ <button type="submit" className="chat-send" disabled={!authCode.trim() || authLoading}>
342
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
343
+ <line x1="5" y1="12" x2="19" y2="12" />
344
+ <polyline points="12 5 19 12 12 19" />
345
+ </svg>
346
+ </button>
347
+ </div>
348
+ </form>
349
+ <a href={authUrl} target="_blank" rel="noopener noreferrer" className="btn-secondary" style={{ marginTop: '8px' }}>
350
+ Open sign-in page again
351
+ </a>
352
+ </>
353
+ )}
354
+ {pinError && <p className="admin-pin-error">{pinError}</p>}
355
+ </div>
356
+ </div>
357
+ )
358
+ }
359
+
255
360
  // --- Enter PIN (returning user) ---
256
361
  if (appState === 'enter-pin') {
257
362
  return (
@@ -274,7 +379,7 @@ export default function AdminPage() {
274
379
  autoFocus
275
380
  />
276
381
  <button type="button" className="pin-toggle" onClick={() => setShowPin(!showPin)} aria-label={showPin ? 'Hide' : 'Show'}>
277
- {showPin ? '◉' : '○'}
382
+ {showPin ? <EyeOff size={18} /> : <Eye size={18} />}
278
383
  </button>
279
384
  <button type="submit" className="chat-send" disabled={!pin}>
280
385
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">