diff --git a/workstation-setup/src/CMakeLists.txt b/workstation-setup/src/CMakeLists.txt new file mode 100644 index 0000000..602779b --- /dev/null +++ b/workstation-setup/src/CMakeLists.txt @@ -0,0 +1,33 @@ +qt_add_executable(nixos-workstations-setup + main.cpp + ProvisioningBackend.cpp + ProvisioningBackend.h +) + +qt_add_qml_module(nixos-workstations-setup + URI org.raspot.nixosworkstations.setup + VERSION 1.0 + QML_FILES + Main.qml +) + +target_compile_definitions(nixos-workstations-setup + PRIVATE + PASSWORD_HELPER_PATH="${PASSWORD_HELPER_PATH}" + PIN_HELPER_PATH="${PIN_HELPER_PATH}" + TARGET_USER="${TARGET_USER}" +) + +target_link_libraries(nixos-workstations-setup + PRIVATE + Qt6::Core + Qt6::Gui + Qt6::Qml + Qt6::Quick + Qt6::QuickControls2 +) + +install( + TARGETS nixos-workstations-setup + RUNTIME DESTINATION bin +) diff --git a/workstation-setup/src/Main.qml b/workstation-setup/src/Main.qml new file mode 100644 index 0000000..42a7b5c --- /dev/null +++ b/workstation-setup/src/Main.qml @@ -0,0 +1,1331 @@ +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 var steps: [ + { "title": "Bienvenue", "subtitle": "Présentation" }, + { "title": "Mot de passe", "subtitle": "Accès de secours" }, + { "title": "YubiKey", "subtitle": "PIN personnel" }, + { "title": "Terminé", "subtitle": "Poste prêt" } + ] + + property bool passwordStepValid: + currentPassword.text.trim().length > 0 && + newPassword.text.length > 0 && + confirmPassword.text.length > 0 && + newPassword.text === confirmPassword.text + + property bool pinStepValid: + currentPin.text.length >= 4 && + newPin.text.length >= 4 && + confirmPin.text.length >= 4 && + newPin.text === confirmPin.text + + // En production, l'état réel vient du backend et des marqueurs locaux. + // La simple saisie des champs ne signifie jamais que l'opération a réussi. + property bool workflowComplete: provisioning.complete + property bool closeAttempted: false + property string operationError: "" + + function goNext() { + if (currentStep === 0) + currentStep = 1 + } + + function goBack() { + // Une étape déjà appliquée au système n'est jamais rejouée. + if (currentStep === 1 && !provisioning.passwordDone) + currentStep = 0 + } + + Component.onCompleted: { + if (provisioning.complete) + 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 + } + } + + 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 + } + + // Indicateur volontairement informatif : il ne bloque jamais + // la validation du mot de passe. La longueur est privilégiée, + // la diversité des caractères apporte un bonus visuel. + function passwordStrengthLevel(password) { + if (password.length === 0) + return 0 + + var classes = 0 + if (/[a-z]/.test(password)) classes += 1 + if (/[A-Z]/.test(password)) classes += 1 + if (/[0-9]/.test(password)) classes += 1 + if (/[^A-Za-z0-9]/.test(password)) classes += 1 + + var level = 1 + + if (password.length >= 16) + level = classes >= 2 ? 4 : 3 + else if (password.length >= 12) + level = classes >= 2 ? 3 : 2 + else if (password.length >= 8) + level = classes >= 3 ? 2 : 1 + + // Quelques motifs manifestement faibles : on réduit + // l'indication sans empêcher l'utilisateur de continuer. + var lower = password.toLowerCase() + if (lower === "password" || lower === "motdepasse" || + lower === "azerty" || lower === "qwerty" || + lower === "12345678" || /^(.)\1+$/.test(password)) { + level = 1 + } + + return level + } + + function passwordStrengthLabel(level) { + if (level === 1) return "Faible" + if (level === 2) return "Moyen" + if (level === 3) return "Bon" + if (level === 4) return "Très bon" + return "Non évalué" + } + + function passwordStrengthColor(level) { + if (level === 1) return "#dc2626" + if (level === 2) return "#d97706" + if (level === 3) return "#2563eb" + if (level === 4) return "#16a34a" + return "#cbd5e1" + } + + function passwordStrengthHint(level) { + if (level === 1) return "Privilégiez surtout la longueur : une phrase de passe de 12 caractères ou plus est un bon point de départ." + if (level === 2) return "Correct pour un usage courant, mais quelques caractères supplémentaires amélioreraient sensiblement la robustesse." + if (level === 3) return "Bonne robustesse apparente : la longueur et la diversité sont satisfaisantes." + if (level === 4) return "Très bonne robustesse apparente : mot de passe long et suffisamment diversifié." + return "Saisissez votre nouveau mot de passe pour obtenir une indication de robustesse." + } + + component PrimaryButton: Controls.Button { + id: control + implicitHeight: 48 + implicitWidth: 170 + leftPadding: 24 + rightPadding: 24 + + contentItem: Controls.Label { + text: control.text + font.pixelSize: 15 + font.weight: Font.DemiBold + color: "white" + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + + background: Rectangle { + radius: 12 + color: control.down ? "#1e40af" : control.hovered ? "#2563eb" : "#1d4ed8" + Behavior on color { ColorAnimation { duration: 120 } } + } + } + + component SecondaryButton: Controls.Button { + id: control + implicitHeight: 48 + implicitWidth: 140 + leftPadding: 22 + rightPadding: 22 + + contentItem: Controls.Label { + text: control.text + font.pixelSize: 15 + font.weight: Font.Medium + color: "#334155" + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + + background: Rectangle { + radius: 12 + color: control.down ? "#dbe3ee" : control.hovered ? "#edf2f7" : "transparent" + border.width: 1 + border.color: "#cbd5e1" + } + } + + 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: 56 + selectByMouse: true + + background: Rectangle { + radius: 10 + color: field.activeFocus ? "#ffffff" : "#f8fafc" + border.width: field.activeFocus ? 2 : 1 + border.color: field.activeFocus ? "#2563eb" : "#cbd5e1" + } + + Controls.ToolButton { + id: visibilityButton + anchors.right: parent.right + anchors.rightMargin: 8 + anchors.verticalCenter: parent.verticalCenter + width: 38 + height: 38 + + hoverEnabled: true + focusPolicy: Qt.NoFocus + Accessible.name: field.secretVisible ? "Masquer la valeur" : "Afficher la valeur" + + onClicked: field.secretVisible = !field.secretVisible + + contentItem: Kirigami.Icon { + source: field.secretVisible ? "view-hidden" : "view-visible" + implicitWidth: 20 + implicitHeight: 20 + color: visibilityButton.hovered ? "#2563eb" : "#64748b" + } + + background: Rectangle { + radius: 8 + color: visibilityButton.pressed ? "#dbeafe" + : visibilityButton.hovered ? "#eff6ff" + : "transparent" + + Behavior on color { + ColorAnimation { duration: 100 } + } + } + } + } + + component FieldLabel: RowLayout { + id: fieldLabel + + property string iconName: "dialog-password" + property string labelText: "" + + spacing: 8 + + Kirigami.Icon { + source: fieldLabel.iconName + implicitWidth: 17 + implicitHeight: 17 + color: "#64748b" + } + + Controls.Label { + text: fieldLabel.labelText + color: "#334155" + font.pixelSize: 13 + font.weight: Font.DemiBold + } + } + + component StepDot: Rectangle { + required property int stepIndex + + width: 34 + height: 34 + radius: 17 + color: stepIndex < root.currentStep ? "#22c55e" + : stepIndex === root.currentStep ? "#ffffff" + : "#17345f" + border.width: stepIndex === root.currentStep ? 2 : 0 + border.color: "#93c5fd" + + Controls.Label { + anchors.centerIn: parent + text: parent.stepIndex < root.currentStep ? "✓" : (parent.stepIndex + 1) + color: parent.stepIndex === root.currentStep ? "#0f2b50" : "white" + font.pixelSize: 14 + font.weight: Font.Bold + } + } + + RowLayout { + anchors.fill: parent + spacing: 0 + + // ----------------------------------------------------- + // Colonne gauche : identité + progression + // ----------------------------------------------------- + Rectangle { + Layout.preferredWidth: Math.max(330, root.width * 0.29) + Layout.fillHeight: true + + gradient: Gradient { + GradientStop { position: 0.0; color: "#081a33" } + GradientStop { position: 0.55; color: "#0b2a50" } + GradientStop { position: 1.0; color: "#123d70" } + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: 44 + spacing: 0 + + RowLayout { + spacing: 14 + + Rectangle { + width: 54 + height: 54 + radius: 16 + color: "#1d4ed8" + + Controls.Label { + anchors.centerIn: parent + text: "N" + color: "white" + font.pixelSize: 25 + font.weight: Font.Bold + } + } + + ColumnLayout { + spacing: 2 + + Controls.Label { + text: "NixOS Workstations" + color: "white" + font.pixelSize: 19 + font.weight: Font.DemiBold + } + + Controls.Label { + text: "Configuration du poste" + color: "#a8c1e1" + font.pixelSize: 13 + } + } + } + + Item { Layout.preferredHeight: 70 } + + Controls.Label { + text: "VOTRE CONFIGURATION" + color: "#7fa4d1" + font.pixelSize: 11 + font.weight: Font.Bold + font.letterSpacing: 1.5 + } + + Item { Layout.preferredHeight: 26 } + + Repeater { + model: root.steps + + delegate: Item { + required property int index + required property var modelData + + Layout.fillWidth: true + Layout.preferredHeight: 78 + + RowLayout { + anchors.fill: parent + spacing: 16 + + // Colonne de largeur fixe : tous les marqueurs et tous + // les titres commencent exactement au même endroit. + Item { + Layout.preferredWidth: 42 + Layout.fillHeight: true + + StepDot { + anchors.top: parent.top + anchors.horizontalCenter: parent.horizontalCenter + stepIndex: index + } + + Rectangle { + visible: index < root.steps.length - 1 + anchors.top: parent.top + anchors.topMargin: 34 + anchors.bottom: parent.bottom + anchors.horizontalCenter: parent.horizontalCenter + width: 2 + color: index < root.currentStep ? "#22c55e" : "#28517e" + } + } + + ColumnLayout { + Layout.fillWidth: true + Layout.alignment: Qt.AlignVCenter + spacing: 3 + + Controls.Label { + Layout.fillWidth: true + text: modelData.title + color: index === root.currentStep ? "white" : "#c1d3e8" + font.pixelSize: 15 + font.weight: index === root.currentStep ? Font.DemiBold : Font.Medium + horizontalAlignment: Text.AlignLeft + } + + Controls.Label { + Layout.fillWidth: true + text: modelData.subtitle + color: "#7699c2" + font.pixelSize: 12 + horizontalAlignment: Text.AlignLeft + } + } + } + } + } + + Item { Layout.fillHeight: true } + + Rectangle { + Layout.fillWidth: true + implicitHeight: 86 + radius: 14 + color: "#102f55" + border.width: 1 + border.color: "#214b78" + + RowLayout { + anchors.fill: parent + anchors.margins: 16 + spacing: 12 + + Controls.Label { + text: "🛡" + font.pixelSize: 23 + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 3 + + Controls.Label { + text: "Configuration locale" + color: "white" + font.pixelSize: 13 + font.weight: Font.DemiBold + } + + Controls.Label { + Layout.fillWidth: true + text: "Les secrets définitifs ne seront pas enregistrés dans Git." + wrapMode: Text.WordWrap + color: "#91add0" + font.pixelSize: 11 + } + } + } + } + + Item { Layout.preferredHeight: 20 } + + RowLayout { + Layout.alignment: Qt.AlignHCenter + spacing: 6 + + Kirigami.Icon { + source: "lock" + implicitWidth: 14 + implicitHeight: 14 + color: "#6289b6" + } + + Controls.Label { + text: "Assistant obligatoire jusqu'à la fin" + color: "#6289b6" + font.pixelSize: 11 + } + } + } + } + + // ----------------------------------------------------- + // Partie droite + // ----------------------------------------------------- + Rectangle { + id: contentArea + Layout.fillWidth: true + Layout.fillHeight: true + color: "#eef2f7" + + ColumnLayout { + anchors.fill: parent + anchors.leftMargin: Math.max(48, contentArea.width * 0.07) + anchors.rightMargin: Math.max(48, contentArea.width * 0.07) + anchors.topMargin: 34 + anchors.bottomMargin: 34 + spacing: 20 + + RowLayout { + Layout.fillWidth: true + + Rectangle { + implicitWidth: previewLabel.implicitWidth + 28 + implicitHeight: 32 + radius: 16 + color: provisioning.busy ? "#eff6ff" : "#f0fdf4" + border.width: 1 + border.color: provisioning.busy ? "#bfdbfe" : "#bbf7d0" + + Controls.Label { + id: previewLabel + anchors.centerIn: parent + text: provisioning.busy ? "OPÉRATION SÉCURISÉE EN COURS" : "FINALISATION SÉCURISÉE DU POSTE" + color: provisioning.busy ? "#1d4ed8" : "#166534" + font.pixelSize: 11 + font.weight: Font.Bold + } + } + + Item { Layout.fillWidth: true } + + Rectangle { + implicitWidth: lockStatusRow.implicitWidth + 24 + implicitHeight: 34 + radius: 17 + color: root.closeAttempted ? "#fef2f2" : "#f8fafc" + border.width: 1 + border.color: root.closeAttempted ? "#fecaca" : "#cbd5e1" + + RowLayout { + id: lockStatusRow + anchors.centerIn: parent + spacing: 7 + + Kirigami.Icon { + source: root.workflowComplete ? "lock-open" : "lock" + implicitWidth: 16 + implicitHeight: 16 + color: root.closeAttempted ? "#dc2626" : "#64748b" + } + + Controls.Label { + text: root.closeAttempted + ? "Terminez la configuration avant de quitter" + : root.workflowComplete + ? "Configuration complète" + : "Configuration en cours" + color: root.closeAttempted ? "#b91c1c" : "#475569" + font.pixelSize: 11 + font.weight: Font.DemiBold + } + } + } + } + + Rectangle { + Layout.fillWidth: true + Layout.fillHeight: true + radius: 24 + color: "white" + border.width: 1 + border.color: "#dce3ec" + + ColumnLayout { + anchors.fill: parent + anchors.margins: Math.max(34, Math.min(64, parent.width * 0.055)) + spacing: 24 + + // Petit indicateur de progression supérieur + 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" + + Behavior on color { ColorAnimation { duration: 180 } } + } + } + } + + StackLayout { + id: wizard + Layout.fillWidth: true + Layout.fillHeight: true + currentIndex: root.currentStep + + // --------------------------------- + // 0 - Bienvenue + // --------------------------------- + Item { + ColumnLayout { + anchors.fill: parent + spacing: 20 + + Item { Layout.fillHeight: true } + + Rectangle { + Layout.preferredWidth: 86 + Layout.preferredHeight: 86 + Layout.alignment: Qt.AlignHCenter + radius: 24 + color: "#eff6ff" + border.width: 1 + border.color: "#bfdbfe" + + Controls.Label { + anchors.centerIn: parent + text: "✦" + color: "#2563eb" + font.pixelSize: 42 + font.weight: Font.Bold + } + } + + Controls.Label { + Layout.fillWidth: true + text: "Bienvenue sur votre nouveau poste" + color: "#0f172a" + font.pixelSize: 32 + font.weight: Font.Bold + horizontalAlignment: Text.AlignHCenter + } + + Controls.Label { + Layout.maximumWidth: 720 + Layout.alignment: Qt.AlignHCenter + text: "Quelques étapes rapides vont personnaliser vos moyens d'authentification et finaliser la préparation de votre environnement de travail." + wrapMode: Text.WordWrap + color: "#64748b" + font.pixelSize: 16 + lineHeight: 1.25 + horizontalAlignment: Text.AlignHCenter + } + + Item { Layout.preferredHeight: 12 } + + RowLayout { + Layout.alignment: Qt.AlignHCenter + spacing: 14 + + Repeater { + model: [ + { "icon": "🔑", "title": "Mot de passe", "text": "Définir votre accès local de secours" }, + { "icon": "🔐", "title": "YubiKey", "text": "Personnaliser votre PIN FIDO2" } + ] + + Rectangle { + required property var modelData + Layout.preferredWidth: 290 + Layout.preferredHeight: 128 + radius: 16 + color: "#f8fafc" + border.width: 1 + border.color: "#e2e8f0" + + RowLayout { + anchors.fill: parent + anchors.margins: 18 + spacing: 14 + + Controls.Label { + text: modelData.icon + font.pixelSize: 28 + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 5 + + Controls.Label { + text: modelData.title + color: "#0f172a" + font.pixelSize: 15 + font.weight: Font.DemiBold + } + + Controls.Label { + Layout.fillWidth: true + text: modelData.text + wrapMode: Text.WordWrap + color: "#64748b" + font.pixelSize: 12 + } + } + } + } + } + } + + Item { Layout.fillHeight: true } + + PrimaryButton { + Layout.alignment: Qt.AlignHCenter + text: "Commencer" + onClicked: root.goNext() + } + } + } + + // --------------------------------- + // 1 - Mot de passe + // --------------------------------- + Item { + ColumnLayout { + anchors.fill: parent + spacing: 18 + + RowLayout { + Layout.fillWidth: true + spacing: 20 + + Rectangle { + Layout.preferredWidth: 80 + Layout.preferredHeight: 80 + radius: 22 + color: "#eff6ff" + border.width: 1 + border.color: "#bfdbfe" + + Kirigami.Icon { + anchors.centerIn: parent + source: "dialog-password" + implicitWidth: 38 + implicitHeight: 38 + color: "#2563eb" + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 5 + + Controls.Label { + text: "Mot de passe local" + color: "#0f172a" + font.pixelSize: 29 + font.weight: Font.Bold + } + + Controls.Label { + Layout.fillWidth: true + text: "Ce mot de passe reste disponible comme solution de secours si votre YubiKey est momentanément indisponible." + wrapMode: Text.WordWrap + color: "#64748b" + font.pixelSize: 15 + lineHeight: 1.2 + } + } + } + + Rectangle { + Layout.fillWidth: true + implicitHeight: 70 + radius: 12 + color: "#eff6ff" + border.width: 1 + border.color: "#bfdbfe" + + RowLayout { + anchors.fill: parent + anchors.margins: 16 + spacing: 12 + + Kirigami.Icon { + source: "emblem-information" + implicitWidth: 22 + implicitHeight: 22 + color: "#2563eb" + } + + Controls.Label { + Layout.fillWidth: true + text: "Le changement est effectué localement par le mécanisme NixOS prévu à cet effet. Votre nouveau mot de passe n’est ni enregistré dans Git, ni transmis au service informatique." + wrapMode: Text.WordWrap + color: "#1e40af" + font.pixelSize: 13 + } + } + } + + Item { Layout.preferredHeight: 4 } + + FieldLabel { + iconName: "dialog-password" + labelText: "Mot de passe temporaire actuel" + } + + SecretField { + id: currentPassword + placeholderText: "Saisissez le mot de passe temporaire" + } + + FieldLabel { + iconName: "document-edit" + labelText: "Nouveau mot de passe" + } + + SecretField { + id: newPassword + placeholderText: "Choisissez votre mot de passe personnel" + } + + // Indicateur de robustesse purement informatif. + // Il ne participe pas à la condition d'activation + // du bouton Continuer. + Rectangle { + id: strengthCard + Layout.fillWidth: true + implicitHeight: 112 + radius: 12 + color: "#f8fafc" + border.width: 1 + border.color: "#e2e8f0" + + property int strengthLevel: root.passwordStrengthLevel(newPassword.text) + + ColumnLayout { + anchors.fill: parent + anchors.margins: 14 + spacing: 8 + + RowLayout { + Layout.fillWidth: true + spacing: 8 + + Kirigami.Icon { + source: "security-high" + implicitWidth: 17 + implicitHeight: 17 + color: root.passwordStrengthColor(strengthCard.strengthLevel) + } + + Controls.Label { + text: "Robustesse du mot de passe" + color: "#334155" + font.pixelSize: 12 + font.weight: Font.DemiBold + } + + Item { Layout.fillWidth: true } + + Controls.Label { + text: root.passwordStrengthLabel(strengthCard.strengthLevel) + color: root.passwordStrengthColor(strengthCard.strengthLevel) + font.pixelSize: 12 + font.weight: Font.Bold + } + } + + RowLayout { + Layout.fillWidth: true + spacing: 6 + + Repeater { + model: 4 + + Rectangle { + required property int index + Layout.fillWidth: true + implicitHeight: 7 + radius: 4 + color: index < parent.parent.parent.strengthLevel + ? root.passwordStrengthColor(strengthCard.strengthLevel) + : "#e2e8f0" + + Behavior on color { + ColorAnimation { duration: 140 } + } + } + } + } + + Controls.Label { + Layout.fillWidth: true + text: root.passwordStrengthHint(strengthCard.strengthLevel) + wrapMode: Text.WordWrap + color: "#64748b" + font.pixelSize: 11 + } + } + } + + Controls.Label { + text: "Indication uniquement : un mot de passe faible n'est pas bloqué." + color: "#94a3b8" + font.pixelSize: 11 + } + + FieldLabel { + iconName: "dialog-ok-apply" + labelText: "Confirmation" + } + + SecretField { + id: confirmPassword + placeholderText: "Confirmez votre nouveau mot de passe" + } + + Controls.Label { + visible: confirmPassword.text.length > 0 && newPassword.text !== confirmPassword.text + text: "Les deux nouveaux mots de passe ne correspondent pas." + color: "#dc2626" + font.pixelSize: 12 + } + + Rectangle { + visible: root.operationError.length > 0 && root.currentStep === 1 + Layout.fillWidth: true + implicitHeight: errorPasswordRow.implicitHeight + 24 + radius: 12 + color: "#fef2f2" + border.width: 1 + border.color: "#fecaca" + + RowLayout { + id: errorPasswordRow + anchors.fill: parent + anchors.margins: 12 + spacing: 10 + + Kirigami.Icon { + source: "dialog-error" + implicitWidth: 20 + implicitHeight: 20 + color: "#dc2626" + } + + Controls.Label { + Layout.fillWidth: true + text: root.operationError + wrapMode: Text.WordWrap + color: "#991b1b" + font.pixelSize: 12 + } + } + } + + Item { Layout.fillHeight: true } + + RowLayout { + Layout.fillWidth: true + + SecondaryButton { + text: "Retour" + onClicked: root.goBack() + } + + Item { Layout.fillWidth: true } + + PrimaryButton { + text: provisioning.busy ? "Modification…" : "Enregistrer et continuer" + enabled: root.passwordStepValid && !provisioning.busy + opacity: enabled ? 1.0 : 0.45 + onClicked: { + root.operationError = "" + var oldValue = currentPassword.text + var newValue = newPassword.text + var confirmationValue = confirmPassword.text + + provisioning.changePassword(oldValue, newValue, confirmationValue) + + // Les champs graphiques sont vidés immédiatement après transmission + // au backend afin de réduire leur durée de présence en mémoire UI. + currentPassword.text = "" + newPassword.text = "" + confirmPassword.text = "" + } + } + } + } + } + + // --------------------------------- + // 2 - YubiKey + // --------------------------------- + Item { + ColumnLayout { + anchors.fill: parent + spacing: 18 + + RowLayout { + Layout.fillWidth: true + spacing: 20 + + Rectangle { + Layout.preferredWidth: 80 + Layout.preferredHeight: 80 + radius: 22 + color: "#f0fdf4" + border.width: 1 + border.color: "#bbf7d0" + + Controls.Label { + anchors.centerIn: parent + text: "🔐" + font.pixelSize: 34 + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 5 + + Controls.Label { + text: "PIN de votre YubiKey" + color: "#0f172a" + font.pixelSize: 29 + font.weight: Font.Bold + } + + Controls.Label { + Layout.fillWidth: true + text: "Remplacez le PIN temporaire de préparation par un PIN personnel que vous serez seul à connaître." + wrapMode: Text.WordWrap + color: "#64748b" + font.pixelSize: 15 + } + } + } + + Rectangle { + Layout.fillWidth: true + implicitHeight: 78 + radius: 12 + color: "#fffbeb" + border.width: 1 + border.color: "#fde68a" + + RowLayout { + anchors.fill: parent + anchors.margins: 16 + spacing: 12 + + Controls.Label { + text: "⌨" + color: "#b45309" + font.pixelSize: 23 + } + + Controls.Label { + Layout.fillWidth: true + text: "Vérifiez Verr Num avant d'utiliser le pavé numérique. En cas de doute, utilisez les chiffres situés au-dessus des lettres." + wrapMode: Text.WordWrap + color: "#92400e" + font.pixelSize: 13 + } + } + } + + FieldLabel { + iconName: "dialog-password" + labelText: "PIN temporaire actuel" + } + + SecretField { + id: currentPin + placeholderText: "PIN temporaire" + inputMethodHints: Qt.ImhDigitsOnly + } + + FieldLabel { + iconName: "security-high" + labelText: "Nouveau PIN" + } + + SecretField { + id: newPin + placeholderText: "Nouveau PIN personnel" + inputMethodHints: Qt.ImhDigitsOnly + } + + FieldLabel { + iconName: "dialog-ok-apply" + labelText: "Confirmation du nouveau PIN" + } + + SecretField { + id: confirmPin + placeholderText: "Confirmez le nouveau PIN" + inputMethodHints: Qt.ImhDigitsOnly + } + + Controls.Label { + visible: confirmPin.text.length > 0 && newPin.text !== confirmPin.text + text: "Les deux nouveaux PIN ne correspondent pas." + color: "#dc2626" + font.pixelSize: 12 + } + + Rectangle { + visible: root.operationError.length > 0 && root.currentStep === 2 + Layout.fillWidth: true + implicitHeight: errorPinRow.implicitHeight + 24 + radius: 12 + color: "#fef2f2" + border.width: 1 + border.color: "#fecaca" + + RowLayout { + id: errorPinRow + anchors.fill: parent + anchors.margins: 12 + spacing: 10 + + Kirigami.Icon { + source: "dialog-error" + implicitWidth: 20 + implicitHeight: 20 + color: "#dc2626" + } + + Controls.Label { + Layout.fillWidth: true + text: root.operationError + wrapMode: Text.WordWrap + color: "#991b1b" + font.pixelSize: 12 + } + } + } + + Item { Layout.fillHeight: true } + + RowLayout { + Layout.fillWidth: true + + SecondaryButton { + visible: !provisioning.passwordDone + text: "Retour" + enabled: !provisioning.busy + onClicked: root.goBack() + } + + Item { Layout.fillWidth: true } + + PrimaryButton { + text: provisioning.busy ? "Configuration…" : "Configurer la YubiKey" + enabled: root.pinStepValid && !provisioning.busy + opacity: enabled ? 1.0 : 0.45 + onClicked: { + root.operationError = "" + var oldValue = currentPin.text + var newValue = newPin.text + var confirmationValue = confirmPin.text + + provisioning.changePin(oldValue, newValue, confirmationValue) + + currentPin.text = "" + newPin.text = "" + confirmPin.text = "" + } + } + } + } + } + + // --------------------------------- + // 3 - Terminé + // --------------------------------- + Item { + 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: 680 + Layout.alignment: Qt.AlignHCenter + text: "Votre mot de passe local de secours et le PIN personnel de votre YubiKey ont été configurés avec succès. Vos nouveaux secrets restent personnels et ne sont pas enregistrés dans Git." + wrapMode: Text.WordWrap + color: "#64748b" + font.pixelSize: 15 + lineHeight: 1.25 + horizontalAlignment: Text.AlignHCenter + } + + Item { Layout.preferredHeight: 12 } + + RowLayout { + Layout.alignment: Qt.AlignHCenter + spacing: 12 + + Repeater { + model: [ + { "icon": "dialog-password", "title": "Mot de passe", "text": "Accès local configuré" }, + { "icon": "security-high", "title": "YubiKey", "text": "PIN personnel configuré" }, + { "icon": "computer-laptop", "title": "Poste", "text": "Configuration finalisée" } + ] + + Rectangle { + required property var modelData + Layout.preferredWidth: 205 + Layout.preferredHeight: 126 + radius: 16 + color: "#f8fafc" + border.width: 1 + border.color: "#e2e8f0" + + ColumnLayout { + anchors.fill: parent + anchors.margins: 16 + spacing: 7 + + RowLayout { + Layout.fillWidth: true + + Rectangle { + Layout.preferredWidth: 38 + Layout.preferredHeight: 38 + radius: 11 + color: "#eff6ff" + + Kirigami.Icon { + anchors.centerIn: parent + source: modelData.icon + implicitWidth: 21 + implicitHeight: 21 + color: "#2563eb" + } + } + + Item { Layout.fillWidth: true } + + Rectangle { + Layout.preferredWidth: 25 + Layout.preferredHeight: 25 + radius: 13 + color: "#dcfce7" + + Kirigami.Icon { + anchors.centerIn: parent + source: "dialog-ok-apply" + implicitWidth: 15 + implicitHeight: 15 + color: "#16a34a" + } + } + } + + Controls.Label { + text: modelData.title + color: "#0f172a" + font.pixelSize: 14 + font.weight: Font.DemiBold + } + + Controls.Label { + Layout.fillWidth: true + text: modelData.text + color: "#64748b" + font.pixelSize: 11 + wrapMode: Text.WordWrap + } + } + } + } + } + + Item { Layout.fillHeight: true } + + RowLayout { + Layout.alignment: Qt.AlignHCenter + spacing: 12 + + PrimaryButton { + text: "Commencer à travailler" + enabled: root.workflowComplete && !provisioning.busy + opacity: enabled ? 1.0 : 0.45 + onClicked: root.close() + } + } + } + } + } + } + } + } + } + } +} diff --git a/workstation-setup/src/ProvisioningBackend.cpp b/workstation-setup/src/ProvisioningBackend.cpp new file mode 100644 index 0000000..bcac72c --- /dev/null +++ b/workstation-setup/src/ProvisioningBackend.cpp @@ -0,0 +1,435 @@ +#include "ProvisioningBackend.h" + +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include + +#ifndef PASSWORD_HELPER_PATH +#error PASSWORD_HELPER_PATH must be defined +#endif + +#ifndef PIN_HELPER_PATH +#error PIN_HELPER_PATH must be defined +#endif + +#ifndef TARGET_USER +#error TARGET_USER must be defined +#endif + +namespace { +constexpr int kHelperTimeoutMs = 60000; + +bool containsLineBreak(const QString &value) +{ + return value.contains(QLatin1Char('\n')) || value.contains(QLatin1Char('\r')); +} +} + +ProvisioningBackend::ProvisioningBackend(QObject *parent) + : QObject(parent) + , m_currentUser(effectiveUserName()) + , m_targetUser(QStringLiteral(TARGET_USER)) + , m_authorizedUser(m_currentUser == m_targetUser) +{ + refreshState(); +} + +QString ProvisioningBackend::effectiveUserName() +{ + const uid_t uid = geteuid(); + if (passwd *entry = getpwuid(uid)) { + return QString::fromLocal8Bit(entry->pw_name); + } + return {}; +} + +QString ProvisioningBackend::stateDirectory() const +{ + const QByteArray xdgState = qgetenv("XDG_STATE_HOME"); + if (!xdgState.isEmpty()) { + return QString::fromLocal8Bit(xdgState) + + QStringLiteral("/nixos-workstations"); + } + + if (passwd *entry = getpwuid(geteuid())) { + return QString::fromLocal8Bit(entry->pw_dir) + + QStringLiteral("/.local/state/nixos-workstations"); + } + + return QDir::homePath() + QStringLiteral("/.local/state/nixos-workstations"); +} + +QString ProvisioningBackend::passwordMarker() const +{ + return stateDirectory() + QStringLiteral("/password-initialized"); +} + +QString ProvisioningBackend::pinMarker() const +{ + return stateDirectory() + QStringLiteral("/yubikey-pin-initialized"); +} + +void ProvisioningBackend::refreshState() +{ + const bool oldPassword = m_passwordDone; + const bool oldPin = m_pinDone; + + m_passwordDone = QFileInfo::exists(passwordMarker()); + m_pinDone = QFileInfo::exists(pinMarker()); + + if (oldPassword != m_passwordDone || oldPin != m_pinDone) { + emit stateChanged(); + } +} + + +bool ProvisioningBackend::ensureStateWritable() +{ + const QString dir = stateDirectory(); + if (!QDir().mkpath(dir)) { + return false; + } + + const QString probePath = dir + QStringLiteral("/.write-test"); + QFile probe(probePath); + if (!probe.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + return false; + } + probe.write("ok\n"); + probe.close(); + probe.remove(); + return true; +} + +bool ProvisioningBackend::createMarker(const QString &path) +{ + const QFileInfo info(path); + if (!QDir().mkpath(info.absolutePath())) { + return false; + } + + QFile marker(path); + if (!marker.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + return false; + } + + const QByteArray content = + QByteArrayLiteral("completed=") + + QDateTime::currentDateTimeUtc().toString(Qt::ISODate).toUtf8() + + '\n'; + + if (marker.write(content) != content.size()) { + marker.close(); + return false; + } + + marker.close(); + marker.setPermissions(QFileDevice::ReadOwner | QFileDevice::WriteOwner); + return true; +} + +void ProvisioningBackend::setBusy(bool busy) +{ + if (m_busy == busy) { + return; + } + m_busy = busy; + emit busyChanged(); +} + +void ProvisioningBackend::secureClear(QByteArray &data) +{ + volatile char *p = data.data(); + for (qsizetype i = 0; i < data.size(); ++i) { + p[i] = 0; + } + data.clear(); +} + +void ProvisioningBackend::changePassword(const QString ¤tPassword, + const QString &newPassword, + const QString &confirmation) +{ + if (!m_authorizedUser) { + emit passwordChangeFinished(false, + QStringLiteral("Cette opération n'est pas autorisée pour cet utilisateur.")); + return; + } + + if (m_busy) { + return; + } + + if (m_passwordDone) { + emit passwordChangeFinished(true, + QStringLiteral("Le mot de passe a déjà été personnalisé.")); + return; + } + + if (currentPassword.isEmpty() || newPassword.isEmpty() || confirmation.isEmpty()) { + emit passwordChangeFinished(false, + QStringLiteral("Tous les champs du mot de passe doivent être renseignés.")); + return; + } + + if (newPassword != confirmation) { + emit passwordChangeFinished(false, + QStringLiteral("Les deux nouveaux mots de passe ne correspondent pas.")); + return; + } + + // Les helpers utilisent un protocole interne délimité par des retours ligne. + // Un secret interactif passwd/FIDO2 ne doit donc jamais en contenir. + if (containsLineBreak(currentPassword) || containsLineBreak(newPassword) + || containsLineBreak(confirmation)) { + emit passwordChangeFinished(false, + QStringLiteral("Le mot de passe contient un caractère non pris en charge.")); + return; + } + + // Vérifie avant toute modification que l'état de provisioning pourra + // effectivement être persisté dans le HOME de l'utilisateur. + if (!ensureStateWritable()) { + emit passwordChangeFinished(false, + QStringLiteral("Impossible d'enregistrer l'état de configuration dans votre profil. Contactez le service informatique.")); + return; + } + + startHelper(Operation::Password, + QStringLiteral(PASSWORD_HELPER_PATH), + currentPassword, + newPassword, + confirmation); +} + +void ProvisioningBackend::changePin(const QString ¤tPin, + const QString &newPin, + const QString &confirmation) +{ + if (!m_authorizedUser) { + emit pinChangeFinished(false, + QStringLiteral("Cette opération n'est pas autorisée pour cet utilisateur.")); + return; + } + + if (m_busy) { + return; + } + + if (!m_passwordDone) { + emit pinChangeFinished(false, + QStringLiteral("Le mot de passe doit être personnalisé avant le PIN YubiKey.")); + return; + } + + if (m_pinDone) { + emit pinChangeFinished(true, + QStringLiteral("Le PIN YubiKey a déjà été personnalisé.")); + return; + } + + if (currentPin.size() < 4 || newPin.size() < 4 || confirmation.size() < 4) { + emit pinChangeFinished(false, + QStringLiteral("Les PIN doivent contenir au moins 4 caractères.")); + return; + } + + if (newPin != confirmation) { + emit pinChangeFinished(false, + QStringLiteral("Les deux nouveaux PIN ne correspondent pas.")); + return; + } + + if (containsLineBreak(currentPin) || containsLineBreak(newPin) + || containsLineBreak(confirmation)) { + emit pinChangeFinished(false, + QStringLiteral("Le PIN contient un caractère non pris en charge.")); + return; + } + + if (!ensureStateWritable()) { + emit pinChangeFinished(false, + QStringLiteral("Impossible d'enregistrer l'état de configuration dans votre profil. Contactez le service informatique.")); + return; + } + + startHelper(Operation::Pin, + QStringLiteral(PIN_HELPER_PATH), + currentPin, + newPin, + confirmation); +} + +void ProvisioningBackend::startHelper(Operation operation, + const QString &helperPath, + const QString &firstSecret, + const QString &secondSecret, + const QString &thirdSecret) +{ + if (!QFileInfo::exists(helperPath)) { + const QString message = QStringLiteral("Le composant système requis est introuvable."); + if (operation == Operation::Password) { + emit passwordChangeFinished(false, message); + } else { + emit pinChangeFinished(false, message); + } + return; + } + + m_operation = operation; + setBusy(true); + + m_process = new QProcess(this); + m_process->setProgram(helperPath); + m_process->setProcessChannelMode(QProcess::SeparateChannels); + + QProcessEnvironment environment = QProcessEnvironment::systemEnvironment(); + environment.insert(QStringLiteral("LC_ALL"), QStringLiteral("C")); + environment.insert(QStringLiteral("LANG"), QStringLiteral("C")); + m_process->setProcessEnvironment(environment); + + auto *timeout = new QTimer(m_process); + timeout->setSingleShot(true); + timeout->setInterval(kHelperTimeoutMs); + + connect(timeout, &QTimer::timeout, m_process, [process = m_process]() { + if (process->state() != QProcess::NotRunning) { + process->kill(); + } + }); + + QByteArray first = firstSecret.toUtf8(); + QByteArray second = secondSecret.toUtf8(); + QByteArray third = thirdSecret.toUtf8(); + + connect(m_process, &QProcess::started, this, + [this, first = std::move(first), second = std::move(second), + third = std::move(third), timeout]() mutable { + QByteArray payload; + payload.reserve(first.size() + second.size() + third.size() + 3); + payload += first; + payload += '\n'; + payload += second; + payload += '\n'; + payload += third; + payload += '\n'; + + m_process->write(payload); + m_process->closeWriteChannel(); + + // Réduit la durée de vie des copies explicites côté backend. + secureClear(first); + secureClear(second); + secureClear(third); + secureClear(payload); + timeout->start(); + }); + + connect(m_process, &QProcess::errorOccurred, this, + [this](QProcess::ProcessError error) { + if (error != QProcess::FailedToStart) { + return; + } + + setBusy(false); + const QString message = QStringLiteral("Impossible de démarrer le composant de configuration."); + if (m_operation == Operation::Password) { + emit passwordChangeFinished(false, message); + } else { + emit pinChangeFinished(false, message); + } + }); + + connect(m_process, + qOverload(&QProcess::finished), + this, + [this, timeout](int exitCode, QProcess::ExitStatus exitStatus) { + timeout->stop(); + + const QByteArray stderrData = m_process->readAllStandardError(); + const QByteArray stdoutData = m_process->readAllStandardOutput(); + const bool success = exitStatus == QProcess::NormalExit && exitCode == 0; + const Operation operation = m_operation; + + QString message; + bool markerCreated = false; + + if (success) { + if (operation == Operation::Password) { + markerCreated = createMarker(passwordMarker()); + message = markerCreated + ? QStringLiteral("Votre mot de passe personnel est maintenant actif.") + : QStringLiteral("Le mot de passe a été modifié, mais l'état local n'a pas pu être enregistré. Contactez le service informatique avant de fermer la session."); + } else { + markerCreated = createMarker(pinMarker()); + message = markerCreated + ? QStringLiteral("Le PIN personnel de votre YubiKey est maintenant actif.") + : QStringLiteral("Le PIN a été modifié, mais l'état local n'a pas pu être enregistré. Contactez le service informatique avant de fermer la session."); + } + } else { + message = safeMessageForFailure(operation, exitCode, stderrData + stdoutData); + } + + m_process->deleteLater(); + m_process = nullptr; + setBusy(false); + refreshState(); + + const bool completed = success && markerCreated; + if (operation == Operation::Password) { + emit passwordChangeFinished(completed, message); + } else { + emit pinChangeFinished(completed, message); + } + }); + + m_process->start(); +} + +QString ProvisioningBackend::safeMessageForFailure(Operation operation, + int exitCode, + const QByteArray &stderrData) const +{ + const QString output = QString::fromUtf8(stderrData).toLower(); + + if (exitCode == 124 || output.contains(QStringLiteral("timeout"))) { + return QStringLiteral("L'opération a expiré. Vérifiez le périphérique puis réessayez."); + } + + if (operation == Operation::Password) { + if (output.contains(QStringLiteral("current_rejected"))) { + return QStringLiteral("Le mot de passe temporaire actuel est incorrect."); + } + if (output.contains(QStringLiteral("new_rejected"))) { + return QStringLiteral("Le système a refusé le nouveau mot de passe. Choisissez-en un autre puis réessayez."); + } + return QStringLiteral("Le mot de passe n'a pas été modifié. Vérifiez le mot de passe actuel puis réessayez."); + } + + if (output.contains(QStringLiteral("wrong_pin"))) { + return QStringLiteral("Le PIN temporaire est incorrect. N'essayez pas au hasard : le nombre de tentatives FIDO2 est limité."); + } + if (output.contains(QStringLiteral("pin_auth_blocked"))) { + return QStringLiteral("Les tentatives de PIN sont temporairement bloquées. Débranchez puis rebranchez la YubiKey avant de réessayer avec le bon PIN."); + } + if (output.contains(QStringLiteral("pin_blocked"))) { + return QStringLiteral("Le PIN FIDO2 de la YubiKey est bloqué. Contactez le service informatique ; ne réinitialisez pas la clé."); + } + if (output.contains(QStringLiteral("no_yubikey"))) { + return QStringLiteral("Aucune YubiKey compatible n'a été détectée. Branchez la clé puis réessayez."); + } + if (output.contains(QStringLiteral("pin_policy"))) { + return QStringLiteral("Le nouveau PIN ne respecte pas la politique configurée sur cette YubiKey."); + } + + return QStringLiteral("Le PIN YubiKey n'a pas été modifié. Vérifiez que la clé est branchée et que le PIN actuel est correct."); +} diff --git a/workstation-setup/src/ProvisioningBackend.h b/workstation-setup/src/ProvisioningBackend.h new file mode 100644 index 0000000..94a8594 --- /dev/null +++ b/workstation-setup/src/ProvisioningBackend.h @@ -0,0 +1,78 @@ +#pragma once + +#include +#include +#include + +class ProvisioningBackend final : public QObject +{ + Q_OBJECT + + Q_PROPERTY(bool busy READ busy NOTIFY busyChanged) + Q_PROPERTY(bool passwordDone READ passwordDone NOTIFY stateChanged) + Q_PROPERTY(bool pinDone READ pinDone NOTIFY stateChanged) + Q_PROPERTY(bool complete READ complete NOTIFY stateChanged) + Q_PROPERTY(QString currentUser READ currentUser CONSTANT) + Q_PROPERTY(QString targetUser READ targetUser CONSTANT) + Q_PROPERTY(bool authorizedUser READ authorizedUser CONSTANT) + +public: + explicit ProvisioningBackend(QObject *parent = nullptr); + + bool busy() const { return m_busy; } + bool passwordDone() const { return m_passwordDone; } + bool pinDone() const { return m_pinDone; } + bool complete() const { return m_passwordDone && m_pinDone; } + QString currentUser() const { return m_currentUser; } + QString targetUser() const { return m_targetUser; } + bool authorizedUser() const { return m_authorizedUser; } + + Q_INVOKABLE void changePassword(const QString ¤tPassword, + const QString &newPassword, + const QString &confirmation); + + Q_INVOKABLE void changePin(const QString ¤tPin, + const QString &newPin, + const QString &confirmation); + +signals: + void busyChanged(); + void stateChanged(); + void passwordChangeFinished(bool success, const QString &message); + void pinChangeFinished(bool success, const QString &message); + +private: + enum class Operation { + Password, + Pin + }; + + void startHelper(Operation operation, + const QString &helperPath, + const QString &firstSecret, + const QString &secondSecret, + const QString &thirdSecret); + + void setBusy(bool busy); + void refreshState(); + bool ensureStateWritable(); + bool createMarker(const QString &path); + QString stateDirectory() const; + QString passwordMarker() const; + QString pinMarker() const; + QString safeMessageForFailure(Operation operation, + int exitCode, + const QByteArray &stderrData) const; + + static QString effectiveUserName(); + static void secureClear(QByteArray &data); + + bool m_busy = false; + bool m_passwordDone = false; + bool m_pinDone = false; + bool m_authorizedUser = false; + QString m_currentUser; + QString m_targetUser; + QProcess *m_process = nullptr; + Operation m_operation = Operation::Password; +}; diff --git a/workstation-setup/src/main.cpp b/workstation-setup/src/main.cpp new file mode 100644 index 0000000..ca4dedc --- /dev/null +++ b/workstation-setup/src/main.cpp @@ -0,0 +1,53 @@ +#include "ProvisioningBackend.h" + +#include +#include +#include +#include +#include + +#include + +int main(int argc, char *argv[]) +{ + // Les champs contiennent temporairement des secrets : pas de core dump. + rlimit coreLimit { 0, 0 }; + setrlimit(RLIMIT_CORE, &coreLimit); + + QGuiApplication app(argc, argv); + + QGuiApplication::setOrganizationName(QStringLiteral("Raspot")); + QGuiApplication::setOrganizationDomain(QStringLiteral("raspot.in")); + QGuiApplication::setApplicationName(QStringLiteral("NixOS Workstations Setup")); + QGuiApplication::setDesktopFileName(QStringLiteral("org.raspot.nixosworkstations.setup")); + + QIcon::setThemeName(QStringLiteral("breeze")); + + if (qEnvironmentVariableIsEmpty("QT_QUICK_CONTROLS_STYLE")) { + QQuickStyle::setStyle(QStringLiteral("Basic")); + } + + ProvisioningBackend provisioning; + + // Défense en profondeur : le lanceur filtre déjà l'utilisateur, mais + // le backend refuse également toute opération pour un autre compte. + if (!provisioning.authorizedUser()) { + return 0; + } + + // Si les deux marqueurs existent, l'assistant n'a plus rien à faire. + if (provisioning.complete()) { + return 0; + } + + QQmlApplicationEngine engine; + engine.rootContext()->setContextProperty(QStringLiteral("provisioning"), &provisioning); + engine.loadFromModule(QStringLiteral("org.raspot.nixosworkstations.setup"), + QStringLiteral("Main")); + + if (engine.rootObjects().isEmpty()) { + return 1; + } + + return app.exec(); +}