feat: Add iOS-specific enhancements and mobile compatibility utilities

- Implemented `remove_key` function in verify.py to allow key removal by user ID.
- Updated run.sh to include additional dependencies for QR code and TOTP support.
- Introduced ios_fixes.js for iOS-specific enhancements including file input handling, form submission fixes, and image handling optimizations.
- Added mobile_compatibility.js for mobile device detection and utility functions to improve compatibility, especially for iOS.
- Created base.html template for consistent layout and styling across the application.
- Developed login.html template with a password toggle feature for better user experience.
This commit is contained in:
Maximilian G.
2026-03-22 17:28:28 +00:00
committed by GitHub
parent 91feb7d010
commit f1cdfba603
8 changed files with 1088 additions and 7 deletions
+74 -3
View File
@@ -1,7 +1,9 @@
from pathlib import Path
import sys
from flask import Flask, render_template, request, jsonify
from flask import Flask, render_template, request, jsonify, flash, redirect, url_for, get_flashed_messages, session
import verify
import pyotp
import qrcode
def _resolve_template_dir() -> Path:
candidates = [
@@ -16,9 +18,10 @@ def _resolve_template_dir() -> Path:
return candidates[0]
totp_key = "Hsdfisdf4n34234dfiseLoasjfj3asnnvhxbbfgrzzuewwndcodrweokyn"
app = Flask(__name__, template_folder=str(_resolve_template_dir()))
app.secret_key = "Test123"
def _read_app_version() -> str:
candidates = [
@@ -33,6 +36,13 @@ def _read_app_version() -> str:
return "unknown"
def generate_totp_qrcode():
uri = pyotp.totp.TOTP(totp_key).provisioning_uri(name='',issuer_name='Key Verification Server')
qrcode.make(uri).save("qr.png")
def check_totp(key):
totp = pyotp.TOTP(totp_key)
return totp.verify(key)
APP_VERSION = _read_app_version()
@@ -50,9 +60,70 @@ def validate__information():
else:
return jsonify({"status": "invalid"}), 402
@app.route('/login', methods=['GET', 'POST'])
def login():
"""
User login route.
Authenticates users and redirects to appropriate homepage based on role.
Returns:
flask.Response: Rendered template or redirect
"""
if 'username' in session:
return redirect(url_for('default'))
if request.method == 'POST':
totp = request.form['password']
if not totp:
flash('Please fill all fields', 'error')
return redirect(url_for('login'))
user_log = check_totp(totp)
if user_log:
session['username'] = "Whatareyoulookingfor"
return redirect(url_for('default'))
else:
flash('Invalid credentials', 'error')
get_flashed_messages()
return render_template('login.html')
@app.route("/")
def default():
return render_template("main.html")
if 'username' not in session:
return redirect(url_for('login'))
keys = verify.load_file()
return render_template("main.html", keys=keys)
@app.route("/generate_new", methods=["POST"])
def generate_new():
if 'username' not in session:
return redirect(url_for('login'))
new_license = verify.new_key()
flash(f"New key generated: {new_license}", "success")
return redirect(url_for('default'))
@app.route("/remove_key/<user_id>", methods=["POST"])
def remove_key(user_id):
if 'username' not in session:
return redirect(url_for('login'))
if verify.remove_key(user_id):
flash(f"Key for user {user_id} removed", "success")
else:
flash(f"No key found for user {user_id}", "error")
return redirect(url_for('default'))
@app.route('/logout')
def logout():
session.pop('username', None)
flash('Logged out successfully', 'info')
return redirect(url_for('login'))
def main():
+401
View File
@@ -0,0 +1,401 @@
/**
* iOS Specific Enhancements for Inventarsystem
*
* This file contains fixes specific to iOS devices for the Inventarsystem application,
* focusing on upload and duplication functionality.
*
* Copyright 2025 Maximilian Gründinger
* Licensed under the Apache License, Version 2.0
*/
// Initialize when document is loaded
document.addEventListener('DOMContentLoaded', function() {
if (isIOSDevice()) {
console.log('iOS device detected, applying iOS-specific enhancements');
applyIOSFixes();
}
});
// Apply all iOS-specific fixes
function applyIOSFixes() {
// Fix file input issues
fixIOSFileInputs();
// Fix form submission
fixIOSFormSubmission();
// Fix image handling
fixIOSImageHandling();
// Fix duplication
fixIOSDuplication();
// Apply CSS fixes
applyIOSCSSFixes();
}
// Fix iOS file input issues
function fixIOSFileInputs() {
// Find all file inputs
const fileInputs = document.querySelectorAll('input[type="file"]');
fileInputs.forEach(input => {
// Create a touch-friendly wrapper
const wrapper = document.createElement('div');
wrapper.className = 'ios-file-input-wrapper';
wrapper.style.position = 'relative';
// Style the original input to be more touch-friendly
input.style.padding = '20px 0';
// Wrap the input
if (input.parentNode) {
input.parentNode.insertBefore(wrapper, input);
wrapper.appendChild(input);
// Add a visible button for better touch target
const fakeButton = document.createElement('div');
fakeButton.className = 'ios-file-button';
fakeButton.textContent = 'Wählen Sie Bilder/Videos aus';
fakeButton.style.display = 'inline-block';
fakeButton.style.padding = '10px 15px';
fakeButton.style.backgroundColor = '#4CAF50';
fakeButton.style.color = 'white';
fakeButton.style.borderRadius = '4px';
fakeButton.style.textAlign = 'center';
fakeButton.style.margin = '10px 0';
fakeButton.style.fontWeight = 'bold';
wrapper.appendChild(fakeButton);
// Make sure the real input covers the fake button
input.style.position = 'absolute';
input.style.top = '0';
input.style.left = '0';
input.style.width = '100%';
input.style.height = '100%';
input.style.opacity = '0';
input.style.zIndex = '10';
}
// Add custom event handling
input.addEventListener('change', function() {
// Update visual feedback
const fileCount = this.files ? this.files.length : 0;
let fileText = fakeButton.textContent;
if (fileCount > 0) {
fileText = `${fileCount} Datei${fileCount !== 1 ? 'en' : ''} ausgewählt`;
fakeButton.style.backgroundColor = '#2E7D32';
} else {
fileText = 'Wählen Sie Bilder/Videos aus';
fakeButton.style.backgroundColor = '#4CAF50';
}
fakeButton.textContent = fileText;
});
});
}
// Fix iOS form submission issues
function fixIOSFormSubmission() {
// Find upload forms
const uploadForms = document.querySelectorAll('form[action*="upload_item"]');
uploadForms.forEach(form => {
// Add an iOS submission handler
form.addEventListener('submit', function(e) {
// Only intercept on iOS
if (!isIOSDevice()) return;
e.preventDefault();
// Show that we're processing
const loadingMessage = document.createElement('div');
loadingMessage.className = 'ios-loading-message';
loadingMessage.textContent = 'Wird verarbeitet...';
loadingMessage.style.padding = '10px';
loadingMessage.style.backgroundColor = '#f0f0f0';
loadingMessage.style.borderRadius = '5px';
loadingMessage.style.margin = '10px 0';
loadingMessage.style.textAlign = 'center';
form.appendChild(loadingMessage);
// Disable the submit button
const submitButton = form.querySelector('button[type="submit"]');
if (submitButton) {
submitButton.disabled = true;
}
// Use timeout to allow UI to update before heavy processing
setTimeout(() => {
// Gather form data with special handling for iOS
const formData = new FormData(form);
// Add iOS flag
formData.append('is_ios', 'true');
// Submit with fetch API which works better on iOS
fetch(form.action, {
method: 'POST',
body: formData,
credentials: 'same-origin'
})
.then(response => response.json())
.then(data => {
// Remove loading message
if (loadingMessage.parentNode) {
loadingMessage.parentNode.removeChild(loadingMessage);
}
// Re-enable submit button
if (submitButton) {
submitButton.disabled = false;
}
if (data.success) {
// Show success and redirect
alert(data.message || 'Element erfolgreich hinzugefügt');
// Redirect to the appropriate page
if (data.itemId) {
window.location.href = `/home_admin?highlight_item=${data.itemId}`;
} else {
window.location.href = '/home_admin';
}
} else {
// Show error
alert(data.message || 'Ein Fehler ist aufgetreten');
}
})
.catch(error => {
console.error('iOS form submission error:', error);
// Remove loading message
if (loadingMessage.parentNode) {
loadingMessage.parentNode.removeChild(loadingMessage);
}
// Re-enable submit button
if (submitButton) {
submitButton.disabled = false;
}
// Show error
alert('Ein Netzwerkfehler ist aufgetreten. Bitte versuchen Sie es erneut.');
});
}, 100);
});
});
}
// Fix iOS image handling issues
function fixIOSImageHandling() {
// Reduce image preview quality on iOS
if (typeof ImagePreviewGenerator !== 'undefined') {
// Override the compression quality for iOS
const originalCompressImage = ImagePreviewGenerator.prototype._compressImage;
if (originalCompressImage) {
ImagePreviewGenerator.prototype._compressImage = function(dataUrl) {
// Lower quality for iOS
this.quality = 0.5;
this.maxWidth = 600;
this.maxHeight = 600;
return originalCompressImage.call(this, dataUrl);
};
}
}
// Fix image preview display
document.querySelectorAll('.image-preview-container').forEach(container => {
// Add iOS-specific styling
container.style.webkitOverflowScrolling = 'touch';
container.style.maxHeight = '150px';
container.style.overflow = 'auto';
});
}
// Fix iOS duplication issues
function fixIOSDuplication() {
// Override duplicateItem function
if (typeof duplicateItem !== 'undefined') {
const originalDuplicateItem = duplicateItem;
duplicateItem = function(itemId) {
// Use localStorage on iOS instead of sessionStorage
const useFunction = originalDuplicateItem;
useFunction(itemId);
// Additional iOS-specific handling
const checkForSessionData = setInterval(() => {
const sessionData = sessionStorage.getItem('duplicateItemData');
if (sessionData) {
// Copy from sessionStorage to localStorage
try {
localStorage.setItem('duplicateItemData', sessionData);
localStorage.setItem('duplicateItemTimestamp', Date.now().toString());
console.log('Copied duplicate data to localStorage for iOS reliability');
} catch (e) {
console.warn('Failed to copy to localStorage:', e);
}
clearInterval(checkForSessionData);
}
}, 100);
// Clear interval after 3 seconds regardless
setTimeout(() => clearInterval(checkForSessionData), 3000);
};
}
// Fix prefill function
if (typeof prefillFormWithDuplicateData !== 'undefined') {
const originalPrefill = prefillFormWithDuplicateData;
prefillFormWithDuplicateData = function() {
// Try to get data from localStorage first
let data = null;
try {
const localData = localStorage.getItem('duplicateItemData');
if (localData) {
data = JSON.parse(localData);
localStorage.removeItem('duplicateItemData');
localStorage.removeItem('duplicateItemTimestamp');
console.log('Using duplicate data from localStorage');
}
} catch (e) {
console.warn('Error accessing localStorage:', e);
}
// If we got data from localStorage, manually populate the form
if (data) {
console.log('Manually populating form with iOS optimization');
// Basic fields
document.getElementById('name').value = data.name || '';
document.getElementById('beschreibung').value = data.description || '';
// Handle filters with a delay
setTimeout(() => {
// Filter 1
if (data.filter1 && Array.isArray(data.filter1)) {
data.filter1.forEach((val, idx) => {
const field = document.getElementById(`filter1-${idx+1}`);
if (field && val) field.value = val;
});
}
// Filter 2
if (data.filter2 && Array.isArray(data.filter2)) {
data.filter2.forEach((val, idx) => {
const field = document.getElementById(`filter2-${idx+1}`);
if (field && val) field.value = val;
});
}
// Filter 3
if (data.filter3 && Array.isArray(data.filter3)) {
data.filter3.forEach((val, idx) => {
const field = document.getElementById(`filter3-${idx+1}`);
if (field && val) field.value = val;
});
}
}, 300);
// Location
setTimeout(() => {
const location = document.getElementById('ort');
if (location && data.location) location.value = data.location;
}, 400);
// Year and cost
const year = document.getElementById('anschaffungsjahr');
if (year && data.year) year.value = data.year;
const cost = document.getElementById('anschaffungskosten');
if (cost && data.cost) cost.value = data.cost;
// Handle images - limited for iOS
const form = document.querySelector('form');
if (form && data.images && Array.isArray(data.images)) {
// Add hidden field to indicate duplication
const isDuplicatingField = document.createElement('input');
isDuplicatingField.type = 'hidden';
isDuplicatingField.name = 'is_duplicating';
isDuplicatingField.value = 'true';
form.appendChild(isDuplicatingField);
// Only use first 3 images on iOS for performance
const limitedImages = data.images.slice(0, 3);
limitedImages.forEach(imageName => {
const imageField = document.createElement('input');
imageField.type = 'hidden';
imageField.name = 'duplicate_images';
imageField.value = imageName;
form.appendChild(imageField);
});
// Add a message about limited images
if (data.images.length > 3) {
const messageDiv = document.createElement('div');
messageDiv.style.margin = '10px 0';
messageDiv.style.padding = '10px';
messageDiv.style.backgroundColor = '#fff3cd';
messageDiv.style.borderRadius = '5px';
messageDiv.style.color = '#856404';
messageDiv.textContent = `Aus Leistungsgründen werden nur ${limitedImages.length} von ${data.images.length} Bildern verwendet`;
const fileInput = document.getElementById('images');
if (fileInput && fileInput.parentNode) {
fileInput.parentNode.appendChild(messageDiv);
} else {
form.appendChild(messageDiv);
}
}
}
} else {
// Use original function as fallback
originalPrefill();
}
};
}
}
// Apply iOS-specific CSS fixes
function applyIOSCSSFixes() {
// Create a style element
const style = document.createElement('style');
style.textContent = `
/* Improve touch targets for iOS */
button, select, input[type="submit"], .ausleihen, .edit-button,
.delete-button, .duplicate-button, .details-button {
min-height: 44px !important;
padding: 10px 15px !important;
}
/* Improve scrolling on iOS */
.items-container, .filter-options, .modal-content {
-webkit-overflow-scrolling: touch !important;
}
/* Prevent zoom on inputs */
input, select, textarea {
font-size: 16px !important;
}
/* Fix iOS image display */
.item-image {
-webkit-transform: translateZ(0);
}
/* Improve form fields on iOS */
.form-group {
margin-bottom: 15px !important;
}
`;
document.head.appendChild(style);
}
+286
View File
@@ -0,0 +1,286 @@
/**
* Mobile Compatibility Utilities
*
* This module provides utility functions for better mobile device compatibility,
* especially for iOS devices where certain browser APIs have limited support.
*
* Copyright 2025 Maximilian Gründinger
* Licensed under the Apache License, Version 2.0
*/
// Detect mobile devices including tablets
function isMobileDevice() {
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
}
// Specifically detect iOS devices
function isIOSDevice() {
return /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
}
// Mobile-compatible file tracker to replace DataTransfer API
class MobileCompatFileTracker {
constructor() {
this.files = [];
this.removedIndices = new Set();
}
// Add files from a FileList
addFiles(fileList) {
// Convert FileList to Array for easier handling
Array.from(fileList).forEach(file => {
this.files.push(file);
});
}
// Remove a file by index
removeFile(index) {
if (index >= 0 && index < this.files.length) {
this.removedIndices.add(index);
}
}
// Get all non-removed files
getFiles() {
return this.files.filter((_, index) => !this.removedIndices.has(index));
}
// Convert to FormData for upload
appendToFormData(formData, fieldName) {
this.getFiles().forEach(file => {
formData.append(fieldName, file);
});
}
// Clear all files
clear() {
this.files = [];
this.removedIndices.clear();
}
}
// Mobile optimized image preview generator
class ImagePreviewGenerator {
constructor(options = {}) {
this.maxWidth = options.maxWidth || 800;
this.maxHeight = options.maxHeight || 600;
this.quality = options.quality || 0.8;
this.maxPreviewsOnMobile = options.maxPreviewsOnMobile || 3;
}
// Create optimized image preview element
createPreview(file, index) {
return new Promise((resolve, reject) => {
if (!file) {
reject(new Error('No file provided'));
return;
}
const isVideo = file.type.startsWith('video/');
if (isVideo) {
this._createVideoPreview(file, index).then(resolve).catch(reject);
} else if (file.type.startsWith('image/')) {
this._createImagePreview(file, index).then(resolve).catch(reject);
} else {
reject(new Error('Unsupported file type'));
}
});
}
// Create image preview with optimization for mobile
_createImagePreview(file, index) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => {
// For mobile, we'll compress the image before creating the preview
if (isMobileDevice()) {
this._compressImage(e.target.result)
.then(compressedDataUrl => {
const previewElement = this._createPreviewElement(compressedDataUrl, file.name, index, false);
resolve(previewElement);
})
.catch(err => {
// Fallback to original if compression fails
console.warn('Image compression failed, using original:', err);
const previewElement = this._createPreviewElement(e.target.result, file.name, index, false);
resolve(previewElement);
});
} else {
// For desktop, use the original image
const previewElement = this._createPreviewElement(e.target.result, file.name, index, false);
resolve(previewElement);
}
};
reader.onerror = () => reject(new Error('Failed to read file'));
reader.readAsDataURL(file);
});
}
// Create video preview with thumbnail generation
_createVideoPreview(file, index) {
return new Promise((resolve, reject) => {
const videoUrl = URL.createObjectURL(file);
const video = document.createElement('video');
video.src = videoUrl;
video.onloadedmetadata = () => {
// Create preview element
const previewElement = this._createPreviewElement(videoUrl, file.name, index, true);
resolve(previewElement);
// Revoke object URL after a delay to ensure it's loaded
setTimeout(() => {
URL.revokeObjectURL(videoUrl);
}, 3000);
};
video.onerror = () => {
URL.revokeObjectURL(videoUrl);
reject(new Error('Failed to load video'));
};
// Set a timeout in case the video never loads
setTimeout(() => {
if (!video.videoWidth) {
URL.revokeObjectURL(videoUrl);
reject(new Error('Video load timeout'));
}
}, 5000);
});
}
// Create HTML element for preview
_createPreviewElement(src, fileName, index, isVideo) {
const previewDiv = document.createElement('div');
previewDiv.className = 'image-preview-item';
if (isVideo) {
const video = document.createElement('video');
video.src = src;
video.controls = true;
video.preload = 'metadata';
video.style.maxWidth = '150px';
video.style.maxHeight = '150px';
video.style.objectFit = 'cover';
previewDiv.appendChild(video);
} else {
const img = document.createElement('img');
img.src = src;
img.alt = fileName;
img.style.maxWidth = '150px';
img.style.maxHeight = '150px';
img.style.objectFit = 'cover';
previewDiv.appendChild(img);
}
const controlsDiv = document.createElement('div');
controlsDiv.className = 'image-controls';
const removeButton = document.createElement('button');
removeButton.type = 'button';
removeButton.textContent = 'Entfernen';
removeButton.style.background = '#dc3545';
removeButton.style.color = 'white';
removeButton.style.border = 'none';
removeButton.style.padding = '5px 10px';
removeButton.style.borderRadius = '3px';
removeButton.style.cursor = 'pointer';
removeButton.style.marginLeft = '10px';
removeButton.dataset.index = index;
removeButton.addEventListener('click', function() {
const indexToRemove = parseInt(this.dataset.index, 10);
// We'll define removeImagePreview elsewhere
if (typeof window.removeImagePreview === 'function') {
window.removeImagePreview(indexToRemove);
}
});
controlsDiv.appendChild(removeButton);
previewDiv.appendChild(controlsDiv);
return previewDiv;
}
// Compress image for mobile devices
_compressImage(dataUrl) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
let width = img.width;
let height = img.height;
// Scale down if needed
if (width > this.maxWidth || height > this.maxHeight) {
const ratio = Math.min(this.maxWidth / width, this.maxHeight / height);
width *= ratio;
height *= ratio;
}
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, width, height);
// Get compressed data URL
const compressedDataUrl = canvas.toDataURL('image/jpeg', this.quality);
// Clean up
canvas.width = 0;
canvas.height = 0;
resolve(compressedDataUrl);
};
img.onerror = () => reject(new Error('Failed to load image for compression'));
img.src = dataUrl;
});
}
}
// Mobile-optimized debounce function for event handlers
function debounce(func, wait = 300) {
let timeout;
return function(...args) {
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(this, args), wait);
};
}
// Mobile-safe promise-based setTimeout
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
// Log mobile-specific issues to console or server
function logMobileIssue(action, error) {
if (isMobileDevice()) {
const diagnosticInfo = {
action: action,
error: error.message,
browser: navigator.userAgent,
timestamp: new Date().toISOString(),
memoryInfo: performance?.memory ? {
jsHeapSizeLimit: performance.memory.jsHeapSizeLimit,
totalJSHeapSize: performance.memory.totalJSHeapSize,
usedJSHeapSize: performance.memory.usedJSHeapSize
} : 'Not available'
};
console.error('Mobile Issue:', diagnosticInfo);
// Optionally send to server
fetch('/log_mobile_issue', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(diagnosticInfo)
}).catch(() => {
// Silently fail if logging fails
});
}
}
+132
View File
@@ -0,0 +1,132 @@
<!--
Copyright 2025 Maximilian Gründinger
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0, user-scalable=yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<title>{% block title %}Admin Inventarsystem{% endblock %}</title>
{% block head %}
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/js/bootstrap.bundle.min.js"></script>
<style>
:root {
color-scheme: light dark;
--c-bg:#f4f6f9;--c-bg-alt:#ffffff;--c-surface:#ffffff;--c-surface-alt:#f1f5f9;--c-border:#d9dde3;--c-border-strong:#b7bcc4;--c-text:#1f2933;--c-text-soft:#4b5563;--c-text-faint:#6b7280;--c-accent:#2563eb;--c-accent-hover:#1d4ed8;--c-accent-active:#1942b3;--c-success:#059669;--c-success-hover:#047857;--c-danger:#dc2626;--c-danger-hover:#b91c1c;--c-warning:#d97706;--c-focus-ring:0 0 0 3px rgba(37,99,235,.35);--c-shadow-sm:0 1px 3px rgba(0,0,0,0.08);--c-shadow-md:0 4px 12px rgba(0,0,0,0.12);--c-shadow-lg:0 8px 28px rgba(0,0,0,0.18);--c-scroll-track:#e5e7eb;--c-scroll-thumb:#c0c6ce;--c-scroll-thumb-hover:#9aa2ac;--c-badge-bg:#eef2ff;--c-badge-text:#3730a3;--c-code-bg:#f8fafc;--c-overlay:rgba(17,24,39,0.45);--c-link:#2563eb;--c-link-hover:#1d4ed8;--radius-sm:6px;--radius-md:10px;--radius-lg:14px;--transition:.18s cubic-bezier(.4,0,.2,1)
}
@media (prefers-color-scheme: dark){:root{--c-bg:#0d1117;--c-bg-alt:#101723;--c-surface:#121c29;--c-surface-alt:#182432;--c-border:#2a3a49;--c-border-strong:#3a4d5e;--c-text:#e3e9f1;--c-text-soft:#c2ccd6;--c-text-faint:#94a3b4;--c-accent:#3b82f6;--c-accent-hover:#2563eb;--c-accent-active:#1d4ed8;--c-success:#10b981;--c-success-hover:#059669;--c-danger:#ef4444;--c-danger-hover:#dc2626;--c-warning:#f59e0b;--c-focus-ring:0 0 0 3px rgba(59,130,246,.45);--c-shadow-sm:0 1px 4px rgba(0,0,0,0.55);--c-shadow-md:0 4px 18px rgba(0,0,0,0.55);--c-shadow-lg:0 10px 38px rgba(0,0,0,0.6);--c-scroll-track:#1c2632;--c-scroll-thumb:#314254;--c-scroll-thumb-hover:#44576a;--c-badge-bg:#1e293b;--c-badge-text:#c7d2fe;--c-code-bg:#182432;--c-overlay:rgba(11,18,32,0.62);--c-link:#60a5fa;--c-link-hover:#3b82f6}}
html{box-sizing:border-box}*,*::before,*::after{box-sizing:inherit}body{min-height:100svh;margin:0;font-family:Arial,system-ui,sans-serif;background:var(--c-bg);color:var(--c-text);padding:0 0 30px 0;transition:background .3s,color .3s}
.container:not(.container-fluid){width:min(100% - 2rem,1200px);margin-inline:auto;padding-top:20px}
.fit-content{width:fit-content;max-width:100%}.fit-content-height{height:fit-content;max-height:100%}
:where(p,h1,h2,h3,h4,h5,h6,li){overflow-wrap:anywhere;word-break:normal}img,video,canvas,svg{max-width:100%;height:auto}
.table-wrap{max-width:100%;overflow-x:auto}table.table-fit{width:max-content;max-width:100%;border-collapse:collapse}table.table-fit th,table.table-fit td{padding:.5rem .75rem;text-align:left}
input,select,textarea,button{max-width:100%}
/* Navbar */
.navbar{box-shadow:0 2px 5px rgba(0,0,0,.1);border-bottom:1px solid var(--c-border);background:linear-gradient(90deg,#111827,#142033)}.navbar-brand{font-weight:600;font-size:1.3rem}
/* High contrast on dark navbar in all modes */
.navbar-dark .navbar-brand{color:#f3f4f6}
.navbar-dark .nav-link{color:#e5e7eb}
.navbar-dark .nav-link:hover{color:#ffffff}
.admin-nav-section{margin-left:auto;display:flex;gap:15px}
.admin-nav-section .nav-item{background:var(--c-success);color:#fff;padding:5px 10px;border-radius:6px;text-decoration:none;transition:var(--transition);font-weight:600;font-size:.85rem}
.admin-nav-section .nav-item:hover{background:var(--c-success-hover);transform:translateY(-1px);box-shadow:0 4px 12px rgba(0,0,0,.18)}
.navbar-nav{align-items:center}.navbar-nav .nav-item{margin-right:5px}.navbar-nav .nav-item:last-child{margin-right:0}
/* Flash messages */
.flashes{margin:20px 0}.flash{padding:12px 20px;margin-bottom:15px;border-radius:8px;font-weight:500;border-left:6px solid transparent;background:var(--c-surface);border:1px solid var(--c-border)}
.flash.success{background:rgba(16,185,129,0.12);border-left-color:var(--c-success);color:var(--c-success)}
.flash.error{background:rgba(239,68,68,0.15);border-left-color:var(--c-danger);color:var(--c-danger)}
.flash.info{background:rgba(59,130,246,0.12);border-left-color:var(--c-accent);color:var(--c-accent)}
/* Dropdown */
.dropdown-menu{box-shadow:0 8px 24px rgba(0,0,0,.15);border:1px solid var(--c-border);border-radius:10px;padding:8px 0;min-width:200px;margin-top:6px;background:var(--c-surface);color:var(--c-text)}
.dropdown-item{padding:10px 16px;font-size:.9rem;color:var(--c-text-soft);transition:var(--transition)}
.dropdown-item:hover,.dropdown-item:focus{background:var(--c-surface-alt);color:var(--c-text);transform:translateX(2px)}
.dropdown-divider{margin:6px 0;border-color:var(--c-border)}
/* Logout */
.logout-btn{border-radius:999px;padding:.375rem .85rem;font-weight:600;letter-spacing:.015em;line-height:1.25;transition:var(--transition)}
.navbar-dark .logout-btn.btn-outline-danger{color:#ffc9ce;border-color:rgba(220,53,69,.6)}
.navbar-dark .logout-btn.btn-outline-danger:hover{color:#fff;background:rgba(220,53,69,.9);border-color:rgba(220,53,69,1);transform:translateY(-1px);box-shadow:0 6px 16px rgba(220,53,69,.25)}
/* Scrollbars */
*::-webkit-scrollbar{width:12px;height:12px}*::-webkit-scrollbar-track{background:var(--c-scroll-track)}*::-webkit-scrollbar-thumb{background:var(--c-scroll-thumb);border-radius:20px;border:2px solid var(--c-scroll-track)}*::-webkit-scrollbar-thumb:hover{background:var(--c-scroll-thumb-hover)}
/* Links */
a{color:var(--c-link);text-decoration:none}a:hover{color:var(--c-link-hover)}
/* Generic surfaces */
.card,.content,.form-card,.item-card,.upload-card{background:var(--c-surface);border:1px solid var(--c-border);box-shadow:var(--c-shadow-sm);color:var(--c-text);border-radius:var(--radius-md)}
.item-card:hover{border-color:var(--c-border-strong);box-shadow:var(--c-shadow-md)}
.badge{background:var(--c-badge-bg);color:var(--c-badge-text);border:1px solid var(--c-border);font-weight:600;border-radius:var(--radius-sm);padding:.25rem .55rem;font-size:.7rem;letter-spacing:.03em}
/* Buttons */
.btn,.action-button,.run-backup,.update-system,.actions a.download,.actions a.restore,.upload-actions .primary{background:var(--c-accent);color:#fff !important;border:1px solid var(--c-accent-hover);transition:var(--transition);font-weight:600}
.btn:hover,.action-button:hover,.run-backup:hover,.update-system:hover,.actions a.download:hover,.actions a.restore:hover,.upload-actions .primary:hover{background:var(--c-accent-hover)}
.btn-danger,.danger-button{background:var(--c-danger);border-color:var(--c-danger-hover)}.btn-danger:hover,.danger-button:hover{background:var(--c-danger-hover)}
.ghost{background:transparent;border:1px solid var(--c-border);color:var(--c-text)}.ghost:hover{background:var(--c-surface-alt)}
/* Forms */
input,select,textarea{background:var(--c-surface-alt);border:1px solid var(--c-border);color:var(--c-text);border-radius:var(--radius-sm);padding:.45rem .65rem}
input:focus,select:focus,textarea:focus{border-color:var(--c-accent);box-shadow:var(--c-focus-ring);outline:none}
/* Modals */
.item-modal{background:var(--c-overlay)}.modal-content{background:var(--c-surface);border:1px solid var(--c-border);border-radius:var(--radius-lg);box-shadow:var(--c-shadow-lg)}
.close-modal{color:var(--c-text-faint)}.close-modal:hover{color:var(--c-text)}
/* Status pill */
.status-pill{background:var(--c-surface);border:1px solid var(--c-border);color:var(--c-text-soft);padding:.25rem .6rem;border-radius:999px;font-weight:600}
.status-pill.on{background:rgba(16,185,129,0.15);border-color:var(--c-success);color:var(--c-success)}
.status-pill.off{background:rgba(239,68,68,0.18);border-color:var(--c-danger);color:var(--c-danger)}
/* Focus */
:focus-visible{outline:none;box-shadow:var(--c-focus-ring)}
/* Utility */
.text-info{opacity:.85}
@media (max-width:768px){.navbar-nav .dropdown-menu{border:none;box-shadow:none;background:rgba(17,24,39,.92);margin-left:15px;margin-top:5px;border-radius:10px}.navbar-nav .dropdown-item{color:#fff}.navbar-nav .dropdown-item:hover,.navbar-nav .dropdown-item:focus{background:rgba(255,255,255,.08);color:#fff;transform:none}.nav-item.dropdown .dropdown-toggle{padding:8px 12px}.dropdown-toggle::after{margin-left:8px}.dropdown-menu-end{background:var(--c-surface);border:1px solid var(--c-border)}.dropdown-menu-end .dropdown-item{color:var(--c-text-soft)}.dropdown-menu-end .dropdown-item:hover{background:var(--c-surface-alt);color:var(--c-text)}}
</style>
{% endblock %}
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container-fluid">
<a class="navbar-brand" href="{{ url_for('default') }}">Inventarsystem</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarContent">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarContent">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item">
<a class="nav-link" href="{{ url_for('default') }}">Home</a>
</li>
</ul>
<div class="d-flex ms-auto">
{% if 'username' in session %}
<a id="logout" class="btn btn-outline-danger btn-sm logout-btn" href="{{ url_for('logout') }}" aria-label="Logout">Logout</a>
{% endif %}
</div>
</div>
</div>
</nav>
<div class="container">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="flashes">
{% for category, message in messages %}
<div class="flash {{ category }}">{{ message }}</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</div>
<!-- Mobile compatibility scripts -->
<script src="{{ url_for('static', filename='js/mobile_compatibility.js') }}"></script>
<script src="{{ url_for('static', filename='js/ios_fixes.js') }}"></script>
</body>
</html>
+129
View File
@@ -0,0 +1,129 @@
<!--
Copyright 2025 Maximilian Gründinger
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
{% extends "base.html" %}
{% block title %}Login{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-6">
<h2 class="text-center">Login</h2>
<form method="POST" action="{{ url_for('login') }}">
<div class="mb-3">
<label for="password" class="form-label">TOTP</label>
<div class="password-input-container">
<input type="password" class="form-control" id="password" name="password" required>
<button type="button" class="password-toggle-btn" id="togglePassword" aria-label="Show password">
<input type="checkbox" checked="checked">
<svg class="eye" xmlns="http://www.w3.org/2000/svg" height="1em" viewBox="0 0 576 512"><path d="M288 32c-80.8 0-145.5 36.8-192.6 80.6C48.6 156 17.3 208 2.5 243.7c-3.3 7.9-3.3 16.7 0 24.6C17.3 304 48.6 356 95.4 399.4C142.5 443.2 207.2 480 288 480s145.5-36.8 192.6-80.6c46.8-43.5 78.1-95.4 93-131.1c3.3-7.9 3.3-16.7 0-24.6c-14.9-35.7-46.2-87.7-93-131.1C433.5 68.8 368.8 32 288 32zM144 256a144 144 0 1 1 288 0 144 144 0 1 1 -288 0zm144-64c0 35.3-28.7 64-64 64c-7.1 0-13.9-1.2-20.3-3.3c-5.5-1.8-11.9 1.6-11.7 7.4c.3 6.9 1.3 13.8 3.2 20.7c13.7 51.2 66.4 81.6 117.6 67.9s81.6-66.4 67.9-117.6c-11.1-41.5-47.8-69.4-88.6-71.1c-5.8-.2-9.2 6.1-7.4 11.7c2.1 6.4 3.3 13.2 3.3 20.3z"></path></svg>
<svg class="eye-slash" xmlns="http://www.w3.org/2000/svg" height="1em" viewBox="0 0 640 512"><path d="M38.8 5.1C28.4-3.1 13.3-1.2 5.1 9.2S-1.2 34.7 9.2 42.9l592 464c10.4 8.2 25.5 6.3 33.7-4.1s6.3-25.5-4.1-33.7L525.6 386.7c39.6-40.6 66.4-86.1 79.9-118.4c3.3-7.9 3.3-16.7 0-24.6c-14.9-35.7-46.2-87.7-93-131.1C465.5 68.8 400.8 32 320 32c-68.2 0-125 26.3-169.3 60.8L38.8 5.1zM223.1 149.5C248.6 126.2 282.7 112 320 112c79.5 0 144 64.5 144 144c0 24.9-6.3 48.3-17.4 68.7L408 294.5c8.4-19.3 10.6-41.4 4.8-63.3c-11.1-41.5-47.8-69.4-88.6-71.1c-5.8-.2-9.2 6.1-7.4 11.7c2.1 6.4 3.3 13.2 3.3 20.3c0 10.2-2.4 19.8-6.6 28.3l-90.3-70.8zM373 389.9c-16.4 6.5-34.3 10.1-53 10.1c-79.5 0-144-64.5-144-144c0-6.9 .5-13.6 1.4-20.2L83.1 161.5C60.3 191.2 44 220.8 34.5 243.7c-3.3 7.9-3.3 16.7 0 24.6c14.9 35.7 46.2 87.7 93 131.1C174.5 443.2 239.2 480 320 480c47.8 0 89.9-12.9 126.2-32.5L373 389.9z"></path></svg>
</button>
</div>
</div>
<button type="submit" class="btn btn-primary w-100">Login</button>
</form>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const togglePassword = document.getElementById('togglePassword');
const passwordInput = document.getElementById('password');
const passwordContainer = document.querySelector('.password-input-container');
togglePassword.addEventListener('click', function() {
// Toggle the password field type
const type = passwordInput.getAttribute('type') === 'password' ? 'text' : 'password';
passwordInput.setAttribute('type', type);
// Toggle the visual state of the container
passwordContainer.classList.toggle('password-visible');
//Toggle visability of eye according to state
// Update aria-label for accessibility
const isVisible = type === 'text';
togglePassword.setAttribute('aria-label', isVisible ? 'Hide password' : 'Show password');
});
});
</script>
<style>
.password-input-container {
display: flex;
gap: 10px;
}
#togglePassword {
border-radius: 5px;
}
#togglePassword {
--color: #a5a5b0;
--size: 20px;
display: flex;
justify-content: center;
align-items: center;
position: relative;
cursor: pointer;
font-size: var(--size);
user-select: none;
fill: var(--color);
width: 15%;
}
#togglePassword .eye-slash {
position: absolute;
animation: keyframes-fill .5s;
display: none;
}
#togglePassword .eye {
position: absolute;
animation: keyframes-fill .5s;
}
/* ------ On check event ------ */
#togglePassword input:checked ~ .eye-slash {
display: block;
}
#togglePassword input:checked ~ .eye {
display: none;
}
/* ------ Hide the default checkbox ------ */
#togglePassword input {
position: absolute;
z-index: 100;
opacity: 0;
cursor: pointer;
height: 100%;
width: 100%;
}
/* ------ Animation ------ */
@keyframes keyframes-fill {
0% {
transform: scale(0);
opacity: 0;
}
50% {
transform: scale(1.2);
}
}
</style>
{% endblock %}
+53 -2
View File
@@ -1,2 +1,53 @@
<a>This is a Test If this works it would be great!</a>
<p>Version: {{ version }}</p>
{% extends "base.html" %}
{% block title %}Keys & HWIDs{% endblock %}
{% block content %}
<div class="d-flex flex-wrap justify-content-between align-items-center gap-3 mb-4">
<div>
<h2 class="mb-1">All Keys</h2>
<p class="text-muted mb-0">Overview of license keys and bound HWIDs.</p>
</div>
<form method="POST" action="{{ url_for('generate_new') }}">
<button type="submit" class="btn btn-success">Generate New Key</button>
</form>
</div>
<div class="table-wrap">
<table class="table table-striped table-hover align-middle">
<thead>
<tr>
<th scope="col">User ID</th>
<th scope="col">License Key</th>
<th scope="col">HWID</th>
<th scope="col" class="text-end">Actions</th>
</tr>
</thead>
<tbody>
{% for item in keys %}
<tr>
<td>{{ item.user_id }}</td>
<td><code>{{ item.license_key }}</code></td>
<td>
{% if item.hwid_uuid %}
<code>{{ item.hwid_uuid }}</code>
{% else %}
<span class="text-warning">Not assigned</span>
{% endif %}
</td>
<td class="text-end">
<form method="POST" action="{{ url_for('remove_key', user_id=item.user_id) }}" onsubmit="return confirm('Remove key for user {{ item.user_id }}?');">
<button type="submit" class="btn btn-sm btn-danger">Remove</button>
</form>
</td>
</tr>
{% else %}
<tr>
<td colspan="4" class="text-center text-muted py-4">No keys found.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}
+11
View File
@@ -46,5 +46,16 @@ def new_key() -> str:
register_new_Key(data)
return generated_key
def remove_key(user_id: str) -> bool:
data = load_file()
filtered = [item for item in data if str(item.get("user_id", "")) != str(user_id)]
if len(filtered) == len(data):
return False
register_new_Key(filtered)
return True
def key_generator():
return secrets.token_urlsafe(32)
+2 -2
View File
@@ -15,7 +15,7 @@ if ! command -v patchelf >/dev/null 2>&1; then
exit 1
fi
pip install -U nuitka ordered-set zstandard flask cryptography
pip install -U nuitka ordered-set zstandard flask cryptography pyotp qrcode
python - <<'PY'
from pathlib import Path
import json
@@ -40,7 +40,7 @@ if settings_path.is_file():
version_file.write_text(f"{version}\n", encoding="utf-8")
print(f"Prepared {version_file} with version: {version}")
PY
python -m nuitka --standalone --follow-imports --include-data-dir=Code/templates=templates --include-data-file=licenses.json=licenses.json --assume-yes-for-downloads --output-dir=build --remove-output Code/main.py
python -m nuitka --standalone --follow-imports --include-data-dir=Code/templates=templates --include-data-dir=Code/static=static --include-data-file=licenses.json=licenses.json --assume-yes-for-downloads --output-dir=build --remove-output Code/main.py
if [[ -x "./build/main.dist/main.bin" ]]; then
./build/main.dist/main.bin
else