OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-orgs / examples / web.js

315 lines · javascript ref:
#!/usr/bin/env node
/**
 * Mini web example for hyper-orgs
 *
 * SSR org browser. Pages:
 *   GET  /                       → list all orgs + create-org form
 *   GET  /:name                  → show org + members + resources + grant form
 *   POST /create                 → create an org
 *   POST /:name/member           → add a member
 *   POST /:name/resource         → add a resource
 *   POST /:name/grant            → create a group grant for a user
 *
 * Usage: node examples/web.js [--port 9097] [--storage <path>]
 *
 * NOTE: Uses its own storage path by default (./.hyper-orgs-web). Does NOT
 * share state with examples/cli.js — RocksDB single-writer lock.
 */

import http from 'http'
import Corestore from 'corestore'
import b4a from 'b4a'
import { HyperOrgs } from '../index.js'

const args = process.argv.slice(2)
function getFlag (name) {
  const i = args.indexOf('--' + name)
  return i !== -1 && args[i + 1] ? args[i + 1] : null
}

const port = parseInt(getFlag('port')) || 9097
const storagePath = getFlag('storage') || './.hyper-orgs-web'

const store = new Corestore(storagePath)
await store.ready()
const orgs = new HyperOrgs(store, { namespace: 'orgs' })
await orgs.ready()

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

function collectBody (req) {
  return new Promise((resolve, reject) => {
    const parts = []
    req.on('data', chunk => parts.push(chunk))
    req.on('end', () => resolve(b4a.concat(parts).toString('utf8')))
    req.on('error', reject)
  })
}

const httpServer = http.createServer(async (req, res) => {
  const url = req.url.split('?')[0]
  try {
    if (req.method === 'GET' && url === '/') {
      const all = await orgs.list()
      res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
      res.end(renderIndex(all))
      return
    }

    const showMatch = url.match(/^\/([a-z0-9][a-z0-9-]*[a-z0-9])$/)
    if (req.method === 'GET' && showMatch) {
      const name = showMatch[1]
      const org = await orgs.get(name)
      if (!org) {
        res.writeHead(404, { 'Content-Type': 'text/plain' })
        res.end('Not found')
        return
      }
      const grants = await orgs.listGrants({ grantType: 'group', groupName: name })
      res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
      res.end(renderOrg(org, grants))
      return
    }

    if (req.method === 'POST' && url === '/create') {
      const params = new URLSearchParams(await collectBody(req))
      const name = params.get('name')
      const type = params.get('type') || 'group'
      const displayName = params.get('displayName') || name
      const description = params.get('description') || ''
      if (name) {
        try {
          await orgs.create(name, type, { displayName, description })
          res.writeHead(303, { Location: '/' + name })
          res.end()
          return
        } catch (err) {
          res.writeHead(400, { 'Content-Type': 'text/plain' })
          res.end(err.message)
          return
        }
      }
      res.writeHead(400)
      res.end('name required')
      return
    }

    const memberMatch = url.match(/^\/([a-z0-9][a-z0-9-]*[a-z0-9])\/member$/)
    if (req.method === 'POST' && memberMatch) {
      const name = memberMatch[1]
      const params = new URLSearchParams(await collectBody(req))
      const userId = params.get('userId')
      const role = params.get('role') || 'member'
      if (userId) await orgs.addMember(name, userId, role)
      res.writeHead(303, { Location: '/' + name })
      res.end()
      return
    }

    const resourceMatch = url.match(/^\/([a-z0-9][a-z0-9-]*[a-z0-9])\/resource$/)
    if (req.method === 'POST' && resourceMatch) {
      const name = resourceMatch[1]
      const params = new URLSearchParams(await collectBody(req))
      const resource = params.get('resource')
      if (resource) await orgs.addResource(name, resource)
      res.writeHead(303, { Location: '/' + name })
      res.end()
      return
    }

    const grantMatch = url.match(/^\/([a-z0-9][a-z0-9-]*[a-z0-9])\/grant$/)
    if (req.method === 'POST' && grantMatch) {
      const name = grantMatch[1]
      const params = new URLSearchParams(await collectBody(req))
      const subjectId = params.get('subjectId')
      const permission = params.get('permission') || 'write'
      if (subjectId) {
        await orgs.createGrant({
          subjectType: 'user',
          subjectId,
          grantType: 'group',
          groupName: name,
          permission,
          grantedBy: 'web'
        })
      }
      res.writeHead(303, { Location: '/' + name })
      res.end()
      return
    }

    res.writeHead(404, { 'Content-Type': 'text/plain' })
    res.end('Not found')
  } catch (err) {
    console.error(err)
    res.writeHead(500, { 'Content-Type': 'text/plain' })
    res.end(err.message)
  }
})

function styles () {
  return `
  body { font-family: monospace; background: #0d1117; color: #c9d1d9; padding: 2rem; max-width: 800px; margin: 0 auto; }
  h1 { color: #58a6ff; }
  h2 { font-size: 1rem; color: #c9d1d9; margin-top: 0; }
  a { color: #58a6ff; text-decoration: none; }
  a:hover { text-decoration: underline; }
  .card { background: #161b22; border: 1px solid #30363d; border-radius: 6px; padding: 1rem; margin: 1rem 0; }
  .card h2 { margin: 0 0 0.5rem; font-size: 0.75rem; text-transform: uppercase; color: #8b949e; }
  .row { display: flex; justify-content: space-between; padding: 0.3rem 0; border-bottom: 1px solid #21262d; }
  .row:last-child { border-bottom: none; }
  .label { color: #8b949e; }
  .org-row { padding: 0.6rem 0.5rem; border-bottom: 1px solid #21262d; }
  .org-row:last-child { border-bottom: none; }
  .org-row .kind { display: inline-block; width: 4rem; color: #8b949e; font-size: 0.75rem; }
  .org-row .name { color: #c9d1d9; font-weight: bold; }
  .org-row .meta { color: #6e7681; font-size: 0.75rem; margin-top: 0.2rem; margin-left: 4rem; }
  .tag { display: inline-block; padding: 0.05rem 0.4rem; margin-right: 0.3rem; border-radius: 999px; font-size: 0.7rem; background: #1f6feb22; border: 1px solid #1f6feb44; color: #58a6ff; }
  .role-admin { background: #2ea04322; color: #3fb950; border: 1px solid #2ea04344; padding: 0.05rem 0.4rem; border-radius: 4px; font-size: 0.7rem; }
  .role-member { background: #30363d; color: #8b949e; border: 1px solid #484f58; padding: 0.05rem 0.4rem; border-radius: 4px; font-size: 0.7rem; }
  input, textarea, button, select { background: #0d1117; color: #c9d1d9; border: 1px solid #30363d; padding: 0.4rem 0.6rem; border-radius: 4px; font-family: monospace; font-size: 0.85rem; }
  textarea { width: 100%; min-height: 3em; }
  input, select { width: 100%; box-sizing: border-box; }
  button { background: #21262d; cursor: pointer; }
  button:hover { background: #30363d; }
  button.btn-primary { background: #1f6feb; border-color: #1f6feb; color: white; }
  button.btn-primary:hover { background: #1158c7; }
  .field { margin-bottom: 0.5rem; }
  .field label { display: block; color: #8b949e; font-size: 0.75rem; margin-bottom: 0.2rem; }
  .empty { color: #484f58; font-style: italic; padding: 1rem 0; }
  form { margin: 0; }
  .hstack { display: flex; gap: 0.5rem; }
  `
}

function renderIndex (all) {
  const groups = all.filter(o => o.type === 'group')
  const users = all.filter(o => o.type === 'user')

  return `<!DOCTYPE html>
<html><head><title>hyper-orgs</title><style>${styles()}</style></head><body>
<h1><a href="/">hyper-orgs</a></h1>

<div class="card">
  <h2>${groups.length} Group${groups.length === 1 ? '' : 's'}</h2>
  ${groups.length === 0 ? '<div class="empty">(no groups)</div>' : groups.map(o => `
  <div class="org-row">
    <div>
      <span class="kind">group</span>
      <a class="name" href="/${o.name}">${escapeHtml(o.name)}</a>
      ${o.visibility === 'private' ? '<span class="tag">private</span>' : ''}
    </div>
    <div class="meta">${escapeHtml(o.displayName)} · ${o.members.length} members · ${o.resources.length} resources</div>
  </div>`).join('')}
</div>

<div class="card">
  <h2>${users.length} User${users.length === 1 ? '' : 's'}</h2>
  ${users.length === 0 ? '<div class="empty">(no user orgs)</div>' : users.map(o => `
  <div class="org-row">
    <div>
      <span class="kind">user</span>
      <a class="name" href="/${o.name}">${escapeHtml(o.name)}</a>
    </div>
    <div class="meta">${escapeHtml(o.displayName)} · ${o.resources.length} resources</div>
  </div>`).join('')}
</div>

<div class="card">
  <h2>Create org</h2>
  <form action="/create" method="POST">
    <div class="field"><label>Name</label><input name="name" placeholder="my-team" required pattern="[a-z0-9][a-z0-9-]*[a-z0-9]"></div>
    <div class="field"><label>Type</label>
      <select name="type">
        <option value="group">group</option>
        <option value="user">user</option>
      </select>
    </div>
    <div class="field"><label>Display name</label><input name="displayName"></div>
    <div class="field"><label>Description</label><textarea name="description"></textarea></div>
    <button class="btn-primary" type="submit">Create</button>
  </form>
</div>

</body></html>`
}

function renderOrg (org, grants) {
  return `<!DOCTYPE html>
<html><head><title>hyper-orgs / ${escapeHtml(org.name)}</title><style>${styles()}</style></head><body>
<h1><a href="/">hyper-orgs</a> / ${escapeHtml(org.name)}</h1>

<div class="card">
  <h2>Org</h2>
  <div><strong>${escapeHtml(org.displayName)}</strong> <span class="tag">${org.type}</span> ${org.visibility === 'private' ? '<span class="tag">private</span>' : ''}</div>
  ${org.description ? '<div style="margin-top:0.5rem;color:#8b949e">' + escapeHtml(org.description) + '</div>' : ''}
  <div class="meta" style="color:#6e7681;font-size:0.75rem;margin-top:0.5rem">
    created ${new Date(org.createdAt).toLocaleString()}
  </div>
</div>

${org.type === 'group' ? `
<div class="card">
  <h2>${org.members.length} Member${org.members.length === 1 ? '' : 's'}</h2>
  ${org.members.length === 0 ? '<div class="empty">(no members)</div>' : org.members.map(m => `
  <div class="row">
    <div>${escapeHtml(m.userId)}</div>
    <div><span class="role-${m.role}">${m.role}</span></div>
  </div>`).join('')}
  <form action="/${org.name}/member" method="POST" style="margin-top:0.8rem">
    <div class="hstack">
      <input name="userId" placeholder="userId" required>
      <select name="role" style="width:auto">
        <option value="member">member</option>
        <option value="admin">admin</option>
      </select>
      <button type="submit">Add</button>
    </div>
  </form>
</div>
` : ''}

<div class="card">
  <h2>${org.resources.length} Resource${org.resources.length === 1 ? '' : 's'}</h2>
  ${org.resources.length === 0 ? '<div class="empty">(no resources)</div>' : org.resources.map(r => `
  <div class="row"><div>${escapeHtml(r)}</div></div>`).join('')}
  <form action="/${org.name}/resource" method="POST" style="margin-top:0.8rem">
    <div class="hstack">
      <input name="resource" placeholder="resource-name" required>
      <button type="submit">Add</button>
    </div>
  </form>
</div>

<div class="card">
  <h2>${grants.length} Active grant${grants.length === 1 ? '' : 's'}</h2>
  ${grants.length === 0 ? '<div class="empty">(no grants)</div>' : grants.map(g => `
  <div class="row">
    <div>${escapeHtml(g.subjectId)} <span class="tag">${g.subjectType}</span></div>
    <div><span class="tag">${g.permission}</span></div>
  </div>`).join('')}
  <form action="/${org.name}/grant" method="POST" style="margin-top:0.8rem">
    <div class="hstack">
      <input name="subjectId" placeholder="userId" required>
      <select name="permission" style="width:auto">
        <option value="write">write</option>
        <option value="moderator">moderator</option>
        <option value="owner">owner</option>
      </select>
      <button type="submit">Grant</button>
    </div>
  </form>
</div>

</body></html>`
}

httpServer.listen(port, () => {
  console.log('hyper-orgs dashboard:')
  console.log('  http://localhost:%d', port)
  console.log('  storage: %s', storagePath)
  console.log('  orgs key: %s', orgs.keyHex)
})