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:
2026-08-04 17:58:49 +02:00
co-authored by Claude Opus 5
parent ed41f7f520
commit b397d3dc3d
38 changed files with 3739 additions and 1014 deletions
+6 -2
View File
@@ -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
View File
@@ -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]}&times;{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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+94 -36
View File
@@ -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"),
+5 -1
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+338 -160
View File
@@ -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: '&#9728;', dark: '&#9790;', auto: '&#9681;' },
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 dimprimantes',
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 limprimante',
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 limprimante',
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 dimpression',
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 limprimante',
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 limprimante',
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 ladresse IP. Modifiable.',
hint_name: 'Le nom vu par lutilisateur sur son poste.',
hint_driver: 'Sans pilote, limprimante est enregistrée mais lexport reste bloqué.',
hint_ip: 'Adresse IPv4 de limprimante 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 lexport.',
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 lexport.',
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 dinstallation et de désinstallation de lapplication Win32.',
install_cmd_label: 'Commande dinstallation',
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 licône',
replace_icon: 'Remplacer licône',
no_icon: 'Aucune icône. Intune affichera licô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 lexport.',
// 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 limprimante',
// 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">&#9681;</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 %}
+24 -6
View File
@@ -1,11 +1,29 @@
{% extends "base.html" %}
{% import "partials/icons.html" as ico %}
{% block head_title %}{{ client.name }} &middot; 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')">&larr; 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 }}&nbsp;<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 }}&nbsp;<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 %}
+30 -15
View File
@@ -1,21 +1,36 @@
{% extends "base.html" %}
{% import "partials/icons.html" as ico %}
{% block head_title %}Clients &middot; 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 %}
+139 -33
View File
@@ -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 %} &middot; {{ 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 %}&mdash;{% 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 %}
+37 -19
View File
@@ -1,25 +1,43 @@
{% extends "base.html" %}
{% import "partials/icons.html" as ico %}
{% block head_title %}Drivers &middot; 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 %}
+71 -17
View File
@@ -1,37 +1,91 @@
{% extends "base.html" %}
{% import "partials/icons.html" as ico %}
{% block head_title %}Packages &middot; 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>
&nbsp;|&nbsp;
<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 %}&mdash;{% 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">&mdash;</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 %}
+40 -17
View File
@@ -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>
+80 -35
View File
@@ -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 %} &middot; {{ 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>
+62
View File
@@ -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()">&times;</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>
+91 -50
View File
@@ -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 }} &middot; {{ 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 %}
+204 -77
View File
@@ -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 }} &middot; 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&times;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 %}
+42 -5
View File
@@ -1,12 +1,49 @@
{% extends "base.html" %}
{% import "partials/icons.html" as ico %}
{% block head_title %}Printers &middot; 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 }}&nbsp;<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 }}&nbsp;<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 %}
+140 -89
View File
@@ -1,100 +1,151 @@
{% extends "base.html" %}
{% import "partials/icons.html" as ico %}
{% block head_title %}Add printer &middot; 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')">&larr; 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 %}
+29 -14
View File
@@ -1,20 +1,35 @@
{% extends "base.html" %}
{% import "partials/icons.html" as ico %}
{% block head_title %}Restore backup key &middot; 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 %}