シンプルな画面で使えるパスワードマネージャ「SPW」

シンプルなパスワードマネージャを作ってみました。今時なのでWebでどこからでも読めるように、しかし、いざという時にはツールが無くても読めるように、ということでデータ自体はjsonファイルで管理。7zipによるAES-256で暗号化しています。保存場所さえ注意すれば個人用途ならば。

なお、サーバーサイドでexecFileSync(‘7z’, …)を呼ぶ必要があり、そのためにNode.js + expressを使っているのですが、Node.js + npmインストールが入っていない環境だとインストールに少し時間がかかります。
Python版を作りました。そちらのほうがおすすめです)

LXDコンテナにインストール

3080番ポートで公開しています。ただ、ツールの用途的に、本格的に使うなら、後半で紹介するTailscale ServeでHTTPS化する方法でインストールしたほうがよいでしょう。

#!/bin/bash
set -e

INSTALL_DIR="/opt/lxd-data/spw"
SERVICE_NAME="spw"
PORT="${PORT:-3080}"

echo "=== SPW Password Manager Installer ==="

# Check root
if [ "$EUID" -ne 0 ]; then
  echo "Error: Run as root (sudo bash $0)"
  exit 1
fi

# Install Node.js if missing
if ! command -v node &>/dev/null; then
  echo "Installing Node.js..."
  if command -v apt-get &>/dev/null; then
    apt-get update -qq && apt-get install -y -qq nodejs npm
  elif command -v yum &>/dev/null; then
    yum install -y nodejs npm
  elif command -v dnf &>/dev/null; then
    dnf install -y nodejs npm
  elif command -v pacman &>/dev/null; then
    pacman -Sy --noconfirm nodejs npm
  else
    echo "Error: Cannot install Node.js. Install manually."
    exit 1
  fi
fi
echo "Node.js: $(node -v)"

# Install 7zip if missing
if ! command -v 7z &>/dev/null; then
  echo "Installing 7zip..."
  if command -v apt-get &>/dev/null; then
    apt-get update -qq && apt-get install -y -qq p7zip-full
  elif command -v yum &>/dev/null; then
    yum install -y p7zip p7zip-plugins
  elif command -v dnf &>/dev/null; then
    dnf install -y p7zip p7zip-plugins
  elif command -v pacman &>/dev/null; then
    pacman -Sy --noconfirm p7zip
  else
    echo "Error: Cannot install 7zip. Install manually."
    exit 1
  fi
fi
echo "7zip: $(7z --help 2>&1 | head -1)"

# Create directory
mkdir -p "$INSTALL_DIR/public" "$INSTALL_DIR/data/backups"

# server.js
cat > "$INSTALL_DIR/server.js" << 'SERVEREOF'
const express = require('express');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { execFileSync } = require('child_process');
const os = require('os');

const app = express();
const PORT = process.env.PORT || 3080;
const DATA_DIR = path.join(__dirname, 'data');
const PASSWORDS_7Z = path.join(DATA_DIR, 'passwords.7z');
const CONFIG_FILE = path.join(DATA_DIR, 'config.json');
const BACKUP_DIR = path.join(DATA_DIR, 'backups');

if (!fs.existsSync(BACKUP_DIR)) {
  fs.mkdirSync(BACKUP_DIR, { recursive: true });
}

app.use(express.json({ limit: '10mb' }));
app.use(express.static(path.join(__dirname, 'public')));

const sessions = new Map();

function loadConfig() {
  try {
    return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
  } catch {
    return {};
  }
}

function saveConfig(config) {
  fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf8');
}

function hashPassword(password) {
  const salt = crypto.randomBytes(16).toString('hex');
  const hash = crypto.scryptSync(password, salt, 64).toString('hex');
  return { salt, hash };
}

function verifyPassword(password, stored) {
  const hash = crypto.scryptSync(password, stored.salt, 64).toString('hex');
  return hash === stored.hash;
}

function generateToken() {
  return crypto.randomBytes(32).toString('hex');
}

function authMiddleware(req, res, next) {
  const token = req.headers['x-auth-token'];
  if (!token || !sessions.has(token)) {
    return res.status(401).json({ error: 'Unauthorized' });
  }
  req.sessionPassword = sessions.get(token).password;
  next();
}

function exec7z(args) {
  execFileSync('7z', args, { stdio: 'pipe', timeout: 30000 });
}

function savePasswordsEncrypted(data, password) {
  const tmpDir = path.join(os.tmpdir(), `pw-save-${Date.now()}`);
  const tmpJson = path.join(tmpDir, 'passwords.json');
  fs.mkdirSync(tmpDir, { recursive: true });
  fs.writeFileSync(tmpJson, JSON.stringify(data, null, 2), 'utf8');
  try {
    if (fs.existsSync(PASSWORDS_7Z)) fs.unlinkSync(PASSWORDS_7Z);
    exec7z(['a', '-t7z', '-mhe=on', `-p${password}`, PASSWORDS_7Z, tmpJson]);
  } finally {
    try { fs.rmSync(tmpDir, { recursive: true }); } catch {}
  }
}

function loadPasswordsEncrypted(password) {
  if (!fs.existsSync(PASSWORDS_7Z)) return { categories: [] };
  const tmpDir = path.join(os.tmpdir(), `pw-load-${Date.now()}`);
  fs.mkdirSync(tmpDir, { recursive: true });
  try {
    exec7z(['x', `-p${password}`, `-o${tmpDir}`, PASSWORDS_7Z, '-y']);
    const jsonFile = path.join(tmpDir, 'passwords.json');
    if (!fs.existsSync(jsonFile)) return { categories: [] };
    return JSON.parse(fs.readFileSync(jsonFile, 'utf8'));
  } catch {
    return { categories: [] };
  } finally {
    try { fs.rmSync(tmpDir, { recursive: true }); } catch {}
  }
}

app.get('/api/auth/status', (req, res) => {
  const config = loadConfig();
  res.json({ hasPassword: !!config.passwordHash });
});

app.post('/api/auth/setup', (req, res) => {
  const config = loadConfig();
  if (config.passwordHash) {
    return res.status(400).json({ error: 'Password already set' });
  }
  const { password } = req.body;
  if (!password || password.length < 4) {
    return res.status(400).json({ error: 'Password must be at least 4 characters' });
  }
  config.passwordHash = hashPassword(password);
  saveConfig(config);
  const token = generateToken();
  sessions.set(token, { created: Date.now(), password });
  res.json({ success: true, token });
});

app.post('/api/auth/login', (req, res) => {
  const config = loadConfig();
  if (!config.passwordHash) {
    return res.status(400).json({ error: 'No password set' });
  }
  const { password } = req.body;
  if (!password) {
    return res.status(400).json({ error: 'Password required' });
  }
  if (!verifyPassword(password, config.passwordHash)) {
    return res.status(401).json({ error: 'Wrong password' });
  }
  const token = generateToken();
  sessions.set(token, { created: Date.now(), password });
  res.json({ success: true, token });
});

app.post('/api/auth/logout', (req, res) => {
  const token = req.headers['x-auth-token'];
  if (token) sessions.delete(token);
  res.json({ success: true });
});

app.post('/api/auth/change-password', authMiddleware, (req, res) => {
  const { currentPassword, newPassword } = req.body;
  const config = loadConfig();
  if (!config.passwordHash) {
    return res.status(400).json({ error: 'No password set' });
  }
  if (!currentPassword || !verifyPassword(currentPassword, config.passwordHash)) {
    return res.status(401).json({ error: 'Current password is wrong' });
  }
  if (!newPassword || newPassword.length < 4) {
    return res.status(400).json({ error: 'New password must be at least 4 characters' });
  }

  const oldPassword = req.sessionPassword;
  const data = loadPasswordsEncrypted(oldPassword);

  config.passwordHash = hashPassword(newPassword);
  saveConfig(config);

  savePasswordsEncrypted(data, newPassword);

  const token = req.headers['x-auth-token'];
  if (token && sessions.has(token)) {
    sessions.get(token).password = newPassword;
  }

  res.json({ success: true });
});

app.get('/api/passwords', authMiddleware, (req, res) => {
  res.json(loadPasswordsEncrypted(req.sessionPassword));
});

app.post('/api/passwords', authMiddleware, (req, res) => {
  savePasswordsEncrypted(req.body, req.sessionPassword);
  res.json({ success: true });
});

app.post('/api/export', authMiddleware, (req, res) => {
  try {
    const data = loadPasswordsEncrypted(req.sessionPassword);
    const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
    const filename = `passwords-${timestamp}.zip`;
    const filepath = path.join(BACKUP_DIR, filename);
    const tmpDir = path.join(os.tmpdir(), `pw-export-${Date.now()}`);
    const tmpJson = path.join(tmpDir, 'passwords.json');

    fs.mkdirSync(tmpDir, { recursive: true });
    fs.writeFileSync(tmpJson, JSON.stringify(data, null, 2), 'utf8');
    try {
      exec7z(['a', '-tzip', '-p' + req.sessionPassword, filepath, tmpJson]);
      res.download(filepath, filename, (err) => {
        fs.unlink(filepath, () => {});
      });
    } finally {
      try { fs.rmSync(tmpDir, { recursive: true }); } catch {}
    }
  } catch (e) {
    res.status(500).json({ error: e.message });
  }
});

app.post('/api/import', authMiddleware, (req, res) => {
  try {
    const { password, fileData } = req.body;
    if (!password || !fileData) return res.status(400).json({ error: 'Password and file required' });

    const tmpFile = path.join(os.tmpdir(), `pw-import-${Date.now()}`);
    const tmpDir = path.join(os.tmpdir(), `pw-import-dir-${Date.now()}`);

    fs.writeFileSync(tmpFile, Buffer.from(fileData, 'base64'));
    fs.mkdirSync(tmpDir, { recursive: true });

    try {
      exec7z(['x', '-p' + password, '-o' + tmpDir, tmpFile, '-y']);

      const jsonFile = path.join(tmpDir, 'passwords.json');
      if (!fs.existsSync(jsonFile)) {
        return res.status(400).json({ error: 'passwords.json not found in archive' });
      }

      const imported = JSON.parse(fs.readFileSync(jsonFile, 'utf8'));
      savePasswordsEncrypted(imported, req.sessionPassword);
      res.json({ success: true });
    } finally {
      try { fs.unlinkSync(tmpFile); } catch {}
      try { fs.rmSync(tmpDir, { recursive: true }); } catch {}
    }
  } catch (e) {
    res.status(500).json({ error: 'Wrong password or corrupt file' });
  }
});

app.listen(PORT, '0.0.0.0', () => {
  console.log(`Password Manager running on http://0.0.0.0:${PORT}`);
});
SERVEREOF

# package.json
cat > "$INSTALL_DIR/package.json" << 'PKGEOF'
{
  "name": "spw",
  "version": "1.0.0",
  "description": "Self-hosted password manager",
  "main": "server.js",
  "scripts": {
    "start": "node server.js"
  },
  "dependencies": {
    "express": "^4.18.2"
  }
}
PKGEOF

# index.html
cat > "$INSTALL_DIR/public/index.html" << 'HTMLEOF'
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>SPW - Password Manager</title>
  <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🔐</text></svg>">
  <link rel="stylesheet" href="style.css">
</head>
<body>
  <div id="auth-screen">
    <div class="auth-box">
      <h2>SPW</h2>
      <div id="auth-setup">
        <p>初回アクセスです。パスワードを設定してください。</p>
        <input type="password" id="setup-password" placeholder="パスワード">
        <input type="password" id="setup-password-confirm" placeholder="パスワード(確認)">
        <button id="btn-setup" class="btn-primary">設定</button>
      </div>
      <div id="auth-login" style="display:none">
        <p>パスワードを入力してください。</p>
        <input type="password" id="login-password" placeholder="パスワード">
        <button id="btn-login" class="btn-primary">ログイン</button>
      </div>
      <div id="auth-error" class="auth-error" style="display:none"></div>
    </div>
  </div>

  <div id="app" style="display:none">
    <aside id="sidebar">
      <div class="sidebar-header">
        <h2>SPW</h2>
        <button id="btn-export" class="btn-small" title="エクスポート">
          <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
        </button>
        <button id="btn-import" class="btn-small" title="インポート">
          <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
        </button>
        <button id="btn-add-category" class="btn-small" title="カテゴリー追加">
          <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
        </button>
      </div>
      <div class="search-box">
        <input type="text" id="search-input" placeholder="検索...">
      </div>
      <div id="category-list"></div>
      <div class="sidebar-footer">
        <button id="btn-change-pw" class="btn-small" title="パスワード変更">
          <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg>
          パスワード変更
        </button>
        <button id="btn-logout" class="btn-small" title="ログアウト">
          <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
          ログアウト
        </button>
      </div>
    </aside>
    <main id="main-content">
      <div id="welcome-screen">
        <p>カテゴリーとカードを選択してください</p>
      </div>
      <div id="card-detail" style="display:none">
        <div class="detail-header">
          <input type="text" id="detail-card-name" class="card-name-input">
          <div class="detail-actions">
            <button id="btn-add-field" class="btn-small">フィールド追加</button>
            <button id="btn-delete-card" class="btn-small btn-danger">カード削除</button>
            <button id="btn-save-card" class="btn-primary">
              <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 21H5a2 2 0 01-2-2V5a2 2 0 012-2h11l5 5v11a2 2 0 01-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>
              保存
            </button>
          </div>
        </div>
        <div id="detail-fields"></div>
      </div>
    </main>
  </div>

  <div id="export-modal" class="modal" style="display:none">
    <div class="modal-content">
      <h3>エクスポート</h3>
      <p>暗号化ファイルをダウンロードします</p>
      <div class="modal-actions">
        <button id="btn-export-cancel" class="btn-small">キャンセル</button>
        <button id="btn-export-confirm" class="btn-primary">ダウンロード</button>
      </div>
    </div>
  </div>

  <div id="import-modal" class="modal" style="display:none">
    <div class="modal-content">
      <h3>インポート</h3>
      <p>パスワードを入力してください</p>
      <input type="password" id="import-password" placeholder="パスワード">
      <input type="file" id="import-file" accept=".7z,.zip" style="margin-bottom:12px;color:#e0e0e0">
      <div class="modal-actions">
        <button id="btn-import-cancel" class="btn-small">キャンセル</button>
        <button id="btn-import-confirm" class="btn-primary">インポート</button>
      </div>
    </div>
  </div>

  <div id="change-pw-modal" class="modal" style="display:none">
    <div class="modal-content">
      <h3>パスワード変更</h3>
      <input type="password" id="current-password" placeholder="現在のパスワード">
      <input type="password" id="new-password" placeholder="新しいパスワード">
      <input type="password" id="new-password-confirm" placeholder="新しいパスワード(確認)">
      <div class="modal-actions">
        <button id="btn-change-pw-cancel" class="btn-small">キャンセル</button>
        <button id="btn-change-pw-confirm" class="btn-primary">変更</button>
      </div>
    </div>
  </div>

  <script src="app.js"></script>
</body>
</html>
HTMLEOF

# style.css
cat > "$INSTALL_DIR/public/style.css" << 'CSSEOF'
* { margin: 0; padding: 0; box-sizing: border-box; }

:root {
  --bg: #f5f5f5;
  --surface: #ffffff;
  --border: #d0d0d0;
  --border-strong: #999;
  --text: #1a1a1a;
  --text-muted: #666;
  --accent: #0055a5;
  --accent-hover: #003d7a;
  --danger: #c0392b;
  --danger-hover: #962d22;
  --row-alt: #f9f9f9;
  --row-hover: #e8f0fe;
  --row-active: #d0e0f5;
  --header-bg: #e2e8f0;
  --input-bg: #fff;
  --scrollbar: #c0c0c0;
  --scrollbar-hover: #999;
  --selection: #b3d4fc;
}

body {
  font-family: 'Segoe UI', 'Meiryo', 'Hiragino Sans', sans-serif;
  background: var(--bg);
  color: var(--text);
  height: 100vh;
  overflow: hidden;
  font-size: 13px;
  line-height: 1.4;
}

::selection { background: var(--selection); }

#auth-screen {
  display: flex;
  align-items: center;
  justify-content: center;
  height: 100vh;
  background: var(--header-bg);
}

.auth-box {
  background: var(--surface);
  border: 1px solid var(--border);
  box-shadow: 0 2px 8px rgba(0,0,0,0.1);
  padding: 32px;
  width: 340px;
}

.auth-box h2 {
  color: var(--text);
  font-size: 20px;
  font-weight: 700;
  margin-bottom: 4px;
  letter-spacing: 1px;
}

.auth-box > div > p {
  color: var(--text-muted);
  font-size: 12px;
  margin-bottom: 16px;
}

.auth-box input {
  width: 100%;
  padding: 8px 10px;
  border: 1px solid var(--border);
  background: var(--input-bg);
  color: var(--text);
  font-size: 13px;
  margin-bottom: 10px;
  outline: none;
}

.auth-box input:focus {
  border-color: var(--accent);
}

.auth-error {
  color: var(--danger);
  font-size: 12px;
  margin-top: 4px;
}

#app {
  display: flex;
  height: 100vh;
}

#sidebar {
  width: 260px;
  background: var(--surface);
  border-right: 1px solid var(--border);
  display: flex;
  flex-direction: column;
  flex-shrink: 0;
}

.sidebar-header {
  display: flex;
  align-items: center;
  padding: 6px 8px;
  border-bottom: 1px solid var(--border);
  background: var(--header-bg);
  gap: 4px;
}

.sidebar-header h2 {
  font-size: 13px;
  font-weight: 700;
  color: var(--text);
  flex: 1;
  letter-spacing: 0.5px;
  text-transform: uppercase;
}

.search-box {
  padding: 4px 6px;
  border-bottom: 1px solid var(--border);
  background: var(--surface);
}

.search-box input {
  width: 100%;
  padding: 5px 8px;
  border: 1px solid var(--border);
  background: var(--input-bg);
  color: var(--text);
  font-size: 12px;
  outline: none;
}

.search-box input:focus {
  border-color: var(--accent);
}

#category-list {
  flex: 1;
  overflow-y: auto;
  padding: 0;
}

.sidebar-footer {
  border-top: 1px solid var(--border);
  padding: 4px 6px;
  display: flex;
  gap: 4px;
  background: var(--header-bg);
}

.sidebar-footer .btn-small {
  flex: 1;
  font-size: 11px;
  padding: 4px 6px;
}

.category-item {
  border-bottom: 1px solid #eee;
}

.category-header {
  display: flex;
  align-items: center;
  padding: 6px 8px;
  cursor: pointer;
  font-weight: 700;
  font-size: 12px;
  color: var(--text);
  background: var(--row-alt);
  border-bottom: 1px solid var(--border);
}

.category-header:hover {
  background: var(--row-hover);
}

.category-header .cat-name {
  flex: 1;
  text-transform: uppercase;
  letter-spacing: 0.3px;
}

.category-header .cat-actions {
  display: none;
  gap: 2px;
}

.category-header:hover .cat-actions {
  display: flex;
}

.card-item {
  display: flex;
  align-items: center;
  padding: 5px 8px 5px 20px;
  cursor: pointer;
  font-size: 12px;
  color: var(--text);
  border-bottom: 1px solid #f0f0f0;
}

.card-item:hover {
  background: var(--row-hover);
}

.card-item.active {
  background: var(--accent);
  color: #fff;
}

.card-item .card-name {
  flex: 1;
}

#main-content {
  flex: 1;
  overflow-y: auto;
  padding: 0;
  background: var(--bg);
}

#welcome-screen {
  display: flex;
  align-items: center;
  justify-content: center;
  height: 100%;
  color: var(--text-muted);
  font-size: 13px;
}

#card-detail {
  padding: 0;
}

.detail-header {
  display: flex;
  align-items: center;
  padding: 8px 12px;
  gap: 8px;
  background: var(--header-bg);
  border-bottom: 1px solid var(--border);
}

.card-name-input {
  flex: 1;
  font-size: 14px;
  font-weight: 700;
  color: var(--text);
  background: var(--input-bg);
  border: 1px solid var(--border);
  padding: 4px 8px;
  outline: none;
}

.card-name-input:focus {
  border-color: var(--accent);
}

.detail-actions {
  display: flex;
  gap: 4px;
}

#detail-fields {
  background: var(--surface);
}

.field-row {
  display: flex;
  align-items: center;
  border-bottom: 1px solid var(--border);
}

.field-row:nth-child(even) {
  background: var(--row-alt);
}

.field-label-input {
  width: 160px;
  font-size: 12px;
  font-weight: 600;
  color: var(--text);
  flex-shrink: 0;
  background: transparent;
  border: none;
  border-right: 1px solid var(--border);
  padding: 8px 10px;
  outline: none;
}

.field-label-input:focus {
  background: var(--row-hover);
}

.field-input-wrap {
  flex: 1;
  display: flex;
  align-items: center;
  gap: 0;
}

.field-input-wrap input {
  flex: 1;
  padding: 8px 10px;
  border: none;
  background: transparent;
  color: var(--text);
  font-size: 13px;
  outline: none;
}

.field-input-wrap input:focus {
  background: var(--row-hover);
}

.btn-toggle-pw {
  padding: 6px 8px;
  background: transparent;
  border: none;
  border-left: 1px solid var(--border);
  color: var(--text-muted);
  cursor: pointer;
  display: flex;
  align-items: center;
  height: 100%;
}

.btn-toggle-pw:hover {
  color: var(--accent);
  background: var(--row-hover);
}

.btn-copy {
  padding: 6px 10px;
  background: transparent;
  border: none;
  border-left: 1px solid var(--border);
  color: var(--text-muted);
  cursor: pointer;
  font-size: 11px;
  white-space: nowrap;
  height: 100%;
}

.btn-copy:hover {
  color: var(--accent);
  background: var(--row-hover);
}

.btn-copy.copied {
  color: #fff;
  background: #2e7d32;
}

.btn-delete-field {
  padding: 6px 8px;
  background: transparent;
  border: none;
  border-left: 1px solid var(--border);
  color: var(--text-muted);
  cursor: pointer;
  font-size: 14px;
  height: 100%;
}

.btn-delete-field:hover {
  color: var(--danger);
  background: #fce4ec;
}

.detail-actions-bottom {
  padding: 8px 12px;
  background: var(--header-bg);
  border-top: 1px solid var(--border);
}

.btn-small {
  padding: 4px 10px;
  background: var(--surface);
  border: 1px solid var(--border);
  color: var(--text);
  cursor: pointer;
  font-size: 11px;
  outline: none;
}

.btn-small:hover {
  background: var(--row-hover);
  border-color: var(--accent);
}

.btn-primary {
  padding: 6px 20px;
  background: var(--accent);
  border: 1px solid var(--accent-hover);
  color: #fff;
  cursor: pointer;
  font-size: 12px;
  font-weight: 600;
  outline: none;
}

.btn-primary:hover {
  background: var(--accent-hover);
}

.btn-danger {
  color: var(--danger);
  border-color: var(--danger);
}

.btn-danger:hover {
  background: #fce4ec;
}

.modal {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background: rgba(0,0,0,0.4);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 100;
}

.modal-content {
  background: var(--surface);
  border: 1px solid var(--border);
  box-shadow: 0 4px 16px rgba(0,0,0,0.15);
  padding: 20px;
  min-width: 340px;
}

.modal-content h3 {
  margin-bottom: 8px;
  color: var(--text);
  font-size: 14px;
  font-weight: 700;
  border-bottom: 1px solid var(--border);
  padding-bottom: 8px;
}

.modal-content p {
  margin-bottom: 12px;
  font-size: 12px;
  color: var(--text-muted);
}

.modal-content input {
  width: 100%;
  padding: 8px 10px;
  border: 1px solid var(--border);
  background: var(--input-bg);
  color: var(--text);
  font-size: 13px;
  margin-bottom: 10px;
  outline: none;
}

.modal-content input:focus {
  border-color: var(--accent);
}

.modal-actions {
  display: flex;
  justify-content: flex-end;
  gap: 6px;
  margin-top: 8px;
}

#category-list::-webkit-scrollbar { width: 6px; }
#category-list::-webkit-scrollbar-track { background: transparent; }
#category-list::-webkit-scrollbar-thumb { background: var(--scrollbar); }
#category-list::-webkit-scrollbar-thumb:hover { background: var(--scrollbar-hover); }

.btn-primary.dirty {
  animation: blink-btn 1s ease-in-out infinite;
}

@keyframes blink-btn {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.4; }
}
CSSEOF

# app.js
cat > "$INSTALL_DIR/public/app.js" << 'JSEOF'
let data = { categories: [] };
let selectedCard = null;
let selectedCategoryId = null;
let authToken = localStorage.getItem('spw_token');
let isDirty = false;

function markDirty() {
  if (isDirty) return;
  isDirty = true;
  document.getElementById('btn-save-card').classList.add('dirty');
}

function markClean() {
  isDirty = false;
  document.getElementById('btn-save-card').classList.remove('dirty');
}

async function apiFetch(url, options = {}) {
  const headers = { ...options.headers, 'x-auth-token': authToken };
  return fetch(url, { ...options, headers });
}

async function checkAuth() {
  const res = await fetch('/api/auth/status');
  const { hasPassword } = await res.json();
  if (!hasPassword) {
    document.getElementById('auth-setup').style.display = 'block';
    document.getElementById('auth-login').style.display = 'none';
  } else if (!authToken) {
    document.getElementById('auth-setup').style.display = 'none';
    document.getElementById('auth-login').style.display = 'block';
  } else {
    const checkRes = await apiFetch('/api/passwords');
    if (checkRes.ok) {
      showApp();
    } else {
      authToken = null;
      localStorage.removeItem('spw_token');
      document.getElementById('auth-setup').style.display = 'none';
      document.getElementById('auth-login').style.display = 'block';
    }
  }
}

function showApp() {
  document.getElementById('auth-screen').style.display = 'none';
  document.getElementById('app').style.display = 'flex';
  loadData();
}

function showAuthError(msg) {
  const el = document.getElementById('auth-error');
  el.textContent = msg;
  el.style.display = 'block';
  setTimeout(() => { el.style.display = 'none'; }, 3000);
}

document.getElementById('btn-setup').addEventListener('click', async () => {
  const pw = document.getElementById('setup-password').value;
  const pw2 = document.getElementById('setup-password-confirm').value;
  if (!pw || pw.length < 4) return showAuthError('パスワードは4文字以上');
  if (pw !== pw2) return showAuthError('パスワードが一致しません');
  const res = await fetch('/api/auth/setup', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ password: pw })
  });
  const result = await res.json();
  if (result.success) {
    authToken = result.token;
    localStorage.setItem('spw_token', authToken);
    showApp();
  } else {
    showAuthError(result.error);
  }
});

document.getElementById('btn-login').addEventListener('click', async () => {
  const pw = document.getElementById('login-password').value;
  if (!pw) return showAuthError('パスワードを入力してください');
  const res = await fetch('/api/auth/login', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ password: pw })
  });
  const result = await res.json();
  if (result.success) {
    authToken = result.token;
    localStorage.setItem('spw_token', authToken);
    showApp();
  } else {
    showAuthError(result.error);
  }
});

document.getElementById('btn-logout').addEventListener('click', async () => {
  await apiFetch('/api/auth/logout', { method: 'POST' });
  authToken = null;
  localStorage.removeItem('spw_token');
  document.getElementById('app').style.display = 'none';
  document.getElementById('auth-screen').style.display = 'flex';
  document.getElementById('auth-setup').style.display = 'none';
  document.getElementById('auth-login').style.display = 'block';
  document.getElementById('login-password').value = '';
});

['setup-password', 'setup-password-confirm', 'login-password'].forEach(id => {
  document.getElementById(id).addEventListener('keydown', e => {
    if (e.key === 'Enter') {
      if (id.startsWith('setup')) document.getElementById('btn-setup').click();
      else document.getElementById('btn-login').click();
    }
  });
});

async function loadData() {
  const res = await apiFetch('/api/passwords');
  if (!res.ok) {
    authToken = null;
    localStorage.removeItem('spw_token');
    location.reload();
    return;
  }
  data = await res.json();
  renderSidebar();
}

async function saveData() {
  await apiFetch('/api/passwords', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(data)
  });
  markClean();
}

function collectCurrentInputs() {
  if (!selectedCard) return;
  const nameInput = document.getElementById('detail-card-name');
  if (nameInput) selectedCard.name = nameInput.value;
  document.querySelectorAll('#detail-fields .field-row').forEach((row, idx) => {
    if (!selectedCard.fields[idx]) return;
    const keyInput = row.querySelector('.field-label-input');
    const valInput = row.querySelector('.field-value-input');
    if (keyInput) selectedCard.fields[idx].key = keyInput.value;
    if (valInput) selectedCard.fields[idx].value = valInput.value;
  });
}

function renderSidebar(filter = '') {
  const list = document.getElementById('category-list');
  list.innerHTML = '';
  const lf = filter.toLowerCase();

  data.categories.forEach(cat => {
    const filteredCards = cat.cards.filter(c =>
      !lf || c.name.toLowerCase().includes(lf) ||
      c.fields.some(f => f.value.toLowerCase().includes(lf) || f.key.toLowerCase().includes(lf))
    );
    if (lf && filteredCards.length === 0) return;

    const catEl = document.createElement('div');
    catEl.className = 'category-item';
    catEl.innerHTML = `
      <div class="category-header" data-id="${cat.id}">
        <span class="cat-name">${escHtml(cat.name)}</span>
        <span class="cat-actions">
          <button class="btn-small btn-add-card" data-cat="${cat.id}" title="カード追加">+</button>
          <button class="btn-small btn-rename-cat" data-cat="${cat.id}" title="名前変更">
            <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
          </button>
          <button class="btn-small btn-del-cat" data-cat="${cat.id}" title="削除">×</button>
        </span>
      </div>
    `;
    list.appendChild(catEl);

    (lf ? filteredCards : cat.cards).forEach(card => {
      const cardEl = document.createElement('div');
      cardEl.className = 'card-item' + (selectedCard && selectedCard.id === card.id ? ' active' : '');
      cardEl.dataset.cardId = card.id;
      cardEl.dataset.catId = cat.id;
      cardEl.innerHTML = `<span class="card-name">${escHtml(card.name)}</span>`;
      list.appendChild(cardEl);
    });
  });
}

function isPasswordField(key) {
  const k = key.toLowerCase();
  return k.includes('パスワード') || k.includes('password') || k.includes('pass') || k.includes('secret');
}

function showCardDetail(catId, cardId) {
  collectCurrentInputs();

  const cat = data.categories.find(c => c.id === catId);
  if (!cat) return;
  const card = cat.cards.find(c => c.id === cardId);
  if (!card) return;

  selectedCard = card;
  selectedCategoryId = catId;

  document.getElementById('welcome-screen').style.display = 'none';
  document.getElementById('card-detail').style.display = 'block';

  const nameInput = document.getElementById('detail-card-name');
  nameInput.value = card.name;

  const fieldsDiv = document.getElementById('detail-fields');
  fieldsDiv.innerHTML = '';

  card.fields.forEach((field, idx) => {
    const row = document.createElement('div');
    row.className = 'field-row';
    const isPw = isPasswordField(field.key);
    row.innerHTML = `
      <input type="text" class="field-label-input" data-idx="${idx}" value="${escAttr(field.key)}">
      <div class="field-input-wrap">
        <input type="${isPw ? 'password' : 'text'}" class="field-value-input" data-idx="${idx}" value="${escAttr(field.value)}">
        ${isPw ? '<button class="btn-toggle-pw" data-idx="' + idx + '" title="表示切替"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></button>' : ''}
        <button class="btn-copy" data-idx="${idx}">コピー</button>
        <button class="btn-delete-field" data-idx="${idx}" title="削除">×</button>
      </div>
    `;
    fieldsDiv.appendChild(row);
  });

  renderSidebar(document.getElementById('search-input').value);
}

function escHtml(s) {
  const d = document.createElement('div');
  d.textContent = s;
  return d.innerHTML;
}

function escAttr(s) {
  return s.replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}

function genId() {
  return 'id-' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
}

function fallbackCopy(text) {
  const ta = document.createElement('textarea');
  ta.value = text;
  ta.style.position = 'fixed';
  ta.style.opacity = '0';
  document.body.appendChild(ta);
  ta.select();
  document.execCommand('copy');
  document.body.removeChild(ta);
}

document.getElementById('category-list').addEventListener('click', e => {
  const addCard = e.target.closest('.btn-add-card');
  if (addCard) {
    collectCurrentInputs();
    const catId = addCard.dataset.cat;
    const cat = data.categories.find(c => c.id === catId);
    if (!cat) return;
    const card = { id: genId(), name: '新しいカード', fields: [{ key: 'ID', value: '' }, { key: 'パスワード', value: '' }] };
    cat.cards.push(card);
    saveData();
    showCardDetail(catId, card.id);
    return;
  }

  const renameCat = e.target.closest('.btn-rename-cat');
  if (renameCat) {
    const catId = renameCat.dataset.cat;
    const cat = data.categories.find(c => c.id === catId);
    if (!cat) return;
    const newName = prompt('カテゴリー名を入力:', cat.name);
    if (!newName || newName === cat.name) return;
    cat.name = newName;
    saveData();
    renderSidebar(document.getElementById('search-input').value);
    return;
  }

  const delCat = e.target.closest('.btn-del-cat');
  if (delCat) {
    const catId = delCat.dataset.cat;
    if (!confirm('カテゴリーを削除しますか?')) return;
    data.categories = data.categories.filter(c => c.id !== catId);
    saveData();
    if (selectedCategoryId === catId) {
      selectedCard = null;
      selectedCategoryId = null;
      document.getElementById('card-detail').style.display = 'none';
      document.getElementById('welcome-screen').style.display = 'flex';
    }
    renderSidebar(document.getElementById('search-input').value);
    return;
  }

  const cardEl = e.target.closest('.card-item');
  if (cardEl) {
    showCardDetail(cardEl.dataset.catId, cardEl.dataset.cardId);
  }
});

document.getElementById('search-input').addEventListener('input', e => {
  renderSidebar(e.target.value);
});

document.getElementById('btn-add-category').addEventListener('click', () => {
  const name = prompt('カテゴリー名を入力:');
  if (!name) return;
  const cat = { id: genId(), name, cards: [] };
  data.categories.push(cat);
  saveData();
  renderSidebar();
});

document.getElementById('btn-add-field').addEventListener('click', () => {
  if (!selectedCard) return;
  collectCurrentInputs();
  selectedCard.fields.push({ key: '新しい項目', value: '' });
  markDirty();
  showCardDetail(selectedCategoryId, selectedCard.id);
});

document.getElementById('btn-delete-card').addEventListener('click', () => {
  if (!selectedCard || !selectedCategoryId) return;
  if (!confirm('カードを削除しますか?')) return;
  const cat = data.categories.find(c => c.id === selectedCategoryId);
  if (!cat) return;
  cat.cards = cat.cards.filter(c => c.id !== selectedCard.id);
  saveData();
  selectedCard = null;
  selectedCategoryId = null;
  document.getElementById('card-detail').style.display = 'none';
  document.getElementById('welcome-screen').style.display = 'flex';
  renderSidebar(document.getElementById('search-input').value);
});

document.getElementById('detail-fields').addEventListener('click', e => {
  const toggleBtn = e.target.closest('.btn-toggle-pw');
  if (toggleBtn) {
    const row = toggleBtn.closest('.field-row');
    const input = row.querySelector('.field-value-input');
    if (input.type === 'password') {
      input.type = 'text';
      toggleBtn.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17.94 17.94A10.07 10.07 0 0112 20c-7 0-11-8-11-8a18.45 18.45 0 015.06-5.94M9.9 4.24A9.12 9.12 0 0112 4c7 0 11 8 11 8a18.5 18.5 0 01-2.16 3.19m-6.72-1.07a3 3 0 11-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>';
    } else {
      input.type = 'password';
      toggleBtn.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>';
    }
    return;
  }

  const copyBtn = e.target.closest('.btn-copy');
  if (copyBtn) {
    const row = copyBtn.closest('.field-row');
    const input = row.querySelector('.field-value-input');
    const text = input.value;
    function showCopied() {
      copyBtn.textContent = 'コピー済み';
      copyBtn.classList.add('copied');
      setTimeout(() => {
        copyBtn.textContent = 'コピー';
        copyBtn.classList.remove('copied');
      }, 1500);
    }
    if (navigator.clipboard && navigator.clipboard.writeText) {
      navigator.clipboard.writeText(text).then(showCopied).catch(() => {
        fallbackCopy(text);
        showCopied();
      });
    } else {
      fallbackCopy(text);
      showCopied();
    }
    return;
  }

  const delBtn = e.target.closest('.btn-delete-field');
  if (delBtn) {
    const idx = parseInt(delBtn.dataset.idx);
    if (!selectedCard) return;
    collectCurrentInputs();
    selectedCard.fields.splice(idx, 1);
    markDirty();
    showCardDetail(selectedCategoryId, selectedCard.id);
  }
});

document.getElementById('btn-save-card').addEventListener('click', () => {
  if (!selectedCard) return;
  collectCurrentInputs();
  saveData();
  renderSidebar(document.getElementById('search-input').value);
});

document.getElementById('btn-export').addEventListener('click', () => {
  document.getElementById('export-modal').style.display = 'flex';
});

document.getElementById('btn-export-cancel').addEventListener('click', () => {
  document.getElementById('export-modal').style.display = 'none';
});

document.getElementById('btn-export-confirm').addEventListener('click', async () => {
  const res = await apiFetch('/api/export', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({})
  });

  if (!res.ok) {
    const err = await res.json();
    alert('エクスポートに失敗しました: ' + (err.error || ''));
    return;
  }

  const blob = await res.blob();
  const disposition = res.headers.get('Content-Disposition');
  const filename = disposition ? disposition.split('filename=')[1].replace(/"/g, '') : 'passwords.zip';
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url;
  a.download = filename;
  a.click();
  URL.revokeObjectURL(url);

  document.getElementById('export-modal').style.display = 'none';
});

document.getElementById('export-modal').addEventListener('click', e => {
  if (e.target === e.currentTarget) {
    document.getElementById('export-modal').style.display = 'none';
  }
});

document.getElementById('btn-import').addEventListener('click', () => {
  document.getElementById('import-modal').style.display = 'flex';
  document.getElementById('import-password').value = '';
  document.getElementById('import-file').value = '';
  document.getElementById('import-password').focus();
});

document.getElementById('btn-import-cancel').addEventListener('click', () => {
  document.getElementById('import-modal').style.display = 'none';
});

document.getElementById('btn-import-confirm').addEventListener('click', async () => {
  const password = document.getElementById('import-password').value;
  const fileInput = document.getElementById('import-file');
  if (!password) return alert('パスワードを入力してください');
  if (!fileInput.files.length) return alert('ファイルを選択してください');

  const file = fileInput.files[0];
  const reader = new FileReader();
  reader.onload = async () => {
    const base64 = reader.result.split(',')[1];
    const res = await apiFetch('/api/import', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ password, fileData: base64 })
    });

    const result = await res.json();
    if (result.success) {
      alert('インポートが完了しました');
      document.getElementById('import-modal').style.display = 'none';
      await loadData();
    } else {
      alert('インポートに失敗しました: ' + (result.error || '不明なエラー'));
    }
  };
  reader.readAsDataURL(file);
});

document.getElementById('import-modal').addEventListener('click', e => {
  if (e.target === e.currentTarget) {
    document.getElementById('import-modal').style.display = 'none';
  }
});

document.getElementById('btn-change-pw').addEventListener('click', () => {
  document.getElementById('change-pw-modal').style.display = 'flex';
  document.getElementById('current-password').value = '';
  document.getElementById('new-password').value = '';
  document.getElementById('new-password-confirm').value = '';
  document.getElementById('current-password').focus();
});

document.getElementById('btn-change-pw-cancel').addEventListener('click', () => {
  document.getElementById('change-pw-modal').style.display = 'none';
});

document.getElementById('btn-change-pw-confirm').addEventListener('click', async () => {
  const current = document.getElementById('current-password').value;
  const newPw = document.getElementById('new-password').value;
  const newPw2 = document.getElementById('new-password-confirm').value;
  if (!current) return alert('現在のパスワードを入力してください');
  if (!newPw || newPw.length < 4) return alert('新しいパスワードは4文字以上');
  if (newPw !== newPw2) return alert('パスワードが一致しません');

  const res = await apiFetch('/api/auth/change-password', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ currentPassword: current, newPassword: newPw })
  });

  const result = await res.json();
  if (result.success) {
    alert('パスワードを変更しました');
    document.getElementById('change-pw-modal').style.display = 'none';
  } else {
    alert(result.error || '変更に失敗しました');
  }
});

document.getElementById('change-pw-modal').addEventListener('click', e => {
  if (e.target === e.currentTarget) {
    document.getElementById('change-pw-modal').style.display = 'none';
  }
});

document.getElementById('detail-card-name').addEventListener('input', () => {
  markDirty();
});

document.getElementById('detail-fields').addEventListener('input', e => {
  if (e.target.classList.contains('field-label-input') || e.target.classList.contains('field-value-input')) {
    markDirty();
  }
});

checkAuth();
JSEOF

# Install npm dependencies
echo "Installing npm dependencies..."
cd "$INSTALL_DIR" && npm install --production 2>&1 | tail -3

# Create systemd service
cat > "/etc/systemd/system/${SERVICE_NAME}.service" << SVCEOF
[Unit]
Description=SPW Password Manager
After=network.target

[Service]
Type=simple
WorkingDirectory=${INSTALL_DIR}
ExecStart=/usr/bin/node ${INSTALL_DIR}/server.js
Restart=on-failure
RestartSec=5
Environment=PORT=${PORT}

[Install]
WantedBy=multi-user.target
SVCEOF

systemctl daemon-reload
systemctl enable ${SERVICE_NAME}
systemctl restart ${SERVICE_NAME}

echo ""
echo "=== Installation Complete ==="
echo "Access: http://$(hostname -I | awk '{print $1}'):${PORT}"
echo "Service: systemctl status ${SERVICE_NAME}"
echo "Logs: journalctl -u ${SERVICE_NAME} -f"

Tailscale ServeでHTTPS化

3344番ポートで公開しています。

#!/bin/bash
set -e

INSTALL_DIR="/opt/lxd-data/spw"
SERVICE_NAME="spw"
PORT="${PORT:-3345}"
TAILSCALE_PORT=3344

echo "=== SPW Password Manager Installer ==="

# Check root
if [ "$EUID" -ne 0 ]; then
  echo "Error: Run as root (sudo bash $0)"
  exit 1
fi

# Install Node.js if missing
if ! command -v node &>/dev/null; then
  echo "Installing Node.js..."
  if command -v apt-get &>/dev/null; then
    apt-get update -qq && apt-get install -y -qq nodejs npm
  elif command -v yum &>/dev/null; then
    yum install -y nodejs npm
  elif command -v dnf &>/dev/null; then
    dnf install -y nodejs npm
  elif command -v pacman &>/dev/null; then
    pacman -Sy --noconfirm nodejs npm
  else
    echo "Error: Cannot install Node.js. Install manually."
    exit 1
  fi
fi
echo "Node.js: $(node -v)"

# Install 7zip if missing
if ! command -v 7z &>/dev/null; then
  echo "Installing 7zip..."
  if command -v apt-get &>/dev/null; then
    apt-get update -qq && apt-get install -y -qq p7zip-full
  elif command -v yum &>/dev/null; then
    yum install -y p7zip p7zip-plugins
  elif command -v dnf &>/dev/null; then
    dnf install -y p7zip p7zip-plugins
  elif command -v pacman &>/dev/null; then
    pacman -Sy --noconfirm p7zip
  else
    echo "Error: Cannot install 7zip. Install manually."
    exit 1
  fi
fi
echo "7zip: $(7z --help 2>&1 | head -1)"

# Create directory
mkdir -p "$INSTALL_DIR/public" "$INSTALL_DIR/data/backups"

# server.js
cat > "$INSTALL_DIR/server.js" << 'SERVEREOF'
const express = require('express');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { execFileSync } = require('child_process');
const os = require('os');

const app = express();
const PORT = process.env.PORT || 3345;
const DATA_DIR = path.join(__dirname, 'data');
const PASSWORDS_7Z = path.join(DATA_DIR, 'passwords.7z');
const CONFIG_FILE = path.join(DATA_DIR, 'config.json');
const BACKUP_DIR = path.join(DATA_DIR, 'backups');

if (!fs.existsSync(BACKUP_DIR)) {
  fs.mkdirSync(BACKUP_DIR, { recursive: true });
}

app.use(express.json({ limit: '10mb' }));
app.use(express.static(path.join(__dirname, 'public')));

const sessions = new Map();

function loadConfig() {
  try {
    return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
  } catch {
    return {};
  }
}

function saveConfig(config) {
  fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf8');
}

function hashPassword(password) {
  const salt = crypto.randomBytes(16).toString('hex');
  const hash = crypto.scryptSync(password, salt, 64).toString('hex');
  return { salt, hash };
}

function verifyPassword(password, stored) {
  const hash = crypto.scryptSync(password, stored.salt, 64).toString('hex');
  return hash === stored.hash;
}

function generateToken() {
  return crypto.randomBytes(32).toString('hex');
}

function authMiddleware(req, res, next) {
  const token = req.headers['x-auth-token'];
  if (!token || !sessions.has(token)) {
    return res.status(401).json({ error: 'Unauthorized' });
  }
  req.sessionPassword = sessions.get(token).password;
  next();
}

function exec7z(args) {
  execFileSync('7z', args, { stdio: 'pipe', timeout: 30000 });
}

function savePasswordsEncrypted(data, password) {
  const tmpDir = path.join(os.tmpdir(), `pw-save-${Date.now()}`);
  const tmpJson = path.join(tmpDir, 'passwords.json');
  fs.mkdirSync(tmpDir, { recursive: true });
  fs.writeFileSync(tmpJson, JSON.stringify(data, null, 2), 'utf8');
  try {
    if (fs.existsSync(PASSWORDS_7Z)) fs.unlinkSync(PASSWORDS_7Z);
    exec7z(['a', '-t7z', '-mhe=on', `-p${password}`, PASSWORDS_7Z, tmpJson]);
  } finally {
    try { fs.rmSync(tmpDir, { recursive: true }); } catch {}
  }
}

function loadPasswordsEncrypted(password) {
  if (!fs.existsSync(PASSWORDS_7Z)) return { categories: [] };
  const tmpDir = path.join(os.tmpdir(), `pw-load-${Date.now()}`);
  fs.mkdirSync(tmpDir, { recursive: true });
  try {
    exec7z(['x', `-p${password}`, `-o${tmpDir}`, PASSWORDS_7Z, '-y']);
    const jsonFile = path.join(tmpDir, 'passwords.json');
    if (!fs.existsSync(jsonFile)) return { categories: [] };
    return JSON.parse(fs.readFileSync(jsonFile, 'utf8'));
  } catch {
    return { categories: [] };
  } finally {
    try { fs.rmSync(tmpDir, { recursive: true }); } catch {}
  }
}

app.get('/api/auth/status', (req, res) => {
  const config = loadConfig();
  res.json({ hasPassword: !!config.passwordHash });
});

app.post('/api/auth/setup', (req, res) => {
  const config = loadConfig();
  if (config.passwordHash) {
    return res.status(400).json({ error: 'Password already set' });
  }
  const { password } = req.body;
  if (!password || password.length < 4) {
    return res.status(400).json({ error: 'Password must be at least 4 characters' });
  }
  config.passwordHash = hashPassword(password);
  saveConfig(config);
  const token = generateToken();
  sessions.set(token, { created: Date.now(), password });
  res.json({ success: true, token });
});

app.post('/api/auth/login', (req, res) => {
  const config = loadConfig();
  if (!config.passwordHash) {
    return res.status(400).json({ error: 'No password set' });
  }
  const { password } = req.body;
  if (!password) {
    return res.status(400).json({ error: 'Password required' });
  }
  if (!verifyPassword(password, config.passwordHash)) {
    return res.status(401).json({ error: 'Wrong password' });
  }
  const token = generateToken();
  sessions.set(token, { created: Date.now(), password });
  res.json({ success: true, token });
});

app.post('/api/auth/logout', (req, res) => {
  const token = req.headers['x-auth-token'];
  if (token) sessions.delete(token);
  res.json({ success: true });
});

app.post('/api/auth/change-password', authMiddleware, (req, res) => {
  const { currentPassword, newPassword } = req.body;
  const config = loadConfig();
  if (!config.passwordHash) {
    return res.status(400).json({ error: 'No password set' });
  }
  if (!currentPassword || !verifyPassword(currentPassword, config.passwordHash)) {
    return res.status(401).json({ error: 'Current password is wrong' });
  }
  if (!newPassword || newPassword.length < 4) {
    return res.status(400).json({ error: 'New password must be at least 4 characters' });
  }

  const oldPassword = req.sessionPassword;
  const data = loadPasswordsEncrypted(oldPassword);

  config.passwordHash = hashPassword(newPassword);
  saveConfig(config);

  savePasswordsEncrypted(data, newPassword);

  const token = req.headers['x-auth-token'];
  if (token && sessions.has(token)) {
    sessions.get(token).password = newPassword;
  }

  res.json({ success: true });
});

app.get('/api/passwords', authMiddleware, (req, res) => {
  res.json(loadPasswordsEncrypted(req.sessionPassword));
});

app.post('/api/passwords', authMiddleware, (req, res) => {
  savePasswordsEncrypted(req.body, req.sessionPassword);
  res.json({ success: true });
});

app.post('/api/export', authMiddleware, (req, res) => {
  try {
    const data = loadPasswordsEncrypted(req.sessionPassword);
    const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
    const filename = `passwords-${timestamp}.zip`;
    const filepath = path.join(BACKUP_DIR, filename);
    const tmpDir = path.join(os.tmpdir(), `pw-export-${Date.now()}`);
    const tmpJson = path.join(tmpDir, 'passwords.json');

    fs.mkdirSync(tmpDir, { recursive: true });
    fs.writeFileSync(tmpJson, JSON.stringify(data, null, 2), 'utf8');
    try {
      exec7z(['a', '-tzip', '-p' + req.sessionPassword, filepath, tmpJson]);
      res.download(filepath, filename, (err) => {
        fs.unlink(filepath, () => {});
      });
    } finally {
      try { fs.rmSync(tmpDir, { recursive: true }); } catch {}
    }
  } catch (e) {
    res.status(500).json({ error: e.message });
  }
});

app.post('/api/import', authMiddleware, (req, res) => {
  try {
    const { password, fileData } = req.body;
    if (!password || !fileData) return res.status(400).json({ error: 'Password and file required' });

    const tmpFile = path.join(os.tmpdir(), `pw-import-${Date.now()}`);
    const tmpDir = path.join(os.tmpdir(), `pw-import-dir-${Date.now()}`);

    fs.writeFileSync(tmpFile, Buffer.from(fileData, 'base64'));
    fs.mkdirSync(tmpDir, { recursive: true });

    try {
      exec7z(['x', '-p' + password, '-o' + tmpDir, tmpFile, '-y']);

      const jsonFile = path.join(tmpDir, 'passwords.json');
      if (!fs.existsSync(jsonFile)) {
        return res.status(400).json({ error: 'passwords.json not found in archive' });
      }

      const imported = JSON.parse(fs.readFileSync(jsonFile, 'utf8'));
      savePasswordsEncrypted(imported, req.sessionPassword);
      res.json({ success: true });
    } finally {
      try { fs.unlinkSync(tmpFile); } catch {}
      try { fs.rmSync(tmpDir, { recursive: true }); } catch {}
    }
  } catch (e) {
    res.status(500).json({ error: 'Wrong password or corrupt file' });
  }
});

app.listen(PORT, '0.0.0.0', () => {
  console.log(`Password Manager running on http://0.0.0.0:${PORT}`);
});
SERVEREOF

# package.json
cat > "$INSTALL_DIR/package.json" << 'PKGEOF'
{
  "name": "spw",
  "version": "1.0.0",
  "description": "Self-hosted password manager",
  "main": "server.js",
  "scripts": {
    "start": "node server.js"
  },
  "dependencies": {
    "express": "^4.18.2"
  }
}
PKGEOF

# index.html
cat > "$INSTALL_DIR/public/index.html" << 'HTMLEOF'
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>SPW - Password Manager</title>
  <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🔐</text></svg>">
  <link rel="stylesheet" href="style.css">
</head>
<body>
  <div id="auth-screen">
    <div class="auth-box">
      <h2>SPW</h2>
      <div id="auth-setup">
        <p>初回アクセスです。パスワードを設定してください。</p>
        <input type="password" id="setup-password" placeholder="パスワード">
        <input type="password" id="setup-password-confirm" placeholder="パスワード(確認)">
        <button id="btn-setup" class="btn-primary">設定</button>
      </div>
      <div id="auth-login" style="display:none">
        <p>パスワードを入力してください。</p>
        <input type="password" id="login-password" placeholder="パスワード">
        <button id="btn-login" class="btn-primary">ログイン</button>
      </div>
      <div id="auth-error" class="auth-error" style="display:none"></div>
    </div>
  </div>

  <div id="app" style="display:none">
    <aside id="sidebar">
      <div class="sidebar-header">
        <h2>SPW</h2>
        <button id="btn-export" class="btn-small" title="エクスポート">
          <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
        </button>
        <button id="btn-import" class="btn-small" title="インポート">
          <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
        </button>
        <button id="btn-add-category" class="btn-small" title="カテゴリー追加">
          <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
        </button>
      </div>
      <div class="search-box">
        <input type="text" id="search-input" placeholder="検索...">
      </div>
      <div id="category-list"></div>
      <div class="sidebar-footer">
        <button id="btn-change-pw" class="btn-small" title="パスワード変更">
          <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg>
          パスワード変更
        </button>
        <button id="btn-logout" class="btn-small" title="ログアウト">
          <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
          ログアウト
        </button>
      </div>
    </aside>
    <main id="main-content">
      <div id="welcome-screen">
        <p>カテゴリーとカードを選択してください</p>
      </div>
      <div id="card-detail" style="display:none">
        <div class="detail-header">
          <input type="text" id="detail-card-name" class="card-name-input">
          <div class="detail-actions">
            <button id="btn-add-field" class="btn-small">フィールド追加</button>
            <button id="btn-delete-card" class="btn-small btn-danger">カード削除</button>
            <button id="btn-save-card" class="btn-primary">
              <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 21H5a2 2 0 01-2-2V5a2 2 0 012-2h11l5 5v11a2 2 0 01-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>
              保存
            </button>
          </div>
        </div>
        <div id="detail-fields"></div>
      </div>
    </main>
  </div>

  <div id="export-modal" class="modal" style="display:none">
    <div class="modal-content">
      <h3>エクスポート</h3>
      <p>暗号化ファイルをダウンロードします</p>
      <div class="modal-actions">
        <button id="btn-export-cancel" class="btn-small">キャンセル</button>
        <button id="btn-export-confirm" class="btn-primary">ダウンロード</button>
      </div>
    </div>
  </div>

  <div id="import-modal" class="modal" style="display:none">
    <div class="modal-content">
      <h3>インポート</h3>
      <p>パスワードを入力してください</p>
      <input type="password" id="import-password" placeholder="パスワード">
      <input type="file" id="import-file" accept=".7z,.zip" style="margin-bottom:12px;color:#e0e0e0">
      <div class="modal-actions">
        <button id="btn-import-cancel" class="btn-small">キャンセル</button>
        <button id="btn-import-confirm" class="btn-primary">インポート</button>
      </div>
    </div>
  </div>

  <div id="change-pw-modal" class="modal" style="display:none">
    <div class="modal-content">
      <h3>パスワード変更</h3>
      <input type="password" id="current-password" placeholder="現在のパスワード">
      <input type="password" id="new-password" placeholder="新しいパスワード">
      <input type="password" id="new-password-confirm" placeholder="新しいパスワード(確認)">
      <div class="modal-actions">
        <button id="btn-change-pw-cancel" class="btn-small">キャンセル</button>
        <button id="btn-change-pw-confirm" class="btn-primary">変更</button>
      </div>
    </div>
  </div>

  <script src="app.js"></script>
</body>
</html>
HTMLEOF

# style.css
cat > "$INSTALL_DIR/public/style.css" << 'CSSEOF'
* { margin: 0; padding: 0; box-sizing: border-box; }

:root {
  --bg: #f5f5f5;
  --surface: #ffffff;
  --border: #d0d0d0;
  --border-strong: #999;
  --text: #1a1a1a;
  --text-muted: #666;
  --accent: #0055a5;
  --accent-hover: #003d7a;
  --danger: #c0392b;
  --danger-hover: #962d22;
  --row-alt: #f9f9f9;
  --row-hover: #e8f0fe;
  --row-active: #d0e0f5;
  --header-bg: #e2e8f0;
  --input-bg: #fff;
  --scrollbar: #c0c0c0;
  --scrollbar-hover: #999;
  --selection: #b3d4fc;
}

body {
  font-family: 'Segoe UI', 'Meiryo', 'Hiragino Sans', sans-serif;
  background: var(--bg);
  color: var(--text);
  height: 100vh;
  overflow: hidden;
  font-size: 13px;
  line-height: 1.4;
}

::selection { background: var(--selection); }

#auth-screen {
  display: flex;
  align-items: center;
  justify-content: center;
  height: 100vh;
  background: var(--header-bg);
}

.auth-box {
  background: var(--surface);
  border: 1px solid var(--border);
  box-shadow: 0 2px 8px rgba(0,0,0,0.1);
  padding: 32px;
  width: 340px;
}

.auth-box h2 {
  color: var(--text);
  font-size: 20px;
  font-weight: 700;
  margin-bottom: 4px;
  letter-spacing: 1px;
}

.auth-box > div > p {
  color: var(--text-muted);
  font-size: 12px;
  margin-bottom: 16px;
}

.auth-box input {
  width: 100%;
  padding: 8px 10px;
  border: 1px solid var(--border);
  background: var(--input-bg);
  color: var(--text);
  font-size: 13px;
  margin-bottom: 10px;
  outline: none;
}

.auth-box input:focus {
  border-color: var(--accent);
}

.auth-error {
  color: var(--danger);
  font-size: 12px;
  margin-top: 4px;
}

#app {
  display: flex;
  height: 100vh;
}

#sidebar {
  width: 260px;
  background: var(--surface);
  border-right: 1px solid var(--border);
  display: flex;
  flex-direction: column;
  flex-shrink: 0;
}

.sidebar-header {
  display: flex;
  align-items: center;
  padding: 6px 8px;
  border-bottom: 1px solid var(--border);
  background: var(--header-bg);
  gap: 4px;
}

.sidebar-header h2 {
  font-size: 13px;
  font-weight: 700;
  color: var(--text);
  flex: 1;
  letter-spacing: 0.5px;
  text-transform: uppercase;
}

.search-box {
  padding: 4px 6px;
  border-bottom: 1px solid var(--border);
  background: var(--surface);
}

.search-box input {
  width: 100%;
  padding: 5px 8px;
  border: 1px solid var(--border);
  background: var(--input-bg);
  color: var(--text);
  font-size: 12px;
  outline: none;
}

.search-box input:focus {
  border-color: var(--accent);
}

#category-list {
  flex: 1;
  overflow-y: auto;
  padding: 0;
}

.sidebar-footer {
  border-top: 1px solid var(--border);
  padding: 4px 6px;
  display: flex;
  gap: 4px;
  background: var(--header-bg);
}

.sidebar-footer .btn-small {
  flex: 1;
  font-size: 11px;
  padding: 4px 6px;
}

.category-item {
  border-bottom: 1px solid #eee;
}

.category-header {
  display: flex;
  align-items: center;
  padding: 6px 8px;
  cursor: pointer;
  font-weight: 700;
  font-size: 12px;
  color: var(--text);
  background: var(--row-alt);
  border-bottom: 1px solid var(--border);
}

.category-header:hover {
  background: var(--row-hover);
}

.category-header .cat-name {
  flex: 1;
  text-transform: uppercase;
  letter-spacing: 0.3px;
}

.category-header .cat-actions {
  display: none;
  gap: 2px;
}

.category-header:hover .cat-actions {
  display: flex;
}

.card-item {
  display: flex;
  align-items: center;
  padding: 5px 8px 5px 20px;
  cursor: pointer;
  font-size: 12px;
  color: var(--text);
  border-bottom: 1px solid #f0f0f0;
}

.card-item:hover {
  background: var(--row-hover);
}

.card-item.active {
  background: var(--accent);
  color: #fff;
}

.card-item .card-name {
  flex: 1;
}

#main-content {
  flex: 1;
  overflow-y: auto;
  padding: 0;
  background: var(--bg);
}

#welcome-screen {
  display: flex;
  align-items: center;
  justify-content: center;
  height: 100%;
  color: var(--text-muted);
  font-size: 13px;
}

#card-detail {
  padding: 0;
}

.detail-header {
  display: flex;
  align-items: center;
  padding: 8px 12px;
  gap: 8px;
  background: var(--header-bg);
  border-bottom: 1px solid var(--border);
}

.card-name-input {
  flex: 1;
  font-size: 14px;
  font-weight: 700;
  color: var(--text);
  background: var(--input-bg);
  border: 1px solid var(--border);
  padding: 4px 8px;
  outline: none;
}

.card-name-input:focus {
  border-color: var(--accent);
}

.detail-actions {
  display: flex;
  gap: 4px;
}

#detail-fields {
  background: var(--surface);
}

.field-row {
  display: flex;
  align-items: center;
  border-bottom: 1px solid var(--border);
}

.field-row:nth-child(even) {
  background: var(--row-alt);
}

.field-label-input {
  width: 160px;
  font-size: 12px;
  font-weight: 600;
  color: var(--text);
  flex-shrink: 0;
  background: transparent;
  border: none;
  border-right: 1px solid var(--border);
  padding: 8px 10px;
  outline: none;
}

.field-label-input:focus {
  background: var(--row-hover);
}

.field-input-wrap {
  flex: 1;
  display: flex;
  align-items: center;
  gap: 0;
}

.field-input-wrap input {
  flex: 1;
  padding: 8px 10px;
  border: none;
  background: transparent;
  color: var(--text);
  font-size: 13px;
  outline: none;
}

.field-input-wrap input:focus {
  background: var(--row-hover);
}

.btn-toggle-pw {
  padding: 6px 8px;
  background: transparent;
  border: none;
  border-left: 1px solid var(--border);
  color: var(--text-muted);
  cursor: pointer;
  display: flex;
  align-items: center;
  height: 100%;
}

.btn-toggle-pw:hover {
  color: var(--accent);
  background: var(--row-hover);
}

.btn-copy {
  padding: 6px 10px;
  background: transparent;
  border: none;
  border-left: 1px solid var(--border);
  color: var(--text-muted);
  cursor: pointer;
  font-size: 11px;
  white-space: nowrap;
  height: 100%;
}

.btn-copy:hover {
  color: var(--accent);
  background: var(--row-hover);
}

.btn-copy.copied {
  color: #fff;
  background: #2e7d32;
}

.btn-delete-field {
  padding: 6px 8px;
  background: transparent;
  border: none;
  border-left: 1px solid var(--border);
  color: var(--text-muted);
  cursor: pointer;
  font-size: 14px;
  height: 100%;
}

.btn-delete-field:hover {
  color: var(--danger);
  background: #fce4ec;
}

.detail-actions-bottom {
  padding: 8px 12px;
  background: var(--header-bg);
  border-top: 1px solid var(--border);
}

.btn-small {
  padding: 4px 10px;
  background: var(--surface);
  border: 1px solid var(--border);
  color: var(--text);
  cursor: pointer;
  font-size: 11px;
  outline: none;
}

.btn-small:hover {
  background: var(--row-hover);
  border-color: var(--accent);
}

.btn-primary {
  padding: 6px 20px;
  background: var(--accent);
  border: 1px solid var(--accent-hover);
  color: #fff;
  cursor: pointer;
  font-size: 12px;
  font-weight: 600;
  outline: none;
}

.btn-primary:hover {
  background: var(--accent-hover);
}

.btn-danger {
  color: var(--danger);
  border-color: var(--danger);
}

.btn-danger:hover {
  background: #fce4ec;
}

.modal {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background: rgba(0,0,0,0.4);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 100;
}

.modal-content {
  background: var(--surface);
  border: 1px solid var(--border);
  box-shadow: 0 4px 16px rgba(0,0,0,0.15);
  padding: 20px;
  min-width: 340px;
}

.modal-content h3 {
  margin-bottom: 8px;
  color: var(--text);
  font-size: 14px;
  font-weight: 700;
  border-bottom: 1px solid var(--border);
  padding-bottom: 8px;
}

.modal-content p {
  margin-bottom: 12px;
  font-size: 12px;
  color: var(--text-muted);
}

.modal-content input {
  width: 100%;
  padding: 8px 10px;
  border: 1px solid var(--border);
  background: var(--input-bg);
  color: var(--text);
  font-size: 13px;
  margin-bottom: 10px;
  outline: none;
}

.modal-content input:focus {
  border-color: var(--accent);
}

.modal-actions {
  display: flex;
  justify-content: flex-end;
  gap: 6px;
  margin-top: 8px;
}

#category-list::-webkit-scrollbar { width: 6px; }
#category-list::-webkit-scrollbar-track { background: transparent; }
#category-list::-webkit-scrollbar-thumb { background: var(--scrollbar); }
#category-list::-webkit-scrollbar-thumb:hover { background: var(--scrollbar-hover); }

.btn-primary.dirty {
  animation: blink-btn 1s ease-in-out infinite;
}

@keyframes blink-btn {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.4; }
}
CSSEOF

# app.js
cat > "$INSTALL_DIR/public/app.js" << 'JSEOF'
let data = { categories: [] };
let selectedCard = null;
let selectedCategoryId = null;
let authToken = localStorage.getItem('spw_token');
let isDirty = false;

function markDirty() {
  if (isDirty) return;
  isDirty = true;
  document.getElementById('btn-save-card').classList.add('dirty');
}

function markClean() {
  isDirty = false;
  document.getElementById('btn-save-card').classList.remove('dirty');
}

async function apiFetch(url, options = {}) {
  const headers = { ...options.headers, 'x-auth-token': authToken };
  return fetch(url, { ...options, headers });
}

async function checkAuth() {
  const res = await fetch('/api/auth/status');
  const { hasPassword } = await res.json();
  if (!hasPassword) {
    document.getElementById('auth-setup').style.display = 'block';
    document.getElementById('auth-login').style.display = 'none';
  } else if (!authToken) {
    document.getElementById('auth-setup').style.display = 'none';
    document.getElementById('auth-login').style.display = 'block';
  } else {
    const checkRes = await apiFetch('/api/passwords');
    if (checkRes.ok) {
      showApp();
    } else {
      authToken = null;
      localStorage.removeItem('spw_token');
      document.getElementById('auth-setup').style.display = 'none';
      document.getElementById('auth-login').style.display = 'block';
    }
  }
}

function showApp() {
  document.getElementById('auth-screen').style.display = 'none';
  document.getElementById('app').style.display = 'flex';
  loadData();
}

function showAuthError(msg) {
  const el = document.getElementById('auth-error');
  el.textContent = msg;
  el.style.display = 'block';
  setTimeout(() => { el.style.display = 'none'; }, 3000);
}

document.getElementById('btn-setup').addEventListener('click', async () => {
  const pw = document.getElementById('setup-password').value;
  const pw2 = document.getElementById('setup-password-confirm').value;
  if (!pw || pw.length < 4) return showAuthError('パスワードは4文字以上');
  if (pw !== pw2) return showAuthError('パスワードが一致しません');
  const res = await fetch('/api/auth/setup', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ password: pw })
  });
  const result = await res.json();
  if (result.success) {
    authToken = result.token;
    localStorage.setItem('spw_token', authToken);
    showApp();
  } else {
    showAuthError(result.error);
  }
});

document.getElementById('btn-login').addEventListener('click', async () => {
  const pw = document.getElementById('login-password').value;
  if (!pw) return showAuthError('パスワードを入力してください');
  const res = await fetch('/api/auth/login', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ password: pw })
  });
  const result = await res.json();
  if (result.success) {
    authToken = result.token;
    localStorage.setItem('spw_token', authToken);
    showApp();
  } else {
    showAuthError(result.error);
  }
});

document.getElementById('btn-logout').addEventListener('click', async () => {
  await apiFetch('/api/auth/logout', { method: 'POST' });
  authToken = null;
  localStorage.removeItem('spw_token');
  document.getElementById('app').style.display = 'none';
  document.getElementById('auth-screen').style.display = 'flex';
  document.getElementById('auth-setup').style.display = 'none';
  document.getElementById('auth-login').style.display = 'block';
  document.getElementById('login-password').value = '';
});

['setup-password', 'setup-password-confirm', 'login-password'].forEach(id => {
  document.getElementById(id).addEventListener('keydown', e => {
    if (e.key === 'Enter') {
      if (id.startsWith('setup')) document.getElementById('btn-setup').click();
      else document.getElementById('btn-login').click();
    }
  });
});

async function loadData() {
  const res = await apiFetch('/api/passwords');
  if (!res.ok) {
    authToken = null;
    localStorage.removeItem('spw_token');
    location.reload();
    return;
  }
  data = await res.json();
  renderSidebar();
}

async function saveData() {
  await apiFetch('/api/passwords', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(data)
  });
  markClean();
}

function collectCurrentInputs() {
  if (!selectedCard) return;
  const nameInput = document.getElementById('detail-card-name');
  if (nameInput) selectedCard.name = nameInput.value;
  document.querySelectorAll('#detail-fields .field-row').forEach((row, idx) => {
    if (!selectedCard.fields[idx]) return;
    const keyInput = row.querySelector('.field-label-input');
    const valInput = row.querySelector('.field-value-input');
    if (keyInput) selectedCard.fields[idx].key = keyInput.value;
    if (valInput) selectedCard.fields[idx].value = valInput.value;
  });
}

function renderSidebar(filter = '') {
  const list = document.getElementById('category-list');
  list.innerHTML = '';
  const lf = filter.toLowerCase();

  data.categories.forEach(cat => {
    const filteredCards = cat.cards.filter(c =>
      !lf || c.name.toLowerCase().includes(lf) ||
      c.fields.some(f => f.value.toLowerCase().includes(lf) || f.key.toLowerCase().includes(lf))
    );
    if (lf && filteredCards.length === 0) return;

    const catEl = document.createElement('div');
    catEl.className = 'category-item';
    catEl.innerHTML = `
      <div class="category-header" data-id="${cat.id}">
        <span class="cat-name">${escHtml(cat.name)}</span>
        <span class="cat-actions">
          <button class="btn-small btn-add-card" data-cat="${cat.id}" title="カード追加">+</button>
          <button class="btn-small btn-rename-cat" data-cat="${cat.id}" title="名前変更">
            <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
          </button>
          <button class="btn-small btn-del-cat" data-cat="${cat.id}" title="削除">×</button>
        </span>
      </div>
    `;
    list.appendChild(catEl);

    (lf ? filteredCards : cat.cards).forEach(card => {
      const cardEl = document.createElement('div');
      cardEl.className = 'card-item' + (selectedCard && selectedCard.id === card.id ? ' active' : '');
      cardEl.dataset.cardId = card.id;
      cardEl.dataset.catId = cat.id;
      cardEl.innerHTML = `<span class="card-name">${escHtml(card.name)}</span>`;
      list.appendChild(cardEl);
    });
  });
}

function isPasswordField(key) {
  const k = key.toLowerCase();
  return k.includes('パスワード') || k.includes('password') || k.includes('pass') || k.includes('secret');
}

function showCardDetail(catId, cardId) {
  collectCurrentInputs();

  const cat = data.categories.find(c => c.id === catId);
  if (!cat) return;
  const card = cat.cards.find(c => c.id === cardId);
  if (!card) return;

  selectedCard = card;
  selectedCategoryId = catId;

  document.getElementById('welcome-screen').style.display = 'none';
  document.getElementById('card-detail').style.display = 'block';

  const nameInput = document.getElementById('detail-card-name');
  nameInput.value = card.name;

  const fieldsDiv = document.getElementById('detail-fields');
  fieldsDiv.innerHTML = '';

  card.fields.forEach((field, idx) => {
    const row = document.createElement('div');
    row.className = 'field-row';
    const isPw = isPasswordField(field.key);
    row.innerHTML = `
      <input type="text" class="field-label-input" data-idx="${idx}" value="${escAttr(field.key)}">
      <div class="field-input-wrap">
        <input type="${isPw ? 'password' : 'text'}" class="field-value-input" data-idx="${idx}" value="${escAttr(field.value)}">
        ${isPw ? '<button class="btn-toggle-pw" data-idx="' + idx + '" title="表示切替"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></button>' : ''}
        <button class="btn-copy" data-idx="${idx}">コピー</button>
        <button class="btn-delete-field" data-idx="${idx}" title="削除">×</button>
      </div>
    `;
    fieldsDiv.appendChild(row);
  });

  renderSidebar(document.getElementById('search-input').value);
}

function escHtml(s) {
  const d = document.createElement('div');
  d.textContent = s;
  return d.innerHTML;
}

function escAttr(s) {
  return s.replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}

function genId() {
  return 'id-' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
}

function fallbackCopy(text) {
  const ta = document.createElement('textarea');
  ta.value = text;
  ta.style.position = 'fixed';
  ta.style.opacity = '0';
  document.body.appendChild(ta);
  ta.select();
  document.execCommand('copy');
  document.body.removeChild(ta);
}

document.getElementById('category-list').addEventListener('click', e => {
  const addCard = e.target.closest('.btn-add-card');
  if (addCard) {
    collectCurrentInputs();
    const catId = addCard.dataset.cat;
    const cat = data.categories.find(c => c.id === catId);
    if (!cat) return;
    const card = { id: genId(), name: '新しいカード', fields: [{ key: 'ID', value: '' }, { key: 'パスワード', value: '' }] };
    cat.cards.push(card);
    saveData();
    showCardDetail(catId, card.id);
    return;
  }

  const renameCat = e.target.closest('.btn-rename-cat');
  if (renameCat) {
    const catId = renameCat.dataset.cat;
    const cat = data.categories.find(c => c.id === catId);
    if (!cat) return;
    const newName = prompt('カテゴリー名を入力:', cat.name);
    if (!newName || newName === cat.name) return;
    cat.name = newName;
    saveData();
    renderSidebar(document.getElementById('search-input').value);
    return;
  }

  const delCat = e.target.closest('.btn-del-cat');
  if (delCat) {
    const catId = delCat.dataset.cat;
    if (!confirm('カテゴリーを削除しますか?')) return;
    data.categories = data.categories.filter(c => c.id !== catId);
    saveData();
    if (selectedCategoryId === catId) {
      selectedCard = null;
      selectedCategoryId = null;
      document.getElementById('card-detail').style.display = 'none';
      document.getElementById('welcome-screen').style.display = 'flex';
    }
    renderSidebar(document.getElementById('search-input').value);
    return;
  }

  const cardEl = e.target.closest('.card-item');
  if (cardEl) {
    showCardDetail(cardEl.dataset.catId, cardEl.dataset.cardId);
  }
});

document.getElementById('search-input').addEventListener('input', e => {
  renderSidebar(e.target.value);
});

document.getElementById('btn-add-category').addEventListener('click', () => {
  const name = prompt('カテゴリー名を入力:');
  if (!name) return;
  const cat = { id: genId(), name, cards: [] };
  data.categories.push(cat);
  saveData();
  renderSidebar();
});

document.getElementById('btn-add-field').addEventListener('click', () => {
  if (!selectedCard) return;
  collectCurrentInputs();
  selectedCard.fields.push({ key: '新しい項目', value: '' });
  markDirty();
  showCardDetail(selectedCategoryId, selectedCard.id);
});

document.getElementById('btn-delete-card').addEventListener('click', () => {
  if (!selectedCard || !selectedCategoryId) return;
  if (!confirm('カードを削除しますか?')) return;
  const cat = data.categories.find(c => c.id === selectedCategoryId);
  if (!cat) return;
  cat.cards = cat.cards.filter(c => c.id !== selectedCard.id);
  saveData();
  selectedCard = null;
  selectedCategoryId = null;
  document.getElementById('card-detail').style.display = 'none';
  document.getElementById('welcome-screen').style.display = 'flex';
  renderSidebar(document.getElementById('search-input').value);
});

document.getElementById('detail-fields').addEventListener('click', e => {
  const toggleBtn = e.target.closest('.btn-toggle-pw');
  if (toggleBtn) {
    const row = toggleBtn.closest('.field-row');
    const input = row.querySelector('.field-value-input');
    if (input.type === 'password') {
      input.type = 'text';
      toggleBtn.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17.94 17.94A10.07 10.07 0 0112 20c-7 0-11-8-11-8a18.45 18.45 0 015.06-5.94M9.9 4.24A9.12 9.12 0 0112 4c7 0 11 8 11 8a18.5 18.5 0 01-2.16 3.19m-6.72-1.07a3 3 0 11-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>';
    } else {
      input.type = 'password';
      toggleBtn.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>';
    }
    return;
  }

  const copyBtn = e.target.closest('.btn-copy');
  if (copyBtn) {
    const row = copyBtn.closest('.field-row');
    const input = row.querySelector('.field-value-input');
    const text = input.value;
    function showCopied() {
      copyBtn.textContent = 'コピー済み';
      copyBtn.classList.add('copied');
      setTimeout(() => {
        copyBtn.textContent = 'コピー';
        copyBtn.classList.remove('copied');
      }, 1500);
    }
    if (navigator.clipboard && navigator.clipboard.writeText) {
      navigator.clipboard.writeText(text).then(showCopied).catch(() => {
        fallbackCopy(text);
        showCopied();
      });
    } else {
      fallbackCopy(text);
      showCopied();
    }
    return;
  }

  const delBtn = e.target.closest('.btn-delete-field');
  if (delBtn) {
    const idx = parseInt(delBtn.dataset.idx);
    if (!selectedCard) return;
    collectCurrentInputs();
    selectedCard.fields.splice(idx, 1);
    markDirty();
    showCardDetail(selectedCategoryId, selectedCard.id);
  }
});

document.getElementById('btn-save-card').addEventListener('click', () => {
  if (!selectedCard) return;
  collectCurrentInputs();
  saveData();
  renderSidebar(document.getElementById('search-input').value);
});

document.getElementById('btn-export').addEventListener('click', () => {
  document.getElementById('export-modal').style.display = 'flex';
});

document.getElementById('btn-export-cancel').addEventListener('click', () => {
  document.getElementById('export-modal').style.display = 'none';
});

document.getElementById('btn-export-confirm').addEventListener('click', async () => {
  const res = await apiFetch('/api/export', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({})
  });

  if (!res.ok) {
    const err = await res.json();
    alert('エクスポートに失敗しました: ' + (err.error || ''));
    return;
  }

  const blob = await res.blob();
  const disposition = res.headers.get('Content-Disposition');
  const filename = disposition ? disposition.split('filename=')[1].replace(/"/g, '') : 'passwords.zip';
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url;
  a.download = filename;
  a.click();
  URL.revokeObjectURL(url);

  document.getElementById('export-modal').style.display = 'none';
});

document.getElementById('export-modal').addEventListener('click', e => {
  if (e.target === e.currentTarget) {
    document.getElementById('export-modal').style.display = 'none';
  }
});

document.getElementById('btn-import').addEventListener('click', () => {
  document.getElementById('import-modal').style.display = 'flex';
  document.getElementById('import-password').value = '';
  document.getElementById('import-file').value = '';
  document.getElementById('import-password').focus();
});

document.getElementById('btn-import-cancel').addEventListener('click', () => {
  document.getElementById('import-modal').style.display = 'none';
});

document.getElementById('btn-import-confirm').addEventListener('click', async () => {
  const password = document.getElementById('import-password').value;
  const fileInput = document.getElementById('import-file');
  if (!password) return alert('パスワードを入力してください');
  if (!fileInput.files.length) return alert('ファイルを選択してください');

  const file = fileInput.files[0];
  const reader = new FileReader();
  reader.onload = async () => {
    const base64 = reader.result.split(',')[1];
    const res = await apiFetch('/api/import', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ password, fileData: base64 })
    });

    const result = await res.json();
    if (result.success) {
      alert('インポートが完了しました');
      document.getElementById('import-modal').style.display = 'none';
      await loadData();
    } else {
      alert('インポートに失敗しました: ' + (result.error || '不明なエラー'));
    }
  };
  reader.readAsDataURL(file);
});

document.getElementById('import-modal').addEventListener('click', e => {
  if (e.target === e.currentTarget) {
    document.getElementById('import-modal').style.display = 'none';
  }
});

document.getElementById('btn-change-pw').addEventListener('click', () => {
  document.getElementById('change-pw-modal').style.display = 'flex';
  document.getElementById('current-password').value = '';
  document.getElementById('new-password').value = '';
  document.getElementById('new-password-confirm').value = '';
  document.getElementById('current-password').focus();
});

document.getElementById('btn-change-pw-cancel').addEventListener('click', () => {
  document.getElementById('change-pw-modal').style.display = 'none';
});

document.getElementById('btn-change-pw-confirm').addEventListener('click', async () => {
  const current = document.getElementById('current-password').value;
  const newPw = document.getElementById('new-password').value;
  const newPw2 = document.getElementById('new-password-confirm').value;
  if (!current) return alert('現在のパスワードを入力してください');
  if (!newPw || newPw.length < 4) return alert('新しいパスワードは4文字以上');
  if (newPw !== newPw2) return alert('パスワードが一致しません');

  const res = await apiFetch('/api/auth/change-password', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ currentPassword: current, newPassword: newPw })
  });

  const result = await res.json();
  if (result.success) {
    alert('パスワードを変更しました');
    document.getElementById('change-pw-modal').style.display = 'none';
  } else {
    alert(result.error || '変更に失敗しました');
  }
});

document.getElementById('change-pw-modal').addEventListener('click', e => {
  if (e.target === e.currentTarget) {
    document.getElementById('change-pw-modal').style.display = 'none';
  }
});

document.getElementById('detail-card-name').addEventListener('input', () => {
  markDirty();
});

document.getElementById('detail-fields').addEventListener('input', e => {
  if (e.target.classList.contains('field-label-input') || e.target.classList.contains('field-value-input')) {
    markDirty();
  }
});

checkAuth();
JSEOF

# Install npm dependencies
echo "Installing npm dependencies..."
cd "$INSTALL_DIR" && npm install --production 2>&1 | tail -3

# Create systemd service
cat > "/etc/systemd/system/${SERVICE_NAME}.service" << SVCEOF
[Unit]
Description=SPW Password Manager
After=network.target

[Service]
Type=simple
WorkingDirectory=${INSTALL_DIR}
ExecStart=/usr/bin/node ${INSTALL_DIR}/server.js
Restart=on-failure
RestartSec=5
Environment=PORT=${PORT}

[Install]
WantedBy=multi-user.target
SVCEOF

systemctl daemon-reload
systemctl enable ${SERVICE_NAME}
systemctl restart ${SERVICE_NAME}

# Tailscale Serve設定
TAILSCALE_DOMAIN=""
echo ""
echo "Tailscale Serve を設定中..."
if ! command -v tailscale &>/dev/null; then
  echo "Warning: tailscale コマンドが見つかりません。Tailscale HTTPSはスキップします。"
else
  tailscale serve --bg --https=${TAILSCALE_PORT} http://127.0.0.1:${PORT}
  TAILSCALE_DOMAIN=$(tailscale status --json 2>/dev/null \
    | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('Self',{}).get('DNSName','').rstrip('.'))" 2>/dev/null || echo "")
  if [ -n "$TAILSCALE_DOMAIN" ]; then
    echo "Tailscale Serve: https://${TAILSCALE_DOMAIN}:${TAILSCALE_PORT}"
  else
    echo "Warning: Tailscale ドメイン取得に失敗しました。"
  fi
fi

echo ""
echo "=== インストール完了 ==="
echo ""
if [ -n "$TAILSCALE_DOMAIN" ]; then
  echo "URL (HTTPS): https://${TAILSCALE_DOMAIN}:${TAILSCALE_PORT}"
fi
echo "URL (ローカル): http://$(hostname -I | awk '{print $1}'):${PORT}"
echo ""
echo "コマンド:"
echo "  systemctl status  ${SERVICE_NAME}   # 状態確認"
echo "  systemctl restart ${SERVICE_NAME}   # 再起動"
echo "  journalctl -u ${SERVICE_NAME} -f    # ログ確認"

アンインストール

#!/bin/bash
set -e

INSTALL_DIR="/opt/lxd-data/spw"
SERVICE_NAME="spw"
TAILSCALE_PORT=3344

echo "=== SPW Password Manager Uninstaller ==="

# Check root
if [ "$EUID" -ne 0 ]; then
  echo "Error: Run as root (sudo bash $0)"
  exit 1
fi

# データ削除確認
echo ""
read -p "パスワードデータも削除しますか? [y/N]: " DEL_DATA

# Tailscale Serve解除
echo ""
echo "Tailscale Serve を解除中..."
if command -v tailscale &>/dev/null; then
  tailscale serve --https=${TAILSCALE_PORT} off 2>/dev/null && echo "解除しました" || echo "設定がないかスキップ"
else
  echo "tailscale が見つかりません。スキップ"
fi

# systemdサービス停止・削除
echo "サービスを停止・削除中..."
systemctl stop ${SERVICE_NAME} 2>/dev/null || true
systemctl disable ${SERVICE_NAME} 2>/dev/null || true
rm -f "/etc/systemd/system/${SERVICE_NAME}.service"
systemctl daemon-reload

# ファイル削除
echo "ファイルを削除中..."
if [[ "$DEL_DATA" =~ ^[Yy]$ ]]; then
  rm -rf "$INSTALL_DIR"
  echo "インストールディレクトリ+データを削除しました: $INSTALL_DIR"
else
  # データは残してアプリ本体だけ削除
  rm -f "$INSTALL_DIR/server.js"
  rm -f "$INSTALL_DIR/package.json"
  rm -f "$INSTALL_DIR/package-lock.json"
  rm -rf "$INSTALL_DIR/node_modules"
  rm -rf "$INSTALL_DIR/public"
  echo "アプリを削除しました(データは残しました: $INSTALL_DIR/data)"
fi

echo ""
echo "=== アンインストール完了 ==="
タイトルとURLをコピーしました