Nutzerverwaltung-Upgrade: 3 Rollen, Ordner-Zuweisung, Passwort-Aenderung
- 3 Rollen: admin (voll), user (permanent), client (30 Tage Ablauf) - Ordner-basierte Bildzuweisung statt Einzelbilder (user_folders Tabelle) - Root-Bilder oeffentlich, Unterordner nur fuer zugewiesene Nutzer - Passwort-Aenderung fuer alle Nutzer (Self-Service + Admin) - Admin kann beliebige Admins erstellen/loeschen - Thumbnail-Generierung fuer Unterordner - Admin-Dashboard komplett ueberarbeitet Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
78
admin.html
78
admin.html
@@ -7,48 +7,88 @@
|
||||
<link rel="stylesheet" href="/assets/css/main.min.css" />
|
||||
<style>
|
||||
.admin-section { background: rgba(0,0,0,0.5); padding: 2em; margin-bottom: 2em; border-radius: 4px; }
|
||||
.img-check { display: inline-block; width: 100px; margin: 5px; text-align: center; }
|
||||
.img-check img { width: 100px; height: 100px; object-fit: cover; }
|
||||
.folder-check { display: inline-block; margin: 5px 10px; padding: 8px 12px; background: rgba(255,255,255,0.1); border-radius: 4px; }
|
||||
.folder-check label { cursor: pointer; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { text-align: left; padding: 10px; border-bottom: 1px solid rgba(255,255,255,0.2); }
|
||||
.badge { display: inline-block; padding: 2px 8px; border-radius: 3px; font-size: 0.8em; }
|
||||
.badge-admin { background: #e44d26; }
|
||||
.badge-user { background: #2196F3; }
|
||||
.badge-client { background: #4CAF50; }
|
||||
.badge-expired { background: #666; }
|
||||
.btn-small { padding: 4px 10px; font-size: 0.85em; margin: 2px; cursor: pointer; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="is-preload">
|
||||
<div id="wrapper">
|
||||
<header id="header">
|
||||
<h1><a href="/index.html"><strong>Mischkomposition</strong> Admin</a></h1>
|
||||
<nav><ul><li><a href="#" id="logout-btn">Logout <i class="fa fa-sign-out"></i></a></li></ul></nav>
|
||||
<nav><ul>
|
||||
<li><a href="#" id="logout-btn">Logout <i class="fa fa-sign-out"></i></a></li>
|
||||
</ul></nav>
|
||||
</header>
|
||||
|
||||
<div id="main" style="display:block; padding: 2em; max-width: 900px; margin: 0 auto; width: 100%;">
|
||||
|
||||
<div id="main" style="display:block; padding: 2em; max-width: 1000px; margin: 0 auto; width: 100%;">
|
||||
|
||||
<div class="admin-section">
|
||||
<h2>System</h2>
|
||||
<p>Aktualisiert den Docker-Container aus dem GitHub Repository. Container startet danach neu.</p>
|
||||
<button id="update-btn" class="primary">Website Aktualisieren (Docker Pull)</button>
|
||||
<p id="update-status" style="margin-top: 10px;"></p>
|
||||
<h2>Nutzer erstellen</h2>
|
||||
<form id="create-user-form" style="display:flex; gap:10px; flex-wrap:wrap; align-items:flex-end;">
|
||||
<div>
|
||||
<label>Username</label><br/>
|
||||
<input type="text" id="new-username" placeholder="Nutzername" required />
|
||||
</div>
|
||||
<div>
|
||||
<label>Passwort</label><br/>
|
||||
<input type="password" id="new-password" placeholder="Passwort" required />
|
||||
</div>
|
||||
<div>
|
||||
<label>Rolle</label><br/>
|
||||
<select id="new-role">
|
||||
<option value="client">Client (30 Tage)</option>
|
||||
<option value="user">User (permanent)</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<button type="submit">Erstellen</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="admin-section">
|
||||
<h2>Nutzerverwaltung</h2>
|
||||
<form id="create-user-form" style="display:flex; gap:10px; margin-bottom:1em;">
|
||||
<input type="text" id="new-username" placeholder="Nutzername" required />
|
||||
<input type="password" id="new-password" placeholder="Passwort" required />
|
||||
<button type="submit">Erstellen</button>
|
||||
</form>
|
||||
<table>
|
||||
<thead><tr><th>ID</th><th>Username</th><th>Aktion</th></tr></thead>
|
||||
<thead><tr><th>ID</th><th>Username</th><th>Rolle</th><th>Erstellt</th><th>Aktionen</th></tr></thead>
|
||||
<tbody id="users-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="admin-section">
|
||||
<h2>BildZuweisung</h2>
|
||||
<h2>Ordner-Zuweisung</h2>
|
||||
<p>Weise Unterordner aus dem Websitebilder-Verzeichnis einem Nutzer zu.</p>
|
||||
<select id="user-select" style="margin-bottom: 1em;">
|
||||
<option value="">Nutzer wählen...</option>
|
||||
<option value="">Nutzer waehlen...</option>
|
||||
</select>
|
||||
<div id="image-checkboxes" style="display:flex; flex-wrap:wrap; gap:10px;"></div>
|
||||
<button id="save-assignments" style="margin-top:1em;">Zuweisung Speichern</button>
|
||||
<div id="folder-checkboxes" style="display:flex; flex-wrap:wrap; gap:5px; margin-bottom:1em;"></div>
|
||||
<button id="save-assignments">Zuweisung Speichern</button>
|
||||
<span id="assign-status" style="margin-left:10px;"></span>
|
||||
</div>
|
||||
|
||||
<div class="admin-section">
|
||||
<h2>Eigenes Passwort aendern</h2>
|
||||
<form id="admin-pw-form" style="display:flex; gap:10px; flex-wrap:wrap; align-items:flex-end;">
|
||||
<div>
|
||||
<label>Altes Passwort</label><br/>
|
||||
<input type="password" id="admin-pw-old" required />
|
||||
</div>
|
||||
<div>
|
||||
<label>Neues Passwort</label><br/>
|
||||
<input type="password" id="admin-pw-new" required />
|
||||
</div>
|
||||
<div>
|
||||
<button type="submit">Aendern</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -1,101 +1,167 @@
|
||||
$(document).ready(function() {
|
||||
// Check if admin
|
||||
// Auth check
|
||||
$.get('/api/auth/me')
|
||||
.done(function(user) {
|
||||
if(user.role !== 'admin') {
|
||||
alert("Kein Zugriff!");
|
||||
alert('Kein Zugriff!');
|
||||
window.location.href = '/';
|
||||
} else {
|
||||
loadUsers();
|
||||
loadAssignmentImages();
|
||||
loadFolders();
|
||||
$('body').removeClass('is-preload');
|
||||
}
|
||||
})
|
||||
.fail(function() {
|
||||
window.location.href = '/';
|
||||
});
|
||||
.fail(function() { window.location.href = '/'; });
|
||||
|
||||
// Logout
|
||||
$('#logout-btn').click(function(e) {
|
||||
e.preventDefault();
|
||||
$.post('/api/auth/logout').done(function() { window.location.href = '/'; });
|
||||
});
|
||||
|
||||
// ==================== NUTZER ====================
|
||||
|
||||
function loadUsers() {
|
||||
$.get('/api/admin/users').done(function(users) {
|
||||
$('#users-tbody').empty();
|
||||
$('#user-select').html('<option value="">Nutzer wählen...</option>');
|
||||
$('#user-select').html('<option value="">Nutzer waehlen...</option>');
|
||||
|
||||
users.forEach(function(u) {
|
||||
var badge = '<span class="badge badge-' + u.role + '">' + u.role + '</span>';
|
||||
var created = u.created_at ? new Date(u.created_at).toLocaleDateString('de-DE') : '-';
|
||||
var info = created;
|
||||
|
||||
if (u.role === 'client' && u.expires_at) {
|
||||
var expDate = new Date(u.expires_at).toLocaleDateString('de-DE');
|
||||
if (u.expired) {
|
||||
badge = '<span class="badge badge-expired">abgelaufen</span>';
|
||||
info = 'Abgelaufen: ' + expDate;
|
||||
} else {
|
||||
info = 'Bis: ' + expDate;
|
||||
}
|
||||
}
|
||||
|
||||
var actions = '<button class="btn-small" onclick="changePassword(' + u.id + ', \'' + u.username.replace(/'/g, "\\'") + '\')">PW</button>';
|
||||
actions += '<button class="btn-small" onclick="deleteUser(' + u.id + ', \'' + u.username.replace(/'/g, "\\'") + '\')">X</button>';
|
||||
|
||||
$('#users-tbody').append(
|
||||
'<tr>' +
|
||||
'<td>' + u.id + '</td>' +
|
||||
'<td>' + u.username + ' ' + (u.role === 'admin' ? '(Admin)' : '') + '</td>' +
|
||||
'<td>' + (u.id !== 1 ? '<button onclick="deleteUser(' + u.id + ')">Löschen</button>' : '') + '</td>' +
|
||||
'</tr>'
|
||||
'<tr><td>' + u.id + '</td><td>' + u.username + ' ' + badge +
|
||||
'</td><td>' + u.role + '</td><td>' + info +
|
||||
'</td><td>' + actions + '</td></tr>'
|
||||
);
|
||||
if(u.role !== 'admin') {
|
||||
$('#user-select').append('<option value="' + u.id + '">' + u.username + '</option>');
|
||||
|
||||
// Dropdown fuer Ordner-Zuweisung (nur non-admin)
|
||||
if (u.role !== 'admin') {
|
||||
$('#user-select').append('<option value="' + u.id + '">' + u.username + ' (' + u.role + ')</option>');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
window.deleteUser = function(id) {
|
||||
if(confirm("Nutzer loeschen?")) {
|
||||
$.ajax({url: '/api/admin/users/'+id, type: 'DELETE'}).done(loadUsers);
|
||||
// Nutzer erstellen
|
||||
$('#create-user-form').submit(function(e) {
|
||||
e.preventDefault();
|
||||
$.post('/api/admin/users', {
|
||||
username: $('#new-username').val(),
|
||||
password: $('#new-password').val(),
|
||||
role: $('#new-role').val()
|
||||
}).done(function() {
|
||||
$('#new-username').val('');
|
||||
$('#new-password').val('');
|
||||
loadUsers();
|
||||
}).fail(function(err) {
|
||||
alert('Fehler: ' + (err.responseJSON ? err.responseJSON.error : 'Unbekannt'));
|
||||
});
|
||||
});
|
||||
|
||||
// Nutzer loeschen
|
||||
window.deleteUser = function(id, name) {
|
||||
if (confirm('Nutzer "' + name + '" wirklich loeschen?')) {
|
||||
$.ajax({ url: '/api/admin/users/' + id, type: 'DELETE' })
|
||||
.done(loadUsers)
|
||||
.fail(function(err) { alert(err.responseJSON ? err.responseJSON.error : 'Fehler'); });
|
||||
}
|
||||
};
|
||||
|
||||
$('#create-user-form').submit(function(e) {
|
||||
e.preventDefault();
|
||||
$.post('/api/admin/users', { username: $('#new-username').val(), password: $('#new-password').val(), role: 'user' })
|
||||
.done(function() {
|
||||
$('#new-username').val('');
|
||||
$('#new-password').val('');
|
||||
loadUsers();
|
||||
}).fail(function(err) { alert("Fehler: " + err.responseJSON.error); });
|
||||
});
|
||||
// Passwort aendern (Admin setzt fuer anderen User)
|
||||
window.changePassword = function(id, name) {
|
||||
var newPw = prompt('Neues Passwort fuer "' + name + '":');
|
||||
if (!newPw) return;
|
||||
$.ajax({
|
||||
url: '/api/admin/users/' + id + '/password',
|
||||
type: 'PUT',
|
||||
contentType: 'application/json',
|
||||
data: JSON.stringify({ new_password: newPw })
|
||||
}).done(function() {
|
||||
alert('Passwort geaendert!');
|
||||
}).fail(function(err) {
|
||||
alert(err.responseJSON ? err.responseJSON.error : 'Fehler');
|
||||
});
|
||||
};
|
||||
|
||||
function loadAssignmentImages() {
|
||||
$.get('/images/fulls/').done(function(files) {
|
||||
$('#image-checkboxes').empty();
|
||||
files.forEach(function(f) {
|
||||
$('#image-checkboxes').append(
|
||||
'<label class="img-check">' +
|
||||
'<img src="/images/thumbs/' + encodeURIComponent(f.name) + '" />' +
|
||||
'<br/><input type="checkbox" value="' + f.name + '" class="img-cb"/>' +
|
||||
'</label>'
|
||||
// ==================== ORDNER-ZUWEISUNG ====================
|
||||
|
||||
function loadFolders() {
|
||||
$.get('/api/admin/folders').done(function(folders) {
|
||||
$('#folder-checkboxes').empty();
|
||||
if (folders.length === 0) {
|
||||
$('#folder-checkboxes').html('<p>Keine Unterordner in Websitebilder gefunden. Erstelle Ordner in Nextcloud.</p>');
|
||||
return;
|
||||
}
|
||||
folders.forEach(function(f) {
|
||||
$('#folder-checkboxes').append(
|
||||
'<div class="folder-check"><label>' +
|
||||
'<input type="checkbox" value="' + f + '" class="folder-cb"/> ' +
|
||||
'<i class="fa fa-folder"></i> ' + f +
|
||||
'</label></div>'
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Bei User-Auswahl: zugewiesene Ordner laden
|
||||
$('#user-select').change(function() {
|
||||
var uid = $(this).val();
|
||||
$('.img-cb').prop('checked', false);
|
||||
if(!uid) return;
|
||||
$.get('/api/admin/assign').done(function(assignments) {
|
||||
assignments.filter(function(a) { return a.user_id == uid; }).forEach(function(a) {
|
||||
$('.img-cb[value="' + a.image_name + '"]').prop('checked', true);
|
||||
$('.folder-cb').prop('checked', false);
|
||||
if (!uid) return;
|
||||
$.get('/api/admin/assign?user_id=' + uid).done(function(assignedFolders) {
|
||||
assignedFolders.forEach(function(f) {
|
||||
$('.folder-cb[value="' + f + '"]').prop('checked', true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Zuweisung speichern
|
||||
$('#save-assignments').click(function() {
|
||||
var uid = $('#user-select').val();
|
||||
if(!uid) return alert('Bitte Nutzer waehlen');
|
||||
if (!uid) return alert('Bitte Nutzer waehlen');
|
||||
var selected = [];
|
||||
$('.img-cb:checked').each(function() { selected.push($(this).val()); });
|
||||
|
||||
$.post('/api/admin/assign', { user_id: uid, image_names: selected })
|
||||
.done(function() { alert("Zuweisung gespeichert!"); });
|
||||
$('.folder-cb:checked').each(function() { selected.push($(this).val()); });
|
||||
|
||||
$.ajax({
|
||||
url: '/api/admin/assign',
|
||||
type: 'POST',
|
||||
contentType: 'application/json',
|
||||
data: JSON.stringify({ user_id: uid, folders: selected })
|
||||
}).done(function() {
|
||||
$('#assign-status').text('Gespeichert!').fadeIn().delay(2000).fadeOut();
|
||||
}).fail(function() {
|
||||
alert('Fehler beim Speichern');
|
||||
});
|
||||
});
|
||||
|
||||
$('#update-btn').click(function() {
|
||||
if(!confirm("Achtung: Dies startet den Server auf dem NAS neu. Fortfahren?")) return;
|
||||
$('#update-status').text('Update laeuft...');
|
||||
$.post('/api/admin/update').done(function(res) {
|
||||
$('#update-status').text(res.message);
|
||||
setTimeout(function() { window.location.reload(); }, 5000);
|
||||
// ==================== EIGENES PASSWORT ====================
|
||||
|
||||
$('#admin-pw-form').submit(function(e) {
|
||||
e.preventDefault();
|
||||
$.post('/api/auth/change-password', {
|
||||
old_password: $('#admin-pw-old').val(),
|
||||
new_password: $('#admin-pw-new').val()
|
||||
}).done(function() {
|
||||
alert('Passwort geaendert!');
|
||||
$('#admin-pw-old, #admin-pw-new').val('');
|
||||
}).fail(function(err) {
|
||||
alert(err.responseJSON ? err.responseJSON.error : 'Fehler');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
2
assets/js/admin.min.js
vendored
2
assets/js/admin.min.js
vendored
@@ -1 +1 @@
|
||||
$(document).ready(function(){function e(){$.get("/api/admin/users").done(function(e){$("#users-tbody").empty(),$("#user-select").html('<option value="">Nutzer wählen...</option>'),e.forEach(function(e){$("#users-tbody").append("<tr><td>"+e.id+"</td><td>"+e.username+" "+("admin"===e.role?"(Admin)":"")+"</td><td>"+(1!==e.id?'<button onclick="deleteUser('+e.id+')">Löschen</button>':"")+"</td></tr>"),"admin"!==e.role&&$("#user-select").append('<option value="'+e.id+'">'+e.username+"</option>")})})}$.get("/api/auth/me").done(function(n){"admin"!==n.role?(alert("Kein Zugriff!"),window.location.href="/"):(e(),$.get("/images/fulls/").done(function(e){$("#image-checkboxes").empty(),e.forEach(function(e){$("#image-checkboxes").append('<label class="img-check"><img src="/images/thumbs/'+encodeURIComponent(e.name)+'" /><br/><input type="checkbox" value="'+e.name+'" class="img-cb"/></label>')})}),$("body").removeClass("is-preload"))}).fail(function(){window.location.href="/"}),$("#logout-btn").click(function(e){e.preventDefault(),$.post("/api/auth/logout").done(function(){window.location.href="/"})}),window.deleteUser=function(n){confirm("Nutzer loeschen?")&&$.ajax({url:"/api/admin/users/"+n,type:"DELETE"}).done(e)},$("#create-user-form").submit(function(n){n.preventDefault(),$.post("/api/admin/users",{username:$("#new-username").val(),password:$("#new-password").val(),role:"user"}).done(function(){$("#new-username").val(""),$("#new-password").val(""),e()}).fail(function(e){alert("Fehler: "+e.responseJSON.error)})}),$("#user-select").change(function(){var e=$(this).val();$(".img-cb").prop("checked",!1),e&&$.get("/api/admin/assign").done(function(n){n.filter(function(n){return n.user_id==e}).forEach(function(e){$('.img-cb[value="'+e.image_name+'"]').prop("checked",!0)})})}),$("#save-assignments").click(function(){var e=$("#user-select").val();if(!e)return alert("Bitte Nutzer waehlen");var n=[];$(".img-cb:checked").each(function(){n.push($(this).val())}),$.post("/api/admin/assign",{user_id:e,image_names:n}).done(function(){alert("Zuweisung gespeichert!")})}),$("#update-btn").click(function(){confirm("Achtung: Dies startet den Server auf dem NAS neu. Fortfahren?")&&($("#update-status").text("Update laeuft..."),$.post("/api/admin/update").done(function(e){$("#update-status").text(e.message),setTimeout(function(){window.location.reload()},5e3)}))})});
|
||||
$(document).ready(function(){function e(){$.get("/api/admin/users").done(function(e){$("#users-tbody").empty(),$("#user-select").html('<option value="">Nutzer waehlen...</option>'),e.forEach(function(e){var n='<span class="badge badge-'+e.role+'">'+e.role+"</span>",a=e.created_at?new Date(e.created_at).toLocaleDateString("de-DE"):"-";if("client"===e.role&&e.expires_at){var t=new Date(e.expires_at).toLocaleDateString("de-DE");e.expired?(n='<span class="badge badge-expired">abgelaufen</span>',a="Abgelaufen: "+t):a="Bis: "+t}var r='<button class="btn-small" onclick="changePassword('+e.id+", '"+e.username.replace(/'/g,"\\'")+"')\">PW</button>";r+='<button class="btn-small" onclick="deleteUser('+e.id+", '"+e.username.replace(/'/g,"\\'")+"')\">X</button>",$("#users-tbody").append("<tr><td>"+e.id+"</td><td>"+e.username+" "+n+"</td><td>"+e.role+"</td><td>"+a+"</td><td>"+r+"</td></tr>"),"admin"!==e.role&&$("#user-select").append('<option value="'+e.id+'">'+e.username+" ("+e.role+")</option>")})})}$.get("/api/auth/me").done(function(n){"admin"!==n.role?(alert("Kein Zugriff!"),window.location.href="/"):(e(),$.get("/api/admin/folders").done(function(e){$("#folder-checkboxes").empty(),0!==e.length?e.forEach(function(e){$("#folder-checkboxes").append('<div class="folder-check"><label><input type="checkbox" value="'+e+'" class="folder-cb"/> <i class="fa fa-folder"></i> '+e+"</label></div>")}):$("#folder-checkboxes").html("<p>Keine Unterordner in Websitebilder gefunden. Erstelle Ordner in Nextcloud.</p>")}),$("body").removeClass("is-preload"))}).fail(function(){window.location.href="/"}),$("#logout-btn").click(function(e){e.preventDefault(),$.post("/api/auth/logout").done(function(){window.location.href="/"})}),$("#create-user-form").submit(function(n){n.preventDefault(),$.post("/api/admin/users",{username:$("#new-username").val(),password:$("#new-password").val(),role:$("#new-role").val()}).done(function(){$("#new-username").val(""),$("#new-password").val(""),e()}).fail(function(e){alert("Fehler: "+(e.responseJSON?e.responseJSON.error:"Unbekannt"))})}),window.deleteUser=function(n,a){confirm('Nutzer "'+a+'" wirklich loeschen?')&&$.ajax({url:"/api/admin/users/"+n,type:"DELETE"}).done(e).fail(function(e){alert(e.responseJSON?e.responseJSON.error:"Fehler")})},window.changePassword=function(e,n){var a=prompt('Neues Passwort fuer "'+n+'":');a&&$.ajax({url:"/api/admin/users/"+e+"/password",type:"PUT",contentType:"application/json",data:JSON.stringify({new_password:a})}).done(function(){alert("Passwort geaendert!")}).fail(function(e){alert(e.responseJSON?e.responseJSON.error:"Fehler")})},$("#user-select").change(function(){var e=$(this).val();$(".folder-cb").prop("checked",!1),e&&$.get("/api/admin/assign?user_id="+e).done(function(e){e.forEach(function(e){$('.folder-cb[value="'+e+'"]').prop("checked",!0)})})}),$("#save-assignments").click(function(){var e=$("#user-select").val();if(!e)return alert("Bitte Nutzer waehlen");var n=[];$(".folder-cb:checked").each(function(){n.push($(this).val())}),$.ajax({url:"/api/admin/assign",type:"POST",contentType:"application/json",data:JSON.stringify({user_id:e,folders:n})}).done(function(){$("#assign-status").text("Gespeichert!").fadeIn().delay(2e3).fadeOut()}).fail(function(){alert("Fehler beim Speichern")})}),$("#admin-pw-form").submit(function(e){e.preventDefault(),$.post("/api/auth/change-password",{old_password:$("#admin-pw-old").val(),new_password:$("#admin-pw-new").val()}).done(function(){alert("Passwort geaendert!"),$("#admin-pw-old, #admin-pw-new").val("")}).fail(function(e){alert(e.responseJSON?e.responseJSON.error:"Fehler")})})});
|
||||
@@ -182,8 +182,11 @@
|
||||
|
||||
images.forEach(function(file) {
|
||||
var article = $('<article class="thumb"></article>');
|
||||
var link = $('<a class="image" href="/images/fulls/' + encodeURIComponent(file.name) + '"></a>');
|
||||
var img = $('<img src="/images/thumbs/' + encodeURIComponent(file.name) + '" alt="" data-name="' + file.name.replace(/"/g, '"') + '" />');
|
||||
var prefix = file.folder ? encodeURIComponent(file.folder) + '/' : '';
|
||||
var fullPath = '/images/fulls/' + prefix + encodeURIComponent(file.name);
|
||||
var thumbPath = '/images/thumbs/' + prefix + encodeURIComponent(file.name);
|
||||
var link = $('<a class="image" href="' + fullPath + '"></a>');
|
||||
var img = $('<img src="' + thumbPath + '" alt="" data-name="' + file.name.replace(/"/g, '"') + '" />');
|
||||
link.append(img);
|
||||
article.append(link);
|
||||
$main.append(article);
|
||||
@@ -286,6 +289,7 @@
|
||||
$.get('/api/auth/me').done(function(u) {
|
||||
$('#login-username, #login-password, #login-submit').hide();
|
||||
$('#logout-btn').show().css('display', 'inline-block');
|
||||
$('#pw-change-section').show();
|
||||
if(u.role === 'admin') {
|
||||
$('#admin-link').html('<a href="/admin.html" class="button">Admin-Dashboard</a>');
|
||||
}
|
||||
@@ -296,7 +300,13 @@
|
||||
e.preventDefault();
|
||||
$.post('/api/auth/login', { username: $('#login-username').val(), password: $('#login-password').val() })
|
||||
.done(function() { window.location.reload(); })
|
||||
.fail(function(err) { alert("Login fehlgeschlagen. Bitte Zugangsdaten ueberpruefen."); });
|
||||
.fail(function(xhr) {
|
||||
var msg = 'Login fehlgeschlagen.';
|
||||
if (xhr.responseJSON && xhr.responseJSON.error === 'Account abgelaufen') {
|
||||
msg = 'Dein Account ist abgelaufen. Bitte kontaktiere den Administrator.';
|
||||
}
|
||||
alert(msg);
|
||||
});
|
||||
});
|
||||
|
||||
$('#logout-btn').click(function(e) {
|
||||
@@ -304,4 +314,14 @@
|
||||
$.post('/api/auth/logout').done(function() { window.location.reload(); });
|
||||
});
|
||||
|
||||
$('#pw-change-form').submit(function(e) {
|
||||
e.preventDefault();
|
||||
var newPw = $('#pw-new').val();
|
||||
var confirmPw = $('#pw-confirm').val();
|
||||
if (newPw !== confirmPw) { alert('Passwoerter stimmen nicht ueberein.'); return; }
|
||||
$.post('/api/auth/change-password', { old_password: $('#pw-old').val(), new_password: newPw })
|
||||
.done(function() { alert('Passwort geaendert!'); $('#pw-old, #pw-new, #pw-confirm').val(''); })
|
||||
.fail(function(xhr) { alert(xhr.responseJSON ? xhr.responseJSON.error : 'Fehler'); });
|
||||
});
|
||||
|
||||
})(jQuery);
|
||||
|
||||
2
assets/js/main.min.js
vendored
2
assets/js/main.min.js
vendored
File diff suppressed because one or more lines are too long
@@ -8,13 +8,29 @@ const db = new sqlite3.Database(dbPath);
|
||||
console.log('Using database at', dbPath);
|
||||
|
||||
db.serialize(() => {
|
||||
// Users: mit created_at fuer Client-Ablauf (30 Tage)
|
||||
db.run(`CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL
|
||||
role TEXT NOT NULL DEFAULT 'client',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)`);
|
||||
|
||||
// Migration: created_at hinzufuegen falls Tabelle schon existiert
|
||||
db.run(`ALTER TABLE users ADD COLUMN created_at DATETIME DEFAULT CURRENT_TIMESTAMP`, (err) => {
|
||||
// Ignoriere Fehler wenn Spalte schon existiert
|
||||
});
|
||||
|
||||
// Ordner-Zuweisung (neu)
|
||||
db.run(`CREATE TABLE IF NOT EXISTS user_folders (
|
||||
user_id INTEGER,
|
||||
folder_name TEXT,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id),
|
||||
UNIQUE(user_id, folder_name)
|
||||
)`);
|
||||
|
||||
// Alte Tabelle behalten fuer Kompatibilitaet, aber nicht mehr nutzen
|
||||
db.run(`CREATE TABLE IF NOT EXISTS user_images (
|
||||
user_id INTEGER,
|
||||
image_name TEXT,
|
||||
@@ -22,12 +38,12 @@ db.serialize(() => {
|
||||
UNIQUE(user_id, image_name)
|
||||
)`);
|
||||
|
||||
// Create default admin user if none exists
|
||||
// Default-Admin erstellen wenn keiner existiert
|
||||
db.get('SELECT * FROM users WHERE username = ?', ['admin'], (err, row) => {
|
||||
if (!row) {
|
||||
const hash = bcrypt.hashSync('admin123', 10);
|
||||
db.run('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)', ['admin', hash, 'admin']);
|
||||
console.log('Default admin created with credentials: admin / admin123');
|
||||
console.log('Default admin created: admin / admin123');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,30 +4,44 @@ const jwt = require('jsonwebtoken');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { exec } = require('child_process');
|
||||
const db = require('./database');
|
||||
|
||||
const app = express();
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'mischkomposition-super-secret-key-change-me';
|
||||
const FULLS_DIR = path.join(__dirname, '../public/images/fulls');
|
||||
const IMAGE_RE = /\.(jpg|jpeg|png|gif|webp)$/i;
|
||||
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
app.use(cookieParser());
|
||||
|
||||
// Middleware to verify JWT cookie
|
||||
// ==================== MIDDLEWARE ====================
|
||||
|
||||
function authMiddleware(req, res, next) {
|
||||
const token = req.cookies.token;
|
||||
if (!token) {
|
||||
req.user = null;
|
||||
return next();
|
||||
}
|
||||
if (!token) { req.user = null; return next(); }
|
||||
jwt.verify(token, JWT_SECRET, (err, decoded) => {
|
||||
if (err) req.user = null;
|
||||
else req.user = decoded;
|
||||
if (err) { req.user = null; return next(); }
|
||||
// Client-Ablauf pruefen (30 Tage)
|
||||
if (decoded.role === 'client' && decoded.created_at) {
|
||||
const expires = new Date(decoded.created_at);
|
||||
expires.setDate(expires.getDate() + 30);
|
||||
if (new Date() > expires) {
|
||||
res.clearCookie('token');
|
||||
req.user = null;
|
||||
return next();
|
||||
}
|
||||
}
|
||||
req.user = decoded;
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
function requireAuth(req, res, next) {
|
||||
if (!req.user) return res.status(401).json({ error: 'Not logged in' });
|
||||
next();
|
||||
}
|
||||
|
||||
function requireAdmin(req, res, next) {
|
||||
if (!req.user || req.user.role !== 'admin') {
|
||||
return res.status(403).json({ error: 'Forbidden' });
|
||||
@@ -37,6 +51,28 @@ function requireAdmin(req, res, next) {
|
||||
|
||||
app.use(authMiddleware);
|
||||
|
||||
// ==================== HELPER ====================
|
||||
|
||||
function listImagesInDir(dir) {
|
||||
try {
|
||||
const files = fs.readdirSync(dir);
|
||||
return files.filter(f => IMAGE_RE.test(f) && fs.statSync(path.join(dir, f)).isFile());
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function listSubfolders(dir) {
|
||||
try {
|
||||
return fs.readdirSync(dir).filter(f => {
|
||||
try { return fs.statSync(path.join(dir, f)).isDirectory(); }
|
||||
catch (e) { return false; }
|
||||
});
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== AUTH API ====================
|
||||
|
||||
app.post('/api/auth/login', (req, res) => {
|
||||
@@ -46,7 +82,18 @@ app.post('/api/auth/login', (req, res) => {
|
||||
if (!user || !bcrypt.compareSync(password, user.password_hash)) {
|
||||
return res.status(401).json({ error: 'Invalid credentials' });
|
||||
}
|
||||
const token = jwt.sign({ id: user.id, username: user.username, role: user.role }, JWT_SECRET, { expiresIn: '7d' });
|
||||
// Client-Ablauf pruefen
|
||||
if (user.role === 'client' && user.created_at) {
|
||||
const expires = new Date(user.created_at);
|
||||
expires.setDate(expires.getDate() + 30);
|
||||
if (new Date() > expires) {
|
||||
return res.status(401).json({ error: 'Account abgelaufen' });
|
||||
}
|
||||
}
|
||||
const token = jwt.sign(
|
||||
{ id: user.id, username: user.username, role: user.role, created_at: user.created_at },
|
||||
JWT_SECRET, { expiresIn: '7d' }
|
||||
);
|
||||
res.cookie('token', token, { httpOnly: true, sameSite: 'strict', maxAge: 7 * 24 * 60 * 60 * 1000 });
|
||||
res.json({ success: true, role: user.role });
|
||||
});
|
||||
@@ -62,123 +109,160 @@ app.get('/api/auth/me', (req, res) => {
|
||||
res.json(req.user);
|
||||
});
|
||||
|
||||
// ==================== IMAGE PROXY ====================
|
||||
|
||||
// Bilder-Listing: Oeffentlich = alle Bilder, eingeloggt = zugewiesene Bilder
|
||||
app.get('/images/fulls/', (req, res) => {
|
||||
const fullsDir = path.join(__dirname, '../public/images/fulls');
|
||||
|
||||
fs.readdir(fullsDir, (err, files) => {
|
||||
if (err) return res.json([]);
|
||||
|
||||
const imageFiles = files.filter(f => f.match(/\.(jpg|jpeg|png|gif|webp)$/i));
|
||||
|
||||
function buildResult(names) {
|
||||
return names.map(name => {
|
||||
try {
|
||||
const stats = fs.statSync(path.join(fullsDir, name));
|
||||
return { name, type: 'file', mtime: stats.mtime.toUTCString(), size: stats.size };
|
||||
} catch (e) {
|
||||
return { name, type: 'file' };
|
||||
}
|
||||
});
|
||||
app.post('/api/auth/change-password', requireAuth, (req, res) => {
|
||||
const { old_password, new_password } = req.body;
|
||||
if (!new_password || new_password.length < 4) {
|
||||
return res.status(400).json({ error: 'Passwort muss mindestens 4 Zeichen lang sein' });
|
||||
}
|
||||
db.get('SELECT * FROM users WHERE id = ?', [req.user.id], (err, user) => {
|
||||
if (err || !user) return res.status(500).json({ error: 'DB Error' });
|
||||
if (!bcrypt.compareSync(old_password, user.password_hash)) {
|
||||
return res.status(401).json({ error: 'Altes Passwort ist falsch' });
|
||||
}
|
||||
|
||||
// Nicht eingeloggt oder Admin: alle Bilder anzeigen
|
||||
if (!req.user || req.user.role === 'admin') {
|
||||
return res.json(buildResult(imageFiles));
|
||||
}
|
||||
|
||||
// Normaler User: nur zugewiesene Bilder
|
||||
db.all('SELECT image_name FROM user_images WHERE user_id = ?', [req.user.id], (err, rows) => {
|
||||
if (err) return res.json([]);
|
||||
const assignedNames = rows.map(r => r.image_name);
|
||||
const assignedFiles = imageFiles.filter(name => assignedNames.includes(name));
|
||||
return res.json(buildResult(assignedFiles));
|
||||
const hash = bcrypt.hashSync(new_password, 10);
|
||||
db.run('UPDATE users SET password_hash = ? WHERE id = ?', [hash, user.id], (err) => {
|
||||
if (err) return res.status(500).json({ error: 'DB Error' });
|
||||
res.json({ success: true });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Proxy image files directly, but we let Express serve static later.
|
||||
// BUT we should restrict access to physical image files?
|
||||
// If we want to prevent direct URL guessing, we would need to serve them via app.get('/images/fulls/:name').
|
||||
// For now, let's just use express.static to serve the actual binary files.
|
||||
// Nextcloud folder names are hard to guess anyway.
|
||||
// ==================== IMAGE LISTING ====================
|
||||
|
||||
app.get('/images/fulls/', (req, res) => {
|
||||
// Root-Bilder sind immer oeffentlich
|
||||
const rootImages = listImagesInDir(FULLS_DIR).map(name => ({
|
||||
name, type: 'file', folder: null
|
||||
}));
|
||||
|
||||
// Nicht eingeloggt: nur Root-Bilder
|
||||
if (!req.user) return res.json(rootImages);
|
||||
|
||||
const folders = listSubfolders(FULLS_DIR);
|
||||
|
||||
// Admin: Root + alle Ordner
|
||||
if (req.user.role === 'admin') {
|
||||
const allImages = [...rootImages];
|
||||
folders.forEach(folder => {
|
||||
listImagesInDir(path.join(FULLS_DIR, folder)).forEach(name => {
|
||||
allImages.push({ name, type: 'file', folder });
|
||||
});
|
||||
});
|
||||
return res.json(allImages);
|
||||
}
|
||||
|
||||
// User/Client: Root + zugewiesene Ordner
|
||||
db.all('SELECT folder_name FROM user_folders WHERE user_id = ?', [req.user.id], (err, rows) => {
|
||||
const assignedFolders = err ? [] : rows.map(r => r.folder_name);
|
||||
const result = [...rootImages];
|
||||
assignedFolders.forEach(folder => {
|
||||
if (folders.includes(folder)) {
|
||||
listImagesInDir(path.join(FULLS_DIR, folder)).forEach(name => {
|
||||
result.push({ name, type: 'file', folder });
|
||||
});
|
||||
}
|
||||
});
|
||||
return res.json(result);
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== ADMIN API ====================
|
||||
|
||||
app.get('/api/admin/users', requireAdmin, (req, res) => {
|
||||
db.all('SELECT id, username, role FROM users', (err, rows) => {
|
||||
res.json(rows);
|
||||
db.all('SELECT id, username, role, created_at FROM users', (err, rows) => {
|
||||
if (err) return res.json([]);
|
||||
// Ablaufdatum berechnen fuer Clients
|
||||
const result = rows.map(u => {
|
||||
const out = { ...u };
|
||||
if (u.role === 'client' && u.created_at) {
|
||||
const expires = new Date(u.created_at);
|
||||
expires.setDate(expires.getDate() + 30);
|
||||
out.expires_at = expires.toISOString();
|
||||
out.expired = new Date() > expires;
|
||||
}
|
||||
return out;
|
||||
});
|
||||
res.json(result);
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/admin/users', requireAdmin, (req, res) => {
|
||||
const { username, password, role } = req.body;
|
||||
if (!username || !password) return res.status(400).json({ error: 'Username und Passwort erforderlich' });
|
||||
const validRoles = ['admin', 'user', 'client'];
|
||||
const userRole = validRoles.includes(role) ? role : 'client';
|
||||
const hash = bcrypt.hashSync(password, 10);
|
||||
db.run('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)', [username, hash, role || 'user'], function(err) {
|
||||
db.run('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)', [username, hash, userRole], function(err) {
|
||||
if (err) return res.status(400).json({ error: err.message });
|
||||
res.json({ success: true, id: this.lastID });
|
||||
});
|
||||
});
|
||||
|
||||
app.delete('/api/admin/users/:id', requireAdmin, (req, res) => {
|
||||
if (req.params.id == 1) return res.status(400).json({ error: 'Cannot delete primary admin' });
|
||||
db.run('DELETE FROM users WHERE id = ?', [req.params.id], err => {
|
||||
db.run('DELETE FROM user_images WHERE user_id = ?', [req.params.id]);
|
||||
const userId = parseInt(req.params.id);
|
||||
// Sich selbst loeschen verhindern
|
||||
if (userId === req.user.id) {
|
||||
return res.status(400).json({ error: 'Du kannst dich nicht selbst loeschen' });
|
||||
}
|
||||
db.run('DELETE FROM users WHERE id = ?', [userId], (err) => {
|
||||
if (err) return res.status(500).json({ error: 'DB Error' });
|
||||
db.run('DELETE FROM user_folders WHERE user_id = ?', [userId]);
|
||||
res.json({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
app.put('/api/admin/users/:id/password', requireAdmin, (req, res) => {
|
||||
const { new_password } = req.body;
|
||||
if (!new_password || new_password.length < 4) {
|
||||
return res.status(400).json({ error: 'Passwort muss mindestens 4 Zeichen lang sein' });
|
||||
}
|
||||
const hash = bcrypt.hashSync(new_password, 10);
|
||||
db.run('UPDATE users SET password_hash = ? WHERE id = ?', [hash, parseInt(req.params.id)], (err) => {
|
||||
if (err) return res.status(500).json({ error: 'DB Error' });
|
||||
res.json({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
// Ordner-Verwaltung
|
||||
app.get('/api/admin/folders', requireAdmin, (req, res) => {
|
||||
res.json(listSubfolders(FULLS_DIR));
|
||||
});
|
||||
|
||||
app.get('/api/admin/assign', requireAdmin, (req, res) => {
|
||||
db.all('SELECT * FROM user_images', (err, rows) => {
|
||||
res.json(rows);
|
||||
});
|
||||
const userId = req.query.user_id;
|
||||
if (userId) {
|
||||
db.all('SELECT folder_name FROM user_folders WHERE user_id = ?', [userId], (err, rows) => {
|
||||
res.json(err ? [] : rows.map(r => r.folder_name));
|
||||
});
|
||||
} else {
|
||||
db.all('SELECT * FROM user_folders', (err, rows) => {
|
||||
res.json(err ? [] : rows);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/admin/assign', requireAdmin, (req, res) => {
|
||||
const { user_id, image_names } = req.body; // image_names is array of strings
|
||||
const { user_id, folders } = req.body;
|
||||
if (!user_id) return res.status(400).json({ error: 'user_id erforderlich' });
|
||||
const folderList = Array.isArray(folders) ? folders : [];
|
||||
db.serialize(() => {
|
||||
db.run('BEGIN TRANSACTION');
|
||||
db.run('DELETE FROM user_images WHERE user_id = ?', [user_id]);
|
||||
const stmt = db.prepare('INSERT INTO user_images (user_id, image_name) VALUES (?, ?)');
|
||||
image_names.forEach(name => {
|
||||
stmt.run(user_id, name);
|
||||
});
|
||||
db.run('DELETE FROM user_folders WHERE user_id = ?', [user_id]);
|
||||
const stmt = db.prepare('INSERT INTO user_folders (user_id, folder_name) VALUES (?, ?)');
|
||||
folderList.forEach(name => stmt.run(user_id, name));
|
||||
stmt.finalize();
|
||||
db.run('COMMIT');
|
||||
res.json({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/admin/update', requireAdmin, (req, res) => {
|
||||
console.log("Triggering Docker Update...");
|
||||
|
||||
// Neues Image pullen und Container neu starten (ohne docker-compose)
|
||||
const image = 'ghcr.io/kroonk/photography:latest';
|
||||
const cmd = `docker pull ${image} && docker stop photography-website && docker rm photography-website`;
|
||||
|
||||
res.json({ success: true, message: 'Update gestartet. Container wird neu gestartet... Seite laedt in 30 Sekunden neu.' });
|
||||
|
||||
setTimeout(() => {
|
||||
exec(cmd, (err, stdout, stderr) => {
|
||||
if (err) console.error("Update failed:", err);
|
||||
else console.log("Update triggered:", stdout);
|
||||
});
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
|
||||
// ==================== STATIC FILES ====================
|
||||
|
||||
app.use(express.static(path.join(__dirname, '../public')));
|
||||
|
||||
// Catch all - send index.html
|
||||
app.get('*', (req, res) => {
|
||||
res.sendFile(path.join(__dirname, '../public/index.html'));
|
||||
});
|
||||
|
||||
const PORT = 8090;
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Backend server listening on port ${PORT}`);
|
||||
console.log(`Mischkomposition Backend on port ${PORT}`);
|
||||
});
|
||||
|
||||
@@ -3,36 +3,49 @@
|
||||
FULLS_DIR="/app/public/images/fulls"
|
||||
THUMBS_DIR="/app/public/images/thumbs"
|
||||
|
||||
generate_thumbs() {
|
||||
mkdir -p "$THUMBS_DIR"
|
||||
for img in "$FULLS_DIR"/*; do
|
||||
# Thumbnails fuer einen Quell- und Zielordner generieren
|
||||
generate_thumbs_for_dir() {
|
||||
src_dir="$1"
|
||||
dst_dir="$2"
|
||||
mkdir -p "$dst_dir"
|
||||
for img in "$src_dir"/*; do
|
||||
[ -f "$img" ] || continue
|
||||
|
||||
filename=$(basename "$img")
|
||||
ext=$(echo "${filename##*.}" | tr '[:upper:]' '[:lower:]')
|
||||
|
||||
# Nur Bilddateien verarbeiten
|
||||
case "$ext" in
|
||||
jpg|jpeg|png|gif|webp) ;;
|
||||
*) continue ;;
|
||||
esac
|
||||
|
||||
# Thumbnail nur erstellen wenn noch nicht vorhanden oder aelter als Original
|
||||
if [ ! -f "$THUMBS_DIR/$filename" ] || [ "$img" -nt "$THUMBS_DIR/$filename" ]; then
|
||||
echo "Thumbnail erstellen: $filename"
|
||||
convert "$img" -resize 512x512 -quality 80 "$THUMBS_DIR/$filename" 2>/dev/null
|
||||
if [ ! -f "$dst_dir/$filename" ] || [ "$img" -nt "$dst_dir/$filename" ]; then
|
||||
echo "Thumbnail erstellen: $dst_dir/$filename"
|
||||
convert "$img" -resize 512x512 -quality 80 "$dst_dir/$filename" 2>/dev/null
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
generate_all_thumbs() {
|
||||
# Root-Bilder
|
||||
generate_thumbs_for_dir "$FULLS_DIR" "$THUMBS_DIR"
|
||||
|
||||
# Unterordner (Kunden-Ordner)
|
||||
for dir in "$FULLS_DIR"/*/; do
|
||||
[ -d "$dir" ] || continue
|
||||
foldername=$(basename "$dir")
|
||||
generate_thumbs_for_dir "$dir" "$THUMBS_DIR/$foldername"
|
||||
done
|
||||
}
|
||||
|
||||
echo "=== Mischkomposition - Starte Thumbnail-Generierung ==="
|
||||
generate_thumbs
|
||||
generate_all_thumbs
|
||||
echo "=== Thumbnail-Generierung abgeschlossen ==="
|
||||
|
||||
# Hintergrund-Prozess: Alle 60 Sekunden nach neuen Bildern suchen
|
||||
(while true; do
|
||||
sleep 60
|
||||
generate_thumbs
|
||||
generate_all_thumbs
|
||||
done) &
|
||||
|
||||
# Node Server starten
|
||||
|
||||
19
index.html
19
index.html
@@ -130,6 +130,25 @@ layout: default
|
||||
<li id="admin-link"></li>
|
||||
</ul>
|
||||
</form>
|
||||
<div id="pw-change-section" style="display:none; margin-top:1.5em; border-top:1px solid rgba(255,255,255,0.2); padding-top:1.5em;">
|
||||
<h3>Passwort aendern</h3>
|
||||
<form id="pw-change-form">
|
||||
<div class="fields">
|
||||
<div class="field">
|
||||
<input type="password" id="pw-old" placeholder="Altes Passwort" required />
|
||||
</div>
|
||||
<div class="field half">
|
||||
<input type="password" id="pw-new" placeholder="Neues Passwort" required />
|
||||
</div>
|
||||
<div class="field half">
|
||||
<input type="password" id="pw-confirm" placeholder="Passwort bestaetigen" required />
|
||||
</div>
|
||||
</div>
|
||||
<ul class="actions">
|
||||
<li><input type="submit" value="Passwort aendern" class="primary" /></li>
|
||||
</ul>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
Reference in New Issue
Block a user