user authentication

This commit is contained in:
nahakubuilde
2025-08-25 21:19:15 +01:00
parent 6c82e2014c
commit e21a0b5b10
23 changed files with 2479 additions and 189 deletions

294
web/templates/admin.html Normal file
View File

@@ -0,0 +1,294 @@
{{define "admin"}}
{{template "base" .}}
{{end}}
{{define "admin_content"}}
<div class="max-w-6xl mx-auto p-6">
<!-- Header -->
<div class="mb-8">
<h1 class="text-3xl font-bold text-white mb-2">Admin Dashboard</h1>
<p class="text-gray-400">Manage users, groups, and permissions</p>
</div>
<div class="space-y-8">
<!-- Users -->
<div class="bg-gray-800 rounded-lg p-6">
<h2 class="text-xl font-semibold text-white mb-4">
<i class="fas fa-users mr-2"></i>Users
</h2>
<!-- Create User -->
<form id="form-create-user" class="mb-4 grid grid-cols-1 md:grid-cols-4 gap-2">
<input name="username" type="text" class="form-input" placeholder="Username" required />
<input name="email" type="email" class="form-input" placeholder="Email" required />
<input name="password" type="password" class="form-input" placeholder="Password" required />
<button type="submit" class="btn-primary text-sm"><i class="fas fa-plus mr-2"></i>Create</button>
</form>
<div class="overflow-x-auto">
<table class="min-w-full text-sm">
<thead>
<tr class="text-left text-gray-300">
<th class="px-3 py-2">Username</th>
<th class="px-3 py-2">Email</th>
<th class="px-3 py-2">Status</th>
<th class="px-3 py-2">MFA</th>
<th class="px-3 py-2">Actions</th>
</tr>
</thead>
<tbody>
{{if .users}}
{{range .users}}
<tr class="border-t border-gray-700" data-user-id="{{.ID}}" data-username="{{.Username}}" data-has-mfa="{{if .MFASecret.Valid}}1{{else}}0{{end}}">
<td class="px-3 py-2 text-white">{{.Username}}</td>
<td class="px-3 py-2 text-gray-300">{{.Email}}</td>
<td class="px-3 py-2">
{{if .IsActive}}<span class="text-green-400">Active</span>{{else}}<span class="text-red-400">Disabled</span>{{end}}
</td>
<td class="px-3 py-2">
{{if .MFASecret.Valid}}<span class="text-green-400">Enabled</span>{{else}}<span class="text-gray-400">None</span>{{end}}
</td>
<td class="px-3 py-2 space-x-1">
{{if ne .ID $.CurrentUserID}}
{{if .IsActive}}
<button class="btn-secondary text-xs px-2 py-1" data-action="user-deactivate" title="Disable account">Disable</button>
{{else}}
<button class="btn-primary text-xs px-2 py-1" data-action="user-activate" title="Enable account">Enable</button>
{{end}}
{{if .MFASecret.Valid}}
<button class="btn-secondary text-xs px-2 py-1" data-action="mfa-disable" title="Disable MFA">MFA Off</button>
<button class="btn-warning text-xs px-2 py-1" data-action="mfa-reset" title="Reset MFA secret">MFA Reset</button>
{{else}}
<button class="btn-primary text-xs px-2 py-1" data-action="mfa-enable" title="Enable MFA">MFA On</button>
{{end}}
<button class="btn-danger text-xs px-2 py-1" data-action="delete-user" title="Delete user"><i class="fas fa-trash"></i></button>
{{else}}
<span class="text-xs text-gray-500">Actions disabled for current user</span>
{{end}}
</td>
</tr>
{{end}}
{{else}}
<tr><td colspan="5" class="px-3 py-4 text-gray-400">No users found.</td></tr>
{{end}}
</tbody>
</table>
</div>
</div>
<!-- Groups -->
<div class="bg-gray-800 rounded-lg p-6">
<h2 class="text-xl font-semibold text-white mb-4">
<i class="fas fa-user-group mr-2"></i>Groups
</h2>
<form id="form-create-group" class="mb-4 grid grid-cols-1 md:grid-cols-4 gap-2">
<input name="name" type="text" class="form-input md:col-span-3" placeholder="Group name" required />
<button type="submit" class="btn-primary text-sm"><i class="fas fa-plus mr-2"></i>Create</button>
</form>
<div class="overflow-x-auto">
<table class="min-w-full text-sm">
<thead>
<tr class="text-left text-gray-300">
<th class="px-3 py-2">Name</th>
<th class="px-3 py-2">Actions</th>
</tr>
</thead>
<tbody>
{{if .groups}}
{{range .groups}}
<tr class="border-t border-gray-700" data-group-id="{{.ID}}" data-group-name="{{.Name}}">
<td class="px-3 py-2 text-white">{{.Name}}</td>
<td class="px-3 py-2">
<button class="btn-danger text-xs px-2 py-1" data-action="delete-group" title="Delete group"><i class="fas fa-trash"></i></button>
</td>
</tr>
{{end}}
{{else}}
<tr><td colspan="2" class="px-3 py-4 text-gray-400">No groups found.</td></tr>
{{end}}
</tbody>
</table>
</div>
</div>
<!-- Permissions and Memberships -->
<div class="bg-gray-800 rounded-lg p-6">
<h2 class="text-xl font-semibold text-white mb-4">
<i class="fas fa-key mr-2"></i>Permissions</h2>
<div class="overflow-x-auto mb-6">
<table class="min-w-full text-sm">
<thead>
<tr class="text-left text-gray-300">
<th class="px-3 py-2">Group</th>
<th class="px-3 py-2">Path</th>
<th class="px-3 py-2">Read</th>
<th class="px-3 py-2">Write</th>
<th class="px-3 py-2">Delete</th>
</tr>
</thead>
<tbody>
{{if .permissions}}
{{range .permissions}}
<tr class="border-t border-gray-700">
<td class="px-3 py-2 text-white">{{.Group}}</td>
<td class="px-3 py-2"><code class="bg-gray-900 px-1 py-0.5 rounded">{{.Path}}</code></td>
<td class="px-3 py-2">{{if .CanRead}}✅{{else}}❌{{end}}</td>
<td class="px-3 py-2">{{if .CanWrite}}✅{{else}}❌{{end}}</td>
<td class="px-3 py-2">{{if .CanDelete}}✅{{else}}❌{{end}}</td>
</tr>
{{end}}
{{else}}
<tr><td colspan="5" class="px-3 py-4 text-gray-400">No permissions configured.</td></tr>
{{end}}
</tbody>
</table>
</div>
<h3 class="text-white font-semibold mb-3">Manage Memberships</h3>
<form id="form-add-membership" class="grid grid-cols-1 md:grid-cols-3 gap-2 mb-3">
<select name="user_id" class="form-input" required>
<option value="" disabled selected>Select user</option>
{{range .users}}<option value="{{.ID}}">{{.Username}}</option>{{end}}
</select>
<select name="group_id" class="form-input" required>
<option value="" disabled selected>Select group</option>
{{range .groups}}<option value="{{.ID}}">{{.Name}}</option>{{end}}
</select>
<button type="submit" class="btn-primary text-sm"><i class="fas fa-user-plus mr-2"></i>Add to Group</button>
</form>
<form id="form-remove-membership" class="grid grid-cols-1 md:grid-cols-3 gap-2">
<select name="user_id" class="form-input" required>
<option value="" disabled selected>Select user</option>
{{range .users}}<option value="{{.ID}}">{{.Username}}</option>{{end}}
</select>
<select name="group_id" class="form-input" required>
<option value="" disabled selected>Select group</option>
{{range .groups}}<option value="{{.ID}}">{{.Name}}</option>{{end}}
</select>
<button type="submit" class="btn-danger text-sm"><i class="fas fa-user-minus mr-2"></i>Remove from Group</button>
</form>
</div>
</div>
</div>
{{end}}
{{define "admin_scripts"}}
<script>
document.addEventListener('DOMContentLoaded', function() {
const getCSRF = () => {
const m = document.cookie.match(/(?:^|; )csrf_token=([^;]+)/);
return m && m[1] ? decodeURIComponent(m[1]) : '';
};
// Create user
const formCreateUser = document.getElementById('form-create-user');
if (formCreateUser) {
formCreateUser.addEventListener('submit', async (e) => {
e.preventDefault();
const fd = new FormData(formCreateUser);
const res = await fetch('/editor/admin/users', { method: 'POST', headers: { 'X-CSRF-Token': getCSRF() }, body: fd });
const data = await res.json().catch(() => ({}));
if (res.ok && data.success) { showNotification('User created', 'success'); window.location.reload(); }
else { showNotification('Create user failed: ' + (data.error || res.statusText), 'error'); }
});
}
// Delete user
document.querySelectorAll('[data-action="delete-user"]').forEach(btn => {
btn.addEventListener('click', async (e) => {
const row = e.currentTarget.closest('[data-user-id]');
const id = row && row.getAttribute('data-user-id');
const username = (row && row.getAttribute('data-username')) || '';
if (!id) return;
if (username === 'admin') { showNotification('Cannot delete default admin user', 'error'); return; }
if (!confirm('Delete user ' + username + ' ?')) return;
const res = await fetch('/editor/admin/users/' + encodeURIComponent(id), { method: 'DELETE', headers: { 'X-CSRF-Token': getCSRF() } });
const data = await res.json().catch(() => ({}));
if (res.ok && data.success) { showNotification('User deleted', 'success'); window.location.reload(); }
else { showNotification('Delete user failed: ' + (data.error || res.statusText), 'error'); }
});
});
// Activate/deactivate user
document.querySelectorAll('[data-action="user-activate"], [data-action="user-deactivate"]').forEach(btn => {
btn.addEventListener('click', async (e) => {
const row = e.currentTarget.closest('[data-user-id]');
const id = row && row.getAttribute('data-user-id');
const action = e.currentTarget.getAttribute('data-action');
const active = action === 'user-activate' ? '1' : '0';
const fd = new FormData();
fd.set('active', active);
const res = await fetch('/editor/admin/users/' + encodeURIComponent(id) + '/active', { method: 'POST', headers: { 'X-CSRF-Token': getCSRF() }, body: fd });
const data = await res.json().catch(() => ({}));
if (res.ok && data.success) { showNotification('User status updated', 'success'); window.location.reload(); }
else { showNotification('Update status failed: ' + (data.error || res.statusText), 'error'); }
});
});
// MFA actions
const mfaRequest = async (row, path, okMsg) => {
const id = row && row.getAttribute('data-user-id');
const res = await fetch('/editor/admin/users/' + encodeURIComponent(id) + path, { method: 'POST', headers: { 'X-CSRF-Token': getCSRF() } });
const data = await res.json().catch(() => ({}));
if (res.ok && data.success) { showNotification(okMsg, 'success'); window.location.reload(); }
else { showNotification('MFA action failed: ' + (data.error || res.statusText), 'error'); }
};
document.querySelectorAll('[data-action="mfa-enable"]').forEach(btn => btn.addEventListener('click', (e) => mfaRequest(e.currentTarget.closest('[data-user-id]'), '/mfa/enable', 'MFA enabled')));
document.querySelectorAll('[data-action="mfa-disable"]').forEach(btn => btn.addEventListener('click', (e) => mfaRequest(e.currentTarget.closest('[data-user-id]'), '/mfa/disable', 'MFA disabled')));
document.querySelectorAll('[data-action="mfa-reset"]').forEach(btn => btn.addEventListener('click', (e) => mfaRequest(e.currentTarget.closest('[data-user-id]'), '/mfa/reset', 'MFA reset')));
// Create group
const formCreateGroup = document.getElementById('form-create-group');
if (formCreateGroup) {
formCreateGroup.addEventListener('submit', async (e) => {
e.preventDefault();
const fd = new FormData(formCreateGroup);
const res = await fetch('/editor/admin/groups', { method: 'POST', headers: { 'X-CSRF-Token': getCSRF() }, body: fd });
const data = await res.json().catch(() => ({}));
if (res.ok && data.success) { showNotification('Group created', 'success'); window.location.reload(); }
else { showNotification('Create group failed: ' + (data.error || res.statusText), 'error'); }
});
}
// Delete group
document.querySelectorAll('[data-action="delete-group"]').forEach(btn => {
btn.addEventListener('click', async (e) => {
const card = e.currentTarget.closest('[data-group-id]');
const id = card && card.getAttribute('data-group-id');
const name = (card && card.getAttribute('data-group-name')) || '';
if (!id) return;
if (name === 'admin' || name === 'public') { showNotification('Cannot delete core group: ' + name, 'error'); return; }
if (!confirm('Delete group ' + name + ' ?')) return;
const res = await fetch('/editor/admin/groups/' + encodeURIComponent(id), { method: 'DELETE', headers: { 'X-CSRF-Token': getCSRF() } });
const data = await res.json().catch(() => ({}));
if (res.ok && data.success) { showNotification('Group deleted', 'success'); window.location.reload(); }
else { showNotification('Delete group failed: ' + (data.error || res.statusText), 'error'); }
});
});
// Add membership
const formAddMem = document.getElementById('form-add-membership');
if (formAddMem) {
formAddMem.addEventListener('submit', async (e) => {
e.preventDefault();
const fd = new FormData(formAddMem);
const res = await fetch('/editor/admin/memberships/add', { method: 'POST', headers: { 'X-CSRF-Token': getCSRF() }, body: fd });
const data = await res.json().catch(() => ({}));
if (res.ok && data.success) { showNotification('User added to group', 'success'); }
else { showNotification('Add membership failed: ' + (data.error || res.statusText), 'error'); }
});
}
// Remove membership
const formRemMem = document.getElementById('form-remove-membership');
if (formRemMem) {
formRemMem.addEventListener('submit', async (e) => {
e.preventDefault();
const fd = new FormData(formRemMem);
const res = await fetch('/editor/admin/memberships/remove', { method: 'POST', headers: { 'X-CSRF-Token': getCSRF() }, body: fd });
const data = await res.json().catch(() => ({}));
if (res.ok && data.success) { showNotification('User removed from group', 'success'); }
else { showNotification('Remove membership failed: ' + (data.error || res.statusText), 'error'); }
});
}
});
</script>
{{end}}

View File

@@ -271,9 +271,28 @@
<button id="open-search" class="text-gray-400 hover:text-white transition-colors" title="Search" aria-label="Search">
<i class="fas fa-magnifying-glass"></i>
</button>
<a href="/settings" class="text-gray-400 hover:text-white transition-colors" title="Settings">
<i class="fas fa-cog"></i>
</a>
{{if .Authenticated}}
{{if .IsAdmin}}
<a href="/editor/admin" class="text-gray-400 hover:text-white transition-colors" title="Admin">
<i class="fas fa-user-shield"></i>
</a>
{{end}}
<a href="/editor/profile" class="text-gray-400 hover:text-white transition-colors" title="Profile">
<i class="fas fa-user"></i>
</a>
<a href="/editor/settings" class="text-gray-400 hover:text-white transition-colors" title="Settings">
<i class="fas fa-gear"></i>
</a>
{{end}}
{{if .Authenticated}}
<button id="logout-btn" class="text-gray-400 hover:text-white transition-colors" title="Logout">
<i class="fas fa-right-from-bracket"></i>
</button>
{{else}}
<a href="/editor/login" class="text-gray-400 hover:text-white transition-colors" title="Login">
<i class="fas fa-right-to-bracket"></i>
</a>
{{end}}
</div>
<button id="sidebar-toggle" class="toggle-btn" title="Toggle sidebar" aria-label="Toggle sidebar">
<i id="sidebar-toggle-icon" class="fas fa-chevron-left"></i>
@@ -284,9 +303,11 @@
<!-- Navigation -->
<div class="sidebar-content px-4 py-4">
<a href="/create" class="btn-primary text-sm w-full text-center">
<i class="fas fa-plus mr-2"></i>New Note
</a>
{{if .Authenticated}}
<a href="/editor/create" class="btn-primary text-sm w-full text-center">
<i class="fas fa-plus mr-2"></i>New Note
</a>
{{end}}
</div>
<!-- File Tree -->
@@ -334,8 +355,18 @@
{{template "edit_content" .}}
{{else if eq .Page "settings"}}
{{template "settings_content" .}}
{{else if eq .Page "admin"}}
{{template "admin_content" .}}
{{else if eq .Page "profile"}}
{{template "profile_content" .}}
{{else if eq .Page "error"}}
{{template "error_content" .}}
{{else if eq .Page "login"}}
{{template "login_content" .}}
{{else if eq .Page "mfa"}}
{{template "mfa_content" .}}
{{else if eq .Page "mfa_setup"}}
{{template "mfa_setup_content" .}}
{{end}}
</div>
</div>
@@ -653,6 +684,29 @@
if (e.key === 'Escape' && !searchModal.classList.contains('hidden')) closeSearch();
});
}
// Logout handler (CSRF protected)
const logoutBtn = document.getElementById('logout-btn');
if (logoutBtn) {
logoutBtn.addEventListener('click', async () => {
try {
const m = document.cookie.match(/(?:^|; )csrf_token=([^;]+)/);
const csrf = m && m[1] ? decodeURIComponent(m[1]) : '';
const res = await fetch('/editor/logout', {
method: 'POST',
headers: csrf ? { 'X-CSRF-Token': csrf } : {},
});
if (res.ok) {
window.location.href = '/editor/login';
} else {
const data = await res.json().catch(() => ({}));
showNotification('Logout failed: ' + (data.error || res.statusText), 'error');
}
} catch (e) {
showNotification('Logout error: ' + e.message, 'error');
}
});
}
});
</script>
@@ -670,8 +724,18 @@
{{template "edit_scripts" .}}
{{else if eq .Page "settings"}}
{{template "settings_scripts" .}}
{{else if eq .Page "admin"}}
{{template "admin_scripts" .}}
{{else if eq .Page "profile"}}
{{template "profile_scripts" .}}
{{else if eq .Page "error"}}
{{template "error_scripts" .}}
{{else if eq .Page "login"}}
{{template "login_scripts" .}}
{{else if eq .Page "mfa"}}
{{template "mfa_scripts" .}}
{{else if eq .Page "mfa_setup"}}
{{template "mfa_setup_scripts" .}}
{{end}}
</body>
</html>

View File

@@ -93,7 +93,7 @@ console.log('Hello, World!');
const editorGrid = document.getElementById('editor-grid');
const togglePreviewBtn = document.getElementById('toggle-preview');
const imageStorageMode = {{.image_storage_mode}};
const imageStorageMode = parseInt('{{.image_storage_mode}}', 10) || 1;
const imageSubfolderName = "{{.image_subfolder_name}}";
const currentFolderPath = "{{.folder_path}}";
@@ -174,8 +174,12 @@ console.log('Hello, World!');
formData.append('content', content);
formData.append('folder_path', folderPath);
fetch('/create', {
// CSRF token from cookie
const csrf = (document.cookie.match(/(?:^|; )csrf_token=([^;]+)/)||[])[1] ? decodeURIComponent((document.cookie.match(/(?:^|; )csrf_token=([^;]+)/)||[])[1]) : '';
fetch('/editor/create', {
method: 'POST',
headers: csrf ? { 'X-CSRF-Token': csrf } : {},
body: formData
})
.then(response => response.json())

View File

@@ -84,7 +84,7 @@
const editorGrid = document.getElementById('editor-grid');
const togglePreviewBtn = document.getElementById('toggle-preview');
const imageStorageMode = {{.image_storage_mode}};
const imageStorageMode = parseInt('{{.image_storage_mode}}', 10) || 1;
const imageSubfolderName = "{{.image_subfolder_name}}";
const currentFolderPath = "{{.folder_path}}";
const currentNotePath = "{{.note_path}}";
@@ -158,8 +158,12 @@
const formData = new FormData();
formData.append('content', content);
fetch('/edit/' + notePath, {
// CSRF token from cookie
const csrf = (document.cookie.match(/(?:^|; )csrf_token=([^;]+)/)||[])[1] ? decodeURIComponent((document.cookie.match(/(?:^|; )csrf_token=([^;]+)/)||[])[1]) : '';
fetch('/editor/edit/' + notePath, {
method: 'POST',
headers: csrf ? { 'X-CSRF-Token': csrf } : {},
body: formData
})
.then(response => response.json())

View File

@@ -169,7 +169,9 @@
}
const formData = new FormData();
formData.append('content', cm ? cm.getValue() : contentEl.value);
fetch('/edit_text/' + filePath, { method: 'POST', body: formData })
const m = document.cookie.match(/(?:^|; )csrf_token=([^;]+)/);
const csrf = m && m[1] ? decodeURIComponent(m[1]) : '';
fetch('/editor/edit_text/' + filePath, { method: 'POST', headers: csrf ? { 'X-CSRF-Token': csrf } : {}, body: formData })
.then(r => r.json())
.then(data => {
if (data.success) {

View File

@@ -26,7 +26,7 @@
<button id="upload-btn" class="btn-primary">
<i class="fas fa-upload mr-2"></i>Upload File
</button>
<a href="/create?folder={{.folder_path}}" class="btn-secondary">
<a href="/editor/create?folder={{.folder_path}}" class="btn-secondary">
<i class="fas fa-plus mr-2"></i>New Note
</a>
</div>
@@ -70,12 +70,12 @@
</div>
<div class="flex items-center space-x-2">
{{if eq .Type "md"}}
<a href="/edit/{{.Path}}" class="text-blue-400 hover:text-blue-300 p-2" title="Edit">
<a href="/editor/edit/{{.Path}}" class="text-blue-400 hover:text-blue-300 p-2" title="Edit">
<i class="fas fa-edit"></i>
</a>
{{end}}
{{if eq .Type "text"}}
<a href="/edit_text/{{.Path}}" class="text-blue-400 hover:text-blue-300 p-2" title="Edit">
<a href="/editor/edit_text/{{.Path}}" class="text-blue-400 hover:text-blue-300 p-2" title="Edit">
<i class="fas fa-edit"></i>
</a>
{{end}}
@@ -179,8 +179,11 @@
formData.append('file', file);
}
fetch('/upload', {
const m = document.cookie.match(/(?:^|; )csrf_token=([^;]+)/);
const csrf = m && m[1] ? decodeURIComponent(m[1]) : '';
fetch('/editor/upload', {
method: 'POST',
headers: csrf ? { 'X-CSRF-Token': csrf } : {},
body: formData
})
.then(response => response.json())
@@ -237,8 +240,11 @@
document.getElementById('confirm-delete').addEventListener('click', function() {
if (deleteTarget) {
fetch('/delete/' + deleteTarget, {
method: 'DELETE'
const m = document.cookie.match(/(?:^|; )csrf_token=([^;]+)/);
const csrf = m && m[1] ? decodeURIComponent(m[1]) : '';
fetch('/editor/delete/' + deleteTarget, {
method: 'DELETE',
headers: csrf ? { 'X-CSRF-Token': csrf } : {}
})
.then(response => response.json())
.then(data => {

33
web/templates/login.html Normal file
View File

@@ -0,0 +1,33 @@
{{define "login"}}
{{template "base" .}}
{{end}}
{{define "login_content"}}
<div class="max-w-md mx-auto p-6">
<h1 class="text-2xl font-bold text-white mb-4">Sign in</h1>
{{if .error}}
<div class="bg-red-900/50 border border-red-700 text-red-200 rounded p-3 mb-4">{{.error}}</div>
{{end}}
<form method="POST" action="/editor/login" class="space-y-4">
<input type="hidden" name="csrf_token" value="{{.csrf_token}}" />
<div>
<label class="block text-sm text-gray-300 mb-1" for="username">Username or Email</label>
<input id="username" name="username" type="text" required class="w-full bg-gray-700 border border-gray-600 rounded px-3 py-2 text-white" />
</div>
<div>
<label class="block text-sm text-gray-300 mb-1" for="password">Password</label>
<input id="password" name="password" type="password" required class="w-full bg-gray-700 border border-gray-600 rounded px-3 py-2 text-white" />
</div>
<div class="flex items-center justify-between">
<button type="submit" class="btn-primary"><i class="fas fa-sign-in-alt mr-2"></i>Login</button>
<a href="#" class="text-sm text-blue-400 hover:underline">Forgot password?</a>
</div>
</form>
</div>
{{end}}
{{define "login_scripts"}}
<script>
// optional: add client-side logic here
</script>
{{end}}

35
web/templates/mfa.html Normal file
View File

@@ -0,0 +1,35 @@
{{define "mfa"}}
{{template "base" .}}
{{end}}
{{define "mfa_content"}}
<div class="max-w-md mx-auto p-6">
<h1 class="text-2xl font-bold text-white mb-2">MultiFactor Authentication</h1>
<p class="text-gray-400 mb-6">Enter the 6digit code from your authenticator app.</p>
{{if .error}}
<div class="mb-4 p-3 rounded bg-red-700 text-white">{{.error}}</div>
{{end}}
<form id="mfa-form" class="space-y-4" method="POST" action="/editor/mfa">
<input type="hidden" name="csrf_token" value="{{.csrf_token}}" />
<div>
<label for="code" class="block text-sm font-medium text-gray-300 mb-2">Authentication code</label>
<input id="code" name="code" inputmode="numeric" pattern="[0-9]{6}" maxlength="6" class="form-input" placeholder="123456" required />
</div>
<div class="flex justify-end">
<button type="submit" class="btn-primary"><i class="fas fa-right-to-bracket mr-2"></i>Verify</button>
</div>
</form>
</div>
{{end}}
{{define "mfa_scripts"}}
<script>
// Optional: autofocus the code field
document.addEventListener('DOMContentLoaded', () => {
const code = document.getElementById('code');
if (code) code.focus();
});
</script>
{{end}}

View File

@@ -0,0 +1,62 @@
{{define "mfa_setup"}}
{{template "base" .}}
{{end}}
{{define "mfa_setup_content"}}
<div class="max-w-lg mx-auto p-6">
<h1 class="text-2xl font-bold text-white mb-2">Set up MultiFactor Authentication</h1>
<p class="text-gray-400 mb-6">Scan the QR code below with your authenticator app (Google Authenticator, Authy, 1Password, etc.), or enter the secret manually, then enter a code to confirm.</p>
<div class="bg-slate-800 border border-slate-700 rounded p-4 mb-4">
<div class="flex items-start gap-4">
<img alt="QR Code" class="bg-white rounded p-2" src="https://api.qrserver.com/v1/create-qr-code/?size=180x180&data={{urlquery .OTPAuthURI}}" width="180" height="180"/>
<div class="flex-1">
<div class="text-sm text-gray-400">Secret</div>
<div class="font-mono text-lg text-white break-all">{{.Secret}}</div>
<div class="text-sm text-gray-400 mt-2">URI</div>
<div class="text-xs text-gray-300 break-all">{{.OTPAuthURI}}</div>
</div>
</div>
</div>
<form id="mfa-verify-form" class="space-y-4">
<div>
<label for="code" class="block text-sm font-medium text-gray-300 mb-2">Enter 6digit code</label>
<input id="code" name="code" inputmode="numeric" pattern="[0-9]{6}" maxlength="6" class="form-input" placeholder="123456" required />
</div>
<div class="flex justify-end">
<button type="submit" class="btn-primary"><i class="fas fa-check mr-2"></i>Verify and Enable</button>
</div>
</form>
</div>
{{end}}
{{define "mfa_setup_scripts"}}
<script>
function getCSRF() {
const m = document.cookie.match(/(?:^|; )csrf_token=([^;]+)/);
return m && m[1] ? decodeURIComponent(m[1]) : '';
}
document.addEventListener('DOMContentLoaded', () => {
const form = document.getElementById('mfa-verify-form');
if (!form) return;
form.addEventListener('submit', async (e) => {
e.preventDefault();
const fd = new FormData(form);
const params = new URLSearchParams(Array.from(fd.entries()));
try {
const res = await fetch('/editor/profile/mfa/verify', { method: 'POST', headers: { 'X-CSRF-Token': getCSRF() }, body: params });
const data = await res.json().catch(() => ({}));
if (res.ok && data.success) {
showNotification('MFA enabled', 'success');
window.location.href = '/editor/profile';
} else {
throw new Error(data.error || res.statusText);
}
} catch (e) {
showNotification('Verification failed: ' + e.message, 'error');
}
});
});
</script>
{{end}}

View File

@@ -9,15 +9,19 @@
<div class="flex items-center justify-between mb-4">
<h1 class="text-3xl font-bold text-white">{{.title}}</h1>
<div class="flex items-center space-x-3">
<a href="/edit/{{.note_path}}" class="btn-primary">
{{if .Authenticated}}
<a href="/editor/edit/{{.note_path}}" class="btn-primary">
<i class="fas fa-edit mr-2"></i>Edit
</a>
{{end}}
<a href="/download/{{.note_path}}" class="btn-secondary">
<i class="fas fa-download mr-2"></i>Download
</a>
{{if .Authenticated}}
<button class="btn-danger delete-note-btn" data-path="{{.note_path}}">
<i class="fas fa-trash mr-2"></i>Delete
</button>
{{end}}
</div>
</div>
@@ -73,8 +77,11 @@ document.addEventListener('DOMContentLoaded', function() {
if (confirmBtn) {
confirmBtn.addEventListener('click', function() {
const path = deleteBtn.dataset.path;
fetch(`/delete/${path}`, {
method: 'DELETE'
const m = document.cookie.match(/(?:^|; )csrf_token=([^;]+)/);
const csrf = m && m[1] ? decodeURIComponent(m[1]) : '';
fetch(`/editor/delete/${path}`, {
method: 'DELETE',
headers: csrf ? { 'X-CSRF-Token': csrf } : {}
})
.then(response => {
if (response.ok) {

175
web/templates/profile.html Normal file
View File

@@ -0,0 +1,175 @@
{{define "profile"}}
{{template "base" .}}
{{end}}
{{define "profile_content"}}
<div class="max-w-3xl mx-auto p-6">
<div class="mb-8">
<h1 class="text-3xl font-bold text-white mb-2">Your Profile</h1>
<p class="text-gray-400">Manage your account details and security</p>
</div>
<div class="space-y-8">
<!-- Email -->
<div class="bg-gray-800 rounded-lg p-6">
<h2 class="text-xl font-semibold text-white mb-4">
<i class="fas fa-envelope mr-2"></i>Email</h2>
<form id="email-form" class="space-y-4">
<div>
<label for="email" class="block text-sm font-medium text-gray-300 mb-2">Email</label>
<input type="email" id="email" name="email" value="{{.Email}}"
class="form-input" placeholder="you@example.com" required>
</div>
<div class="flex justify-end">
<button type="submit" class="btn-primary">
<i class="fas fa-save mr-2"></i>Update Email
</button>
</div>
</form>
</div>
<!-- Password -->
<div class="bg-gray-800 rounded-lg p-6">
<h2 class="text-xl font-semibold text-white mb-4">
<i class="fas fa-key mr-2"></i>Change Password</h2>
<form id="password-form" class="space-y-4">
<div>
<label for="current_password" class="block text-sm font-medium text-gray-300 mb-2">Current Password</label>
<input type="password" id="current_password" name="current_password" class="form-input" required>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="new_password" class="block text-sm font-medium text-gray-300 mb-2">New Password</label>
<input type="password" id="new_password" name="new_password" class="form-input" minlength="8" required>
</div>
<div>
<label for="confirm_password" class="block text-sm font-medium text-gray-300 mb-2">Confirm New Password</label>
<input type="password" id="confirm_password" name="confirm_password" class="form-input" minlength="8" required>
</div>
</div>
<div class="flex justify-end">
<button type="submit" class="btn-primary">
<i class="fas fa-save mr-2"></i>Change Password
</button>
</div>
</form>
</div>
<!-- MFA -->
<div class="bg-gray-800 rounded-lg p-6">
<h2 class="text-xl font-semibold text-white mb-4">
<i class="fas fa-shield-halved mr-2"></i>MultiFactor Authentication</h2>
<div class="flex items-center justify-between">
<div>
<div class="text-white font-medium">Status: <span id="mfa-status" class="ml-1">{{if .MFAEnabled}}Enabled{{else}}Disabled{{end}}</span></div>
<div class="text-sm text-gray-400">Add an extra layer of security to your account</div>
</div>
<div class="space-x-2">
<button id="mfa-enable" class="btn-primary {{if .MFAEnabled}}hidden{{end}}"><i class="fas fa-toggle-on mr-2"></i>Enable</button>
<button id="mfa-disable" class="btn-danger {{if not .MFAEnabled}}hidden{{end}}"><i class="fas fa-toggle-off mr-2"></i>Disable</button>
</div>
</div>
<p class="text-xs text-gray-500 mt-3">Note: This simple flow generates or clears your MFA secret. A full QR/TOTP enrollment flow can be added later.</p>
</div>
</div>
</div>
{{end}}
{{define "profile_scripts"}}
<script>
function getCSRF() {
const m = document.cookie.match(/(?:^|; )csrf_token=([^;]+)/);
return m && m[1] ? decodeURIComponent(m[1]) : '';
}
function formToJSON(form) {
const fd = new FormData(form);
return new URLSearchParams(Array.from(fd.entries()));
}
document.addEventListener('DOMContentLoaded', () => {
const emailForm = document.getElementById('email-form');
const passwordForm = document.getElementById('password-form');
const btnEnable = document.getElementById('mfa-enable');
const btnDisable = document.getElementById('mfa-disable');
const mfaStatus = document.getElementById('mfa-status');
if (emailForm) emailForm.addEventListener('submit', async (e) => {
e.preventDefault();
try {
const res = await fetch('/editor/profile/email', {
method: 'POST',
headers: Object.assign({'X-CSRF-Token': getCSRF()}),
body: formToJSON(emailForm)
});
const data = await res.json().catch(() => ({}));
if (res.ok && data.success) {
showNotification('Email updated', 'success');
} else {
throw new Error(data.error || res.statusText);
}
} catch (err) {
showNotification('Update failed: ' + err.message, 'error');
}
});
if (passwordForm) passwordForm.addEventListener('submit', async (e) => {
e.preventDefault();
const newpw = document.getElementById('new_password').value;
const conf = document.getElementById('confirm_password').value;
if (newpw !== conf) {
showNotification('New passwords do not match', 'error');
return;
}
try {
const res = await fetch('/editor/profile/password', {
method: 'POST',
headers: Object.assign({'X-CSRF-Token': getCSRF()}),
body: formToJSON(passwordForm)
});
const data = await res.json().catch(() => ({}));
if (res.ok && data.success) {
passwordForm.reset();
showNotification('Password changed', 'success');
} else {
throw new Error(data.error || res.statusText);
}
} catch (err) {
showNotification('Password change failed: ' + err.message, 'error');
}
});
async function toggleMFA(enable) {
try {
const url = enable ? '/editor/profile/mfa/enable' : '/editor/profile/mfa/disable';
const res = await fetch(url, { method: 'POST', headers: {'X-CSRF-Token': getCSRF()} });
const data = await res.json().catch(() => ({}));
if (res.ok && data.success) {
if (enable) {
if (data.setup && data.redirect) {
// Enrollment flow: go to setup page; do not toggle UI yet
window.location.href = data.redirect;
return;
}
// Direct enable (no setup)
btnEnable.classList.add('hidden');
btnDisable.classList.remove('hidden');
mfaStatus.textContent = 'Enabled';
} else {
btnDisable.classList.add('hidden');
btnEnable.classList.remove('hidden');
mfaStatus.textContent = 'Disabled';
}
showNotification('MFA ' + (enable ? 'enabled' : 'disabled'), 'success');
} else {
throw new Error(data.error || res.statusText);
}
} catch (err) {
showNotification('MFA update failed: ' + err.message, 'error');
}
}
if (btnEnable) btnEnable.addEventListener('click', (e) => { e.preventDefault(); toggleMFA(true); });
if (btnDisable) btnDisable.addEventListener('click', (e) => { e.preventDefault(); toggleMFA(false); });
});
</script>
{{end}}

View File

@@ -179,7 +179,7 @@
// Load current settings
function loadSettings() {
// Load image storage settings
fetch('/settings/image_storage')
fetch('/editor/settings/image_storage')
.then(response => response.json())
.then(data => {
document.querySelector(`input[name="storage_mode"][value="${data.mode}"]`).checked = true;
@@ -190,7 +190,7 @@
.catch(error => console.error('Error loading image storage settings:', error));
// Load notes directory settings
fetch('/settings/notes_dir')
fetch('/editor/settings/notes_dir')
.then(response => response.json())
.then(data => {
document.getElementById('notes_dir').value = data.notes_dir || '';
@@ -198,7 +198,7 @@
.catch(error => console.error('Error loading notes directory settings:', error));
// Load file extensions settings
fetch('/settings/file_extensions')
fetch('/editor/settings/file_extensions')
.then(response => response.json())
.then(data => {
document.getElementById('allowed_image_extensions').value = data.allowed_image_extensions || '';
@@ -237,9 +237,12 @@
e.preventDefault();
const formData = new FormData(this);
fetch('/settings/image_storage', {
const csrf = (document.cookie.match(/(?:^|; )csrf_token=([^;]+)/)||[])[1] ? decodeURIComponent((document.cookie.match(/(?:^|; )csrf_token=([^;]+)/)||[])[1]) : '';
fetch('/editor/settings/image_storage', {
method: 'POST',
headers: csrf ? { 'X-CSRF-Token': csrf } : {},
body: formData
})
.then(response => response.json())
@@ -263,9 +266,12 @@
e.preventDefault();
const formData = new FormData(this);
fetch('/settings/notes_dir', {
const csrf = (document.cookie.match(/(?:^|; )csrf_token=([^;]+)/)||[])[1] ? decodeURIComponent((document.cookie.match(/(?:^|; )csrf_token=([^;]+)/)||[])[1]) : '';
fetch('/editor/settings/notes_dir', {
method: 'POST',
headers: csrf ? { 'X-CSRF-Token': csrf } : {},
body: formData
})
.then(response => response.json())
@@ -289,9 +295,12 @@
e.preventDefault();
const formData = new FormData(this);
fetch('/settings/file_extensions', {
const csrf = (document.cookie.match(/(?:^|; )csrf_token=([^;]+)/)||[])[1] ? decodeURIComponent((document.cookie.match(/(?:^|; )csrf_token=([^;]+)/)||[])[1]) : '';
fetch('/editor/settings/file_extensions', {
method: 'POST',
headers: csrf ? { 'X-CSRF-Token': csrf } : {},
body: formData
})
.then(response => response.json())

View File

@@ -1,18 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>Test Page</title>
</head>
<body>
<h1>Test Page Works!</h1>
<p>App Name: {{.app_name}}</p>
<p>Folder Contents Count: {{len .folder_contents}}</p>
{{if .folder_contents}}
<ul>
{{range .folder_contents}}
<li>{{.DisplayName}} ({{.Type}})</li>
{{end}}
</ul>
{{end}}
</body>
</html>

View File

@@ -9,7 +9,7 @@
<div class="flex items-center justify-between mb-4">
<h1 class="text-3xl font-bold text-white">{{.file_name}}</h1>
<div class="flex items-center space-x-3">
{{if .is_editable}}
{{if and .Authenticated .is_editable}}
<a href="/edit_text/{{.file_path}}" class="btn-primary">
<i class="fas fa-edit mr-2"></i>Edit
</a>
@@ -17,9 +17,11 @@
<a href="/download/{{.file_path}}" class="btn-secondary">
<i class="fas fa-download mr-2"></i>Download
</a>
{{if .Authenticated}}
<button class="btn-danger delete-file-btn" data-path="{{.file_path}}">
<i class="fas fa-trash mr-2"></i>Delete
</button>
{{end}}
</div>
</div>