Add self-service web UI for user registration and settings management

Users visit the home page, enter their Nextcloud credentials and an admin
password, and receive a unique webhook URL for AudioPen. A bookmarkable
settings page (/?token=...) lets them update credentials later without
needing the admin password. New users.json entries are written at runtime
via POST/PUT API endpoints.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
nickandClaude Sonnet 4.6 committed 2026-06-18 17:33:28 -07:00
1 parent 68be646f37
commit 611a4a6da3
3 files changed
+516 -40

No files matched your search

+22 -35
View File
@@ -1,13 +1,13 @@
# AudioPen Nextcloud Webhook
A small Node.js webhook service that receives notes from AudioPen and uploads them to a folder in Nextcloud using WebDAV. Supports multiple users, each with their own Nextcloud credentials and a unique webhook token.
A Node.js service that receives notes from AudioPen and uploads them to Nextcloud via WebDAV. Includes a web UI for self-service setup — each user enters their own Nextcloud credentials and gets a unique webhook URL.
When AudioPen sends a webhook request, the service:
When AudioPen sends a webhook, the service:
1. Validates the token in the URL
2. Receives the note content
2. Parses the note content (title, body, original transcript)
3. Creates a timestamped Markdown file
4. Uploads the file to the configured Nextcloud folder
4. Uploads it to the user's configured Nextcloud folder
Example filename:
@@ -17,49 +17,36 @@ Example filename:
## Setup
### 1. Configure users
### 1. Configure environment
Copy `users.json.example` to `users.json` and fill in credentials for each user:
```json
{
"abc123secrettoken": {
"nextcloudUrl": "https://nextcloud.example.com",
"username": "alice",
"password": "alice-app-password",
"folder": "AudioPen"
}
}
```
Each key is a secret token that identifies a user. Generate one with:
```bash
node -e "console.log(crypto.randomUUID())"
```
### 2. Configure the server
Optionally create a `.env` file to set the port or a custom path to `users.json`:
Create a `.env` file in the project root:
```env
ADMIN_PASSWORD=choose-a-strong-password
PORT=3000
USERS_FILE=users.json
# USERS_FILE=users.json # optional, defaults to users.json
```
### 3. Run
`ADMIN_PASSWORD` is required to create new accounts via the web UI. Without it, registration is disabled (existing users can still update their settings).
### 2. Run
```bash
npm install
node server.js
```
### 4. Configure AudioPen
### 3. Register users
In AudioPen's webhook settings, set the URL to:
Open `http://localhost:3000` in a browser. Each user fills in their Nextcloud credentials and the admin password, then receives:
```
https://your-server/webhook/<token>
```
- A **webhook URL** to paste into AudioPen (`/webhook/<token>`)
- A **settings page URL** to bookmark for updating credentials later (`/?token=<token>`)
Each user gets their own URL containing their unique token.
### Returning users
Visiting `/?token=<token>` pre-fills the form with existing settings and allows updating credentials without needing the admin password.
## Manual user management
User credentials are stored in `users.json` (gitignored). You can also edit this file directly — see `users.json.example` for the format.
+416
View File
@@ -0,0 +1,416 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AudioPen → Nextcloud Setup</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #f5f5f5;
color: #1a1a1a;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.card {
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 16px rgba(0,0,0,0.08);
padding: 40px;
width: 100%;
max-width: 480px;
}
.logo {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 28px;
}
.logo-icon {
width: 36px;
height: 36px;
background: #0082c9;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 18px;
}
h1 {
font-size: 20px;
font-weight: 600;
color: #1a1a1a;
}
.subtitle {
font-size: 13px;
color: #666;
margin-top: 2px;
}
.webhook-banner {
background: #f0f9ff;
border: 1px solid #bae6fd;
border-radius: 8px;
padding: 14px 16px;
margin-bottom: 24px;
}
.webhook-banner label {
display: block;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #0369a1;
margin-bottom: 6px;
}
.copy-row {
display: flex;
gap: 8px;
}
.copy-row input {
flex: 1;
font-size: 13px;
padding: 8px 10px;
border: 1px solid #e2e8f0;
border-radius: 6px;
background: #fff;
color: #1a1a1a;
font-family: 'SF Mono', 'Fira Code', monospace;
}
.btn-copy {
padding: 8px 12px;
background: #0369a1;
color: #fff;
border: none;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
white-space: nowrap;
}
.btn-copy:hover { background: #0284c7; }
.btn-copy.copied { background: #16a34a; }
form { display: flex; flex-direction: column; gap: 16px; }
.field { display: flex; flex-direction: column; gap: 6px; }
label {
font-size: 13px;
font-weight: 500;
color: #374151;
}
.hint {
font-size: 12px;
color: #9ca3af;
margin-top: -2px;
}
input[type="text"],
input[type="url"],
input[type="password"] {
padding: 9px 12px;
border: 1px solid #d1d5db;
border-radius: 7px;
font-size: 14px;
color: #1a1a1a;
transition: border-color 0.15s;
width: 100%;
}
input:focus {
outline: none;
border-color: #0082c9;
box-shadow: 0 0 0 3px rgba(0,130,201,0.12);
}
.password-wrap {
position: relative;
}
.password-wrap input {
padding-right: 40px;
}
.toggle-pw {
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
cursor: pointer;
color: #9ca3af;
font-size: 16px;
padding: 4px;
line-height: 1;
}
.toggle-pw:hover { color: #374151; }
.divider {
height: 1px;
background: #f3f4f6;
margin: 4px 0;
}
.admin-section label {
color: #6b7280;
}
.btn-submit {
padding: 10px;
background: #0082c9;
color: #fff;
border: none;
border-radius: 8px;
font-size: 15px;
font-weight: 500;
cursor: pointer;
transition: background 0.15s;
margin-top: 4px;
}
.btn-submit:hover { background: #006ba6; }
.btn-submit:disabled { background: #93c5fd; cursor: not-allowed; }
.alert {
padding: 12px 14px;
border-radius: 8px;
font-size: 13px;
line-height: 1.5;
}
.alert-success {
background: #f0fdf4;
border: 1px solid #bbf7d0;
color: #15803d;
}
.alert-error {
background: #fef2f2;
border: 1px solid #fecaca;
color: #b91c1c;
}
.manage-link {
font-size: 12px;
color: #6b7280;
text-align: center;
margin-top: 4px;
}
.manage-link a {
color: #0082c9;
text-decoration: none;
}
.manage-link a:hover { text-decoration: underline; }
.hidden { display: none !important; }
</style>
</head>
<body>
<div class="card">
<div class="logo">
<div class="logo-icon">&#9654;</div>
<div>
<h1>AudioPen → Nextcloud</h1>
<div class="subtitle" id="subtitle">Connect your AudioPen notes to Nextcloud</div>
</div>
</div>
<!-- Webhook URL banner (shown after registration or for returning users) -->
<div class="webhook-banner hidden" id="webhookBanner">
<label>Your AudioPen Webhook URL</label>
<div class="copy-row">
<input type="text" id="webhookUrl" readonly />
<button class="btn-copy" id="copyBtn" onclick="copyWebhook()">Copy</button>
</div>
</div>
<div id="alertBox" class="alert hidden"></div>
<form id="setupForm" onsubmit="handleSubmit(event)">
<div class="field">
<label for="nextcloudUrl">Nextcloud URL</label>
<input type="url" id="nextcloudUrl" placeholder="https://nextcloud.example.com" required />
</div>
<div class="field">
<label for="username">Username</label>
<input type="text" id="username" placeholder="youruser" required autocomplete="username" />
</div>
<div class="field">
<label for="password">App Password</label>
<div class="password-wrap">
<input type="password" id="password" placeholder="Generate one in Nextcloud → Settings → Security" required autocomplete="current-password" />
<button type="button" class="toggle-pw" onclick="togglePassword('password', this)">&#128065;</button>
</div>
<span class="hint">Use an app password, not your login password.</span>
</div>
<div class="field">
<label for="folder">Nextcloud Folder</label>
<input type="text" id="folder" placeholder="AudioPen" />
<span class="hint">Folder must already exist in your Nextcloud.</span>
</div>
<div class="divider"></div>
<!-- Admin password field — hidden for returning users updating settings -->
<div class="field admin-section" id="adminSection">
<label for="adminPassword">Admin Password</label>
<div class="password-wrap">
<input type="password" id="adminPassword" placeholder="Required to create a new account" autocomplete="off" />
<button type="button" class="toggle-pw" onclick="togglePassword('adminPassword', this)">&#128065;</button>
</div>
</div>
<button type="submit" class="btn-submit" id="submitBtn">Set up integration</button>
</form>
<p class="manage-link hidden" id="manageLink">
Bookmark this page to return and update your settings.
</p>
</div>
<script>
const params = new URLSearchParams(location.search)
const token = params.get('token')
let webhookUrlValue = ''
if (token) {
loadExistingUser(token)
}
async function loadExistingUser(token) {
try {
const res = await fetch(`/api/users/${token}`)
if (!res.ok) {
showAlert('error', 'This link is invalid or the account no longer exists.')
return
}
const user = await res.json()
document.getElementById('nextcloudUrl').value = user.nextcloudUrl
document.getElementById('username').value = user.username
document.getElementById('password').value = user.password
document.getElementById('folder').value = user.folder
document.getElementById('adminSection').classList.add('hidden')
document.getElementById('submitBtn').textContent = 'Update settings'
document.getElementById('subtitle').textContent = 'Update your integration settings'
webhookUrlValue = `${location.protocol}//${location.host}/webhook/${token}`
showWebhookBanner(webhookUrlValue)
document.getElementById('manageLink').classList.add('hidden')
} catch (err) {
showAlert('error', 'Failed to load settings. Please try again.')
}
}
async function handleSubmit(e) {
e.preventDefault()
const btn = document.getElementById('submitBtn')
btn.disabled = true
btn.textContent = token ? 'Saving…' : 'Creating…'
clearAlert()
const body = {
nextcloudUrl: document.getElementById('nextcloudUrl').value.trim(),
username: document.getElementById('username').value.trim(),
password: document.getElementById('password').value,
folder: document.getElementById('folder').value.trim() || 'AudioPen',
}
try {
let res, data
if (token) {
res = await fetch(`/api/users/${token}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
})
data = await res.json()
if (!res.ok) throw new Error(data.error)
showAlert('success', 'Settings updated successfully.')
} else {
body.adminPassword = document.getElementById('adminPassword').value
res = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
})
data = await res.json()
if (!res.ok) throw new Error(data.error)
webhookUrlValue = data.webhookUrl
showWebhookBanner(data.webhookUrl)
const manageUrl = `${location.protocol}//${location.host}/?token=${data.token}`
document.getElementById('manageLink').innerHTML =
`Bookmark <a href="${manageUrl}">your settings page</a> to update credentials later.`
document.getElementById('manageLink').classList.remove('hidden')
showAlert('success', 'Integration created! Copy your webhook URL and paste it into AudioPen.')
}
} catch (err) {
showAlert('error', err.message)
} finally {
btn.disabled = false
btn.textContent = token ? 'Update settings' : 'Set up integration'
}
}
function showWebhookBanner(url) {
document.getElementById('webhookUrl').value = url
document.getElementById('webhookBanner').classList.remove('hidden')
}
function copyWebhook() {
navigator.clipboard.writeText(webhookUrlValue)
const btn = document.getElementById('copyBtn')
btn.textContent = 'Copied!'
btn.classList.add('copied')
setTimeout(() => { btn.textContent = 'Copy'; btn.classList.remove('copied') }, 2000)
}
function togglePassword(id, btn) {
const input = document.getElementById(id)
const isHidden = input.type === 'password'
input.type = isHidden ? 'text' : 'password'
btn.textContent = isHidden ? '🙈' : '👁'
}
function showAlert(type, msg) {
const box = document.getElementById('alertBox')
box.className = `alert alert-${type}`
box.textContent = msg
box.classList.remove('hidden')
}
function clearAlert() {
document.getElementById('alertBox').classList.add('hidden')
}
</script>
</body>
</html>
+78 -5
View File
@@ -1,26 +1,102 @@
import 'dotenv/config'
import express from 'express'
import axios from 'axios'
import { readFileSync } from 'fs'
import { readFileSync, writeFileSync } from 'fs'
import { randomUUID } from 'crypto'
import path from 'path'
import { fileURLToPath } from 'url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const app = express()
app.use(express.urlencoded({ extended: false }))
app.use(express.json())
app.use(express.text({ type: '*/*' }))
app.use(express.static(path.join(__dirname, 'public')))
const PORT = process.env.PORT || 3000
const USERS_FILE = process.env.USERS_FILE || 'users.json'
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD
if (!ADMIN_PASSWORD) {
console.warn('Warning: ADMIN_PASSWORD is not set. New user registration is disabled.')
}
let users = {}
try {
users = JSON.parse(readFileSync(USERS_FILE, 'utf8'))
console.log(`Loaded ${Object.keys(users).length} user(s) from ${USERS_FILE}`)
} catch (err) {
if (err.code === 'ENOENT') {
console.log(`${USERS_FILE} not found, starting with empty user list`)
} else {
console.error(`Failed to load ${USERS_FILE}: ${err.message}`)
process.exit(1)
}
}
function saveUsers() {
writeFileSync(USERS_FILE, JSON.stringify(users, null, 2))
}
// --- Setup API ---
app.post('/api/users', (req, res) => {
if (!ADMIN_PASSWORD) {
return res.status(503).json({ error: 'Registration is disabled: ADMIN_PASSWORD is not configured.' })
}
if (req.body.adminPassword !== ADMIN_PASSWORD) {
return res.status(403).json({ error: 'Invalid admin password.' })
}
const { nextcloudUrl, username, password, folder } = req.body
if (!nextcloudUrl || !username || !password) {
return res.status(400).json({ error: 'nextcloudUrl, username, and password are required.' })
}
const token = randomUUID()
users[token] = {
nextcloudUrl: nextcloudUrl.replace(/\/$/, ''),
username,
password,
folder: folder || 'AudioPen'
}
saveUsers()
const webhookUrl = `${req.protocol}://${req.get('host')}/webhook/${token}`
console.log(`New user registered: ${username}`)
res.json({ token, webhookUrl })
})
app.get('/api/users/:token', (req, res) => {
const user = users[req.params.token]
if (!user) return res.status(404).json({ error: 'Not found.' })
res.json(user)
})
app.put('/api/users/:token', (req, res) => {
if (!users[req.params.token]) return res.status(404).json({ error: 'Not found.' })
const { nextcloudUrl, username, password, folder } = req.body
if (!nextcloudUrl || !username || !password) {
return res.status(400).json({ error: 'nextcloudUrl, username, and password are required.' })
}
users[req.params.token] = {
nextcloudUrl: nextcloudUrl.replace(/\/$/, ''),
username,
password,
folder: folder || 'AudioPen'
}
saveUsers()
console.log(`User updated: ${username}`)
res.json({ success: true })
})
// --- Webhook ---
app.post('/webhook/:token', async (req, res) => {
const user = users[req.params.token]
if (!user) {
@@ -38,9 +114,7 @@ app.post('/webhook/:token', async (req, res) => {
// If body comes in as raw string (VERY likely here)
if (typeof data === 'string') {
try {
// try parsing as URL encoded manually
const params = new URLSearchParams(data)
data = Object.fromEntries(params.entries())
} catch (e) {
console.log('Failed to parse raw body')
@@ -59,7 +133,6 @@ app.post('/webhook/:token', async (req, res) => {
.toLowerCase()
const timestamp = new Date().toISOString().replace(/:/g, '-')
const filename = `${timestamp}-${safeTitle}.md`
const markdown = `# ${title}