Files
ImpTune/imptune/templates/base.html
T
kawaandClaude Opus 5 b397d3dc3d 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>
2026-08-04 17:58:49 +02:00

538 lines
25 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% 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>{% 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>
document.addEventListener('alpine:init', () => {
// Theme store: cycles Light -> Dark -> System, persists in localStorage
Alpine.store('theme', {
current: localStorage.getItem('imptune_theme') || 'auto',
icons: {
light: '<svg class="ico" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><circle cx="12" cy="12" r="4"/><path d="M12 3v2M12 19v2M3 12h2M19 12h2M5.6 5.6l1.4 1.4M17 17l1.4 1.4M18.4 5.6L17 7M7 17l-1.4 1.4"/></svg>',
dark: '<svg class="ico" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M20 14.5A8.5 8.5 0 0 1 9.5 4a8.5 8.5 0 1 0 10.5 10.5Z"/></svg>',
auto: '<svg class="ico" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><circle cx="12" cy="12" r="8"/><path d="M12 4v16a8 8 0 0 0 0-16Z" fill="currentColor" stroke="none"/></svg>'
},
init() {
document.documentElement.setAttribute('data-theme', this.current);
},
cycle() {
const order = ['light', 'dark', 'auto'];
this.current = order[(order.indexOf(this.current) + 1) % order.length];
localStorage.setItem('imptune_theme', this.current);
document.documentElement.setAttribute('data-theme', this.current);
}
});
// 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: (() => {
const saved = localStorage.getItem('imptune_lang');
if (saved) return saved;
return navigator.language && navigator.language.startsWith('en') ? 'en' : 'fr';
})(),
t(key) {
return (this.translations[this.lang] || {})[key] || key;
},
toggle() {
this.lang = this.lang === 'fr' ? 'en' : 'fr';
localStorage.setItem('imptune_lang', this.lang);
},
translations: {
fr: {
dashboard: 'Tableau de bord',
drivers: 'Pilotes',
printers: 'Imprimantes',
clients: 'Clients',
packages: 'Paquets',
add_printer: 'Ajouter une imprimante',
add_client: 'Ajouter un client',
printer_library: 'Bibliothèque dimprimantes',
edit: 'Modifier',
delete: 'Supprimer',
save: 'Enregistrer',
cancel: 'Annuler',
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ôté',
short_edge: 'Petit côté',
color_mode: 'Couleur',
paper_size: 'Format papier',
collate: 'Assembler',
client: 'Client',
edit_printer: 'Modifier limprimante',
no_printers: 'Aucune imprimante configurée.',
no_clients: 'Aucun client configuré.',
client_list: 'Liste des clients',
name: 'Nom',
created: 'Créé le',
back_to_printers: 'Retour aux imprimantes',
theme_label: 'Thème',
lang_label: 'FR',
// 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: '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',
th_driver: 'Pilote',
th_duplex: 'Recto-verso',
th_color: 'Couleur',
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: '—',
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',
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
drivers_title: 'Pilotes',
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: 'Importé le',
unknown: 'Inconnu',
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 un pilote assigné — prêtes pour lexport.',
printer_col: 'Imprimante',
client_col: 'Client',
driver_col: 'Pilote',
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',
color_value: 'Couleur',
grayscale_value: 'Niveaux de gris',
paper_size_label: 'Format papier',
collate_label: 'Assemblage',
client_label: 'Client',
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é.',
assign_driver_cta: 'Assigner un pilote',
intune_commands: 'Commandes Intune',
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é',
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 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',
drivers: 'Drivers',
printers: 'Printers',
clients: 'Clients',
packages: 'Packages',
add_printer: 'Add printer',
add_client: 'Add client',
printer_library: 'Printer Library',
edit: 'Edit',
delete: 'Delete',
save: 'Save',
cancel: 'Cancel',
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',
paper_size: 'Paper size',
collate: 'Collate',
client: 'Client',
edit_printer: 'Edit printer',
no_printers: 'No printers configured yet.',
no_clients: 'No clients configured yet.',
client_list: 'Client List',
name: 'Name',
created: 'Created',
back_to_printers: 'Back to Printers',
theme_label: 'Theme',
lang_label: 'EN',
// 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_port: 'Port',
th_driver: 'Driver',
th_duplex: 'Duplex',
th_color: 'Color',
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: '—',
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
drivers_title: 'Drivers',
upload_driver_section: 'Add a driver package',
driver_library: 'Driver library',
uploading: 'Uploading…',
upload_btn: 'Upload',
driver_filename: 'File',
driver_names_col: 'Driver name(s)',
architecture: 'Architecture',
uploaded_at: 'Added',
unknown: 'Unknown',
no_drivers: 'No drivers uploaded yet.',
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 a driver assigned — ready for export.',
printer_col: 'Printer',
client_col: 'Client',
driver_col: 'Driver',
downloads_col: 'Downloads',
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',
color_value: 'Color',
grayscale_value: 'Grayscale',
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)',
architecture_label: 'Architecture',
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: '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 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',
// 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'
}
}
});
});
</script>
<script defer src="/static/alpine.min.js"></script>
<script src="/static/htmx.min.js"></script>
</head>
<body x-data="{ nav: false }" :class="nav && 'nav-open'">
<div class="layout">
<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>
</nav>
<div class="main-wrapper">
<header class="topbar">
<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="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="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>
<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 %}
{% block content %}{% endblock %}
</main>
</div>
</div>
</body>
</html>