import QtQuick import QtQuick.Window import QtQuick.Layouts import QtQuick.Controls as Controls import org.kde.kirigami as Kirigami Kirigami.ApplicationWindow { id: root title: "Configuration initiale du poste" visibility: Window.FullScreen color: "#eef2f7" property int currentStep: 0 property string operationError: "" property bool closeAttempted: false property var steps: [ { "title": "Bienvenue", "subtitle": "Première connexion" }, { "title": "Mot de passe", "subtitle": "Protection des données" }, { "title": "YubiKey", "subtitle": "Création du code PIN" }, { "title": "Association", "subtitle": "Association de la clé" }, { "title": "Terminé", "subtitle": "Poste prêt" } ] property bool passwordStepValid: currentPassword.text.length > 0 && newPassword.text.length >= 8 && confirmPassword.text.length > 0 && newPassword.text === confirmPassword.text property bool pinStepValid: newPin.text.length >= 4 && confirmPin.text.length >= 4 && newPin.text === confirmPin.text property bool fidoStepValid: fidoPassword.text.length > 0 && fidoPin.text.length >= 4 property bool workflowComplete: provisioning.complete Component.onCompleted: { if (provisioning.complete) currentStep = 4 else if (provisioning.fidoDone) currentStep = 4 else if (provisioning.pinDone) currentStep = 3 else if (provisioning.passwordDone) currentStep = 2 else currentStep = 0 } Connections { target: provisioning function onPasswordChangeFinished(success, message) { root.operationError = success ? "" : message if (success) root.currentStep = 2 } function onPinChangeFinished(success, message) { root.operationError = success ? "" : message if (success) root.currentStep = 3 } function onFidoEnrollmentFinished(success, message) { root.operationError = success ? "" : message if (success) root.currentStep = 4 } } onClosing: function(close) { if (!workflowComplete) { close.accepted = false closeAttempted = true closeWarningTimer.restart() } } onVisibilityChanged: { if (!workflowComplete && visibility !== Window.FullScreen) visibility = Window.FullScreen } Timer { id: closeWarningTimer interval: 3500 repeat: false onTriggered: root.closeAttempted = false } function passwordStrengthLevel(password) { if (password.length === 0) return 0 var classes = 0 if (/[a-z]/.test(password)) classes++ if (/[A-Z]/.test(password)) classes++ if (/[0-9]/.test(password)) classes++ if (/[^A-Za-z0-9]/.test(password)) classes++ if (password.length >= 16 && classes >= 2) return 4 if (password.length >= 12 && classes >= 2) return 3 if (password.length >= 8 && classes >= 2) return 2 return 1 } function strengthLabel(level) { if (level === 1) return "Faible" if (level === 2) return "Moyen" if (level === 3) return "Bon" if (level === 4) return "Très bon" return "" } function strengthColor(level) { if (level === 1) return "#dc2626" if (level === 2) return "#d97706" if (level === 3) return "#2563eb" if (level === 4) return "#16a34a" return "#cbd5e1" } component PrimaryButton: Controls.Button { id: control implicitHeight: 52 leftPadding: 26 rightPadding: 26 implicitWidth: Math.max(210, buttonLabel.implicitWidth + leftPadding + rightPadding) contentItem: Controls.Label { id: buttonLabel text: control.text color: "white" font.pixelSize: 15 font.weight: Font.DemiBold horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter wrapMode: Text.NoWrap } background: Rectangle { radius: 11 color: control.enabled ? (control.down ? "#1e40af" : control.hovered ? "#2563eb" : "#1d4ed8") : "#94a3b8" } } component SecretField: Controls.TextField { id: field property bool secretVisible: false Layout.fillWidth: true implicitHeight: 50 echoMode: secretVisible ? TextInput.Normal : TextInput.Password passwordCharacter: "●" font.pixelSize: 15 leftPadding: 16 rightPadding: 54 selectByMouse: true background: Rectangle { radius: 10 color: "#ffffff" border.width: field.activeFocus ? 2 : 1 border.color: field.activeFocus ? "#2563eb" : "#cbd5e1" } Controls.ToolButton { anchors.right: parent.right anchors.rightMargin: 8 anchors.verticalCenter: parent.verticalCenter width: 36 height: 36 focusPolicy: Qt.NoFocus onClicked: field.secretVisible = !field.secretVisible contentItem: Kirigami.Icon { source: field.secretVisible ? "view-hidden" : "view-visible" implicitWidth: 19 implicitHeight: 19 color: "#64748b" } } } component FieldLabel: Controls.Label { font.pixelSize: 13 font.weight: Font.DemiBold color: "#334155" } component InfoBox: Rectangle { id: infoBox property alias text: infoLabel.text property string iconText: "i" Layout.fillWidth: true implicitHeight: infoRow.implicitHeight + 28 radius: 12 color: "#eff6ff" border.width: 1 border.color: "#bfdbfe" RowLayout { id: infoRow anchors.fill: parent anchors.margins: 14 spacing: 12 Rectangle { Layout.preferredWidth: 30 Layout.preferredHeight: 30 radius: 15 color: "#dbeafe" Controls.Label { anchors.centerIn: parent text: infoBox.iconText color: "#1d4ed8" font.weight: Font.Bold } } Controls.Label { id: infoLabel Layout.fillWidth: true wrapMode: Text.WordWrap color: "#334155" font.pixelSize: 13 lineHeight: 1.2 } } } component ErrorBox: Rectangle { visible: root.operationError.length > 0 Layout.fillWidth: true implicitHeight: errorLabel.implicitHeight + 28 radius: 12 color: "#fef2f2" border.width: 1 border.color: "#fecaca" Controls.Label { id: errorLabel anchors.fill: parent anchors.margins: 14 text: root.operationError wrapMode: Text.WordWrap color: "#991b1b" font.pixelSize: 13 } } RowLayout { anchors.fill: parent spacing: 0 Rectangle { Layout.preferredWidth: Math.max(310, root.width * 0.27) Layout.fillHeight: true color: "#0f2b50" ColumnLayout { anchors.fill: parent anchors.margins: 34 spacing: 26 RowLayout { spacing: 12 Rectangle { Layout.preferredWidth: 44 Layout.preferredHeight: 44 radius: 12 color: "#1d4ed8" Controls.Label { anchors.centerIn: parent text: "N" color: "white" font.pixelSize: 21 font.weight: Font.Bold } } ColumnLayout { spacing: 2 Controls.Label { text: "NixOS Workstations" color: "white" font.pixelSize: 18 font.weight: Font.Bold } Controls.Label { text: "Configuration sécurisée" color: "#b9cee5" font.pixelSize: 12 } } } Rectangle { Layout.fillWidth: true implicitHeight: 1 color: "#28517e" } ColumnLayout { Layout.fillWidth: true spacing: 8 Repeater { model: root.steps Rectangle { required property var modelData required property int index Layout.fillWidth: true implicitHeight: 66 radius: 12 color: index === root.currentStep ? "#173f6f" : "transparent" RowLayout { anchors.fill: parent anchors.margins: 10 spacing: 13 Rectangle { Layout.preferredWidth: 34 Layout.preferredHeight: 34 radius: 17 color: index < root.currentStep ? "#22c55e" : index === root.currentStep ? "#ffffff" : "#28517e" Controls.Label { anchors.centerIn: parent text: index < root.currentStep ? "✓" : (index + 1) color: index === root.currentStep ? "#0f2b50" : "white" font.weight: Font.Bold } } Item { Layout.fillWidth: true Layout.fillHeight: true Column { anchors.left: parent.left anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter spacing: 2 Controls.Label { width: parent.width text: modelData.title color: "white" font.pixelSize: 14 font.weight: index === root.currentStep ? Font.DemiBold : Font.Medium horizontalAlignment: Text.AlignLeft } Controls.Label { width: parent.width text: modelData.subtitle color: "#b9cee5" font.pixelSize: 11 horizontalAlignment: Text.AlignLeft } } } } } } } Item { Layout.fillHeight: true } Controls.Label { visible: root.closeAttempted Layout.fillWidth: true text: "La configuration doit être terminée avant de fermer cet assistant." wrapMode: Text.WordWrap color: "#fde68a" font.pixelSize: 12 } } } Rectangle { Layout.fillWidth: true Layout.fillHeight: true color: "#f8fafc" ColumnLayout { anchors.fill: parent anchors.margins: Math.max(34, Math.min(64, parent.width * 0.055)) spacing: 24 RowLayout { Layout.fillWidth: true spacing: 10 Repeater { model: root.steps.length Rectangle { required property int index Layout.fillWidth: true implicitHeight: 5 radius: 3 color: index <= root.currentStep ? "#2563eb" : "#e2e8f0" } } } Item { id: pageViewport Layout.fillWidth: true Layout.fillHeight: true clip: true Row { id: pageStrip height: parent.height spacing: 0 x: -root.currentStep * pageViewport.width Behavior on x { NumberAnimation { duration: 330 easing.type: Easing.OutCubic } } // 0 - Bienvenue Item { width: pageViewport.width height: pageViewport.height ColumnLayout { anchors.fill: parent spacing: 20 Item { Layout.fillHeight: true } Controls.Label { Layout.fillWidth: true text: "Bienvenue sur votre poste" color: "#0f172a" font.pixelSize: 32 font.weight: Font.Bold horizontalAlignment: Text.AlignHCenter } Controls.Label { Layout.maximumWidth: 720 Layout.alignment: Qt.AlignHCenter text: "Votre espace personnel est déjà chiffré. Il reste à remplacer le mot de passe temporaire et à préparer votre YubiKey." wrapMode: Text.WordWrap horizontalAlignment: Text.AlignHCenter color: "#64748b" font.pixelSize: 16 lineHeight: 1.25 } InfoBox { Layout.maximumWidth: 720 Layout.alignment: Qt.AlignHCenter iconText: "🔐" text: "À la fin, vous pourrez ouvrir votre espace personnel soit avec votre mot de passe, soit avec votre YubiKey et son code PIN." } Item { Layout.fillHeight: true } PrimaryButton { Layout.alignment: Qt.AlignHCenter text: "Commencer" onClicked: { root.operationError = "" root.currentStep = 1 } } } } // 1 - Mot de passe Item { width: pageViewport.width height: pageViewport.height ColumnLayout { anchors.fill: parent spacing: 16 Controls.Label { text: "Remplacez le mot de passe temporaire" color: "#0f172a" font.pixelSize: 28 font.weight: Font.Bold } Controls.Label { Layout.fillWidth: true text: "Ce mot de passe reste votre solution de secours si la YubiKey est oubliée ou indisponible." wrapMode: Text.WordWrap color: "#64748b" font.pixelSize: 14 } Item { Layout.preferredHeight: 8 } FieldLabel { text: "Mot de passe temporaire actuel" } SecretField { id: currentPassword } FieldLabel { text: "Nouveau mot de passe" } SecretField { id: newPassword } FieldLabel { text: "Confirmation du nouveau mot de passe" } SecretField { id: confirmPassword } Rectangle { id: strengthCard Layout.fillWidth: true implicitHeight: 52 radius: 10 color: "#ffffff" border.width: 1 border.color: "#e2e8f0" property int level: root.passwordStrengthLevel(newPassword.text) RowLayout { anchors.fill: parent anchors.margins: 13 Controls.Label { text: "Robustesse indicative" color: "#64748b" } Item { Layout.fillWidth: true } Rectangle { width: 92 height: 8 radius: 4 color: strengthCard.level > 0 ? root.strengthColor(strengthCard.level) : "#e2e8f0" } Controls.Label { text: root.strengthLabel(strengthCard.level) color: root.strengthColor(strengthCard.level) font.weight: Font.DemiBold } } } ErrorBox { } Item { Layout.fillHeight: true } PrimaryButton { Layout.alignment: Qt.AlignRight text: provisioning.busy ? "Modification…" : "Valider le mot de passe" enabled: root.passwordStepValid && !provisioning.busy onClicked: { root.operationError = "" var c = currentPassword.text var n = newPassword.text var r = confirmPassword.text provisioning.changePassword(c, n, r) currentPassword.text = "" newPassword.text = "" confirmPassword.text = "" } } } } // 2 - PIN YubiKey Item { width: pageViewport.width height: pageViewport.height ColumnLayout { anchors.fill: parent spacing: 16 Controls.Label { text: "Initialisez votre YubiKey vierge" color: "#0f172a" font.pixelSize: 28 font.weight: Font.Bold } Controls.Label { Layout.fillWidth: true text: "Branchez une seule YubiKey vierge puis choisissez son code PIN. Aucun code PIN temporaire n'est utilisé." wrapMode: Text.WordWrap color: "#64748b" font.pixelSize: 14 } InfoBox { iconText: "☝" text: "La YubiKey doit être vierge ou avoir été réinitialisée. Elle ne doit pas déjà posséder de code PIN." } Item { Layout.preferredHeight: 8 } FieldLabel { text: "Nouveau code PIN" } SecretField { id: newPin } FieldLabel { text: "Confirmation du code PIN" } SecretField { id: confirmPin } ErrorBox { } Item { Layout.fillHeight: true } PrimaryButton { Layout.alignment: Qt.AlignRight text: provisioning.busy ? "Initialisation…" : "Créer le code PIN" enabled: root.pinStepValid && !provisioning.busy onClicked: { root.operationError = "" var p = newPin.text var r = confirmPin.text provisioning.initializePin(p, r) newPin.text = "" confirmPin.text = "" } } } } // 3 - Association FIDO2 au home Item { width: pageViewport.width height: pageViewport.height ColumnLayout { anchors.fill: parent spacing: 16 Controls.Label { text: "Associez la YubiKey à votre espace personnel" color: "#0f172a" font.pixelSize: 28 font.weight: Font.Bold } Controls.Label { Layout.fillWidth: true text: "La YubiKey sera ajoutée comme second moyen d'ouvrir votre espace personnel chiffré. Votre mot de passe reste utilisable en secours." wrapMode: Text.WordWrap color: "#64748b" font.pixelSize: 14 } InfoBox { iconText: "🔐" text: "Saisissez le mot de passe que vous venez de choisir et le code PIN de la YubiKey." } InfoBox { visible: !provisioning.busy iconText: "☝" text: "Après avoir lancé l'association, suivez les indications affichées à l'écran. La YubiKey peut demander plusieurs touchers." } Rectangle { id: fidoLiveStatus visible: provisioning.busy && root.currentStep === 3 Layout.fillWidth: true implicitHeight: fidoLiveRow.implicitHeight + 32 radius: 12 color: provisioning.fidoTouchRequired ? "#fff7ed" : "#eff6ff" border.width: provisioning.fidoTouchRequired ? 3 : 1 border.color: provisioning.fidoTouchRequired ? "#f97316" : "#bfdbfe" SequentialAnimation on opacity { running: fidoLiveStatus.visible && provisioning.fidoTouchRequired loops: Animation.Infinite NumberAnimation { from: 1.0; to: 0.58; duration: 520 } NumberAnimation { from: 0.58; to: 1.0; duration: 520 } } RowLayout { id: fidoLiveRow anchors.fill: parent anchors.margins: 16 spacing: 14 Controls.BusyIndicator { visible: !provisioning.fidoTouchRequired running: visible Layout.preferredWidth: 30 Layout.preferredHeight: 30 } Controls.Label { visible: provisioning.fidoTouchRequired text: "☝" font.pixelSize: 30 Layout.alignment: Qt.AlignTop } ColumnLayout { Layout.fillWidth: true spacing: 4 Controls.Label { Layout.fillWidth: true text: provisioning.fidoTouchRequired ? "TOUCHEZ LA YUBIKEY MAINTENANT" : "Association de la YubiKey en cours" wrapMode: Text.WordWrap color: provisioning.fidoTouchRequired ? "#9a3412" : "#1d4ed8" font.pixelSize: provisioning.fidoTouchRequired ? 18 : 15 font.weight: Font.Bold } Controls.Label { Layout.fillWidth: true text: provisioning.fidoStatus wrapMode: Text.WordWrap color: provisioning.fidoTouchRequired ? "#9a3412" : "#334155" font.pixelSize: 13 lineHeight: 1.2 } } } } Item { Layout.preferredHeight: 8 } FieldLabel { text: "Nouveau mot de passe choisi à l’étape 1" } SecretField { id: fidoPassword } FieldLabel { text: "Code PIN de la YubiKey" } SecretField { id: fidoPin } ErrorBox { } Item { Layout.fillHeight: true } PrimaryButton { Layout.alignment: Qt.AlignRight text: provisioning.busy ? "Association…" : "Associer la YubiKey" enabled: root.fidoStepValid && !provisioning.busy onClicked: { root.operationError = "" var p = fidoPassword.text var k = fidoPin.text provisioning.enrollFido(p, k) fidoPassword.text = "" fidoPin.text = "" } } } } // 4 - Terminé Item { width: pageViewport.width height: pageViewport.height ColumnLayout { anchors.fill: parent spacing: 18 Item { Layout.fillHeight: true } Rectangle { Layout.preferredWidth: 96 Layout.preferredHeight: 96 Layout.alignment: Qt.AlignHCenter radius: 48 color: "#dcfce7" border.width: 1 border.color: "#86efac" Kirigami.Icon { anchors.centerIn: parent source: "dialog-ok-apply" implicitWidth: 52 implicitHeight: 52 color: "#16a34a" } } Controls.Label { Layout.fillWidth: true text: "Votre poste est prêt" color: "#0f172a" font.pixelSize: 32 font.weight: Font.Bold horizontalAlignment: Text.AlignHCenter } Controls.Label { Layout.maximumWidth: 720 Layout.alignment: Qt.AlignHCenter text: "Votre espace personnel est chiffré et votre YubiKey est prête. Vous pourrez ouvrir votre session avec votre mot de passe ou avec votre YubiKey et son code PIN." wrapMode: Text.WordWrap horizontalAlignment: Text.AlignHCenter color: "#64748b" font.pixelSize: 15 lineHeight: 1.25 } InfoBox { Layout.maximumWidth: 720 Layout.alignment: Qt.AlignHCenter iconText: "✓" text: "Votre configuration sécurisée est terminée. Conservez votre YubiKey avec vous et mémorisez son code PIN." } Item { Layout.fillHeight: true } PrimaryButton { Layout.alignment: Qt.AlignHCenter text: "Commencer à travailler" enabled: root.workflowComplete && !provisioning.busy onClicked: root.close() } } } } // Row pageStrip } // pageViewport } } } }