Files
nixos-workstations/workstation-setup/src/Main.qml
T

805 lines
32 KiB
QML

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
}
}
}
}