feat: memory-only sessions on HTTP, streamed exports, UI refresh
Session - COOKIE_SECURE=false no longer persists the owner key for ten years. services/session.cookie_kwargs() drops max_age in that mode, so the browser holds the key in memory and the session ends with the window. Everything still persists server-side; only the browser link is temporary. base.html shows a warning banner (FR/EN) and an extra paragraph in the onboarding modal, and the README explains the trade-off and the backup-key escape hatch. - Both cookie writers (middleware, POST /session/restore) go through cookie_kwargs() so the policy cannot drift between them. - The CSRF guard on /session/restore compared request.url.scheme against the Origin header. Behind a TLS-terminating proxy uvicorn sees http while the browser sends https, so every legitimate restore was rejected with 403. It now compares hosts only, including X-Forwarded-Host. - /static/*, /favicon.ico and /robots.txt skip the middleware. Each cookieless hit was inserting an Owner row no browser could ever use. Reliability - Malformed printer-form FK fields no longer escape as HTTP 500: a non-numeric client_id/driver_id raised ValueError and an unknown driver_id hit a FOREIGN KEY constraint. Both are now 400/404 HTMX fragments, and the duplicated field checks moved into _validate_fields(). - Package exports stream. build_intunewin() encrypts the inner ZIP in 1 MB chunks against temp files with a streaming HMAC and SHA256, and both endpoints serve the result with FileResponse plus a background cleanup task. A 100 MB driver used to be held in memory three or four times over per concurrent download. The byte layout is unchanged. - FileResponse also escapes the download filename, which was previously interpolated raw into Content-Disposition. - python-multipart >= 0.0.18 (CVE-2024-53981, reachable from /drivers/upload) and Pillow >= 10.3 (CVE-2024-28219, reachable from icon upload). - icons.py reads cfg.ICONS_DIR instead of re-deriving the path from DATA_DIR, matching the .intunewin export. UI - Sidebar/topbar shell, inline SVG icon macros (partials/icons.html), card and data-table components, grouped printer list, and the dedicated /printers/new page replacing partials/printer_form.html. Tests - 194 pass with a bare `pytest tests/`: tests/conftest.py now forces cfg.COOKIE_SECURE = False like the e2e conftest already did, so the Secure cookie is no longer dropped over http://testserver. - New coverage for the malformed-FK guards, the chunk-boundary cases in the encrypt loop (every residue mod _CHUNK plus a multi-megabyte payload), temp-dir cleanup after both exports, and the whole COOKIE_SECURE matrix. - test_printer_edit.py located the Edit button by its translated label, so it only passed on English-locale machines. It now targets the showModal() hook, which also cuts the e2e run from 84s to 15s. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -27,11 +27,15 @@ def _error_response(message: str, status_code: int = 400) -> HTMLResponse:
|
||||
|
||||
def _render_client_list(request: Request) -> HTMLResponse:
|
||||
"""Render the client list partial for HTMX swap."""
|
||||
clients = list(Client.select().where(Client.owner == request.state.owner).order_by(Client.name))
|
||||
from imptune.api.pages import printer_counts_by_client
|
||||
|
||||
owner = request.state.owner
|
||||
clients = list(Client.select().where(Client.owner == owner).order_by(Client.name))
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="partials/client_list.html",
|
||||
context={"clients": clients},
|
||||
# Counts must come along, or the swapped-in rows all read "0 printers".
|
||||
context={"clients": clients, "counts": printer_counts_by_client(owner)},
|
||||
)
|
||||
|
||||
|
||||
|
||||
+37
-4
@@ -6,7 +6,7 @@ import io
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Request, UploadFile
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.responses import FileResponse, HTMLResponse, Response
|
||||
from PIL import Image
|
||||
|
||||
import imptune.config as cfg
|
||||
@@ -68,9 +68,11 @@ def upload_icon(request: Request, printer_id: int, file: UploadFile) -> HTMLResp
|
||||
status_code=422,
|
||||
)
|
||||
|
||||
# Store SHA256-addressed on disk
|
||||
# Store SHA256-addressed on disk. cfg.ICONS_DIR is the same path the
|
||||
# .intunewin export reads from — deriving it a second time from DATA_DIR
|
||||
# would silently diverge if the layout ever changes.
|
||||
sha256 = hashlib.sha256(data).hexdigest()
|
||||
icons_dir = Path(cfg.DATA_DIR) / "icons"
|
||||
icons_dir = Path(cfg.ICONS_DIR)
|
||||
icons_dir.mkdir(parents=True, exist_ok=True)
|
||||
icon_path = icons_dir / sha256
|
||||
icon_path.write_bytes(data)
|
||||
@@ -85,6 +87,37 @@ def upload_icon(request: Request, printer_id: int, file: UploadFile) -> HTMLResp
|
||||
)
|
||||
|
||||
return HTMLResponse(
|
||||
content="<p>Icon uploaded successfully</p>",
|
||||
content=(
|
||||
'<p class="ok-note">Icon uploaded successfully</p>'
|
||||
f'<div class="icon-preview"><img src="/printers/{printer_id}/icon?v={sha256[:8]}"'
|
||||
f' width="56" height="56" alt=""><span class="meta">{img.size[0]}×{img.size[1]} PNG</span></div>'
|
||||
),
|
||||
status_code=200,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{printer_id}/icon")
|
||||
def get_icon(request: Request, printer_id: int) -> Response:
|
||||
"""Serve the stored 256x256 PNG so the UI can show what was uploaded.
|
||||
|
||||
Owner-scoped: a printer belonging to another owner reads as missing.
|
||||
"""
|
||||
printer = Printer.get_or_none(
|
||||
(Printer.id == printer_id) & (Printer.owner == request.state.owner)
|
||||
)
|
||||
if printer is None:
|
||||
return Response(status_code=404)
|
||||
|
||||
icon = Icon.get_or_none(Icon.printer == printer_id)
|
||||
if icon is None:
|
||||
return Response(status_code=404)
|
||||
|
||||
icon_path = Path(cfg.ICONS_DIR) / icon.sha256
|
||||
if not icon_path.exists():
|
||||
return Response(status_code=404)
|
||||
|
||||
return FileResponse(
|
||||
icon_path,
|
||||
media_type="image/png",
|
||||
headers={"Cache-Control": "private, max-age=300"},
|
||||
)
|
||||
|
||||
+66
-37
@@ -1,5 +1,4 @@
|
||||
"""Package export endpoints — serves deployment packages for NinjaRMM and Microsoft Intune."""
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
@@ -7,7 +6,8 @@ import tempfile
|
||||
import zipfile
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import PlainTextResponse, Response
|
||||
from fastapi.responses import FileResponse, PlainTextResponse
|
||||
from starlette.background import BackgroundTask
|
||||
|
||||
import imptune.config as cfg
|
||||
from imptune.db.models import Icon, Owner, Printer
|
||||
@@ -17,6 +17,11 @@ from imptune.storage.driver_store import DriverStore
|
||||
|
||||
router = APIRouter(prefix="/printers")
|
||||
|
||||
# Driver payloads run to ~100 MB. Packages are assembled in a temp dir and
|
||||
# streamed from disk instead of being held in memory, so N concurrent
|
||||
# downloads cost N file handles rather than N × package size of RAM.
|
||||
_CHUNK = 1024 * 1024
|
||||
|
||||
|
||||
def _get_printer_and_driver(printer_id: int, owner: Owner):
|
||||
"""Fetch printer (scoped to owner) and validate driver — returns (printer, driver, driver_name) or PlainTextResponse error."""
|
||||
@@ -47,6 +52,16 @@ def _get_driver_zip_path(driver) -> str:
|
||||
return str(DriverStore(cfg.DRIVERS_DIR).get_path(driver.sha256))
|
||||
|
||||
|
||||
def _streamed_download(path: str, tmpdir: str, media_type: str, filename: str) -> FileResponse:
|
||||
"""Serve a built package off disk, deleting its temp dir once sent."""
|
||||
return FileResponse(
|
||||
path,
|
||||
media_type=media_type,
|
||||
filename=filename,
|
||||
background=BackgroundTask(shutil.rmtree, tmpdir, True),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{printer_id}/packages/ninja")
|
||||
def get_ninja_package(request: Request, printer_id: int):
|
||||
"""Download a NinjaRMM-ready ZIP containing install.ps1 and the driver files."""
|
||||
@@ -76,22 +91,30 @@ def get_ninja_package(request: Request, printer_id: int):
|
||||
collate=printer.collate,
|
||||
)
|
||||
|
||||
# Build ZIP in-memory
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||
# Add install script
|
||||
zf.writestr(f"{safe_name}/install.ps1", install_script)
|
||||
# Build the ZIP on disk, copying driver members through in chunks so a
|
||||
# 100 MB driver never lands in memory whole.
|
||||
tmpdir = tempfile.mkdtemp(prefix="imptune_ninja_")
|
||||
try:
|
||||
# Fixed on-disk name — the printer name only shapes the download name,
|
||||
# so a "/" or ":" in it can't break the temp path.
|
||||
out_path = os.path.join(tmpdir, "package.zip")
|
||||
with zipfile.ZipFile(out_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||
zf.writestr(f"{safe_name}/install.ps1", install_script)
|
||||
|
||||
# Extract and re-add driver files from driver ZIP
|
||||
with zipfile.ZipFile(driver_zip_path, "r") as driver_zf:
|
||||
for member in driver_zf.namelist():
|
||||
member_data = driver_zf.read(member)
|
||||
zf.writestr(f"{safe_name}/drivers/{member}", member_data)
|
||||
with zipfile.ZipFile(driver_zip_path, "r") as driver_zf:
|
||||
for member in driver_zf.infolist():
|
||||
target = f"{safe_name}/drivers/{member.filename}"
|
||||
if member.is_dir():
|
||||
zf.writestr(target, b"")
|
||||
continue
|
||||
with driver_zf.open(member) as src, zf.open(target, "w") as dst:
|
||||
shutil.copyfileobj(src, dst, _CHUNK)
|
||||
except BaseException:
|
||||
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||
raise
|
||||
|
||||
return Response(
|
||||
content=buf.getvalue(),
|
||||
media_type="application/zip",
|
||||
headers={"Content-Disposition": f'attachment; filename="{safe_name}_ninja.zip"'},
|
||||
return _streamed_download(
|
||||
out_path, tmpdir, "application/zip", f"{safe_name}_ninja.zip"
|
||||
)
|
||||
|
||||
|
||||
@@ -130,17 +153,24 @@ def get_intunewin_package(request: Request, printer_id: int):
|
||||
)
|
||||
detect_script = render_detect(printer_name=printer.name)
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="imptune_") as tmpdir:
|
||||
# Write scripts
|
||||
with open(os.path.join(tmpdir, "install.ps1"), "w", encoding="utf-8") as f:
|
||||
f.write(install_script)
|
||||
with open(os.path.join(tmpdir, "uninstall.ps1"), "w", encoding="utf-8") as f:
|
||||
f.write(uninstall_script)
|
||||
with open(os.path.join(tmpdir, "detect.ps1"), "w", encoding="utf-8") as f:
|
||||
f.write(detect_script)
|
||||
# The build output is streamed straight off disk, so the temp tree has to
|
||||
# outlive this handler — the response's background task removes it.
|
||||
tmpdir = tempfile.mkdtemp(prefix="imptune_")
|
||||
try:
|
||||
staging = os.path.join(tmpdir, "staging")
|
||||
os.makedirs(staging, exist_ok=True)
|
||||
|
||||
# Extract driver ZIP contents into tmpdir/drivers/
|
||||
drivers_subdir = os.path.join(tmpdir, "drivers")
|
||||
# Write scripts
|
||||
for filename, script in (
|
||||
("install.ps1", install_script),
|
||||
("uninstall.ps1", uninstall_script),
|
||||
("detect.ps1", detect_script),
|
||||
):
|
||||
with open(os.path.join(staging, filename), "w", encoding="utf-8") as f:
|
||||
f.write(script)
|
||||
|
||||
# Extract driver ZIP contents into staging/drivers/
|
||||
drivers_subdir = os.path.join(staging, "drivers")
|
||||
os.makedirs(drivers_subdir, exist_ok=True)
|
||||
with zipfile.ZipFile(driver_zip_path, "r") as driver_zf:
|
||||
driver_zf.extractall(drivers_subdir)
|
||||
@@ -150,17 +180,16 @@ def get_intunewin_package(request: Request, printer_id: int):
|
||||
if icon_record is not None:
|
||||
icon_src = os.path.join(cfg.ICONS_DIR, icon_record.sha256)
|
||||
if os.path.isfile(icon_src):
|
||||
shutil.copy2(icon_src, os.path.join(tmpdir, "icon.png"))
|
||||
shutil.copy2(icon_src, os.path.join(staging, "icon.png"))
|
||||
|
||||
# Build .intunewin
|
||||
output_path = os.path.join(tmpdir, "out.intunewin")
|
||||
build_intunewin(tmpdir, "install.ps1", output_path)
|
||||
# Build .intunewin outside the staging dir — an output file written into
|
||||
# the tree being packaged would end up inside its own package.
|
||||
output_path = os.path.join(tmpdir, "package.intunewin")
|
||||
build_intunewin(staging, "install.ps1", output_path)
|
||||
except BaseException:
|
||||
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||
raise
|
||||
|
||||
with open(output_path, "rb") as f:
|
||||
content = f.read()
|
||||
|
||||
return Response(
|
||||
content=content,
|
||||
media_type="application/octet-stream",
|
||||
headers={"Content-Disposition": f'attachment; filename="{safe_name}.intunewin"'},
|
||||
return _streamed_download(
|
||||
output_path, tmpdir, "application/octet-stream", f"{safe_name}.intunewin"
|
||||
)
|
||||
|
||||
+108
-35
@@ -5,37 +5,102 @@ from pathlib import Path
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from peewee import JOIN
|
||||
from peewee import JOIN, fn
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templates"))
|
||||
|
||||
# Group label for printers with no client — the template translates it.
|
||||
UNASSIGNED_GROUP = "Unassigned"
|
||||
|
||||
|
||||
def printer_counts_by_driver(owner) -> dict[int, int]:
|
||||
"""How many of this owner's printers use each (globally shared) driver."""
|
||||
from imptune.db.models import Printer
|
||||
|
||||
rows = (
|
||||
Printer.select(Printer.driver, fn.COUNT(Printer.id).alias("n"))
|
||||
.where((Printer.owner == owner) & Printer.driver.is_null(False))
|
||||
.group_by(Printer.driver)
|
||||
)
|
||||
return {row.driver_id: row.n for row in rows}
|
||||
|
||||
|
||||
def printer_counts_by_client(owner) -> dict[int, int]:
|
||||
"""How many printers each client groups."""
|
||||
from imptune.db.models import Printer
|
||||
|
||||
rows = (
|
||||
Printer.select(Printer.client, fn.COUNT(Printer.id).alias("n"))
|
||||
.where((Printer.owner == owner) & Printer.client.is_null(False))
|
||||
.group_by(Printer.client)
|
||||
)
|
||||
return {row.client_id: row.n for row in rows}
|
||||
|
||||
|
||||
def group_printers_by_client(printers) -> dict[str, list]:
|
||||
"""Group printers under their client name, unassigned ones last.
|
||||
|
||||
The template renders groups in insertion order, and "a printer nobody has
|
||||
filed yet" belongs at the bottom of the page, not the top.
|
||||
"""
|
||||
grouped: dict[str, list] = defaultdict(list)
|
||||
for p in printers:
|
||||
grouped[p.client.name if p.client_id else UNASSIGNED_GROUP].append(p)
|
||||
|
||||
unassigned = grouped.pop(UNASSIGNED_GROUP, None)
|
||||
ordered = {name: grouped[name] for name in sorted(grouped)}
|
||||
if unassigned:
|
||||
ordered[UNASSIGNED_GROUP] = unassigned
|
||||
return ordered
|
||||
|
||||
|
||||
def _driver_data(drivers) -> list[dict]:
|
||||
"""Attach the parsed driver names to each Driver row for template use."""
|
||||
return [
|
||||
{"driver": d, "names": json.loads(d.driver_desc) if d.driver_desc else []}
|
||||
for d in drivers
|
||||
]
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
def dashboard(request: Request):
|
||||
from imptune.db.models import Printer
|
||||
from imptune.db.models import Client, Driver, Printer
|
||||
|
||||
owner = request.state.owner
|
||||
recent_printers = list(
|
||||
Printer.select()
|
||||
Printer.select(Printer, Client)
|
||||
.join(Client, JOIN.LEFT_OUTER)
|
||||
.where(Printer.owner == owner)
|
||||
.order_by(Printer.created_at.desc())
|
||||
.limit(5)
|
||||
)
|
||||
recent_packages = list(
|
||||
Printer.select()
|
||||
Printer.select(Printer, Client)
|
||||
.join(Client, JOIN.LEFT_OUTER)
|
||||
.switch(Printer)
|
||||
.where((Printer.owner == owner) & Printer.driver.is_null(False))
|
||||
.order_by(Printer.created_at.desc())
|
||||
.limit(5)
|
||||
)
|
||||
printer_count = Printer.select().where(Printer.owner == owner).count()
|
||||
ready_count = (
|
||||
Printer.select()
|
||||
.where((Printer.owner == owner) & Printer.driver.is_null(False))
|
||||
.count()
|
||||
)
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="dashboard.html",
|
||||
context={
|
||||
"recent_printers": recent_printers,
|
||||
"recent_packages": recent_packages,
|
||||
"driver_count": Driver.select().count(),
|
||||
"printer_count": printer_count,
|
||||
"client_count": Client.select().where(Client.owner == owner).count(),
|
||||
"ready_count": ready_count,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -45,14 +110,13 @@ def drivers_page(request: Request):
|
||||
from imptune.db.models import Driver
|
||||
|
||||
drivers = list(Driver.select().order_by(Driver.uploaded_at.desc()))
|
||||
driver_data = []
|
||||
for d in drivers:
|
||||
names = json.loads(d.driver_desc) if d.driver_desc else []
|
||||
driver_data.append({"driver": d, "names": names})
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="drivers.html",
|
||||
context={"driver_data": driver_data},
|
||||
context={
|
||||
"driver_data": _driver_data(drivers),
|
||||
"usage": printer_counts_by_driver(request.state.owner),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -67,18 +131,13 @@ def printers_page(request: Request):
|
||||
.where(Printer.owner == owner)
|
||||
.order_by(Client.name, Printer.name)
|
||||
)
|
||||
grouped: dict[str, list] = defaultdict(list)
|
||||
for p in query:
|
||||
client_name = p.client.name if p.client_id else "Unassigned"
|
||||
grouped[client_name].append(p)
|
||||
grouped = group_printers_by_client(query)
|
||||
|
||||
clients = list(Client.select().where(Client.owner == owner).order_by(Client.name))
|
||||
|
||||
all_drivers = list(Driver.select().order_by(Driver.uploaded_at.desc()))
|
||||
driver_data = []
|
||||
for d in all_drivers:
|
||||
names = json.loads(d.driver_desc) if d.driver_desc else []
|
||||
driver_data.append({"driver": d, "names": names})
|
||||
|
||||
printer_count = sum(len(v) for v in grouped.values())
|
||||
ready_count = sum(1 for v in grouped.values() for p in v if p.driver_id)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
@@ -86,7 +145,9 @@ def printers_page(request: Request):
|
||||
context={
|
||||
"grouped": grouped,
|
||||
"clients": clients,
|
||||
"driver_data": driver_data,
|
||||
"driver_data": _driver_data(all_drivers),
|
||||
"printer_count": printer_count,
|
||||
"ready_count": ready_count,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -97,14 +158,10 @@ def printers_new_page(request: Request):
|
||||
|
||||
clients = list(Client.select().where(Client.owner == request.state.owner).order_by(Client.name))
|
||||
all_drivers = list(Driver.select().order_by(Driver.uploaded_at.desc()))
|
||||
driver_data = [
|
||||
{"driver": d, "names": json.loads(d.driver_desc) if d.driver_desc else []}
|
||||
for d in all_drivers
|
||||
]
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="printers_new.html",
|
||||
context={"clients": clients, "driver_data": driver_data},
|
||||
context={"clients": clients, "driver_data": _driver_data(all_drivers)},
|
||||
)
|
||||
|
||||
|
||||
@@ -154,11 +211,12 @@ def printer_detail(request: Request, printer_id: int):
|
||||
def clients_page(request: Request):
|
||||
from imptune.db.models import Client
|
||||
|
||||
clients = list(Client.select().where(Client.owner == request.state.owner).order_by(Client.name))
|
||||
owner = request.state.owner
|
||||
clients = list(Client.select().where(Client.owner == owner).order_by(Client.name))
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="clients.html",
|
||||
context={"clients": clients},
|
||||
context={"clients": clients, "counts": printer_counts_by_client(owner)},
|
||||
)
|
||||
|
||||
|
||||
@@ -180,14 +238,11 @@ def client_detail(request: Request, client_id: int):
|
||||
.where((Printer.client == client_id) & (Printer.owner == owner))
|
||||
.order_by(Printer.name)
|
||||
)
|
||||
grouped = {client.name: list(query)}
|
||||
printers = list(query)
|
||||
grouped = {client.name: printers}
|
||||
|
||||
clients = list(Client.select().where(Client.owner == owner).order_by(Client.name))
|
||||
all_drivers = list(Driver.select().order_by(Driver.uploaded_at.desc()))
|
||||
driver_data = [
|
||||
{"driver": d, "names": json.loads(d.driver_desc) if d.driver_desc else []}
|
||||
for d in all_drivers
|
||||
]
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
@@ -196,25 +251,43 @@ def client_detail(request: Request, client_id: int):
|
||||
"client": client,
|
||||
"grouped": grouped,
|
||||
"clients": clients,
|
||||
"driver_data": driver_data,
|
||||
"driver_data": _driver_data(all_drivers),
|
||||
"printer_count": len(printers),
|
||||
"ready_count": sum(1 for p in printers if p.driver_id),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/packages", response_class=HTMLResponse)
|
||||
def packages_page(request: Request):
|
||||
from imptune.db.models import Client, Driver, Printer
|
||||
from imptune.db.models import Client, Driver, Icon, Printer
|
||||
|
||||
owner = request.state.owner
|
||||
printers = list(
|
||||
Printer.select(Printer, Client, Driver)
|
||||
.join(Client, JOIN.LEFT_OUTER)
|
||||
.switch(Printer)
|
||||
.join(Driver, JOIN.LEFT_OUTER)
|
||||
.where((Printer.owner == request.state.owner) & Printer.driver.is_null(False))
|
||||
.where((Printer.owner == owner) & Printer.driver.is_null(False))
|
||||
.order_by(Printer.name)
|
||||
)
|
||||
pending_count = (
|
||||
Printer.select()
|
||||
.where((Printer.owner == owner) & Printer.driver.is_null(True))
|
||||
.count()
|
||||
)
|
||||
icon_printer_ids = {
|
||||
row.printer_id
|
||||
for row in Icon.select(Icon.printer).where(
|
||||
Icon.printer.in_([p.id for p in printers] or [0])
|
||||
)
|
||||
}
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="packages.html",
|
||||
context={"printers": printers},
|
||||
context={
|
||||
"printers": printers,
|
||||
"pending_count": pending_count,
|
||||
"icon_printer_ids": icon_printer_ids,
|
||||
},
|
||||
)
|
||||
|
||||
+81
-45
@@ -1,14 +1,14 @@
|
||||
"""Printer CRUD API — POST /printers, DELETE /printers/{id}, PATCH /printers/{id}."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Form, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse, Response
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from peewee import JOIN
|
||||
|
||||
from imptune.api.pages import group_printers_by_client
|
||||
from imptune.db.models import Client, Driver, Printer
|
||||
|
||||
router = APIRouter(prefix="/printers")
|
||||
@@ -29,6 +29,59 @@ def _error_response(message: str, status_code: int = 400) -> HTMLResponse:
|
||||
)
|
||||
|
||||
|
||||
def _validate_fields(
|
||||
name: str, ip_address: str, port_name: str, duplex_mode: str, paper_size: str
|
||||
) -> HTMLResponse | None:
|
||||
"""Shared field validation for create and update — None when everything is valid."""
|
||||
if not name:
|
||||
return _error_response("Printer name is required.")
|
||||
if not ip_address:
|
||||
return _error_response("IP address is required.")
|
||||
if not port_name:
|
||||
return _error_response("Port name is required.")
|
||||
if duplex_mode not in _VALID_DUPLEX:
|
||||
return _error_response(f"Invalid duplex mode: {duplex_mode}.")
|
||||
if paper_size not in _VALID_PAPER:
|
||||
return _error_response(f"Invalid paper size: {paper_size}.")
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_client(raw: str, owner) -> tuple[int | None, HTMLResponse | None]:
|
||||
"""Resolve the optional client_id form field to an owned Client id.
|
||||
|
||||
The form value is attacker-controlled text: a non-numeric value used to
|
||||
raise ValueError (HTTP 500) instead of the 400 the HTMX form can render.
|
||||
"""
|
||||
raw = raw.strip()
|
||||
if not raw:
|
||||
return None, None
|
||||
try:
|
||||
client_fk = int(raw)
|
||||
except ValueError:
|
||||
return None, _error_response(f"Invalid client id: {raw}.")
|
||||
if Client.get_or_none((Client.id == client_fk) & (Client.owner == owner)) is None:
|
||||
return None, _error_response(f"Client {client_fk} not found.", status_code=404)
|
||||
return client_fk, None
|
||||
|
||||
|
||||
def _resolve_driver(raw: str) -> tuple[int | None, HTMLResponse | None]:
|
||||
"""Resolve the optional driver_id form field. Drivers are global/shared.
|
||||
|
||||
Existence is checked here because an unknown id otherwise reaches SQLite as
|
||||
a FOREIGN KEY violation — an IntegrityError (HTTP 500) rather than a 404.
|
||||
"""
|
||||
raw = raw.strip()
|
||||
if not raw:
|
||||
return None, None
|
||||
try:
|
||||
driver_fk = int(raw)
|
||||
except ValueError:
|
||||
return None, _error_response(f"Invalid driver id: {raw}.")
|
||||
if Driver.get_or_none(Driver.id == driver_fk) is None:
|
||||
return None, _error_response(f"Driver {driver_fk} not found.", status_code=404)
|
||||
return driver_fk, None
|
||||
|
||||
|
||||
def _render_printer_list(request: Request) -> HTMLResponse:
|
||||
"""Query printers with LEFT JOIN on client and render grouped partial."""
|
||||
import json
|
||||
@@ -40,10 +93,7 @@ def _render_printer_list(request: Request) -> HTMLResponse:
|
||||
.where(Printer.owner == owner)
|
||||
.order_by(Client.name, Printer.name)
|
||||
)
|
||||
grouped: dict[str, list[Printer]] = defaultdict(list)
|
||||
for p in query:
|
||||
client_name = p.client.name if p.client_id else "Unassigned"
|
||||
grouped[client_name].append(p)
|
||||
grouped = group_printers_by_client(query)
|
||||
|
||||
clients = list(Client.select().where(Client.owner == owner).order_by(Client.name))
|
||||
all_drivers = list(Driver.select().order_by(Driver.uploaded_at.desc()))
|
||||
@@ -71,48 +121,39 @@ def create_printer(
|
||||
collate: str = Form(""),
|
||||
client_id: str = Form(""),
|
||||
driver_id: str = Form(""),
|
||||
) -> HTMLResponse:
|
||||
) -> Response:
|
||||
"""Create a new printer configuration.
|
||||
|
||||
Boolean fields (color_mode, collate) use HTML checkbox convention:
|
||||
"on" = True, absent/empty = False.
|
||||
Redirects to /printers on success; returns an inline HTMX error fragment
|
||||
(400/404) on validation failure.
|
||||
"""
|
||||
name = name.strip()
|
||||
ip_address = ip_address.strip()
|
||||
port_name = port_name.strip()
|
||||
|
||||
if not name:
|
||||
return _error_response("Printer name is required.")
|
||||
if not ip_address:
|
||||
return _error_response("IP address is required.")
|
||||
if not port_name:
|
||||
return _error_response("Port name is required.")
|
||||
if duplex_mode not in _VALID_DUPLEX:
|
||||
return _error_response(f"Invalid duplex mode: {duplex_mode}.")
|
||||
if paper_size not in _VALID_PAPER:
|
||||
return _error_response(f"Invalid paper size: {paper_size}.")
|
||||
|
||||
# Convert checkbox values
|
||||
color_mode_bool = color_mode == "on"
|
||||
collate_bool = collate == "on"
|
||||
invalid = _validate_fields(name, ip_address, port_name, duplex_mode, paper_size)
|
||||
if invalid is not None:
|
||||
return invalid
|
||||
|
||||
owner = request.state.owner
|
||||
|
||||
# Resolve optional FK IDs — client must belong to this owner
|
||||
client_fk = int(client_id) if client_id.strip() else None
|
||||
if client_fk is not None:
|
||||
if Client.get_or_none((Client.id == client_fk) & (Client.owner == owner)) is None:
|
||||
return _error_response(f"Client {client_fk} not found.", status_code=404)
|
||||
driver_fk = int(driver_id) if driver_id.strip() else None
|
||||
client_fk, error = _resolve_client(client_id, owner)
|
||||
if error is not None:
|
||||
return error
|
||||
driver_fk, error = _resolve_driver(driver_id)
|
||||
if error is not None:
|
||||
return error
|
||||
|
||||
Printer.create(
|
||||
name=name,
|
||||
ip_address=ip_address,
|
||||
port_name=port_name,
|
||||
duplex_mode=duplex_mode,
|
||||
color_mode=color_mode_bool,
|
||||
# HTML checkbox convention: "on" = True, absent/empty = False
|
||||
color_mode=color_mode == "on",
|
||||
paper_size=paper_size,
|
||||
collate=collate_bool,
|
||||
collate=collate == "on",
|
||||
owner=owner,
|
||||
client=client_fk,
|
||||
driver=driver_fk,
|
||||
@@ -161,21 +202,16 @@ def update_printer(
|
||||
ip_address = ip_address.strip() or printer.ip_address
|
||||
port_name = port_name.strip() or printer.port_name
|
||||
|
||||
if not name:
|
||||
return _error_response("Printer name is required.")
|
||||
if not ip_address:
|
||||
return _error_response("IP address is required.")
|
||||
if not port_name:
|
||||
return _error_response("Port name is required.")
|
||||
if duplex_mode not in _VALID_DUPLEX:
|
||||
return _error_response(f"Invalid duplex mode: {duplex_mode}.")
|
||||
if paper_size not in _VALID_PAPER:
|
||||
return _error_response(f"Invalid paper size: {paper_size}.")
|
||||
invalid = _validate_fields(name, ip_address, port_name, duplex_mode, paper_size)
|
||||
if invalid is not None:
|
||||
return invalid
|
||||
|
||||
client_fk = int(client_id) if client_id.strip() else None
|
||||
if client_fk is not None:
|
||||
if Client.get_or_none((Client.id == client_fk) & (Client.owner == owner)) is None:
|
||||
return _error_response(f"Client {client_fk} not found.", status_code=404)
|
||||
client_fk, error = _resolve_client(client_id, owner)
|
||||
if error is not None:
|
||||
return error
|
||||
driver_fk, error = _resolve_driver(driver_id)
|
||||
if error is not None:
|
||||
return error
|
||||
|
||||
printer.name = name
|
||||
printer.ip_address = ip_address
|
||||
@@ -185,7 +221,7 @@ def update_printer(
|
||||
printer.paper_size = paper_size
|
||||
printer.collate = collate == "on"
|
||||
printer.client = client_fk
|
||||
printer.driver = int(driver_id) if driver_id.strip() else None
|
||||
printer.driver = driver_fk
|
||||
printer.updated_at = datetime.now(UTC).replace(tzinfo=None)
|
||||
printer.save()
|
||||
|
||||
|
||||
+2
-10
@@ -7,9 +7,8 @@ from fastapi import APIRouter, Form, Request
|
||||
from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
import imptune.config as cfg
|
||||
from imptune.db.models import Owner
|
||||
from imptune.services.session import COOKIE_MAX_AGE, COOKIE_NAME, is_same_origin
|
||||
from imptune.services.session import COOKIE_NAME, cookie_kwargs, is_same_origin
|
||||
|
||||
router = APIRouter(prefix="/session")
|
||||
|
||||
@@ -62,12 +61,5 @@ def restore_session(request: Request, key: str = Form(...)):
|
||||
)
|
||||
|
||||
response = RedirectResponse(url="/", status_code=303)
|
||||
response.set_cookie(
|
||||
COOKIE_NAME,
|
||||
owner.key,
|
||||
max_age=COOKIE_MAX_AGE,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
secure=cfg.COOKIE_SECURE,
|
||||
)
|
||||
response.set_cookie(COOKIE_NAME, owner.key, **cookie_kwargs())
|
||||
return response
|
||||
|
||||
@@ -27,8 +27,9 @@ Outer ZIP structure:
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import io
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import zipfile
|
||||
from xml.etree.ElementTree import Element, SubElement, indent, tostring
|
||||
|
||||
@@ -41,6 +42,10 @@ from Crypto.Util.Padding import pad
|
||||
# was produced by a compatible tool version.
|
||||
_TOOL_VERSION = "1.8.6.0"
|
||||
|
||||
# Streaming chunk size. Must be a multiple of AES.block_size (16) so every
|
||||
# chunk but the last is a whole number of CBC blocks.
|
||||
_CHUNK = 1024 * 1024
|
||||
|
||||
|
||||
def build_intunewin(source_dir: str, setup_file: str, output_path: str) -> None:
|
||||
"""Build a .intunewin file from source_dir, with setup_file as entry point.
|
||||
@@ -54,43 +59,93 @@ def build_intunewin(source_dir: str, setup_file: str, output_path: str) -> None:
|
||||
Raises:
|
||||
FileNotFoundError: If source_dir does not exist.
|
||||
ValueError: If setup_file is empty.
|
||||
|
||||
Driver payloads run to ~100 MB, so every step streams through 1 MB chunks
|
||||
against temp files: buffering the inner ZIP, its ciphertext and the outer
|
||||
ZIP in memory held 3-4 full copies of the package per concurrent download.
|
||||
"""
|
||||
# --- Step 1: Create inner ZIP (DEFLATE-compressed content) ---
|
||||
inner_zip_buf = io.BytesIO()
|
||||
with zipfile.ZipFile(inner_zip_buf, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||
for root, dirs, files in os.walk(source_dir):
|
||||
dirs.sort() # deterministic ordering
|
||||
for filename in sorted(files):
|
||||
abs_path = os.path.join(root, filename)
|
||||
arc_name = os.path.relpath(abs_path, source_dir)
|
||||
# Normalise to forward slashes for cross-platform consistency
|
||||
arc_name = arc_name.replace("\\", "/")
|
||||
zf.write(abs_path, arc_name)
|
||||
plaintext = inner_zip_buf.getvalue()
|
||||
if not setup_file:
|
||||
raise ValueError("setup_file must not be empty")
|
||||
if not os.path.isdir(source_dir):
|
||||
raise FileNotFoundError(f"source_dir does not exist: {source_dir}")
|
||||
|
||||
# --- Step 2: Generate random keys and IV ---
|
||||
aes_key = os.urandom(32) # 256-bit AES key
|
||||
mac_key = os.urandom(32) # 256-bit HMAC key (same size as AES key)
|
||||
iv = os.urandom(16) # 128-bit IV — standard AES-CBC block size (NOT 32 bytes)
|
||||
with tempfile.TemporaryDirectory(prefix="intunewin_") as staging:
|
||||
inner_zip_path = os.path.join(staging, "inner.zip")
|
||||
ciphertext_path = os.path.join(staging, "inner.enc")
|
||||
|
||||
# --- Step 3: Encrypt with AES-256-CBC (PKCS7 padding) ---
|
||||
cipher = AES.new(aes_key, AES.MODE_CBC, iv)
|
||||
ciphertext = cipher.encrypt(pad(plaintext, AES.block_size))
|
||||
# --- Step 1: Create inner ZIP (DEFLATE-compressed content) ---
|
||||
with zipfile.ZipFile(inner_zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||
for root, dirs, files in os.walk(source_dir):
|
||||
dirs.sort() # deterministic ordering
|
||||
for filename in sorted(files):
|
||||
abs_path = os.path.join(root, filename)
|
||||
arc_name = os.path.relpath(abs_path, source_dir)
|
||||
# Normalise to forward slashes for cross-platform consistency
|
||||
arc_name = arc_name.replace("\\", "/")
|
||||
zf.write(abs_path, arc_name)
|
||||
plaintext_size = os.path.getsize(inner_zip_path)
|
||||
|
||||
# --- Step 4: Compute HMAC-SHA256 over (IV + ciphertext) using mac_key ---
|
||||
# The reference (svrooij/ContentPrep Zipper.cs DecryptFileAsync) reads the first
|
||||
# 32 bytes as the stored HMAC, then computes the hash of the *remaining* bytes
|
||||
# (= IV || ciphertext) to verify integrity. Authenticated-encryption best
|
||||
# practice (Encrypt-then-MAC) also requires the IV to be covered by the MAC so
|
||||
# that a forged IV cannot redirect decryption.
|
||||
mac_digest = hmac.new(mac_key, iv + ciphertext, hashlib.sha256).digest()
|
||||
# --- Step 2: Generate random keys and IV ---
|
||||
aes_key = os.urandom(32) # 256-bit AES key
|
||||
mac_key = os.urandom(32) # 256-bit HMAC key (same size as AES key)
|
||||
iv = os.urandom(16) # 128-bit IV — standard AES-CBC block size (NOT 32 bytes)
|
||||
|
||||
# --- Step 5: Assemble encrypted blob: [HMAC(32)] + [IV(16)] + [ciphertext] ---
|
||||
encrypted_blob = mac_digest + iv + ciphertext
|
||||
# --- Steps 3-4: Encrypt (AES-256-CBC, PKCS7) while MAC-ing and digesting ---
|
||||
# The reference (svrooij/ContentPrep Zipper.cs DecryptFileAsync) reads the first
|
||||
# 32 bytes as the stored HMAC, then computes the hash of the *remaining* bytes
|
||||
# (= IV || ciphertext) to verify integrity. Authenticated-encryption best
|
||||
# practice (Encrypt-then-MAC) also requires the IV to be covered by the MAC so
|
||||
# that a forged IV cannot redirect decryption.
|
||||
cipher = AES.new(aes_key, AES.MODE_CBC, iv)
|
||||
mac = hmac.new(mac_key, iv, hashlib.sha256)
|
||||
plaintext_hash = hashlib.sha256()
|
||||
|
||||
# --- Step 6: Compute plaintext (inner ZIP) SHA256 digest for Detection.xml ---
|
||||
file_digest = hashlib.sha256(plaintext).digest()
|
||||
with open(inner_zip_path, "rb") as src, open(ciphertext_path, "wb") as dst:
|
||||
while True:
|
||||
# BufferedReader.read(n) returns n bytes unless EOF, so a short
|
||||
# read means "last chunk" — the only one that carries padding.
|
||||
chunk = src.read(_CHUNK)
|
||||
plaintext_hash.update(chunk)
|
||||
if len(chunk) < _CHUNK:
|
||||
block = cipher.encrypt(pad(chunk, AES.block_size))
|
||||
mac.update(block)
|
||||
dst.write(block)
|
||||
break
|
||||
block = cipher.encrypt(chunk)
|
||||
mac.update(block)
|
||||
dst.write(block)
|
||||
|
||||
mac_digest = mac.digest()
|
||||
|
||||
# --- Step 6: Plaintext (inner ZIP) SHA256 digest for Detection.xml ---
|
||||
file_digest = plaintext_hash.digest()
|
||||
|
||||
return _finalize(
|
||||
output_path=output_path,
|
||||
ciphertext_path=ciphertext_path,
|
||||
setup_file=setup_file,
|
||||
plaintext_size=plaintext_size,
|
||||
aes_key=aes_key,
|
||||
mac_key=mac_key,
|
||||
iv=iv,
|
||||
mac_digest=mac_digest,
|
||||
file_digest=file_digest,
|
||||
)
|
||||
|
||||
|
||||
def _finalize(
|
||||
*,
|
||||
output_path: str,
|
||||
ciphertext_path: str,
|
||||
setup_file: str,
|
||||
plaintext_size: int,
|
||||
aes_key: bytes,
|
||||
mac_key: bytes,
|
||||
iv: bytes,
|
||||
mac_digest: bytes,
|
||||
file_digest: bytes,
|
||||
) -> None:
|
||||
"""Write Detection.xml plus the [HMAC(32)][IV(16)][ciphertext] blob into the outer ZIP."""
|
||||
# --- Step 7: Build Detection.xml ---
|
||||
# Format MUST match IntuneWinAppUtil.exe reference output exactly:
|
||||
# - ToolVersion is an XML attribute on ApplicationInfo (not a child element)
|
||||
@@ -102,7 +157,7 @@ def build_intunewin(source_dir: str, setup_file: str, output_path: str) -> None:
|
||||
attrib={"ToolVersion": _TOOL_VERSION},
|
||||
)
|
||||
SubElement(app_info, "Name").text = setup_file
|
||||
SubElement(app_info, "UnencryptedContentSize").text = str(len(plaintext))
|
||||
SubElement(app_info, "UnencryptedContentSize").text = str(plaintext_size)
|
||||
SubElement(app_info, "FileName").text = "IntunePackage.intunewin"
|
||||
SubElement(app_info, "SetupFile").text = setup_file
|
||||
|
||||
@@ -121,11 +176,14 @@ def build_intunewin(source_dir: str, setup_file: str, output_path: str) -> None:
|
||||
detection_xml = tostring(app_info, encoding="unicode", xml_declaration=False)
|
||||
|
||||
# --- Step 8: Build outer ZIP (STORED — no extra compression on encrypted content) ---
|
||||
# The blob is streamed in as [HMAC(32)] + [IV(16)] + [ciphertext] so the
|
||||
# encrypted payload is never materialised as a second in-memory copy.
|
||||
with zipfile.ZipFile(output_path, "w", compression=zipfile.ZIP_STORED) as outer:
|
||||
outer.writestr(
|
||||
"IntuneWinPackage/Contents/IntunePackage.intunewin",
|
||||
encrypted_blob,
|
||||
)
|
||||
with outer.open("IntuneWinPackage/Contents/IntunePackage.intunewin", "w") as dest:
|
||||
dest.write(mac_digest)
|
||||
dest.write(iv)
|
||||
with open(ciphertext_path, "rb") as ct:
|
||||
shutil.copyfileobj(ct, dest, _CHUNK)
|
||||
outer.writestr(
|
||||
"IntuneWinPackage/Metadata/Detection.xml",
|
||||
detection_xml.encode("utf-8"),
|
||||
|
||||
@@ -173,7 +173,11 @@ def parse_inf(inf_text: str, inf_filename: str, zip_names: list[str]) -> ParsedI
|
||||
for key, _val in parser.items(section):
|
||||
if key.startswith("__bare_"):
|
||||
continue
|
||||
resolved = _resolve_tokens(key, strings)
|
||||
# DriverDesc keys are sometimes quoted directly in the INF
|
||||
# (e.g. `"Canon Generic PCL6" = SectionName, HardwareID`)
|
||||
# instead of via %TOKEN%; configparser keeps those quotes as
|
||||
# part of the key, so strip them same as [Strings] values.
|
||||
resolved = _resolve_tokens(key, strings).strip('"')
|
||||
# Skip empty, purely numeric, or clearly non-driver-name entries
|
||||
if resolved and not resolved.isdigit():
|
||||
driver_names.add(resolved)
|
||||
|
||||
+56
-15
@@ -12,11 +12,50 @@ from imptune.db.models import Owner
|
||||
COOKIE_NAME = "imptune_owner_key"
|
||||
COOKIE_MAX_AGE = 10 * 365 * 24 * 60 * 60 # 10 years
|
||||
|
||||
# Paths that never need an Owner. Minting one for them means a DB write per
|
||||
# request for any client that doesn't carry the cookie — asset fetches racing
|
||||
# the first page load, health probes, crawlers, favicon hunts — and every row
|
||||
# is dead weight, since only a browser holding the cookie can ever use it.
|
||||
_UNSCOPED_PATHS = frozenset({"/health", "/favicon.ico", "/robots.txt"})
|
||||
_UNSCOPED_PREFIXES = ("/static/",)
|
||||
|
||||
|
||||
def generate_key() -> str:
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
def cookie_kwargs() -> dict:
|
||||
"""`set_cookie` attributes for the owner key — persistent only when Secure.
|
||||
|
||||
With `COOKIE_SECURE=false` the key travels over plain HTTP, so persisting it
|
||||
for ten years would leave a long-lived bearer credential on disk and in
|
||||
cleartext traffic. Instead `max_age` is dropped: the browser holds the cookie
|
||||
in memory only. The app stays fully usable and keeps remembering printers,
|
||||
configs and clients for as long as the window is open — the session just
|
||||
ends when the browser does, and the UI says so (see `ephemeral_session`).
|
||||
The backup key remains the way to carry a session across browsers.
|
||||
"""
|
||||
kwargs: dict = {
|
||||
"httponly": True,
|
||||
"samesite": "lax",
|
||||
"secure": cfg.COOKIE_SECURE,
|
||||
}
|
||||
if cfg.COOKIE_SECURE:
|
||||
kwargs["max_age"] = COOKIE_MAX_AGE
|
||||
return kwargs
|
||||
|
||||
|
||||
def _request_hosts(request: Request) -> set[str]:
|
||||
"""Every host spelling that legitimately identifies this deployment."""
|
||||
hosts = {request.url.netloc}
|
||||
for header in ("host", "x-forwarded-host"):
|
||||
value = request.headers.get(header)
|
||||
if value:
|
||||
# X-Forwarded-Host may be a proxy chain: the client-facing host is first.
|
||||
hosts.add(value.split(",")[0].strip())
|
||||
return hosts
|
||||
|
||||
|
||||
def is_same_origin(request: Request) -> bool:
|
||||
"""Origin/Referer check — the CSRF guard for /session/restore.
|
||||
|
||||
@@ -28,19 +67,25 @@ def is_same_origin(request: Request) -> bool:
|
||||
Origin (and usually Referer) on form POSTs, same-site or not, so
|
||||
requiring a match — and rejecting when both are absent — blocks a plain
|
||||
auto-submitting HTML form without needing a token.
|
||||
|
||||
Only the *host* is compared, not the scheme: behind a TLS-terminating
|
||||
proxy the browser sends `Origin: https://host` while uvicorn sees
|
||||
`http` (it only trusts X-Forwarded-Proto from `forwarded_allow_ips`,
|
||||
which excludes a proxy in a sibling container), so a scheme comparison
|
||||
rejected every legitimate restore in production. A same-host attacker
|
||||
origin is not a capability the scheme check was buying.
|
||||
"""
|
||||
expected = f"{request.url.scheme}://{request.url.netloc}"
|
||||
from urllib.parse import urlparse
|
||||
|
||||
hosts = _request_hosts(request)
|
||||
|
||||
origin = request.headers.get("origin")
|
||||
if origin is not None:
|
||||
return origin == expected
|
||||
return urlparse(origin).netloc in hosts
|
||||
|
||||
referer = request.headers.get("referer")
|
||||
if referer:
|
||||
from urllib.parse import urlparse
|
||||
|
||||
parsed = urlparse(referer)
|
||||
return f"{parsed.scheme}://{parsed.netloc}" == expected
|
||||
return urlparse(referer).netloc in hosts
|
||||
|
||||
return False
|
||||
|
||||
@@ -49,7 +94,8 @@ class OwnerSessionMiddleware(BaseHTTPMiddleware):
|
||||
"""Resolves request.state.owner from a cookie, creating one on first visit."""
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
if request.url.path == "/health":
|
||||
path = request.url.path
|
||||
if path in _UNSCOPED_PATHS or path.startswith(_UNSCOPED_PREFIXES):
|
||||
return await call_next(request)
|
||||
|
||||
key = request.cookies.get(COOKIE_NAME)
|
||||
@@ -60,6 +106,8 @@ class OwnerSessionMiddleware(BaseHTTPMiddleware):
|
||||
|
||||
request.state.owner = owner
|
||||
request.state.is_new_owner = is_new
|
||||
# Templates warn about it; see cookie_kwargs().
|
||||
request.state.ephemeral_session = not cfg.COOKIE_SECURE
|
||||
|
||||
response = await call_next(request)
|
||||
|
||||
@@ -72,13 +120,6 @@ class OwnerSessionMiddleware(BaseHTTPMiddleware):
|
||||
)
|
||||
|
||||
if is_new and not route_already_set_cookie:
|
||||
response.set_cookie(
|
||||
COOKIE_NAME,
|
||||
owner.key,
|
||||
max_age=COOKIE_MAX_AGE,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
secure=cfg.COOKIE_SECURE,
|
||||
)
|
||||
response.set_cookie(COOKIE_NAME, owner.key, **cookie_kwargs())
|
||||
|
||||
return response
|
||||
|
||||
+1349
-94
File diff suppressed because it is too large
Load Diff
+338
-160
@@ -1,9 +1,11 @@
|
||||
{% import "partials/icons.html" as ico %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="auto">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>ImpTune</title>
|
||||
<title>{% block head_title %}ImpTune{% endblock %}</title>
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%230d6f79'/%3E%3Ctext x='16' y='22' font-family='monospace' font-size='16' font-weight='700' text-anchor='middle' fill='white'%3EiT%3C/text%3E%3C/svg%3E">
|
||||
<link rel="stylesheet" href="/static/pico.min.css">
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
<script>
|
||||
@@ -11,7 +13,11 @@
|
||||
// Theme store: cycles Light -> Dark -> System, persists in localStorage
|
||||
Alpine.store('theme', {
|
||||
current: localStorage.getItem('imptune_theme') || 'auto',
|
||||
icons: { light: '☀', dark: '☾', auto: '◑' },
|
||||
icons: {
|
||||
light: '<svg class="ico" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><circle cx="12" cy="12" r="4"/><path d="M12 3v2M12 19v2M3 12h2M19 12h2M5.6 5.6l1.4 1.4M17 17l1.4 1.4M18.4 5.6L17 7M7 17l-1.4 1.4"/></svg>',
|
||||
dark: '<svg class="ico" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M20 14.5A8.5 8.5 0 0 1 9.5 4a8.5 8.5 0 1 0 10.5 10.5Z"/></svg>',
|
||||
auto: '<svg class="ico" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><circle cx="12" cy="12" r="8"/><path d="M12 4v16a8 8 0 0 0 0-16Z" fill="currentColor" stroke="none"/></svg>'
|
||||
},
|
||||
init() {
|
||||
document.documentElement.setAttribute('data-theme', this.current);
|
||||
},
|
||||
@@ -23,6 +29,10 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Client-side list filter. Lives in a store so rows swapped in by HTMX
|
||||
// (partials/printer_list.html) find it no matter which page included them.
|
||||
Alpine.store('filter', { q: '' });
|
||||
|
||||
// i18n store: FR/EN toggle, persists in localStorage
|
||||
Alpine.store('i18n', {
|
||||
lang: (() => {
|
||||
@@ -46,49 +56,78 @@
|
||||
packages: 'Paquets',
|
||||
add_printer: 'Ajouter une imprimante',
|
||||
add_client: 'Ajouter un client',
|
||||
printer_library: 'Biblioth\u00e8que d\u2019imprimantes',
|
||||
printer_library: 'Bibliothèque d’imprimantes',
|
||||
edit: 'Modifier',
|
||||
delete: 'Supprimer',
|
||||
save: 'Enregistrer',
|
||||
cancel: 'Annuler',
|
||||
upload_driver: 'T\u00e9l\u00e9charger un pilote',
|
||||
printer_name: 'Nom de l\u2019imprimante',
|
||||
upload_driver: 'Importer un pilote',
|
||||
printer_name: 'Nom de l’imprimante',
|
||||
ip_address: 'Adresse IP',
|
||||
port_name: 'Nom du port',
|
||||
driver: 'Pilote',
|
||||
duplex_mode: 'Mode recto-verso',
|
||||
one_sided: 'Recto simple',
|
||||
long_edge: 'Grand c\u00f4t\u00e9',
|
||||
short_edge: 'Petit c\u00f4t\u00e9',
|
||||
color_mode: 'Mode couleur',
|
||||
long_edge: 'Grand côté',
|
||||
short_edge: 'Petit côté',
|
||||
color_mode: 'Couleur',
|
||||
paper_size: 'Format papier',
|
||||
collate: 'Assembler',
|
||||
client: 'Client',
|
||||
edit_printer: 'Modifier l\u2019imprimante',
|
||||
no_printers: 'Aucune imprimante configur\u00e9e.',
|
||||
no_clients: 'Aucun client configur\u00e9.',
|
||||
edit_printer: 'Modifier l’imprimante',
|
||||
no_printers: 'Aucune imprimante configurée.',
|
||||
no_clients: 'Aucun client configuré.',
|
||||
client_list: 'Liste des clients',
|
||||
name: 'Nom',
|
||||
created: 'Cr\u00e9\u00e9 le',
|
||||
created: 'Créé le',
|
||||
back_to_printers: 'Retour aux imprimantes',
|
||||
theme_label: 'Th\u00e8me',
|
||||
theme_label: 'Thème',
|
||||
lang_label: 'FR',
|
||||
// Dashboard page
|
||||
new_printer: 'Nouvelle imprimante',
|
||||
upload_driver_btn: 'T\u00e9l\u00e9charger un pilote',
|
||||
export_package: 'Exporter un paquet',
|
||||
recent_activity: 'Activit\u00e9 r\u00e9cente',
|
||||
recent_printers: 'Imprimantes r\u00e9centes',
|
||||
recent_packages: 'Paquets r\u00e9cents',
|
||||
no_packages: 'Aucun paquet export\u00e9 pour l\u2019instant.',
|
||||
// Printers new page
|
||||
// Shell
|
||||
brand_sub: 'Paquets d’impression',
|
||||
nav_library: 'Bibliothèque',
|
||||
nav_deploy: 'Déploiement',
|
||||
session_menu: 'Cette session',
|
||||
session_download_key: 'Télécharger ma clé de secours',
|
||||
session_restore_key: 'Restaurer depuis une clé',
|
||||
session_note: 'Vos imprimantes sont liées à ce navigateur. La clé de secours les récupère ailleurs.',
|
||||
ephemeral_warning: 'Cette session ne dure que tant que ce navigateur reste ouvert : vos imprimantes et réglages sont bien enregistrés, mais le lien vers eux est perdu à la fermeture. Téléchargez votre clé de secours pour les conserver.',
|
||||
open_menu: 'Ouvrir le menu',
|
||||
// Dashboard
|
||||
dashboard_intro: 'Trois étapes du fichier ZIP au paquet déployable.',
|
||||
step_driver_title: 'Importer le pilote',
|
||||
step_driver_desc: 'Un ZIP contenant le .inf et ses fichiers. ImpTune lit le .inf et détecte les modèles.',
|
||||
step_printer_title: 'Configurer l’imprimante',
|
||||
step_printer_desc: 'Nom, adresse IP, pilote et réglages par défaut du poste.',
|
||||
step_package_title: 'Exporter le paquet',
|
||||
step_package_desc: '.intunewin pour Intune, ZIP pour NinjaRMM. Scripts inclus.',
|
||||
step_done: 'Fait',
|
||||
go_upload_driver: 'Importer un pilote',
|
||||
go_add_printer: 'Ajouter une imprimante',
|
||||
go_export: 'Voir les paquets',
|
||||
view_all: 'Tout voir',
|
||||
recent_activity: 'Activité récente',
|
||||
recent_printers: 'Imprimantes récentes',
|
||||
recent_packages: 'Prêtes à exporter',
|
||||
no_packages: 'Aucune imprimante prête à exporter.',
|
||||
ready_to_export: 'Prête',
|
||||
needs_driver: 'Pilote manquant',
|
||||
// Printers new
|
||||
add_printer_title: 'Ajouter une imprimante',
|
||||
back_to_printer_library: '\u2190 Retour \u00e0 la biblioth\u00e8que',
|
||||
save_printer: 'Enregistrer l\u2019imprimante',
|
||||
upload_new_driver: 'T\u00e9l\u00e9charger un nouveau pilote',
|
||||
no_driver_option: '-- Aucun pilote --',
|
||||
unassigned_option: '-- Non assign\u00e9 --',
|
||||
// Printer list table headers
|
||||
back_to_printer_library: 'Imprimantes',
|
||||
save_printer: 'Enregistrer l’imprimante',
|
||||
upload_new_driver: 'Importer un nouveau pilote',
|
||||
no_driver_option: 'Aucun pilote',
|
||||
unassigned_option: 'Non assigné',
|
||||
section_identity: 'Identification',
|
||||
section_connection: 'Connexion réseau',
|
||||
section_driver: 'Pilote',
|
||||
section_defaults: 'Réglages par défaut',
|
||||
hint_port: 'Dérivé automatiquement de l’adresse IP. Modifiable.',
|
||||
hint_name: 'Le nom vu par l’utilisateur sur son poste.',
|
||||
hint_driver: 'Sans pilote, l’imprimante est enregistrée mais l’export reste bloqué.',
|
||||
hint_ip: 'Adresse IPv4 de l’imprimante sur le réseau du client.',
|
||||
// Printer list
|
||||
th_name: 'Nom',
|
||||
th_ip: 'Adresse IP',
|
||||
th_port: 'Port',
|
||||
@@ -98,37 +137,58 @@
|
||||
th_paper: 'Format',
|
||||
th_collate: 'Assemblage',
|
||||
th_actions: 'Actions',
|
||||
th_status: 'État',
|
||||
th_config: 'Réglages',
|
||||
th_export: 'Export',
|
||||
th_printers: 'Imprimantes',
|
||||
th_used_by: 'Utilisé par',
|
||||
th_size: 'Taille',
|
||||
yes: 'Oui',
|
||||
no: 'Non',
|
||||
no_driver_assigned: '\u2014',
|
||||
// Clients page
|
||||
no_driver_assigned: '—',
|
||||
filter_printers: 'Filtrer par nom, IP ou client',
|
||||
no_match: 'Aucune imprimante ne correspond au filtre.',
|
||||
printers_intro: 'Une imprimante prête a un pilote assigné : elle peut être exportée.',
|
||||
// Clients
|
||||
client_name_label: 'Nom du client',
|
||||
add_client_section: 'Ajouter un client',
|
||||
client_list_section: 'Liste des clients',
|
||||
// Client detail page
|
||||
back_to_clients: '\u2190 Tous les clients',
|
||||
clients_intro: 'Les clients regroupent les imprimantes par site ou par organisation.',
|
||||
open_client: 'Ouvrir',
|
||||
// Client detail
|
||||
back_to_clients: 'Clients',
|
||||
printers_section: 'Imprimantes',
|
||||
// Drivers page
|
||||
// Drivers
|
||||
drivers_title: 'Pilotes',
|
||||
upload_driver_section: 'T\u00e9l\u00e9charger un package de pilote',
|
||||
driver_library: 'Biblioth\u00e8que de pilotes',
|
||||
uploading: 'T\u00e9l\u00e9chargement...',
|
||||
upload_btn: 'T\u00e9l\u00e9charger',
|
||||
driver_filename: 'Nom du fichier',
|
||||
upload_driver_section: 'Importer un package de pilote',
|
||||
driver_library: 'Bibliothèque de pilotes',
|
||||
uploading: 'Import en cours…',
|
||||
upload_btn: 'Importer',
|
||||
driver_filename: 'Fichier',
|
||||
driver_names_col: 'Nom(s) du pilote',
|
||||
architecture: 'Architecture',
|
||||
uploaded_at: 'T\u00e9l\u00e9charg\u00e9 le',
|
||||
uploaded_at: 'Importé le',
|
||||
unknown: 'Inconnu',
|
||||
no_drivers: 'Aucun pilote t\u00e9l\u00e9charg\u00e9.',
|
||||
// Packages page
|
||||
no_drivers: 'Aucun pilote importé.',
|
||||
drivers_intro: 'Les pilotes sont partagés : tout le monde voit la même bibliothèque.',
|
||||
driver_package_label: 'Package de pilote (ZIP)',
|
||||
hint_driver_zip: 'Le ZIP doit contenir le fichier .inf et tous les fichiers auxquels il renvoie.',
|
||||
unused_files: 'fichier(s) du ZIP ne sont pas référencés par le .inf.',
|
||||
show_unused: 'Voir ces fichiers',
|
||||
// Packages
|
||||
packages_title: 'Paquets',
|
||||
packages_description: 'Imprimantes avec pilotes assign\u00e9s \u2014 pr\u00eates pour l\u2019export.',
|
||||
packages_description: 'Imprimantes avec un pilote assigné — prêtes pour l’export.',
|
||||
printer_col: 'Imprimante',
|
||||
client_col: 'Client',
|
||||
driver_col: 'Pilote',
|
||||
downloads_col: 'T\u00e9l\u00e9chargements',
|
||||
no_packages_ready: 'Aucune imprimante pr\u00eate. Assignez un pilote pour activer l\u2019export.',
|
||||
// Printer detail page
|
||||
downloads_col: 'Téléchargements',
|
||||
no_packages_ready: 'Aucune imprimante prête. Assignez un pilote pour activer l’export.',
|
||||
format_intune: 'Intune',
|
||||
format_ninja: 'NinjaRMM',
|
||||
hint_intunewin: '.intunewin — à téléverser dans Intune comme application Win32.',
|
||||
hint_ninja: 'ZIP — scripts en clair pour NinjaRMM ou exécution manuelle.',
|
||||
pending_printers: 'imprimante(s) attendent encore un pilote',
|
||||
// Printer detail
|
||||
configuration: 'Configuration',
|
||||
duplex_mode_label: 'Mode recto-verso',
|
||||
color_mode_label: 'Mode couleur',
|
||||
@@ -137,32 +197,41 @@
|
||||
paper_size_label: 'Format papier',
|
||||
collate_label: 'Assemblage',
|
||||
client_label: 'Client',
|
||||
unassigned: 'Non assign\u00e9',
|
||||
unassigned: 'Non assigné',
|
||||
driver_section: 'Pilote',
|
||||
package_label: 'Package',
|
||||
driver_names_label: 'Nom(s) du pilote',
|
||||
architecture_label: 'Architecture',
|
||||
no_driver_detail: 'Aucun pilote assign\u00e9',
|
||||
no_driver_detail: 'Aucun pilote assigné.',
|
||||
assign_driver_cta: 'Assigner un pilote',
|
||||
intune_commands: 'Commandes Intune',
|
||||
install_cmd_label: 'Commande d\u2019installation',
|
||||
uninstall_cmd_label: 'Commande de d\u00e9sinstallation',
|
||||
hint_intune_cmds: 'À coller dans les champs d’installation et de désinstallation de l’application Win32.',
|
||||
install_cmd_label: 'Commande d’installation',
|
||||
uninstall_cmd_label: 'Commande de désinstallation',
|
||||
copy: 'Copier',
|
||||
copied: 'Copi\u00e9\u00a0!',
|
||||
scripts_section: 'Scripts',
|
||||
download_install: 'T\u00e9l\u00e9charger le script d\u2019installation',
|
||||
download_uninstall: 'T\u00e9l\u00e9charger le script de d\u00e9sinstallation',
|
||||
download_detect: 'T\u00e9l\u00e9charger le script de d\u00e9tection',
|
||||
export_section: 'Export',
|
||||
download_ninja: 'T\u00e9l\u00e9charger NinjaRMM ZIP',
|
||||
download_intunewin: 'T\u00e9l\u00e9charger .intunewin',
|
||||
icon_section: 'Ic\u00f4ne',
|
||||
icon_uploaded: 'Ic\u00f4ne t\u00e9l\u00e9charg\u00e9e',
|
||||
upload_icon: 'T\u00e9l\u00e9charger l\u2019ic\u00f4ne',
|
||||
copied: 'Copié',
|
||||
scripts_section: 'Scripts PowerShell',
|
||||
download_install: 'install.ps1',
|
||||
download_uninstall: 'uninstall.ps1',
|
||||
download_detect: 'detect.ps1',
|
||||
export_section: 'Déployer',
|
||||
download_ninja: 'Télécharger le ZIP NinjaRMM',
|
||||
download_intunewin: 'Télécharger le .intunewin',
|
||||
icon_section: 'Icône',
|
||||
icon_uploaded: 'Icône enregistrée',
|
||||
upload_icon: 'Téléverser l’icône',
|
||||
replace_icon: 'Remplacer l’icône',
|
||||
no_icon: 'Aucune icône. Intune affichera l’icône par défaut.',
|
||||
hint_icon: 'PNG, exactement 256 × 256, 750 Ko maximum.',
|
||||
back_to_printers_btn: 'Retour aux imprimantes',
|
||||
export_locked: 'Assignez un pilote à cette imprimante pour débloquer les scripts et l’export.',
|
||||
// Edit modal
|
||||
edit_printer_title: 'Modifier l\u2019imprimante',
|
||||
// Driver upload section
|
||||
driver_package_label: 'Package de pilote (ZIP contenant .inf + fichiers pilote)'
|
||||
edit_printer_title: 'Modifier l’imprimante',
|
||||
// Session restore
|
||||
restore_title: 'Restaurer une clé de secours',
|
||||
restore_desc: 'Collez la clé du fichier imptune-backup-key.txt pour retrouver vos imprimantes, réglages et clients dans ce navigateur.',
|
||||
backup_key_label: 'Clé de secours',
|
||||
restore_btn: 'Restaurer'
|
||||
},
|
||||
en: {
|
||||
dashboard: 'Dashboard',
|
||||
@@ -170,27 +239,27 @@
|
||||
printers: 'Printers',
|
||||
clients: 'Clients',
|
||||
packages: 'Packages',
|
||||
add_printer: 'Add Printer',
|
||||
add_client: 'Add Client',
|
||||
add_printer: 'Add printer',
|
||||
add_client: 'Add client',
|
||||
printer_library: 'Printer Library',
|
||||
edit: 'Edit',
|
||||
delete: 'Delete',
|
||||
save: 'Save',
|
||||
cancel: 'Cancel',
|
||||
upload_driver: 'Upload Driver',
|
||||
printer_name: 'Printer Name',
|
||||
ip_address: 'IP Address',
|
||||
port_name: 'Port Name',
|
||||
upload_driver: 'Add driver',
|
||||
printer_name: 'Printer name',
|
||||
ip_address: 'IP address',
|
||||
port_name: 'Port name',
|
||||
driver: 'Driver',
|
||||
duplex_mode: 'Duplex Mode',
|
||||
one_sided: 'One-Sided',
|
||||
long_edge: 'Long Edge',
|
||||
short_edge: 'Short Edge',
|
||||
color_mode: 'Color Mode',
|
||||
paper_size: 'Paper Size',
|
||||
duplex_mode: 'Duplex mode',
|
||||
one_sided: 'One-sided',
|
||||
long_edge: 'Long edge',
|
||||
short_edge: 'Short edge',
|
||||
color_mode: 'Color',
|
||||
paper_size: 'Paper size',
|
||||
collate: 'Collate',
|
||||
client: 'Client',
|
||||
edit_printer: 'Edit Printer',
|
||||
edit_printer: 'Edit printer',
|
||||
no_printers: 'No printers configured yet.',
|
||||
no_clients: 'No clients configured yet.',
|
||||
client_list: 'Client List',
|
||||
@@ -199,24 +268,53 @@
|
||||
back_to_printers: 'Back to Printers',
|
||||
theme_label: 'Theme',
|
||||
lang_label: 'EN',
|
||||
// Dashboard page
|
||||
new_printer: 'New Printer',
|
||||
upload_driver_btn: 'Upload Driver',
|
||||
export_package: 'Export Package',
|
||||
recent_activity: 'Recent Activity',
|
||||
recent_printers: 'Recent Printers',
|
||||
recent_packages: 'Recent Packages',
|
||||
no_packages: 'No packages exported yet.',
|
||||
// Printers new page
|
||||
add_printer_title: 'Add Printer',
|
||||
back_to_printer_library: '\u2190 Back to Printer Library',
|
||||
save_printer: 'Save Printer',
|
||||
upload_new_driver: 'Upload New Driver',
|
||||
no_driver_option: '-- No driver --',
|
||||
unassigned_option: '-- Unassigned --',
|
||||
// Printer list table headers
|
||||
// Shell
|
||||
brand_sub: 'Print packaging',
|
||||
nav_library: 'Library',
|
||||
nav_deploy: 'Deploy',
|
||||
session_menu: 'This session',
|
||||
session_download_key: 'Download my backup key',
|
||||
session_restore_key: 'Restore from a key',
|
||||
session_note: 'Your printers live in this browser. The backup key brings them back elsewhere.',
|
||||
ephemeral_warning: 'This session lasts only while this browser stays open — your printers and configs are saved, but the link to them is lost when you close it. Download your backup key to keep them.',
|
||||
open_menu: 'Open menu',
|
||||
// Dashboard
|
||||
dashboard_intro: 'Three steps from driver ZIP to deployable package.',
|
||||
step_driver_title: 'Add the driver',
|
||||
step_driver_desc: 'A ZIP holding the .inf and its files. ImpTune reads the .inf and finds the models.',
|
||||
step_printer_title: 'Configure the printer',
|
||||
step_printer_desc: 'Name, IP address, driver, and the defaults the workstation gets.',
|
||||
step_package_title: 'Export the package',
|
||||
step_package_desc: '.intunewin for Intune, ZIP for NinjaRMM. Scripts included.',
|
||||
step_done: 'Done',
|
||||
go_upload_driver: 'Add a driver',
|
||||
go_add_printer: 'Add a printer',
|
||||
go_export: 'View packages',
|
||||
view_all: 'View all',
|
||||
recent_activity: 'Recent activity',
|
||||
recent_printers: 'Recent printers',
|
||||
recent_packages: 'Ready to export',
|
||||
no_packages: 'No printers are ready to export yet.',
|
||||
ready_to_export: 'Ready',
|
||||
needs_driver: 'Driver needed',
|
||||
// Printers new
|
||||
add_printer_title: 'Add printer',
|
||||
back_to_printer_library: 'Printers',
|
||||
save_printer: 'Save printer',
|
||||
upload_new_driver: 'Add a new driver',
|
||||
no_driver_option: 'No driver',
|
||||
unassigned_option: 'Unassigned',
|
||||
section_identity: 'Identity',
|
||||
section_connection: 'Network connection',
|
||||
section_driver: 'Driver',
|
||||
section_defaults: 'Print defaults',
|
||||
hint_port: 'Derived from the IP address. Edit it if your naming differs.',
|
||||
hint_name: 'The name the user sees on their workstation.',
|
||||
hint_driver: 'Without a driver the printer is saved, but export stays locked.',
|
||||
hint_ip: 'The printer IPv4 address on the client network.',
|
||||
// Printer list
|
||||
th_name: 'Name',
|
||||
th_ip: 'IP Address',
|
||||
th_ip: 'IP address',
|
||||
th_port: 'Port',
|
||||
th_driver: 'Driver',
|
||||
th_duplex: 'Duplex',
|
||||
@@ -224,71 +322,101 @@
|
||||
th_paper: 'Paper',
|
||||
th_collate: 'Collate',
|
||||
th_actions: 'Actions',
|
||||
th_status: 'Status',
|
||||
th_config: 'Defaults',
|
||||
th_export: 'Export',
|
||||
th_printers: 'Printers',
|
||||
th_used_by: 'Used by',
|
||||
th_size: 'Size',
|
||||
yes: 'Yes',
|
||||
no: 'No',
|
||||
no_driver_assigned: '\u2014',
|
||||
// Clients page
|
||||
client_name_label: 'Client Name',
|
||||
add_client_section: 'Add Client',
|
||||
client_list_section: 'Client List',
|
||||
// Client detail page
|
||||
back_to_clients: '\u2190 All Clients',
|
||||
no_driver_assigned: '—',
|
||||
filter_printers: 'Filter by name, IP, or client',
|
||||
no_match: 'No printer matches this filter.',
|
||||
printers_intro: 'A printer with a driver assigned is ready to export.',
|
||||
// Clients
|
||||
client_name_label: 'Client name',
|
||||
add_client_section: 'Add client',
|
||||
client_list_section: 'Client list',
|
||||
clients_intro: 'Clients group printers by site or organization.',
|
||||
open_client: 'Open',
|
||||
// Client detail
|
||||
back_to_clients: 'Clients',
|
||||
printers_section: 'Printers',
|
||||
// Drivers page
|
||||
// Drivers
|
||||
drivers_title: 'Drivers',
|
||||
upload_driver_section: 'Upload Driver Package',
|
||||
driver_library: 'Driver Library',
|
||||
uploading: 'Uploading...',
|
||||
upload_driver_section: 'Add a driver package',
|
||||
driver_library: 'Driver library',
|
||||
uploading: 'Uploading…',
|
||||
upload_btn: 'Upload',
|
||||
driver_filename: 'Filename',
|
||||
driver_names_col: 'Driver Name(s)',
|
||||
driver_filename: 'File',
|
||||
driver_names_col: 'Driver name(s)',
|
||||
architecture: 'Architecture',
|
||||
uploaded_at: 'Uploaded',
|
||||
uploaded_at: 'Added',
|
||||
unknown: 'Unknown',
|
||||
no_drivers: 'No drivers uploaded yet.',
|
||||
// Packages page
|
||||
drivers_intro: 'Drivers are shared — everyone sees the same library.',
|
||||
driver_package_label: 'Driver package (ZIP)',
|
||||
hint_driver_zip: 'The ZIP must contain the .inf file and every file it references.',
|
||||
unused_files: 'file(s) in the ZIP are unused — the .inf never references them.',
|
||||
show_unused: 'Show these files',
|
||||
// Packages
|
||||
packages_title: 'Packages',
|
||||
packages_description: 'Printers with drivers assigned \u2014 ready for deployment package export.',
|
||||
packages_description: 'Printers with a driver assigned — ready for export.',
|
||||
printer_col: 'Printer',
|
||||
client_col: 'Client',
|
||||
driver_col: 'Driver',
|
||||
downloads_col: 'Downloads',
|
||||
no_packages_ready: 'No package-ready printers yet. Assign a driver to a printer to enable package export.',
|
||||
// Printer detail page
|
||||
no_packages_ready: 'No printer is ready yet. Assign a driver to enable export.',
|
||||
format_intune: 'Intune',
|
||||
format_ninja: 'NinjaRMM',
|
||||
hint_intunewin: '.intunewin — upload to Intune as a Win32 app.',
|
||||
hint_ninja: 'ZIP — plain scripts for NinjaRMM or a manual run.',
|
||||
pending_printers: 'printer(s) still need a driver',
|
||||
// Printer detail
|
||||
configuration: 'Configuration',
|
||||
duplex_mode_label: 'Duplex Mode',
|
||||
color_mode_label: 'Color Mode',
|
||||
duplex_mode_label: 'Duplex mode',
|
||||
color_mode_label: 'Color mode',
|
||||
color_value: 'Color',
|
||||
grayscale_value: 'Grayscale',
|
||||
paper_size_label: 'Paper Size',
|
||||
paper_size_label: 'Paper size',
|
||||
collate_label: 'Collate',
|
||||
client_label: 'Client',
|
||||
unassigned: 'Unassigned',
|
||||
driver_section: 'Driver',
|
||||
package_label: 'Package',
|
||||
driver_names_label: 'Driver Name(s)',
|
||||
driver_names_label: 'Driver name(s)',
|
||||
architecture_label: 'Architecture',
|
||||
no_driver_detail: 'No driver assigned',
|
||||
intune_commands: 'Intune Commands',
|
||||
no_driver_detail: 'No driver assigned.',
|
||||
assign_driver_cta: 'Assign a driver',
|
||||
intune_commands: 'Intune commands',
|
||||
hint_intune_cmds: 'Paste these into the Win32 app install and uninstall fields.',
|
||||
install_cmd_label: 'Install command',
|
||||
uninstall_cmd_label: 'Uninstall command',
|
||||
copy: 'Copy',
|
||||
copied: 'Copied!',
|
||||
scripts_section: 'Scripts',
|
||||
download_install: 'Download Install Script',
|
||||
download_uninstall: 'Download Uninstall Script',
|
||||
download_detect: 'Download Detect Script',
|
||||
export_section: 'Export',
|
||||
copied: 'Copied',
|
||||
scripts_section: 'PowerShell scripts',
|
||||
download_install: 'install.ps1',
|
||||
download_uninstall: 'uninstall.ps1',
|
||||
download_detect: 'detect.ps1',
|
||||
export_section: 'Deploy',
|
||||
download_ninja: 'Download NinjaRMM ZIP',
|
||||
download_intunewin: 'Download .intunewin',
|
||||
icon_section: 'Icon',
|
||||
icon_uploaded: 'Icon uploaded',
|
||||
upload_icon: 'Upload Icon',
|
||||
back_to_printers_btn: 'Back to Printers',
|
||||
icon_uploaded: 'Icon saved',
|
||||
upload_icon: 'Upload icon',
|
||||
replace_icon: 'Replace icon',
|
||||
no_icon: 'No icon. Intune will show its default.',
|
||||
hint_icon: 'PNG, exactly 256 × 256, 750 KB max.',
|
||||
back_to_printers_btn: 'Back to printers',
|
||||
export_locked: 'Assign a driver to this printer to unlock scripts and export.',
|
||||
// Edit modal
|
||||
edit_printer_title: 'Edit Printer',
|
||||
// Driver upload section
|
||||
driver_package_label: 'Driver Package (ZIP containing .inf + driver files)'
|
||||
edit_printer_title: 'Edit printer',
|
||||
// Session restore
|
||||
restore_title: 'Restore a backup key',
|
||||
restore_desc: 'Paste the key from imptune-backup-key.txt to get your printers, settings, and clients back in this browser.',
|
||||
backup_key_label: 'Backup key',
|
||||
restore_btn: 'Restore'
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -297,57 +425,107 @@
|
||||
<script defer src="/static/alpine.min.js"></script>
|
||||
<script src="/static/htmx.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<body x-data="{ nav: false }" :class="nav && 'nav-open'">
|
||||
<div class="layout">
|
||||
<nav class="sidebar">
|
||||
<div class="sidebar-brand">
|
||||
<strong>ImpTune</strong>
|
||||
<div class="nav-scrim" x-show="nav" @click="nav = false" style="display:none"></div>
|
||||
<nav class="sidebar" @click="nav = false">
|
||||
<a class="brand" href="/">
|
||||
<span class="brand-mark">iT</span>
|
||||
<span>
|
||||
<span class="brand-name">ImpTune</span>
|
||||
<span class="brand-sub" x-data x-text="$store.i18n.t('brand_sub')">Print packaging</span>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<div class="nav-group">
|
||||
<ul class="sidebar-nav">
|
||||
<li><a href="/" {% if request.url.path == "/" %}class="active"{% endif %}>{{ ico.i('gauge') }}<span x-data x-text="$store.i18n.t('dashboard')">Dashboard</span></a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="nav-group">
|
||||
<div class="nav-group-label" x-data x-text="$store.i18n.t('nav_library')">Library</div>
|
||||
<ul class="sidebar-nav">
|
||||
<li><a href="/drivers" {% if request.url.path == "/drivers" %}class="active"{% endif %}>{{ ico.i('driver') }}<span x-data x-text="$store.i18n.t('drivers')">Drivers</span></a></li>
|
||||
<li><a href="/printers" {% if request.url.path.startswith("/printers") %}class="active"{% endif %}>{{ ico.i('printer') }}<span x-data x-text="$store.i18n.t('printers')">Printers</span></a></li>
|
||||
<li><a href="/clients" {% if request.url.path.startswith("/clients") %}class="active"{% endif %}>{{ ico.i('group') }}<span x-data x-text="$store.i18n.t('clients')">Clients</span></a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="nav-group">
|
||||
<div class="nav-group-label" x-data x-text="$store.i18n.t('nav_deploy')">Deploy</div>
|
||||
<ul class="sidebar-nav">
|
||||
<li><a href="/packages" {% if request.url.path == "/packages" %}class="active"{% endif %}>{{ ico.i('package') }}<span x-data x-text="$store.i18n.t('packages')">Packages</span></a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-foot">
|
||||
<details {% if request.url.path == "/session/restore" %}open{% endif %}>
|
||||
<summary>{{ ico.i('key') }}<span x-data x-text="$store.i18n.t('session_menu')">This session</span></summary>
|
||||
<div class="foot-links">
|
||||
<a href="/session/key/download" x-data x-text="$store.i18n.t('session_download_key')">Download my backup key</a>
|
||||
<a href="/session/restore" x-data x-text="$store.i18n.t('session_restore_key')">Restore from a key</a>
|
||||
<p class="foot-note" x-data x-text="$store.i18n.t('session_note')">Your printers live in this browser.</p>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<ul class="sidebar-nav">
|
||||
<li><a href="/" {% if request.url.path == "/" %}class="active"{% endif %}
|
||||
x-data x-text="$store.i18n.t('dashboard')">Dashboard</a></li>
|
||||
<li><a href="/drivers" {% if request.url.path == "/drivers" %}class="active"{% endif %}
|
||||
x-data x-text="$store.i18n.t('drivers')">Drivers</a></li>
|
||||
<li><a href="/printers" {% if request.url.path == "/printers" %}class="active"{% endif %}
|
||||
x-data x-text="$store.i18n.t('printers')">Printers</a></li>
|
||||
<li><a href="/clients" {% if request.url.path == "/clients" %}class="active"{% endif %}
|
||||
x-data x-text="$store.i18n.t('clients')">Clients</a></li>
|
||||
<li><a href="/packages" {% if request.url.path == "/packages" %}class="active"{% endif %}
|
||||
x-data x-text="$store.i18n.t('packages')">Packages</a></li>
|
||||
</ul>
|
||||
<ul class="sidebar-nav sidebar-nav-footer">
|
||||
<li><a href="/session/key/download">Download backup key</a></li>
|
||||
<li><a href="/session/restore" {% if request.url.path == "/session/restore" %}class="active"{% endif %}>Restore from backup key</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<div class="main-wrapper">
|
||||
<header class="topbar">
|
||||
<div class="topbar-controls" x-data>
|
||||
<button class="icon-btn sidebar-toggle" @click="nav = !nav"
|
||||
x-data :aria-label="$store.i18n.t('open_menu')">{{ ico.i('menu', 18) }}</button>
|
||||
<div class="topbar-title">
|
||||
{% block crumb %}{% endblock %}
|
||||
<h1>{% block page_title %}ImpTune{% endblock %}</h1>
|
||||
</div>
|
||||
<div class="topbar-actions" x-data>
|
||||
{% block page_actions %}{% endblock %}
|
||||
<span class="topbar-sep"></span>
|
||||
<!-- Theme toggle button: cycles Light -> Dark -> System -->
|
||||
<button class="secondary outline"
|
||||
<button class="icon-btn"
|
||||
x-html="$store.theme.icons[$store.theme.current]"
|
||||
:aria-label="$store.theme.current"
|
||||
@click="$store.theme.cycle()"
|
||||
title="Toggle theme">◑</button>
|
||||
<!-- Language toggle button -->
|
||||
<button class="secondary outline"
|
||||
<button class="icon-btn"
|
||||
x-text="$store.i18n.t('lang_label')"
|
||||
@click="$store.i18n.toggle()"
|
||||
title="Toggle language">FR</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="main-content">
|
||||
{% if request.state.ephemeral_session %}
|
||||
{# COOKIE_SECURE=false: the owner key is memory-only, so the session
|
||||
dies with the browser window. Say it before work is lost. #}
|
||||
<div class="notice session-ephemeral" id="ephemeral-session-warning">
|
||||
{{ ico.i('key', 16) }}
|
||||
<p x-data x-text="$store.i18n.t('ephemeral_warning')">This session lasts only while this browser stays open — your printers and configs are saved, but the link to them is lost when you close it. Download your backup key to keep them.</p>
|
||||
<a class="btn ghost" href="/session/key/download" x-data x-text="$store.i18n.t('session_download_key')">Download my backup key</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if request.state.is_new_owner %}
|
||||
<dialog open id="session-choice-modal">
|
||||
<article>
|
||||
<h3>Keep your printers?</h3>
|
||||
<p>Your printers, configs, and groups are private to this browser
|
||||
(drivers stay shared with everyone). If you clear your cookies
|
||||
without a backup key, they're gone for good.</p>
|
||||
<footer>
|
||||
<a role="button" href="/session/key/download">Store permanently (download backup key)</a>
|
||||
<button class="secondary" onclick="this.closest('dialog').close()">Keep temporary, this browser only</button>
|
||||
</footer>
|
||||
<div class="dialog-head">
|
||||
<h3>Keep your printers?</h3>
|
||||
</div>
|
||||
<div class="dialog-body">
|
||||
<p>Your printers, configs, and clients are private to this browser —
|
||||
drivers stay shared with everyone. Clear your cookies without a backup
|
||||
key and they are gone for good.</p>
|
||||
{% if request.state.ephemeral_session %}
|
||||
<p><strong>This server runs without HTTPS</strong>, so this session is
|
||||
kept in memory only: closing the browser ends it. The backup key is the
|
||||
only way back in.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="dialog-foot">
|
||||
<button class="btn ghost" onclick="this.closest('dialog').close()">Keep in this browser</button>
|
||||
<a role="button" class="btn" href="/session/key/download">Download backup key</a>
|
||||
</div>
|
||||
</article>
|
||||
</dialog>
|
||||
{% endif %}
|
||||
|
||||
@@ -1,11 +1,29 @@
|
||||
{% extends "base.html" %}
|
||||
{% import "partials/icons.html" as ico %}
|
||||
|
||||
{% block head_title %}{{ client.name }} · ImpTune{% endblock %}
|
||||
{% block crumb %}
|
||||
<span class="crumb" x-data>
|
||||
<a href="/clients">{{ ico.i('back', 12) }}<span x-text="$store.i18n.t('back_to_clients')">Clients</span></a>
|
||||
</span>
|
||||
{% endblock %}
|
||||
{% block page_title %}{{ client.name }}{% endblock %}
|
||||
|
||||
{% block page_actions %}
|
||||
<a href="/printers/new" class="btn sm">{{ ico.i('plus') }}<span x-text="$store.i18n.t('add_printer')">Add printer</span></a>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>{{ client.name }}</h1>
|
||||
<p><a href="/clients" x-data x-text="$store.i18n.t('back_to_clients')">← All Clients</a></p>
|
||||
<div class="toolbar">
|
||||
<h2 class="eyebrow" x-data x-text="$store.i18n.t('printers_section')">Printers</h2>
|
||||
<span class="spacer"></span>
|
||||
{% if printer_count %}
|
||||
<span class="badge ok">{{ ico.i('check', 12) }}{{ ready_count }} <span x-data x-text="$store.i18n.t('ready_to_export')">Ready</span></span>
|
||||
{% if printer_count - ready_count %}
|
||||
<span class="badge warn">{{ ico.i('alert', 12) }}{{ printer_count - ready_count }} <span x-data x-text="$store.i18n.t('needs_driver')">Driver needed</span></span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<h2 x-data x-text="$store.i18n.t('printers_section')">Printers</h2>
|
||||
{% include "partials/printer_list.html" %}
|
||||
</section>
|
||||
{% include "partials/printer_list.html" %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,21 +1,36 @@
|
||||
{% extends "base.html" %}
|
||||
{% import "partials/icons.html" as ico %}
|
||||
|
||||
{% block head_title %}Clients · ImpTune{% endblock %}
|
||||
{% block page_title %}<span x-data x-text="$store.i18n.t('clients')">Clients</span>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1 x-data x-text="$store.i18n.t('clients')">Clients</h1>
|
||||
<p class="page-intro" x-data x-text="$store.i18n.t('clients_intro')">Clients group printers by site or organization.</p>
|
||||
|
||||
<section>
|
||||
<h2 x-data x-text="$store.i18n.t('add_client_section')">Add Client</h2>
|
||||
<form hx-post="/clients" hx-target="#client-list" hx-swap="outerHTML">
|
||||
<label>
|
||||
<span x-data x-text="$store.i18n.t('client_name_label')">Client Name</span>
|
||||
<input type="text" name="name" placeholder="e.g. Contoso" required>
|
||||
</label>
|
||||
<button type="submit" x-data x-text="$store.i18n.t('add_client')">Add Client</button>
|
||||
</form>
|
||||
</section>
|
||||
<div class="split">
|
||||
<div>
|
||||
{% include "partials/client_list.html" %}
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<h2 x-data x-text="$store.i18n.t('client_list_section')">Client List</h2>
|
||||
{% include "partials/client_list.html" %}
|
||||
</section>
|
||||
<section class="card">
|
||||
<div class="card-head">
|
||||
{{ ico.i('plus') }}
|
||||
<h2 x-data x-text="$store.i18n.t('add_client_section')">Add client</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form hx-post="/clients" hx-target="#client-list" hx-swap="outerHTML"
|
||||
hx-on::after-request="if (event.detail.successful) this.reset()">
|
||||
<label>
|
||||
<span class="label-text" x-data x-text="$store.i18n.t('client_name_label')">Client name</span>
|
||||
<input type="text" name="name" placeholder="Contoso" required>
|
||||
</label>
|
||||
<div class="btn-row" style="margin-top:.8rem">
|
||||
<button type="submit" class="btn" x-data>
|
||||
{{ ico.i('plus', 14) }}<span x-text="$store.i18n.t('add_client')">Add client</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,41 +1,147 @@
|
||||
{% extends "base.html" %}
|
||||
{% import "partials/icons.html" as ico %}
|
||||
|
||||
{% block head_title %}ImpTune{% endblock %}
|
||||
{% block page_title %}<span x-data x-text="$store.i18n.t('dashboard')">Dashboard</span>{% endblock %}
|
||||
|
||||
{% block page_actions %}
|
||||
<a href="/drivers" class="btn ghost sm">{{ ico.i('upload') }}<span x-text="$store.i18n.t('upload_driver')">Add driver</span></a>
|
||||
<a href="/printers/new" class="btn sm">{{ ico.i('plus') }}<span x-text="$store.i18n.t('add_printer')">Add printer</span></a>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1 x-data x-text="$store.i18n.t('dashboard')">Dashboard</h1>
|
||||
<p class="page-intro" x-data x-text="$store.i18n.t('dashboard_intro')">Three steps from driver ZIP to deployable package.</p>
|
||||
|
||||
<div class="quick-actions">
|
||||
<a href="/printers" class="btn-action" x-data x-text="$store.i18n.t('new_printer')">New Printer</a>
|
||||
<a href="/drivers" class="btn-action" x-data x-text="$store.i18n.t('upload_driver_btn')">Upload Driver</a>
|
||||
<a href="/packages" class="btn-action" x-data x-text="$store.i18n.t('export_package')">Export Package</a>
|
||||
{#
|
||||
The rail is the spine of the tool: a printer cannot be exported before it has
|
||||
a driver, so the order is real and the state of each stage is worth showing.
|
||||
#}
|
||||
{% set s1 = 'done' if driver_count else 'next' %}
|
||||
{% set s2 = 'done' if printer_count else ('next' if driver_count else '') %}
|
||||
{% set s3 = 'done' if ready_count else ('next' if printer_count else '') %}
|
||||
<div class="rail">
|
||||
<a class="rail-step {{ s1 }}" href="/drivers">
|
||||
<div class="rail-top">
|
||||
<span class="rail-num">{% if s1 == 'done' %}{{ ico.i('check', 13) }}{% else %}1{% endif %}</span>
|
||||
<span class="rail-title" x-data x-text="$store.i18n.t('step_driver_title')">Add the driver</span>
|
||||
<span class="rail-count">{{ driver_count }}</span>
|
||||
</div>
|
||||
<p class="rail-desc" x-data x-text="$store.i18n.t('step_driver_desc')">A ZIP holding the .inf and its files.</p>
|
||||
<span class="rail-cta" x-data>
|
||||
{% if s1 == 'done' %}<span x-text="$store.i18n.t('drivers')">Drivers</span>{{ ico.i('chevron', 13) }}
|
||||
{% else %}<span x-text="$store.i18n.t('go_upload_driver')">Add a driver</span>{{ ico.i('chevron', 13) }}{% endif %}
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<a class="rail-step {{ s2 }}" href="{% if printer_count %}/printers{% else %}/printers/new{% endif %}">
|
||||
<div class="rail-top">
|
||||
<span class="rail-num">{% if s2 == 'done' %}{{ ico.i('check', 13) }}{% else %}2{% endif %}</span>
|
||||
<span class="rail-title" x-data x-text="$store.i18n.t('step_printer_title')">Configure the printer</span>
|
||||
<span class="rail-count">{{ printer_count }}</span>
|
||||
</div>
|
||||
<p class="rail-desc" x-data x-text="$store.i18n.t('step_printer_desc')">Name, IP address, driver, and defaults.</p>
|
||||
<span class="rail-cta" x-data>
|
||||
{% if s2 == 'done' %}<span x-text="$store.i18n.t('printers')">Printers</span>{{ ico.i('chevron', 13) }}
|
||||
{% else %}<span x-text="$store.i18n.t('go_add_printer')">Add a printer</span>{{ ico.i('chevron', 13) }}{% endif %}
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<a class="rail-step {{ s3 }}" href="/packages">
|
||||
<div class="rail-top">
|
||||
<span class="rail-num">{% if s3 == 'done' %}{{ ico.i('check', 13) }}{% else %}3{% endif %}</span>
|
||||
<span class="rail-title" x-data x-text="$store.i18n.t('step_package_title')">Export the package</span>
|
||||
<span class="rail-count">{{ ready_count }}</span>
|
||||
</div>
|
||||
<p class="rail-desc" x-data x-text="$store.i18n.t('step_package_desc')">.intunewin for Intune, ZIP for NinjaRMM.</p>
|
||||
<span class="rail-cta" x-data>
|
||||
<span x-text="$store.i18n.t('go_export')">View packages</span>{{ ico.i('chevron', 13) }}
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<section class="recent-activity">
|
||||
<h2 x-data x-text="$store.i18n.t('recent_activity')">Recent Activity</h2>
|
||||
<div class="split">
|
||||
<section class="card card-flush">
|
||||
<div class="card-head">
|
||||
<h2 x-data x-text="$store.i18n.t('recent_printers')">Recent printers</h2>
|
||||
{% if recent_printers %}
|
||||
<span class="head-actions">
|
||||
<a href="/printers" class="btn quiet sm" x-data><span x-text="$store.i18n.t('view_all')">View all</span>{{ ico.i('chevron', 13) }}</a>
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if recent_printers %}
|
||||
<div class="table-scroll">
|
||||
<table class="data-table">
|
||||
<tbody>
|
||||
{% for printer in recent_printers %}
|
||||
<tr>
|
||||
<td>
|
||||
<a class="cell-name" href="/printers/{{ printer.id }}">{{ printer.name }}</a>
|
||||
<span class="cell-sub">{{ printer.ip_address }}{% if printer.client_id %} · {{ printer.client.name }}{% endif %}</span>
|
||||
</td>
|
||||
<td class="actions">
|
||||
{% if printer.driver_id %}
|
||||
<span class="badge ok">{{ ico.i('check', 12) }}<span x-data x-text="$store.i18n.t('ready_to_export')">Ready</span></span>
|
||||
{% else %}
|
||||
<span class="badge warn">{{ ico.i('alert', 12) }}<span x-data x-text="$store.i18n.t('needs_driver')">Driver needed</span></span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty">
|
||||
<span class="empty-ico">{{ ico.i('printer', 18) }}</span>
|
||||
<strong x-data x-text="$store.i18n.t('no_printers')">No printers configured yet.</strong>
|
||||
<p x-data x-text="$store.i18n.t('step_printer_desc')">Name, IP address, driver, and defaults.</p>
|
||||
<a href="/printers/new" class="btn sm" x-data>{{ ico.i('plus', 14) }}<span x-text="$store.i18n.t('add_printer')">Add printer</span></a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="activity-section">
|
||||
<h3 x-data x-text="$store.i18n.t('recent_printers')">Recent Printers</h3>
|
||||
{% if recent_printers %}
|
||||
<ul>
|
||||
{% for printer in recent_printers %}
|
||||
<li><a href="/printers/{{ printer.id }}">{{ printer.name }}</a> — {{ printer.ip_address }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="empty-state" x-data x-text="$store.i18n.t('no_printers')">No printers configured yet.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="activity-section">
|
||||
<h3 x-data x-text="$store.i18n.t('recent_packages')">Recent Packages</h3>
|
||||
{% if recent_packages %}
|
||||
<ul>
|
||||
{% for package in recent_packages %}
|
||||
<li><a href="/printers/{{ package.id }}">{{ package.name }}</a>{% if package.client_id %} — {{ package.client.name }}{% endif %}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="empty-state" x-data x-text="$store.i18n.t('no_packages')">No packages exported yet.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
<section class="card card-flush">
|
||||
<div class="card-head">
|
||||
<h2 x-data x-text="$store.i18n.t('recent_packages')">Ready to export</h2>
|
||||
{% if recent_packages %}
|
||||
<span class="head-actions">
|
||||
<a href="/packages" class="btn quiet sm" x-data><span x-text="$store.i18n.t('view_all')">View all</span>{{ ico.i('chevron', 13) }}</a>
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if recent_packages %}
|
||||
<div class="table-scroll">
|
||||
<table class="data-table">
|
||||
<tbody>
|
||||
{% for package in recent_packages %}
|
||||
<tr>
|
||||
<td>
|
||||
<a class="cell-name" href="/printers/{{ package.id }}">{{ package.name }}</a>
|
||||
<span class="cell-sub">{% if package.client_id %}{{ package.client.name }}{% else %}—{% endif %}</span>
|
||||
</td>
|
||||
<td class="actions">
|
||||
<span class="btn-row">
|
||||
<a class="btn quiet sm" href="/printers/{{ package.id }}/packages/intunewin">{{ ico.i('download', 13) }}<span class="mono">.intunewin</span></a>
|
||||
<a class="btn quiet sm" href="/printers/{{ package.id }}/packages/ninja">{{ ico.i('download', 13) }}<span class="mono">.zip</span></a>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty">
|
||||
<span class="empty-ico">{{ ico.i('package', 18) }}</span>
|
||||
<strong x-data x-text="$store.i18n.t('no_packages')">No printers are ready to export yet.</strong>
|
||||
<p x-data x-text="$store.i18n.t('no_packages_ready')">No printer is ready yet. Assign a driver to enable export.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,25 +1,43 @@
|
||||
{% extends "base.html" %}
|
||||
{% import "partials/icons.html" as ico %}
|
||||
|
||||
{% block head_title %}Drivers · ImpTune{% endblock %}
|
||||
{% block page_title %}<span x-data x-text="$store.i18n.t('drivers_title')">Drivers</span>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1 x-data x-text="$store.i18n.t('drivers_title')">Drivers</h1>
|
||||
<p class="page-intro" x-data x-text="$store.i18n.t('drivers_intro')">Drivers are shared — everyone sees the same library.</p>
|
||||
|
||||
<section>
|
||||
<h2 x-data x-text="$store.i18n.t('upload_driver_section')">Upload Driver Package</h2>
|
||||
<form
|
||||
hx-post="/drivers/upload"
|
||||
hx-encoding="multipart/form-data"
|
||||
hx-target="#driver-list"
|
||||
hx-swap="outerHTML"
|
||||
hx-indicator="#upload-spinner"
|
||||
>
|
||||
<label for="driver-file" x-data x-text="$store.i18n.t('driver_package_label')">Driver Package (ZIP containing .inf + driver files)</label>
|
||||
<input type="file" id="driver-file" name="file" accept=".zip" required>
|
||||
<button type="submit" x-data x-text="$store.i18n.t('upload_btn')">Upload</button>
|
||||
<span id="upload-spinner" class="htmx-indicator" aria-busy="true" x-data x-text="$store.i18n.t('uploading')">Uploading...</span>
|
||||
</form>
|
||||
<section class="card">
|
||||
<div class="card-head">
|
||||
{{ ico.i('upload') }}
|
||||
<div>
|
||||
<h2 x-data x-text="$store.i18n.t('upload_driver_section')">Add a driver package</h2>
|
||||
<p class="sub" x-data x-text="$store.i18n.t('hint_driver_zip')">The ZIP must contain the .inf file and every file it references.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form
|
||||
hx-post="/drivers/upload"
|
||||
hx-encoding="multipart/form-data"
|
||||
hx-target="#driver-list"
|
||||
hx-swap="outerHTML"
|
||||
hx-indicator="#upload-spinner"
|
||||
>
|
||||
<div class="uploader">
|
||||
<label for="driver-file" class="label-text" x-data x-text="$store.i18n.t('driver_package_label')">Driver package (ZIP)</label>
|
||||
<input type="file" id="driver-file" name="file" accept=".zip" required>
|
||||
<div class="btn-row">
|
||||
<button type="submit" class="btn" x-data>
|
||||
{{ ico.i('upload', 14) }}<span x-text="$store.i18n.t('upload_btn')">Upload</span>
|
||||
</button>
|
||||
<span id="upload-spinner" class="htmx-indicator" aria-busy="true"
|
||||
x-data x-text="$store.i18n.t('uploading')">Uploading…</span>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 x-data x-text="$store.i18n.t('driver_library')">Driver Library</h2>
|
||||
{% include "partials/driver_list.html" %}
|
||||
</section>
|
||||
<h2 class="eyebrow" style="margin-bottom:.6rem" x-data x-text="$store.i18n.t('driver_library')">Driver library</h2>
|
||||
{% include "partials/driver_list.html" %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,37 +1,91 @@
|
||||
{% extends "base.html" %}
|
||||
{% import "partials/icons.html" as ico %}
|
||||
|
||||
{% block head_title %}Packages · ImpTune{% endblock %}
|
||||
{% block page_title %}<span x-data x-text="$store.i18n.t('packages_title')">Packages</span>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1 x-data x-text="$store.i18n.t('packages_title')">Packages</h1>
|
||||
<p x-data x-text="$store.i18n.t('packages_description')">Printers with drivers assigned — ready for deployment package export.</p>
|
||||
<p class="page-intro" x-data x-text="$store.i18n.t('packages_description')">Printers with a driver assigned — ready for export.</p>
|
||||
|
||||
{% if printers %}
|
||||
<figure>
|
||||
<table role="grid">
|
||||
<div class="card-grid">
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
{{ ico.i('package') }}
|
||||
<h2 class="mono">.intunewin</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="dim" x-data x-text="$store.i18n.t('hint_intunewin')">.intunewin — upload to Intune as a Win32 app.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
{{ ico.i('script') }}
|
||||
<h2 class="mono">.zip</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="dim" x-data x-text="$store.i18n.t('hint_ninja')">ZIP — plain scripts for NinjaRMM or a manual run.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card card-flush">
|
||||
<div class="table-scroll">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" x-data x-text="$store.i18n.t('printer_col')">Printer</th>
|
||||
<th scope="col" x-data x-text="$store.i18n.t('client_col')">Client</th>
|
||||
<th scope="col" x-data x-text="$store.i18n.t('driver_col')">Driver</th>
|
||||
<th scope="col" x-data x-text="$store.i18n.t('icon_section')">Icon</th>
|
||||
<th scope="col" x-data x-text="$store.i18n.t('downloads_col')">Downloads</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for printer in printers %}
|
||||
<tr>
|
||||
<td><a href="/printers/{{ printer.id }}">{{ printer.name }}</a></td>
|
||||
<td>{% if printer.client_id %}{{ printer.client.name }}{% else %}—{% endif %}</td>
|
||||
<td>{{ printer.driver.original_filename }}</td>
|
||||
<td>
|
||||
<a href="/printers/{{ printer.id }}/packages/intunewin">.intunewin</a>
|
||||
|
|
||||
<a href="/printers/{{ printer.id }}/packages/ninja">NinjaRMM ZIP</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<a class="cell-name" href="/printers/{{ printer.id }}">{{ printer.name }}</a>
|
||||
<span class="cell-sub">{% if printer.client_id %}{{ printer.client.name }}{% else %}—{% endif %}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="mono">{{ printer.driver.original_filename }}</span>
|
||||
{% if printer.driver.architecture %}<span class="cell-sub">{{ printer.driver.architecture }}</span>{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if printer.id in icon_printer_ids %}
|
||||
<img src="/printers/{{ printer.id }}/icon" width="28" height="28" alt=""
|
||||
style="border-radius:5px;vertical-align:middle">
|
||||
{% else %}
|
||||
<span class="faint">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="actions">
|
||||
<span class="btn-row">
|
||||
<a class="btn ghost sm" href="/printers/{{ printer.id }}/packages/intunewin">{{ ico.i('download', 13) }}<span class="mono">.intunewin</span></a>
|
||||
<a class="btn ghost sm" href="/printers/{{ printer.id }}/packages/ninja">{{ ico.i('download', 13) }}<span class="mono">.zip</span></a>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</figure>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if pending_count %}
|
||||
<p class="hint" style="margin-top:.9rem" x-data>
|
||||
<a href="/printers">{{ pending_count }} <span x-text="$store.i18n.t('pending_printers')">printers still need a driver</span></a>
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<p class="empty-state" x-data x-text="$store.i18n.t('no_packages_ready')">No package-ready printers yet. Assign a driver to a printer to enable package export.</p>
|
||||
<div class="card">
|
||||
<div class="empty">
|
||||
<span class="empty-ico">{{ ico.i('package', 18) }}</span>
|
||||
<strong x-data x-text="$store.i18n.t('no_packages_ready')">No printer is ready yet. Assign a driver to enable export.</strong>
|
||||
<p x-data x-text="$store.i18n.t('step_package_desc')">.intunewin for Intune, ZIP for NinjaRMM. Scripts included.</p>
|
||||
<a href="/printers" class="btn sm" x-data>{{ ico.i('printer', 14) }}<span x-text="$store.i18n.t('printers')">Printers</span></a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,22 +1,45 @@
|
||||
{% import "partials/icons.html" as ico %}
|
||||
{% set counts = counts | default({}, true) %}
|
||||
<div id="client-list">
|
||||
{% if not clients %}
|
||||
<p x-data x-text="$store.i18n.t('no_clients')">No clients configured yet.</p>
|
||||
<div class="card">
|
||||
<div class="empty">
|
||||
<span class="empty-ico">{{ ico.i('group', 18) }}</span>
|
||||
<strong x-data x-text="$store.i18n.t('no_clients')">No clients configured yet.</strong>
|
||||
<p x-data x-text="$store.i18n.t('clients_intro')">Clients group printers by site or organization.</p>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th x-data x-text="$store.i18n.t('th_name')">Name</th>
|
||||
<th x-data x-text="$store.i18n.t('created')">Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for c in clients %}
|
||||
<tr>
|
||||
<td><a href="/clients/{{ c.id }}">{{ c.name }}</a></td>
|
||||
<td>{{ c.created_at.strftime('%Y-%m-%d') if c.created_at else '—' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="card card-flush">
|
||||
<div class="table-scroll">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th x-data x-text="$store.i18n.t('th_name')">Name</th>
|
||||
<th x-data x-text="$store.i18n.t('th_printers')">Printers</th>
|
||||
<th x-data x-text="$store.i18n.t('created')">Created</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for c in clients %}
|
||||
<tr>
|
||||
<td><a class="cell-name" href="/clients/{{ c.id }}">{{ c.name }}</a></td>
|
||||
<td class="num">
|
||||
{% set n = counts.get(c.id, 0) %}
|
||||
{% if n %}{{ n }}{% else %}<span class="faint">0</span>{% endif %}
|
||||
</td>
|
||||
<td class="num">{{ c.created_at.strftime('%Y-%m-%d') if c.created_at else '—' }}</td>
|
||||
<td class="actions">
|
||||
<a class="btn quiet sm" href="/clients/{{ c.id }}" x-data>
|
||||
<span x-text="$store.i18n.t('open_client')">Open</span>{{ ico.i('chevron', 13) }}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -1,50 +1,95 @@
|
||||
{% import "partials/icons.html" as ico %}
|
||||
{% set usage = usage | default({}, true) %}
|
||||
{% set new_driver_id = new_driver_id | default(None, true) %}
|
||||
<div id="driver-list">
|
||||
{% if parsed is defined and parsed.unused_files %}
|
||||
<p class="notice">
|
||||
{{ parsed.unused_files | length }} file(s) may be unused (not referenced by the INF):
|
||||
<div class="notice">
|
||||
<p>
|
||||
{{ parsed.unused_files | length }}
|
||||
<span x-data x-text="$store.i18n.t('unused_files')">file(s) in the ZIP are unused — the .inf never references them.</span>
|
||||
</p>
|
||||
<details>
|
||||
<summary>Show unused files</summary>
|
||||
<summary x-data x-text="$store.i18n.t('show_unused')">Show unused files</summary>
|
||||
<ul>
|
||||
{% for f in parsed.unused_files %}
|
||||
<li>{{ f }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</details>
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if driver_data %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th x-data x-text="$store.i18n.t('driver_filename')">Filename</th>
|
||||
<th x-data x-text="$store.i18n.t('driver_names_col')">Driver Name(s)</th>
|
||||
<th x-data x-text="$store.i18n.t('architecture')">Architecture</th>
|
||||
<th x-data x-text="$store.i18n.t('uploaded_at')">Uploaded</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in driver_data %}
|
||||
<tr>
|
||||
<td>{{ item.driver.original_filename }}</td>
|
||||
<td>
|
||||
{% if item.names %}
|
||||
<select aria-label="Driver names">
|
||||
{% for name in item.names %}
|
||||
<option>{{ name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% else %}
|
||||
<em x-data x-text="$store.i18n.t('unknown')">Unknown</em>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{% if item.driver.architecture %}{{ item.driver.architecture }}{% else %}<span x-data x-text="$store.i18n.t('unknown')">Unknown</span>{% endif %}</td>
|
||||
<td>{{ item.driver.uploaded_at }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="card card-flush">
|
||||
<div class="table-scroll">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th x-data x-text="$store.i18n.t('driver_filename')">File</th>
|
||||
<th x-data x-text="$store.i18n.t('driver_names_col')">Driver name(s)</th>
|
||||
<th class="col-arch" x-data x-text="$store.i18n.t('architecture')">Architecture</th>
|
||||
<th class="col-used" x-data x-text="$store.i18n.t('th_used_by')">Used by</th>
|
||||
<th class="col-added" x-data x-text="$store.i18n.t('uploaded_at')">Added</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in driver_data %}
|
||||
<tr>
|
||||
<td>
|
||||
<span class="cell-name mono">{{ item.driver.original_filename }}</span>
|
||||
<span class="cell-sub">
|
||||
{% if item.driver.size_bytes >= 1048576 %}{{ (item.driver.size_bytes / 1048576) | round(1) }} MB{% elif item.driver.size_bytes >= 1024 %}{{ (item.driver.size_bytes / 1024) | round(0) | int }} KB{% else %}{{ item.driver.size_bytes }} B{% endif %}
|
||||
{% if item.driver.inf_filename %} · {{ item.driver.inf_filename }}{% endif %}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{% if item.names %}
|
||||
{# Multi-model INFs can carry a dozen names — show three, fold the rest. #}
|
||||
<span class="pill-row">
|
||||
{% for name in item.names[:3] %}<span class="pill">{{ name }}</span>{% endfor %}
|
||||
{% if item.names | length > 3 %}
|
||||
<details class="more-pills">
|
||||
<summary class="pill">+{{ item.names | length - 3 }}</summary>
|
||||
<span class="pill-row">
|
||||
{% for name in item.names[3:] %}<span class="pill">{{ name }}</span>{% endfor %}
|
||||
</span>
|
||||
</details>
|
||||
{% endif %}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="faint" x-data x-text="$store.i18n.t('unknown')">Unknown</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="col-arch">
|
||||
{% if item.driver.architecture %}
|
||||
<span class="badge">{{ item.driver.architecture }}</span>
|
||||
{% else %}
|
||||
<span class="faint" x-data x-text="$store.i18n.t('unknown')">Unknown</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="num col-used">
|
||||
{% set n = usage.get(item.driver.id, 0) %}
|
||||
{% if n %}<a href="/printers">{{ n }}</a>{% else %}<span class="faint">0</span>{% endif %}
|
||||
</td>
|
||||
<td class="num col-added">
|
||||
{{ item.driver.uploaded_at.strftime('%Y-%m-%d %H:%M') if item.driver.uploaded_at is not string else item.driver.uploaded_at }}
|
||||
{% if new_driver_id and item.driver.id == new_driver_id %}
|
||||
<span class="badge ok">{{ ico.i('check', 12) }}new</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<p x-data x-text="$store.i18n.t('no_drivers')">No drivers uploaded yet.</p>
|
||||
<div class="card">
|
||||
<div class="empty">
|
||||
<span class="empty-ico">{{ ico.i('driver', 18) }}</span>
|
||||
<strong x-data x-text="$store.i18n.t('no_drivers')">No drivers uploaded yet.</strong>
|
||||
<p x-data x-text="$store.i18n.t('hint_driver_zip')">The ZIP must contain the .inf file and every file it references.</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
{% include "partials/driver_list.html" %}
|
||||
|
||||
{# Out-of-band: refresh the printer form's driver picker with the new driver chosen. #}
|
||||
<select name="driver_id" id="printer-form-driver-select" hx-swap-oob="true">
|
||||
<option value="">-- No driver --</option>
|
||||
<option value="" x-data x-text="$store.i18n.t('no_driver_option')">No driver</option>
|
||||
{% for item in driver_data %}
|
||||
<option value="{{ item.driver.id }}"
|
||||
{% if item.driver.id == new_driver_id %}selected{% endif %}>
|
||||
{{ item.driver.original_filename }} ({{ item.names | join(', ') }})
|
||||
{{ item.driver.original_filename }}{% if item.names %} — {{ item.names | join(', ') }}{% endif %}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
{#
|
||||
Inline SVG icon set — no external files, no text nodes.
|
||||
|
||||
Text-node-free on purpose: E2E tests read `textContent` of nav links to assert
|
||||
the translated label, so icons must contribute no characters.
|
||||
|
||||
Usage: {% import "partials/icons.html" as ico %} → {{ ico.i('printer') }}
|
||||
#}
|
||||
{% macro i(name, size=16) -%}
|
||||
{%- set s = size -%}
|
||||
<svg class="ico" width="{{ s }}" height="{{ s }}" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="1.6" stroke-linecap="round"
|
||||
stroke-linejoin="round" aria-hidden="true" focusable="false">
|
||||
{%- if name == 'gauge' -%}
|
||||
<path d="M12 21a9 9 0 1 0-9-9"/><path d="M3 12h2"/><path d="M19 12h2"/><path d="M12 3v2"/><path d="m12 12 4.5-3.5"/><circle cx="12" cy="12" r="1.6"/>
|
||||
{%- elif name == 'driver' -%}
|
||||
<path d="M3 8.5 12 4l9 4.5-9 4.5-9-4.5Z"/><path d="M3 8.5v7L12 20l9-4.5v-7"/><path d="M12 13v7"/>
|
||||
{%- elif name == 'printer' -%}
|
||||
<path d="M6 9V4h12v5"/><rect x="3" y="9" width="18" height="7" rx="1.5"/><path d="M7 16h10v4H7z"/><circle cx="17.5" cy="12" r=".8" fill="currentColor" stroke="none"/>
|
||||
{%- elif name == 'group' -%}
|
||||
<path d="M4 20v-1.5A3.5 3.5 0 0 1 7.5 15h3A3.5 3.5 0 0 1 14 18.5V20"/><circle cx="9" cy="8.5" r="3"/><path d="M16 15.5h.5A3.5 3.5 0 0 1 20 19v1"/><circle cx="17" cy="9.5" r="2.2"/>
|
||||
{%- elif name == 'package' -%}
|
||||
<path d="M4 7.5 12 4l8 3.5v9L12 20l-8-3.5v-9Z"/><path d="M4 7.5 12 11l8-3.5"/><path d="M12 11v9"/><path d="M8 5.7 16 9.2"/>
|
||||
{%- elif name == 'key' -%}
|
||||
<circle cx="8" cy="15" r="3.5"/><path d="m10.5 12.5 8-8"/><path d="m15.5 7.5 2 2"/><path d="m18 5 2 2"/>
|
||||
{%- elif name == 'upload' -%}
|
||||
<path d="M12 16V4"/><path d="m7.5 8.5 4.5-4.5 4.5 4.5"/><path d="M4 15v3.5A1.5 1.5 0 0 0 5.5 20h13a1.5 1.5 0 0 0 1.5-1.5V15"/>
|
||||
{%- elif name == 'download' -%}
|
||||
<path d="M12 4v12"/><path d="m7.5 11.5 4.5 4.5 4.5-4.5"/><path d="M4 15v3.5A1.5 1.5 0 0 0 5.5 20h13a1.5 1.5 0 0 0 1.5-1.5V15"/>
|
||||
{%- elif name == 'plus' -%}
|
||||
<path d="M12 5v14"/><path d="M5 12h14"/>
|
||||
{%- elif name == 'search' -%}
|
||||
<circle cx="11" cy="11" r="6"/><path d="m15.5 15.5 4 4"/>
|
||||
{%- elif name == 'pencil' -%}
|
||||
<path d="M4 20h4l10-10-4-4L4 16v4Z"/><path d="m13.5 6.5 4 4"/>
|
||||
{%- elif name == 'trash' -%}
|
||||
<path d="M4 7h16"/><path d="M9 7V4.5h6V7"/><path d="M6 7v11.5A1.5 1.5 0 0 0 7.5 20h9a1.5 1.5 0 0 0 1.5-1.5V7"/><path d="M10 11v5"/><path d="M14 11v5"/>
|
||||
{%- elif name == 'check' -%}
|
||||
<path d="m5 12.5 4.5 4.5L19 7.5"/>
|
||||
{%- elif name == 'alert' -%}
|
||||
<path d="M12 4.5 21 19.5H3L12 4.5Z"/><path d="M12 10v4"/><circle cx="12" cy="16.8" r=".9" fill="currentColor" stroke="none"/>
|
||||
{%- elif name == 'copy' -%}
|
||||
<rect x="9" y="9" width="11" height="11" rx="1.5"/><path d="M15 6.5V5.5A1.5 1.5 0 0 0 13.5 4h-8A1.5 1.5 0 0 0 4 5.5v8A1.5 1.5 0 0 0 5.5 15h1"/>
|
||||
{%- elif name == 'terminal' -%}
|
||||
<rect x="3" y="4.5" width="18" height="15" rx="2"/><path d="m7.5 10 2.5 2.5-2.5 2.5"/><path d="M12.5 15h4"/>
|
||||
{%- elif name == 'script' -%}
|
||||
<path d="M7 3.5h7l4 4v13H7z"/><path d="M14 3.5v4h4"/><path d="M10 12h5"/><path d="M10 16h5"/>
|
||||
{%- elif name == 'image' -%}
|
||||
<rect x="3.5" y="4.5" width="17" height="15" rx="2"/><circle cx="9" cy="10" r="1.8"/><path d="m4.5 18 4.5-4.5 3.5 3.5 3-2.5 4 3.5"/>
|
||||
{%- elif name == 'menu' -%}
|
||||
<path d="M4 7h16"/><path d="M4 12h16"/><path d="M4 17h16"/>
|
||||
{%- elif name == 'chevron' -%}
|
||||
<path d="m8.5 5.5 6 6.5-6 6.5"/>
|
||||
{%- elif name == 'back' -%}
|
||||
<path d="M19 12H5"/><path d="m11 6-6 6 6 6"/>
|
||||
{%- elif name == 'link' -%}
|
||||
<path d="M10 13.5a3.5 3.5 0 0 0 5 0l3-3a3.5 3.5 0 0 0-5-5l-1 1"/><path d="M14 10.5a3.5 3.5 0 0 0-5 0l-3 3a3.5 3.5 0 0 0 5 5l1-1"/>
|
||||
{%- elif name == 'network' -%}
|
||||
<rect x="3.5" y="14.5" width="6" height="5" rx="1"/><rect x="14.5" y="14.5" width="6" height="5" rx="1"/><rect x="9" y="4.5" width="6" height="5" rx="1"/><path d="M12 9.5v3H6.5v2"/><path d="M12 12.5h5.5v2"/>
|
||||
{%- endif -%}
|
||||
</svg>
|
||||
{%- endmacro %}
|
||||
@@ -1,102 +1,122 @@
|
||||
<!-- Edit trigger button — placed in Actions column -->
|
||||
<button class="secondary outline"
|
||||
{% import "partials/icons.html" as ico %}
|
||||
<!-- Edit trigger — sits in the row's Actions cell -->
|
||||
<button class="btn ghost sm"
|
||||
onclick="document.getElementById('edit-modal-{{ p.id }}').showModal()"
|
||||
x-data x-text="$store.i18n.t('edit')">
|
||||
Edit
|
||||
x-data>
|
||||
{{ ico.i('pencil', 14) }}<span x-text="$store.i18n.t('edit')">Edit</span>
|
||||
</button>
|
||||
|
||||
<!-- Edit dialog — Pico CSS native dialog, no extra library -->
|
||||
<!-- Edit dialog — native <dialog>, no extra library -->
|
||||
<dialog id="edit-modal-{{ p.id }}">
|
||||
<article>
|
||||
<header>
|
||||
<button aria-label="Close" rel="prev"
|
||||
onclick="document.getElementById('edit-modal-{{ p.id }}').close()"></button>
|
||||
<h3 x-data x-text="$store.i18n.t('edit_printer_title')">Edit Printer</h3>
|
||||
</header>
|
||||
<div x-data="{ ip: '{{ p.ip_address }}', port: '{{ p.port_name }}', portEdited: true }">
|
||||
<form hx-patch="/printers/{{ p.id }}"
|
||||
hx-target="#printer-list"
|
||||
hx-swap="outerHTML"
|
||||
hx-on::after-request="document.getElementById('edit-modal-{{ p.id }}').close()">
|
||||
|
||||
<label><span x-text="$store.i18n.t('printer_name')">Printer Name</span>
|
||||
<input type="text" name="name" value="{{ p.name }}" required>
|
||||
</label>
|
||||
<div class="dialog-head">
|
||||
<h3 x-text="$store.i18n.t('edit_printer_title')">Edit printer</h3>
|
||||
{# No aria-label="Close": Pico hooks that attribute to draw its own
|
||||
floated X icon, which fights this button's glyph and placement. #}
|
||||
<button type="button" class="icon-btn close"
|
||||
:aria-label="$store.i18n.t('cancel')"
|
||||
onclick="document.getElementById('edit-modal-{{ p.id }}').close()">×</button>
|
||||
</div>
|
||||
|
||||
<label><span x-text="$store.i18n.t('ip_address')">IP Address</span>
|
||||
<input type="text" name="ip_address"
|
||||
x-model="ip"
|
||||
@input="if (!portEdited) port = 'IP_' + ip.replaceAll('.', '_')"
|
||||
required>
|
||||
</label>
|
||||
<div class="dialog-body">
|
||||
<fieldset class="form-section">
|
||||
<legend x-text="$store.i18n.t('section_identity')">Identity</legend>
|
||||
<label>
|
||||
<span class="label-text" x-text="$store.i18n.t('printer_name')">Printer name</span>
|
||||
<input type="text" name="name" value="{{ p.name }}" required>
|
||||
</label>
|
||||
<label>
|
||||
<span class="label-text" x-text="$store.i18n.t('client')">Client</span>
|
||||
<select name="client_id">
|
||||
<option value="" x-text="$store.i18n.t('unassigned_option')">Unassigned</option>
|
||||
{% for c in clients %}
|
||||
<option value="{{ c.id }}" {% if p.client_id == c.id %}selected{% endif %}>{{ c.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<label><span x-text="$store.i18n.t('port_name')">Port Name</span>
|
||||
<input type="text" name="port_name"
|
||||
x-model="port"
|
||||
@change="portEdited = true"
|
||||
@keydown="portEdited = true">
|
||||
</label>
|
||||
<fieldset class="form-section">
|
||||
<legend x-text="$store.i18n.t('section_connection')">Network connection</legend>
|
||||
<div class="form-row">
|
||||
<label>
|
||||
<span class="label-text" x-text="$store.i18n.t('ip_address')">IP address</span>
|
||||
<input type="text" name="ip_address" class="mono"
|
||||
x-model="ip"
|
||||
@input="if (!portEdited) port = 'IP_' + ip.replaceAll('.', '_')"
|
||||
required>
|
||||
</label>
|
||||
<label>
|
||||
<span class="label-text" x-text="$store.i18n.t('port_name')">Port name</span>
|
||||
<input type="text" name="port_name" class="mono"
|
||||
x-model="port"
|
||||
@change="portEdited = true"
|
||||
@keydown="portEdited = true">
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<label><span x-text="$store.i18n.t('driver')">Driver</span>
|
||||
<select name="driver_id">
|
||||
<option value="" x-text="$store.i18n.t('no_driver_option')">-- No driver --</option>
|
||||
{% for item in driver_data %}
|
||||
<option value="{{ item.driver.id }}"
|
||||
{% if p.driver_id == item.driver.id %}selected{% endif %}>
|
||||
{{ item.driver.original_filename }} ({{ item.names | join(', ') }})
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<fieldset class="form-section">
|
||||
<legend x-text="$store.i18n.t('section_driver')">Driver</legend>
|
||||
<label>
|
||||
<select name="driver_id">
|
||||
<option value="" x-text="$store.i18n.t('no_driver_option')">No driver</option>
|
||||
{% for item in driver_data %}
|
||||
<option value="{{ item.driver.id }}"
|
||||
{% if p.driver_id == item.driver.id %}selected{% endif %}>
|
||||
{{ item.driver.original_filename }}{% if item.names %} — {{ item.names | join(', ') }}{% endif %}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<span class="hint" x-text="$store.i18n.t('hint_driver')">Without a driver the printer is saved, but export stays locked.</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<label><span x-text="$store.i18n.t('duplex_mode')">Duplex Mode</span>
|
||||
<select name="duplex_mode">
|
||||
<option value="OneSided" {% if p.duplex_mode == 'OneSided' %}selected{% endif %} x-text="$store.i18n.t('one_sided')">One-Sided</option>
|
||||
<option value="LongEdge" {% if p.duplex_mode == 'LongEdge' %}selected{% endif %} x-text="$store.i18n.t('long_edge')">Long Edge</option>
|
||||
<option value="ShortEdge" {% if p.duplex_mode == 'ShortEdge' %}selected{% endif %} x-text="$store.i18n.t('short_edge')">Short Edge</option>
|
||||
</select>
|
||||
</label>
|
||||
<fieldset class="form-section">
|
||||
<legend x-text="$store.i18n.t('section_defaults')">Print defaults</legend>
|
||||
<div class="form-row">
|
||||
<label>
|
||||
<span class="label-text" x-text="$store.i18n.t('duplex_mode')">Duplex mode</span>
|
||||
<select name="duplex_mode">
|
||||
<option value="OneSided" {% if p.duplex_mode == 'OneSided' %}selected{% endif %} x-text="$store.i18n.t('one_sided')">One-sided</option>
|
||||
<option value="LongEdge" {% if p.duplex_mode == 'LongEdge' %}selected{% endif %} x-text="$store.i18n.t('long_edge')">Long edge</option>
|
||||
<option value="ShortEdge" {% if p.duplex_mode == 'ShortEdge' %}selected{% endif %} x-text="$store.i18n.t('short_edge')">Short edge</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span class="label-text" x-text="$store.i18n.t('paper_size')">Paper size</span>
|
||||
<select name="paper_size">
|
||||
<option value="A4" {% if p.paper_size == 'A4' %}selected{% endif %}>A4</option>
|
||||
<option value="Letter" {% if p.paper_size == 'Letter' %}selected{% endif %}>Letter</option>
|
||||
<option value="Legal" {% if p.paper_size == 'Legal' %}selected{% endif %}>Legal</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="check-row">
|
||||
<label>
|
||||
<input type="checkbox" name="color_mode" value="on" {% if p.color_mode %}checked{% endif %}>
|
||||
<span x-text="$store.i18n.t('color_mode')">Color</span>
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" name="collate" value="on" {% if p.collate %}checked{% endif %}>
|
||||
<span x-text="$store.i18n.t('collate')">Collate</span>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<label>
|
||||
<input type="checkbox" name="color_mode" value="on"
|
||||
{% if p.color_mode %}checked{% endif %}>
|
||||
<span x-text="$store.i18n.t('color_mode')">Color Mode</span>
|
||||
</label>
|
||||
|
||||
<label><span x-text="$store.i18n.t('paper_size')">Paper Size</span>
|
||||
<select name="paper_size">
|
||||
<option value="A4" {% if p.paper_size == 'A4' %}selected{% endif %}>A4</option>
|
||||
<option value="Letter" {% if p.paper_size == 'Letter' %}selected{% endif %}>Letter</option>
|
||||
<option value="Legal" {% if p.paper_size == 'Legal' %}selected{% endif %}>Legal</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<input type="checkbox" name="collate" value="on"
|
||||
{% if p.collate %}checked{% endif %}>
|
||||
<span x-text="$store.i18n.t('collate')">Collate</span>
|
||||
</label>
|
||||
|
||||
<label><span x-text="$store.i18n.t('client')">Client</span>
|
||||
<select name="client_id">
|
||||
<option value="" x-text="$store.i18n.t('unassigned_option')">-- Unassigned --</option>
|
||||
{% for c in clients %}
|
||||
<option value="{{ c.id }}"
|
||||
{% if p.client_id == c.id %}selected{% endif %}>
|
||||
{{ c.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<footer>
|
||||
<button type="submit" x-text="$store.i18n.t('save')">Save</button>
|
||||
<button type="button" class="secondary"
|
||||
<div class="dialog-foot">
|
||||
<button type="button" class="btn ghost"
|
||||
onclick="document.getElementById('edit-modal-{{ p.id }}').close()"
|
||||
x-text="$store.i18n.t('cancel')">
|
||||
Cancel
|
||||
</button>
|
||||
</footer>
|
||||
x-text="$store.i18n.t('cancel')">Cancel</button>
|
||||
<button type="submit" class="btn" x-text="$store.i18n.t('save')">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
<div x-data="{ ip: '{{ printer.ip_address if printer else '' }}', port: '{{ printer.port_name if printer else '' }}', portEdited: {{ 'true' if printer else 'false' }} }">
|
||||
<form hx-post="/printers" hx-target="#printer-list" hx-swap="outerHTML">
|
||||
|
||||
<label>
|
||||
Printer Name
|
||||
<input type="text" name="name" placeholder="e.g. HP LaserJet 4050" required
|
||||
value="{{ printer.name if printer else '' }}">
|
||||
</label>
|
||||
|
||||
<label>
|
||||
IP Address
|
||||
<input type="text" name="ip_address"
|
||||
x-model="ip"
|
||||
@input="if (!portEdited) port = 'IP_' + ip.replaceAll('.', '_')"
|
||||
placeholder="e.g. 192.168.1.100"
|
||||
required>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Port Name
|
||||
<input type="text" name="port_name"
|
||||
x-model="port"
|
||||
@change="portEdited = true"
|
||||
@keydown="portEdited = true"
|
||||
placeholder="e.g. IP_192_168_1_100">
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Driver
|
||||
<select name="driver_id" id="printer-form-driver-select">
|
||||
<option value="">-- No driver --</option>
|
||||
{% for item in driver_data %}
|
||||
<option value="{{ item.driver.id }}"
|
||||
{% if printer and printer.driver_id == item.driver.id %}selected{% endif %}>
|
||||
{{ item.driver.original_filename }} ({{ item.names | join(', ') }})
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Duplex Mode
|
||||
<select name="duplex_mode">
|
||||
<option value="OneSided" {% if not printer or printer.duplex_mode == 'OneSided' %}selected{% endif %}>One-Sided</option>
|
||||
<option value="LongEdge" {% if printer and printer.duplex_mode == 'LongEdge' %}selected{% endif %}>Long Edge</option>
|
||||
<option value="ShortEdge" {% if printer and printer.duplex_mode == 'ShortEdge' %}selected{% endif %}>Short Edge</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<input type="checkbox" name="color_mode" value="on"
|
||||
{% if not printer or printer.color_mode %}checked{% endif %}>
|
||||
Color Mode
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Paper Size
|
||||
<select name="paper_size">
|
||||
<option value="A4" {% if not printer or printer.paper_size == 'A4' %}selected{% endif %}>A4</option>
|
||||
<option value="Letter" {% if printer and printer.paper_size == 'Letter' %}selected{% endif %}>Letter</option>
|
||||
<option value="Legal" {% if printer and printer.paper_size == 'Legal' %}selected{% endif %}>Legal</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<input type="checkbox" name="collate" value="on"
|
||||
{% if not printer or printer.collate %}checked{% endif %}>
|
||||
Collate
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Client
|
||||
<select name="client_id">
|
||||
<option value="">-- Unassigned --</option>
|
||||
{% for c in clients %}
|
||||
<option value="{{ c.id }}"
|
||||
{% if printer and printer.client_id == c.id %}selected{% endif %}>
|
||||
{{ c.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<button type="submit">Save Printer</button>
|
||||
</form>
|
||||
|
||||
<form hx-post="/drivers/upload"
|
||||
hx-target="#driver-list"
|
||||
hx-encoding="multipart/form-data"
|
||||
hx-swap="outerHTML">
|
||||
<input type="hidden" name="caller" value="printer_form">
|
||||
<label>
|
||||
Upload New Driver
|
||||
<input type="file" name="file" accept=".zip" required>
|
||||
</label>
|
||||
<button type="submit" class="secondary">Upload Driver</button>
|
||||
</form>
|
||||
<div id="driver-list" style="display:none"></div>
|
||||
</div>
|
||||
@@ -1,57 +1,98 @@
|
||||
{% import "partials/icons.html" as ico %}
|
||||
<div id="printer-list">
|
||||
{% if not grouped %}
|
||||
<p x-data x-text="$store.i18n.t('no_printers')">No printers configured yet.</p>
|
||||
<div class="card">
|
||||
<div class="empty">
|
||||
<span class="empty-ico">{{ ico.i('printer', 18) }}</span>
|
||||
<strong x-data x-text="$store.i18n.t('no_printers')">No printers configured yet.</strong>
|
||||
<p x-data x-text="$store.i18n.t('step_printer_desc')">Name, IP address, driver, and the defaults the workstation gets.</p>
|
||||
<a href="/printers/new" class="btn sm" x-data>{{ ico.i('plus', 14) }}<span x-text="$store.i18n.t('add_printer')">Add printer</span></a>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
{% for client_name, printers in grouped.items() %}
|
||||
<section>
|
||||
{% set group_client_id = printers[0].client_id if printers else None %}
|
||||
{% if group_client_id %}
|
||||
<h3><a href="/clients/{{ group_client_id }}">{{ client_name }}</a></h3>
|
||||
{% else %}
|
||||
<h3>{{ client_name }}</h3>
|
||||
{% endif %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th x-data x-text="$store.i18n.t('th_name')">Name</th>
|
||||
<th x-data x-text="$store.i18n.t('th_ip')">IP Address</th>
|
||||
<th x-data x-text="$store.i18n.t('th_port')">Port</th>
|
||||
<th x-data x-text="$store.i18n.t('th_driver')">Driver</th>
|
||||
<th x-data x-text="$store.i18n.t('th_duplex')">Duplex</th>
|
||||
<th x-data x-text="$store.i18n.t('th_color')">Color</th>
|
||||
<th x-data x-text="$store.i18n.t('th_paper')">Paper</th>
|
||||
<th x-data x-text="$store.i18n.t('th_collate')">Collate</th>
|
||||
<th x-data x-text="$store.i18n.t('th_actions')">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for p in printers %}
|
||||
<tr>
|
||||
<td><a href="/printers/{{ p.id }}">{{ p.name }}</a></td>
|
||||
<td>{{ p.ip_address }}</td>
|
||||
<td>{{ p.port_name }}</td>
|
||||
<td>{{ p.driver.original_filename if p.driver_id else '—' }}</td>
|
||||
<td>{{ p.duplex_mode }}</td>
|
||||
<td x-data="{ val: {{ 'true' if p.color_mode else 'false' }} }"
|
||||
x-text="val ? $store.i18n.t('yes') : $store.i18n.t('no')">{{ 'Yes' if p.color_mode else 'No' }}</td>
|
||||
<td>{{ p.paper_size }}</td>
|
||||
<td x-data="{ val: {{ 'true' if p.collate else 'false' }} }"
|
||||
x-text="val ? $store.i18n.t('yes') : $store.i18n.t('no')">{{ 'Yes' if p.collate else 'No' }}</td>
|
||||
<td>
|
||||
{% include "partials/printer_edit_modal.html" %}
|
||||
<button
|
||||
hx-delete="/printers/{{ p.id }}"
|
||||
hx-target="#printer-list"
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm="Delete '{{ p.name }}'?"
|
||||
x-data x-text="$store.i18n.t('delete')">
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% set group_client_id = printers[0].client_id if printers else None %}
|
||||
{# Group search text must cover everything its rows match on, or a row can
|
||||
match while its group stays hidden. #}
|
||||
{% set gsearch = (printers | map(attribute='name') | join(' ')) ~ ' '
|
||||
~ (printers | map(attribute='ip_address') | join(' ')) ~ ' '
|
||||
~ (printers | map(attribute='port_name') | join(' ')) ~ ' '
|
||||
~ (printers | selectattr('driver_id') | map(attribute='driver.original_filename') | join(' ')) ~ ' '
|
||||
~ client_name %}
|
||||
<section class="card card-flush"
|
||||
data-search="{{ gsearch | lower }}"
|
||||
x-data
|
||||
x-show="!$store.filter.q.trim() || $el.dataset.search.includes($store.filter.q.trim().toLowerCase())">
|
||||
<div class="group-head">
|
||||
{{ ico.i('group') }}
|
||||
{% if group_client_id %}
|
||||
<h3><a href="/clients/{{ group_client_id }}">{{ client_name }}</a></h3>
|
||||
{% else %}
|
||||
<h3 x-data x-text="$store.i18n.t('unassigned')">{{ client_name }}</h3>
|
||||
{% endif %}
|
||||
<span class="count">{{ printers | length }}</span>
|
||||
</div>
|
||||
<div class="table-scroll">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th x-data x-text="$store.i18n.t('th_name')">Name</th>
|
||||
<th x-data x-text="$store.i18n.t('th_status')">Status</th>
|
||||
<th class="col-defaults" x-data x-text="$store.i18n.t('th_config')">Defaults</th>
|
||||
<th x-data x-text="$store.i18n.t('th_actions')">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for p in printers %}
|
||||
<tr data-search="{{ (p.name ~ ' ' ~ p.ip_address ~ ' ' ~ p.port_name ~ ' ' ~ client_name ~ ' ' ~ (p.driver.original_filename if p.driver_id else '')) | lower }}"
|
||||
x-data
|
||||
x-show="!$store.filter.q.trim() || $el.dataset.search.includes($store.filter.q.trim().toLowerCase())">
|
||||
<td>
|
||||
<a class="cell-name" href="/printers/{{ p.id }}">{{ p.name }}</a>
|
||||
<span class="cell-sub">{{ p.ip_address }} · {{ p.port_name }}</span>
|
||||
</td>
|
||||
<td>
|
||||
{% if p.driver_id %}
|
||||
<span class="badge ok">{{ ico.i('check', 12) }}<span x-data x-text="$store.i18n.t('ready_to_export')">Ready</span></span>
|
||||
<span class="cell-sub">{{ p.driver.original_filename }}</span>
|
||||
{% else %}
|
||||
<span class="badge warn">{{ ico.i('alert', 12) }}<span x-data x-text="$store.i18n.t('needs_driver')">Driver needed</span></span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="col-defaults">
|
||||
<span class="pill-row">
|
||||
<span class="pill" x-data
|
||||
x-text="$store.i18n.t('{{ 'one_sided' if p.duplex_mode == 'OneSided' else ('long_edge' if p.duplex_mode == 'LongEdge' else 'short_edge') }}')">{{ p.duplex_mode }}</span>
|
||||
<span class="pill" x-data
|
||||
x-text="$store.i18n.t('{{ 'color_value' if p.color_mode else 'grayscale_value' }}')">{{ 'Color' if p.color_mode else 'Grayscale' }}</span>
|
||||
<span class="pill">{{ p.paper_size }}</span>
|
||||
{% if p.collate %}
|
||||
<span class="pill" x-data x-text="$store.i18n.t('collate')">Collate</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
</td>
|
||||
<td class="actions">
|
||||
<span class="btn-row">
|
||||
{% if p.driver_id %}
|
||||
<a class="btn quiet sm" href="/printers/{{ p.id }}/packages/intunewin"
|
||||
title=".intunewin">{{ ico.i('download', 14) }}</a>
|
||||
{% endif %}
|
||||
{% include "partials/printer_edit_modal.html" %}
|
||||
<button class="btn danger sm"
|
||||
hx-delete="/printers/{{ p.id }}"
|
||||
hx-target="#printer-list"
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm="Delete '{{ p.name }}'?"
|
||||
x-data :title="$store.i18n.t('delete')">
|
||||
{{ ico.i('trash', 14) }}
|
||||
</button>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
@@ -1,82 +1,209 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<h1>{{ printer.name }}</h1>
|
||||
<article>
|
||||
<h2 x-data x-text="$store.i18n.t('configuration')">Configuration</h2>
|
||||
<dl>
|
||||
<dt x-data x-text="$store.i18n.t('ip_address')">IP Address</dt><dd>{{ printer.ip_address }}</dd>
|
||||
<dt x-data x-text="$store.i18n.t('port_name')">Port Name</dt><dd>{{ printer.port_name }}</dd>
|
||||
<dt x-data x-text="$store.i18n.t('duplex_mode_label')">Duplex Mode</dt><dd>{{ printer.duplex_mode }}</dd>
|
||||
<dt x-data x-text="$store.i18n.t('color_mode_label')">Color Mode</dt>
|
||||
{% if printer.color_mode %}<dd x-data x-text="$store.i18n.t('color_value')">Color</dd>{% else %}<dd x-data x-text="$store.i18n.t('grayscale_value')">Grayscale</dd>{% endif %}
|
||||
<dt x-data x-text="$store.i18n.t('paper_size_label')">Paper Size</dt><dd>{{ printer.paper_size }}</dd>
|
||||
<dt x-data x-text="$store.i18n.t('collate_label')">Collate</dt>
|
||||
{% if printer.collate %}<dd x-data x-text="$store.i18n.t('yes')">Yes</dd>{% else %}<dd x-data x-text="$store.i18n.t('no')">No</dd>{% endif %}
|
||||
<dt x-data x-text="$store.i18n.t('client_label')">Client</dt>
|
||||
{% if printer.client_id %}<dd>{{ printer.client.name }}</dd>{% else %}<dd x-data x-text="$store.i18n.t('unassigned')">Unassigned</dd>{% endif %}
|
||||
</dl>
|
||||
{% import "partials/icons.html" as ico %}
|
||||
|
||||
<h2 x-data x-text="$store.i18n.t('driver_section')">Driver</h2>
|
||||
{% if printer.driver_id %}
|
||||
<dl>
|
||||
<dt x-data x-text="$store.i18n.t('package_label')">Package</dt><dd>{{ printer.driver.original_filename }}</dd>
|
||||
<dt x-data x-text="$store.i18n.t('driver_names_label')">Driver Name(s)</dt><dd>{{ driver_names | join(", ") }}</dd>
|
||||
<dt x-data x-text="$store.i18n.t('architecture_label')">Architecture</dt>
|
||||
<dd>{% if printer.driver.architecture %}{{ printer.driver.architecture }}{% else %}<span x-data x-text="$store.i18n.t('unknown')">Unknown</span>{% endif %}</dd>
|
||||
</dl>
|
||||
{% else %}
|
||||
<p x-data x-text="$store.i18n.t('no_driver_detail')">No driver assigned</p>
|
||||
{% endif %}
|
||||
|
||||
{% if has_driver %}
|
||||
<h2 x-data x-text="$store.i18n.t('intune_commands')">Intune Commands</h2>
|
||||
<div x-data="{ copiedInstall: false }">
|
||||
<label x-text="$store.i18n.t('install_cmd_label')">Install command</label>
|
||||
<code id="install-cmd">{{ install_cmd }}</code>
|
||||
<button @click="
|
||||
const text = document.getElementById('install-cmd').innerText;
|
||||
navigator.clipboard.writeText(text).then(() => { copiedInstall = true; setTimeout(() => copiedInstall = false, 2000) })
|
||||
.catch(() => { /* fallback: text is visible for manual copy */ })
|
||||
" x-text="copiedInstall ? $store.i18n.t('copied') : $store.i18n.t('copy')" class="secondary outline">Copy</button>
|
||||
{% block head_title %}{{ printer.name }} · ImpTune{% endblock %}
|
||||
{% block crumb %}
|
||||
<span class="crumb" x-data>
|
||||
<a href="/printers">{{ ico.i('back', 12) }}<span x-text="$store.i18n.t('back_to_printer_library')">Printers</span></a>
|
||||
{% if printer.client_id %}<span>/</span><a href="/clients/{{ printer.client_id }}">{{ printer.client.name }}</a>{% endif %}
|
||||
</span>
|
||||
{% endblock %}
|
||||
{% block page_title %}{{ printer.name }}{% endblock %}
|
||||
|
||||
{% block page_actions %}
|
||||
{% if has_driver %}
|
||||
<span class="badge ok">{{ ico.i('check', 12) }}<span x-text="$store.i18n.t('ready_to_export')">Ready</span></span>
|
||||
{% else %}
|
||||
<span class="badge warn">{{ ico.i('alert', 12) }}<span x-text="$store.i18n.t('needs_driver')">Driver needed</span></span>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="split">
|
||||
<!-- Left: what this printer is -->
|
||||
<div>
|
||||
<section class="card">
|
||||
<div class="card-head">
|
||||
{{ ico.i('network') }}
|
||||
<h2 x-data x-text="$store.i18n.t('configuration')">Configuration</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<dl class="kv">
|
||||
<dt x-data x-text="$store.i18n.t('ip_address')">IP address</dt>
|
||||
<dd class="mono-val">{{ printer.ip_address }}</dd>
|
||||
|
||||
<dt x-data x-text="$store.i18n.t('port_name')">Port name</dt>
|
||||
<dd class="mono-val">{{ printer.port_name }}</dd>
|
||||
|
||||
<dt x-data x-text="$store.i18n.t('client_label')">Client</dt>
|
||||
{% if printer.client_id %}
|
||||
<dd><a href="/clients/{{ printer.client_id }}">{{ printer.client.name }}</a></dd>
|
||||
{% else %}
|
||||
<dd class="faint" x-data x-text="$store.i18n.t('unassigned')">Unassigned</dd>
|
||||
{% endif %}
|
||||
|
||||
<dt x-data x-text="$store.i18n.t('duplex_mode_label')">Duplex mode</dt>
|
||||
<dd x-data x-text="$store.i18n.t('{{ 'one_sided' if printer.duplex_mode == 'OneSided' else ('long_edge' if printer.duplex_mode == 'LongEdge' else 'short_edge') }}')">{{ printer.duplex_mode }}</dd>
|
||||
|
||||
<dt x-data x-text="$store.i18n.t('color_mode_label')">Color mode</dt>
|
||||
<dd x-data x-text="$store.i18n.t('{{ 'color_value' if printer.color_mode else 'grayscale_value' }}')">{{ 'Color' if printer.color_mode else 'Grayscale' }}</dd>
|
||||
|
||||
<dt x-data x-text="$store.i18n.t('paper_size_label')">Paper size</dt>
|
||||
<dd>{{ printer.paper_size }}</dd>
|
||||
|
||||
<dt x-data x-text="$store.i18n.t('collate_label')">Collate</dt>
|
||||
<dd x-data x-text="$store.i18n.t('{{ 'yes' if printer.collate else 'no' }}')">{{ 'Yes' if printer.collate else 'No' }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card-head">
|
||||
{{ ico.i('driver') }}
|
||||
<h2 x-data x-text="$store.i18n.t('driver_section')">Driver</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if printer.driver_id %}
|
||||
<dl class="kv">
|
||||
<dt x-data x-text="$store.i18n.t('package_label')">Package</dt>
|
||||
<dd class="mono-val">{{ printer.driver.original_filename }}</dd>
|
||||
|
||||
<dt x-data x-text="$store.i18n.t('driver_names_label')">Driver name(s)</dt>
|
||||
<dd>
|
||||
<span class="pill-row">
|
||||
{% for name in driver_names %}<span class="pill">{{ name }}</span>{% endfor %}
|
||||
</span>
|
||||
</dd>
|
||||
|
||||
<dt x-data x-text="$store.i18n.t('architecture_label')">Architecture</dt>
|
||||
<dd>{% if printer.driver.architecture %}<span class="badge">{{ printer.driver.architecture }}</span>{% else %}<span class="faint" x-data x-text="$store.i18n.t('unknown')">Unknown</span>{% endif %}</dd>
|
||||
</dl>
|
||||
{% else %}
|
||||
<div class="empty">
|
||||
<span class="empty-ico">{{ ico.i('driver', 18) }}</span>
|
||||
<strong>No driver assigned</strong>
|
||||
<p x-data x-text="$store.i18n.t('export_locked')">Assign a driver to this printer to unlock scripts and export.</p>
|
||||
<a href="/printers" class="btn sm" x-data>{{ ico.i('pencil', 14) }}<span x-text="$store.i18n.t('assign_driver_cta')">Assign a driver</span></a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% if has_driver %}
|
||||
<section class="card">
|
||||
<div class="card-head">
|
||||
{{ ico.i('terminal') }}
|
||||
<div>
|
||||
<h2 x-data x-text="$store.i18n.t('intune_commands')">Intune commands</h2>
|
||||
<p class="sub" x-data x-text="$store.i18n.t('hint_intune_cmds')">Paste these into the Win32 app's install and uninstall fields.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body field-stack">
|
||||
<div x-data="{ copiedInstall: false }">
|
||||
<span class="label-text" x-text="$store.i18n.t('install_cmd_label')">Install command</span>
|
||||
<div class="cmd">
|
||||
<code id="install-cmd">{{ install_cmd }}</code>
|
||||
<button type="button" @click="
|
||||
navigator.clipboard.writeText(document.getElementById('install-cmd').innerText)
|
||||
.then(() => { copiedInstall = true; setTimeout(() => copiedInstall = false, 2000) })
|
||||
.catch(() => { /* command stays visible for manual copy */ })
|
||||
" x-text="copiedInstall ? $store.i18n.t('copied') : $store.i18n.t('copy')">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
<div x-data="{ copiedUninstall: false }">
|
||||
<span class="label-text" x-text="$store.i18n.t('uninstall_cmd_label')">Uninstall command</span>
|
||||
<div class="cmd">
|
||||
<code id="uninstall-cmd">{{ uninstall_cmd }}</code>
|
||||
<button type="button" @click="
|
||||
navigator.clipboard.writeText(document.getElementById('uninstall-cmd').innerText)
|
||||
.then(() => { copiedUninstall = true; setTimeout(() => copiedUninstall = false, 2000) })
|
||||
.catch(() => { /* command stays visible for manual copy */ })
|
||||
" x-text="copiedUninstall ? $store.i18n.t('copied') : $store.i18n.t('copy')">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div x-data="{ copiedUninstall: false }">
|
||||
<label x-text="$store.i18n.t('uninstall_cmd_label')">Uninstall command</label>
|
||||
<code id="uninstall-cmd">{{ uninstall_cmd }}</code>
|
||||
<button @click="
|
||||
const text = document.getElementById('uninstall-cmd').innerText;
|
||||
navigator.clipboard.writeText(text).then(() => { copiedUninstall = true; setTimeout(() => copiedUninstall = false, 2000) })
|
||||
.catch(() => { /* fallback: text is visible for manual copy */ })
|
||||
" x-text="copiedUninstall ? $store.i18n.t('copied') : $store.i18n.t('copy')" class="secondary outline">Copy</button>
|
||||
|
||||
<!-- Right: what you can do with it -->
|
||||
<div>
|
||||
<section class="card">
|
||||
<div class="card-head">
|
||||
{{ ico.i('package') }}
|
||||
<h2 x-data x-text="$store.i18n.t('export_section')">Deploy</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if has_driver %}
|
||||
<div class="field-stack">
|
||||
<div>
|
||||
<a class="btn" href="/printers/{{ printer.id }}/packages/intunewin" x-data>
|
||||
{{ ico.i('download', 14) }}<span x-text="$store.i18n.t('download_intunewin')">Download .intunewin</span>
|
||||
</a>
|
||||
<span class="hint" x-data x-text="$store.i18n.t('hint_intunewin')">.intunewin — upload to Intune as a Win32 app.</span>
|
||||
</div>
|
||||
<div>
|
||||
<a class="btn ghost" href="/printers/{{ printer.id }}/packages/ninja" x-data>
|
||||
{{ ico.i('download', 14) }}<span x-text="$store.i18n.t('download_ninja')">Download NinjaRMM ZIP</span>
|
||||
</a>
|
||||
<span class="hint" x-data x-text="$store.i18n.t('hint_ninja')">ZIP — plain scripts for NinjaRMM or a manual run.</span>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="dim" x-data x-text="$store.i18n.t('export_locked')">Assign a driver to this printer to unlock scripts and export.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% if has_driver %}
|
||||
<section class="card">
|
||||
<div class="card-head">
|
||||
{{ ico.i('script') }}
|
||||
<h2 x-data x-text="$store.i18n.t('scripts_section')">PowerShell scripts</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="btn-row">
|
||||
<a class="btn ghost sm mono" href="/printers/{{ printer.id }}/scripts/install.ps1">{{ ico.i('download', 13) }}install.ps1</a>
|
||||
<a class="btn ghost sm mono" href="/printers/{{ printer.id }}/scripts/uninstall.ps1">{{ ico.i('download', 13) }}uninstall.ps1</a>
|
||||
<a class="btn ghost sm mono" href="/printers/{{ printer.id }}/scripts/detect.ps1">{{ ico.i('download', 13) }}detect.ps1</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<section class="card">
|
||||
<div class="card-head">
|
||||
{{ ico.i('image') }}
|
||||
<h2 x-data x-text="$store.i18n.t('icon_section')">Icon</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="icon-status">
|
||||
{% if has_icon %}
|
||||
<p class="ok-note">{{ ico.i('check', 14) }}<span x-data x-text="$store.i18n.t('icon_uploaded')">Icon saved</span></p>
|
||||
<div class="icon-preview">
|
||||
<img src="/printers/{{ printer.id }}/icon" width="56" height="56" alt="">
|
||||
<span class="meta">256×256 PNG</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="dim" x-data x-text="$store.i18n.t('no_icon')">No icon. Intune will show its default.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<form hx-post="/printers/{{ printer.id }}/icon"
|
||||
hx-target="#icon-status" hx-swap="innerHTML"
|
||||
hx-encoding="multipart/form-data">
|
||||
<div class="uploader">
|
||||
<input type="file" name="file" accept="image/png" required
|
||||
x-data :aria-label="$store.i18n.t('upload_icon')">
|
||||
<div class="btn-row">
|
||||
<button type="submit" class="btn ghost sm" x-data>
|
||||
{{ ico.i('upload', 13) }}<span x-text="$store.i18n.t('{{ 'replace_icon' if has_icon else 'upload_icon' }}')">Upload icon</span>
|
||||
</button>
|
||||
</div>
|
||||
<p class="hint" x-data x-text="$store.i18n.t('hint_icon')">PNG, exactly 256 × 256, 750 KB max.</p>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<h2 x-data x-text="$store.i18n.t('scripts_section')">Scripts</h2>
|
||||
<a href="/printers/{{ printer.id }}/scripts/install.ps1" role="button" class="secondary" x-data x-text="$store.i18n.t('download_install')">
|
||||
Download Install Script
|
||||
</a>
|
||||
<a href="/printers/{{ printer.id }}/scripts/uninstall.ps1" role="button" class="secondary" x-data x-text="$store.i18n.t('download_uninstall')">
|
||||
Download Uninstall Script
|
||||
</a>
|
||||
<a href="/printers/{{ printer.id }}/scripts/detect.ps1" role="button" class="secondary" x-data x-text="$store.i18n.t('download_detect')">
|
||||
Download Detect Script
|
||||
</a>
|
||||
|
||||
<h2 x-data x-text="$store.i18n.t('export_section')">Export</h2>
|
||||
<a href="/printers/{{ printer.id }}/packages/ninja" role="button" x-data x-text="$store.i18n.t('download_ninja')">Download NinjaRMM ZIP</a>
|
||||
<a href="/printers/{{ printer.id }}/packages/intunewin" role="button" x-data x-text="$store.i18n.t('download_intunewin')">Download .intunewin</a>
|
||||
{% endif %}
|
||||
|
||||
<h2 x-data x-text="$store.i18n.t('icon_section')">Icon</h2>
|
||||
{% if has_icon %}
|
||||
<p x-data x-text="$store.i18n.t('icon_uploaded')">Icon uploaded</p>
|
||||
{% endif %}
|
||||
<form hx-post="/printers/{{ printer.id }}/icon"
|
||||
hx-target="#icon-status" hx-swap="innerHTML"
|
||||
enctype="multipart/form-data">
|
||||
<input type="file" name="file" accept="image/png" required>
|
||||
<button type="submit" x-data x-text="$store.i18n.t('upload_icon')">Upload Icon</button>
|
||||
</form>
|
||||
<div id="icon-status"></div>
|
||||
|
||||
<a href="/printers" role="button" class="secondary" x-data x-text="$store.i18n.t('back_to_printers_btn')">Back to Printers</a>
|
||||
</article>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,12 +1,49 @@
|
||||
{% extends "base.html" %}
|
||||
{% import "partials/icons.html" as ico %}
|
||||
|
||||
{% block head_title %}Printers · ImpTune{% endblock %}
|
||||
{% block page_title %}<span x-data x-text="$store.i18n.t('printers')">Printers</span>{% endblock %}
|
||||
|
||||
{% block page_actions %}
|
||||
<a href="/printers/new" class="btn sm">{{ ico.i('plus') }}<span x-text="$store.i18n.t('add_printer')">Add printer</span></a>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1 x-data x-text="$store.i18n.t('printers')">Printers</h1>
|
||||
<p class="page-intro" x-data x-text="$store.i18n.t('printers_intro')">A printer with a driver assigned is ready to export.</p>
|
||||
|
||||
<p><a href="/printers/new" role="button" x-data x-text="$store.i18n.t('add_printer')">Add Printer</a></p>
|
||||
<div x-data="{
|
||||
shown: 0,
|
||||
total: 0,
|
||||
recount() {
|
||||
const rows = Array.from(document.querySelectorAll('#printer-list tr[data-search]'));
|
||||
this.total = rows.length;
|
||||
const q = this.$store.filter.q.trim().toLowerCase();
|
||||
this.shown = q ? rows.filter(r => r.dataset.search.includes(q)).length : rows.length;
|
||||
}
|
||||
}"
|
||||
x-init="recount()"
|
||||
@htmx:after-swap.window="recount()">
|
||||
|
||||
<div class="toolbar">
|
||||
<label class="search">
|
||||
{{ ico.i('search') }}
|
||||
<input type="search" x-model="$store.filter.q" @input="recount()"
|
||||
:placeholder="$store.i18n.t('filter_printers')"
|
||||
:aria-label="$store.i18n.t('filter_printers')">
|
||||
</label>
|
||||
<span class="filter-count" x-show="$store.filter.q.trim()" x-text="shown + ' / ' + total"></span>
|
||||
<span class="spacer"></span>
|
||||
{% if printer_count %}
|
||||
<span class="badge ok">{{ ico.i('check', 12) }}{{ ready_count }} <span x-text="$store.i18n.t('ready_to_export')">Ready</span></span>
|
||||
{% if printer_count - ready_count %}
|
||||
<span class="badge warn">{{ ico.i('alert', 12) }}{{ printer_count - ready_count }} <span x-text="$store.i18n.t('needs_driver')">Driver needed</span></span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<h2 x-data x-text="$store.i18n.t('printer_library')">Printer Library</h2>
|
||||
{% include "partials/printer_list.html" %}
|
||||
</section>
|
||||
|
||||
<p class="empty-state" x-show="$store.filter.q.trim() && shown === 0" style="display:none"
|
||||
x-text="$store.i18n.t('no_match')">No printer matches this filter.</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,100 +1,151 @@
|
||||
{% extends "base.html" %}
|
||||
{% import "partials/icons.html" as ico %}
|
||||
|
||||
{% block head_title %}Add printer · ImpTune{% endblock %}
|
||||
{% block crumb %}
|
||||
<span class="crumb" x-data>
|
||||
<a href="/printers">{{ ico.i('back', 12) }}<span x-text="$store.i18n.t('back_to_printer_library')">Printers</span></a>
|
||||
</span>
|
||||
{% endblock %}
|
||||
{% block page_title %}<span x-data x-text="$store.i18n.t('add_printer_title')">Add printer</span>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1 x-data x-text="$store.i18n.t('add_printer_title')">Add Printer</h1>
|
||||
<div class="split" x-data="{ ip: '', port: '', portEdited: false }">
|
||||
<section class="card">
|
||||
<div class="card-body">
|
||||
<form action="/printers" method="post">
|
||||
|
||||
<p><a href="/printers" x-data x-text="$store.i18n.t('back_to_printer_library')">← Back to Printer Library</a></p>
|
||||
<fieldset class="form-section">
|
||||
<legend x-text="$store.i18n.t('section_identity')">Identity</legend>
|
||||
<label>
|
||||
<span class="label-text" x-text="$store.i18n.t('printer_name')">Printer name</span>
|
||||
<input type="text" name="name" placeholder="HP LaserJet 4050 — Accounting" required>
|
||||
<span class="hint" x-text="$store.i18n.t('hint_name')">The name the user sees on their workstation.</span>
|
||||
</label>
|
||||
<label>
|
||||
<span class="label-text" x-text="$store.i18n.t('client')">Client</span>
|
||||
<select name="client_id">
|
||||
<option value="" x-text="$store.i18n.t('unassigned_option')">Unassigned</option>
|
||||
{% for c in clients %}
|
||||
<option value="{{ c.id }}">{{ c.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<div x-data="{ ip: '', port: '', portEdited: false }">
|
||||
<form action="/printers" method="post">
|
||||
<fieldset class="form-section">
|
||||
<legend x-text="$store.i18n.t('section_connection')">Network connection</legend>
|
||||
<div class="form-row">
|
||||
<label>
|
||||
<span class="label-text" x-text="$store.i18n.t('ip_address')">IP address</span>
|
||||
<input type="text" name="ip_address" class="mono"
|
||||
x-model="ip"
|
||||
@input="if (!portEdited) port = 'IP_' + ip.replaceAll('.', '_')"
|
||||
placeholder="192.168.1.100"
|
||||
inputmode="decimal"
|
||||
required>
|
||||
<span class="hint" x-text="$store.i18n.t('hint_ip')">The printer IPv4 address on the client network.</span>
|
||||
</label>
|
||||
<label>
|
||||
<span class="label-text" x-text="$store.i18n.t('port_name')">Port name</span>
|
||||
<input type="text" name="port_name" class="mono"
|
||||
x-model="port"
|
||||
@change="portEdited = true"
|
||||
@keydown="portEdited = true"
|
||||
placeholder="IP_192_168_1_100">
|
||||
<span class="hint" x-text="$store.i18n.t('hint_port')">Derived from the IP address. Edit it if your naming differs.</span>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<label>
|
||||
<span x-text="$store.i18n.t('printer_name')">Printer Name</span>
|
||||
<input type="text" name="name" placeholder="e.g. HP LaserJet 4050" required>
|
||||
</label>
|
||||
<fieldset class="form-section">
|
||||
<legend x-text="$store.i18n.t('section_driver')">Driver</legend>
|
||||
<label>
|
||||
<select name="driver_id" id="printer-form-driver-select">
|
||||
<option value="" x-text="$store.i18n.t('no_driver_option')">No driver</option>
|
||||
{% for item in driver_data %}
|
||||
<option value="{{ item.driver.id }}">
|
||||
{{ item.driver.original_filename }}{% if item.names %} — {{ item.names | join(', ') }}{% endif %}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<span class="hint" x-text="$store.i18n.t('hint_driver')">Without a driver the printer is saved, but export stays locked.</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<label>
|
||||
<span x-text="$store.i18n.t('ip_address')">IP Address</span>
|
||||
<input type="text" name="ip_address"
|
||||
x-model="ip"
|
||||
@input="if (!portEdited) port = 'IP_' + ip.replaceAll('.', '_')"
|
||||
placeholder="e.g. 192.168.1.100"
|
||||
required>
|
||||
</label>
|
||||
<fieldset class="form-section">
|
||||
<legend x-text="$store.i18n.t('section_defaults')">Print defaults</legend>
|
||||
<div class="form-row">
|
||||
<label>
|
||||
<span class="label-text" x-text="$store.i18n.t('duplex_mode')">Duplex mode</span>
|
||||
<select name="duplex_mode">
|
||||
<option value="OneSided" selected x-text="$store.i18n.t('one_sided')">One-sided</option>
|
||||
<option value="LongEdge" x-text="$store.i18n.t('long_edge')">Long edge</option>
|
||||
<option value="ShortEdge" x-text="$store.i18n.t('short_edge')">Short edge</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span class="label-text" x-text="$store.i18n.t('paper_size')">Paper size</span>
|
||||
<select name="paper_size">
|
||||
<option value="A4" selected>A4</option>
|
||||
<option value="Letter">Letter</option>
|
||||
<option value="Legal">Legal</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="check-row">
|
||||
<label>
|
||||
<input type="checkbox" name="color_mode" value="on" checked>
|
||||
<span x-text="$store.i18n.t('color_mode')">Color</span>
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" name="collate" value="on" checked>
|
||||
<span x-text="$store.i18n.t('collate')">Collate</span>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<label>
|
||||
<span x-text="$store.i18n.t('port_name')">Port Name</span>
|
||||
<input type="text" name="port_name"
|
||||
x-model="port"
|
||||
@change="portEdited = true"
|
||||
@keydown="portEdited = true"
|
||||
placeholder="e.g. IP_192_168_1_100">
|
||||
</label>
|
||||
<div class="form-foot">
|
||||
<button type="submit" class="btn" x-text="$store.i18n.t('save_printer')">Save printer</button>
|
||||
<a href="/printers" class="btn ghost" x-text="$store.i18n.t('cancel')">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<label>
|
||||
<span x-text="$store.i18n.t('driver')">Driver</span>
|
||||
<select name="driver_id" id="printer-form-driver-select">
|
||||
<option value="" x-text="$store.i18n.t('no_driver_option')">-- No driver --</option>
|
||||
{% for item in driver_data %}
|
||||
<option value="{{ item.driver.id }}">
|
||||
{{ item.driver.original_filename }} ({{ item.names | join(', ') }})
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span x-text="$store.i18n.t('duplex_mode')">Duplex Mode</span>
|
||||
<select name="duplex_mode">
|
||||
<option value="OneSided" selected x-text="$store.i18n.t('one_sided')">One-Sided</option>
|
||||
<option value="LongEdge" x-text="$store.i18n.t('long_edge')">Long Edge</option>
|
||||
<option value="ShortEdge" x-text="$store.i18n.t('short_edge')">Short Edge</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<input type="checkbox" name="color_mode" value="on" checked>
|
||||
<span x-text="$store.i18n.t('color_mode')">Color Mode</span>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span x-text="$store.i18n.t('paper_size')">Paper Size</span>
|
||||
<select name="paper_size">
|
||||
<option value="A4" selected>A4</option>
|
||||
<option value="Letter">Letter</option>
|
||||
<option value="Legal">Legal</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<input type="checkbox" name="collate" value="on" checked>
|
||||
<span x-text="$store.i18n.t('collate')">Collate</span>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span x-text="$store.i18n.t('client')">Client</span>
|
||||
<select name="client_id">
|
||||
<option value="" x-text="$store.i18n.t('unassigned_option')">-- Unassigned --</option>
|
||||
{% for c in clients %}
|
||||
<option value="{{ c.id }}">{{ c.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<button type="submit" x-text="$store.i18n.t('save_printer')">Save Printer</button>
|
||||
</form>
|
||||
|
||||
<form hx-post="/drivers/upload"
|
||||
hx-target="#driver-list"
|
||||
hx-encoding="multipart/form-data"
|
||||
hx-swap="outerHTML">
|
||||
<input type="hidden" name="caller" value="printer_form">
|
||||
<label>
|
||||
<span x-text="$store.i18n.t('upload_new_driver')">Upload New Driver</span>
|
||||
<input type="file" name="file" accept=".zip" required>
|
||||
</label>
|
||||
<button type="submit" class="secondary" x-text="$store.i18n.t('upload_driver')">Upload Driver</button>
|
||||
</form>
|
||||
<div id="driver-list"></div>
|
||||
{#
|
||||
Separate form on purpose: HTML forbids nesting, and the upload answers a
|
||||
question the technician has *while* filling the form ("my driver isn't in
|
||||
the list"). The response OOB-swaps the select above, new driver selected.
|
||||
#}
|
||||
<section class="card form-aside">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<h2 x-data x-text="$store.i18n.t('upload_new_driver')">Add a new driver</h2>
|
||||
<p class="sub" x-data x-text="$store.i18n.t('hint_driver_zip')">The ZIP must contain the .inf file and every file it references.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form hx-post="/drivers/upload"
|
||||
hx-target="#driver-list"
|
||||
hx-encoding="multipart/form-data"
|
||||
hx-swap="outerHTML"
|
||||
hx-indicator="#form-upload-spinner">
|
||||
<input type="hidden" name="caller" value="printer_form">
|
||||
<div class="uploader">
|
||||
<input type="file" name="file" accept=".zip" required
|
||||
x-data :aria-label="$store.i18n.t('driver_package_label')">
|
||||
<div class="btn-row">
|
||||
<button type="submit" class="btn ghost" x-data>
|
||||
{{ ico.i('upload', 14) }}<span x-text="$store.i18n.t('upload_btn')">Upload</span>
|
||||
</button>
|
||||
<span id="form-upload-spinner" class="htmx-indicator" aria-busy="true"
|
||||
x-data x-text="$store.i18n.t('uploading')">Uploading…</span>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<div id="driver-list"></div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,20 +1,35 @@
|
||||
{% extends "base.html" %}
|
||||
{% import "partials/icons.html" as ico %}
|
||||
|
||||
{% block head_title %}Restore backup key · ImpTune{% endblock %}
|
||||
{% block page_title %}<span x-data x-text="$store.i18n.t('restore_title')">Restore a backup key</span>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Restore backup key</h1>
|
||||
<section class="card" style="max-width:34rem">
|
||||
<div class="card-head">
|
||||
{{ ico.i('key') }}
|
||||
<h2 x-data x-text="$store.i18n.t('restore_title')">Restore a backup key</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="dim" x-data x-text="$store.i18n.t('restore_desc')">
|
||||
Paste the key from <code>imptune-backup-key.txt</code> to get your printers,
|
||||
settings, and clients back in this browser.
|
||||
</p>
|
||||
|
||||
<p>Paste the key from your <code>imptune-backup-key.txt</code> backup file to
|
||||
recover your printers, configs, and groups on this browser.</p>
|
||||
{% if error %}
|
||||
<div class="error"><p>{{ error }}</p></div>
|
||||
{% endif %}
|
||||
|
||||
{% if error %}
|
||||
<div class="error"><p>{{ error }}</p></div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/session/restore">
|
||||
<label>
|
||||
Backup key
|
||||
<input type="text" name="key" placeholder="paste your key here" required autofocus>
|
||||
</label>
|
||||
<button type="submit">Restore</button>
|
||||
</form>
|
||||
<form method="post" action="/session/restore">
|
||||
<label>
|
||||
<span class="label-text" x-data x-text="$store.i18n.t('backup_key_label')">Backup key</span>
|
||||
<input type="text" name="key" class="mono" placeholder="paste your key here" required autofocus>
|
||||
</label>
|
||||
<div class="btn-row" style="margin-top:.9rem">
|
||||
<button type="submit" class="btn" x-data x-text="$store.i18n.t('restore_btn')">Restore</button>
|
||||
<a href="/" class="btn ghost" x-data x-text="$store.i18n.t('cancel')">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user