From 5366b4662871b346f199daf5ada36347442b1252 Mon Sep 17 00:00:00 2001 From: etiennecollin Date: Wed, 13 Aug 2025 20:10:00 +0200 Subject: [PATCH] feat: added QR code modal --- frontend/package-lock.json | 14 ++- frontend/package.json | 11 +- frontend/public/unifi.svg | 11 ++ frontend/src/components/Header.tsx | 34 ++++- .../src/components/modals/WifiQrModal.tsx | 72 +++++++++++ frontend/src/utils/wifi.ts | 117 ++++++++++++++++++ 6 files changed, 251 insertions(+), 8 deletions(-) create mode 100644 frontend/public/unifi.svg create mode 100644 frontend/src/components/modals/WifiQrModal.tsx create mode 100644 frontend/src/utils/wifi.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0709bae..e983969 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,14 +1,15 @@ { "name": "unifi-voucher-manager", - "version": "0.1.0", + "version": "0.0.0-git", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "unifi-voucher-manager", - "version": "0.1.0", + "version": "0.0.0-git", "dependencies": { "next": "15.4.5", + "qrcode.react": "^4.2.0", "react": "19.1.0", "react-dom": "19.1.0" }, @@ -1534,6 +1535,15 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/react": { "version": "19.1.0", "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 1f49857..6a23705 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,16 +9,17 @@ "lint": "next lint" }, "dependencies": { + "next": "15.4.5", + "qrcode.react": "^4.2.0", "react": "19.1.0", - "react-dom": "19.1.0", - "next": "15.4.5" + "react-dom": "19.1.0" }, "devDependencies": { - "typescript": "^5", + "@tailwindcss/postcss": "^4", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", - "@tailwindcss/postcss": "^4", - "tailwindcss": "^4" + "tailwindcss": "^4", + "typescript": "^5" } } diff --git a/frontend/public/unifi.svg b/frontend/public/unifi.svg new file mode 100644 index 0000000..2050248 --- /dev/null +++ b/frontend/public/unifi.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index ff52b37..7c35c32 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -1,16 +1,48 @@ +"use client"; + +import { useState } from "react"; import ThemeSwitcher from "@/components/utils/ThemeSwitcher"; +import WifiQrModal from "@/components/modals/WifiQrModal"; +import { generateWifiConfig, WifiConfig } from "@/utils/wifi"; + +const wifiConfig: WifiConfig | null = (() => { + try { + return generateWifiConfig(); + } catch (e) { + console.warn(`Could not generate WiFi configuration: ${e}`); + return null; + } +})(); export default function Header() { + const [showWifi, setShowWifi] = useState(false); + return (

UniFi Voucher Manager

-
+
+
+ {showWifi && wifiConfig && ( + setShowWifi(false)} + /> + )}
); } diff --git a/frontend/src/components/modals/WifiQrModal.tsx b/frontend/src/components/modals/WifiQrModal.tsx new file mode 100644 index 0000000..6ff4eed --- /dev/null +++ b/frontend/src/components/modals/WifiQrModal.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { useRef, useState, useEffect } from "react"; +import Modal from "@/components/modals/Modal"; +import { QRCodeSVG } from "qrcode.react"; +import { generateWiFiQRString, WifiConfig } from "@/utils/wifi"; + +type Props = { + wifiConfig: WifiConfig; + onClose: () => void; +}; + +export default function WifiQrModal({ wifiConfig, onClose }: Props) { + const modalRef = useRef(null); + const [qrSize, setQrSize] = useState(220); + const wifiString = wifiConfig && generateWiFiQRString(wifiConfig); + console.log(wifiString); + + useEffect(() => { + function updateSize() { + if (modalRef.current) { + // Make sure the QR code scales with the size of the modal + const modalWidth = modalRef.current.offsetWidth; + const modalHeight = modalRef.current.offsetHeight; + const sizeFromWidth = modalWidth * 0.8; + const sizeFromHeight = modalHeight * 0.8; + setQrSize(Math.floor(Math.min(sizeFromWidth, sizeFromHeight))); + } + } + updateSize(); + + window.addEventListener("resize", updateSize); + return () => window.removeEventListener("resize", updateSize); + }, []); + + return ( + +
+

+ Wi‑Fi QR Code +

+ {wifiString ? ( + <> + +

+ Scan this QR code to join the network:{" "} + {wifiConfig.ssid} +

+ + ) : ( +

+ No Wi‑Fi credentials configured. +

+ )} +
+
+ ); +} diff --git a/frontend/src/utils/wifi.ts b/frontend/src/utils/wifi.ts new file mode 100644 index 0000000..7d8eac5 --- /dev/null +++ b/frontend/src/utils/wifi.ts @@ -0,0 +1,117 @@ +type WifiType = "WPA" | "WEP" | "nopass"; + +export interface WifiConfig { + ssid: string; + password: string; + type: WifiType; + hidden?: boolean; +} + +export function generateWifiConfig(): WifiConfig { + const ssid = process.env.NEXT_WIFI_SSID; + const password = process.env.NEXT_WIFI_PASSWORD; + const type = process.env.NEXT_WIFI_TYPE; + const hidden = process.env.NEXT_WIFI_HIDDEN; + + if (ssid == null) { + throw "No SSID provided, use the environment variable WIFI_SSID to set one"; + } + + if (password == null) { + throw 'No password provided, use the environment variable WIFI_PASSWORD to set one. If your network does not have a password, use WIFI_PASSWORD=""'; + } + + let type_parsed: WifiType; + if (type == null) { + if (password) { + type_parsed = "WPA"; + console.log( + `WiFi Configuration: type not provided, but password provided. Defaulting to 'type=${type_parsed}' `, + ); + } else { + type_parsed = "nopass"; + console.log( + `WiFi Configuration: type and password not provided. Defaulting to 'type=${type_parsed}' `, + ); + } + } else { + switch (type.trim().toLowerCase()) { + case "wpa": + type_parsed = "WPA"; + break; + case "wep": + type_parsed = "WEP"; + break; + case "nopass": + type_parsed = "nopass"; + break; + default: + // TODO: Find how to print a type + throw `Invalid WiFi type provided: ${type}. Valid types: "WPA" | "WEP" | "nopass"`; + } + } + + if (password && type_parsed === "nopass") { + throw "Incoherent WiFi configuration: password provided, but type set to 'nopass'"; + } else if (!password && type_parsed !== "nopass") { + // TODO: This is true for WPA, but check for WEP + throw "Incoherent WiFi configuration: password not provided, but type implies a password"; + } + + let hidden_parsed: boolean; + if (hidden == null) { + hidden_parsed = true; + console.log( + `WiFi Configuration: hidden state not provided, defaulting to 'hidden=${hidden_parsed}' `, + ); + } else { + switch (hidden.trim().toLowerCase()) { + case "true": + hidden_parsed = true; + break; + case "false": + hidden_parsed = false; + break; + default: + throw `Invalid WiFi hidden state provided: ${hidden}`; + } + } + + const config: WifiConfig = { + ssid: ssid, + password: password, + type: type_parsed, + hidden: hidden_parsed, + }; + return config; +} + +export function generateWiFiQRString(config: WifiConfig): string { + const { ssid, password, type, hidden = false } = config; + + const encodedSSID = escapeWiFiString(ssid); + const encodedPassword = escapeWiFiString(password); + + // Format: WIFI:[T:security;][S:ssid;][P:password;][H:hidden;]; + let qrString = "WIFI:"; + + if (type !== "nopass") { + qrString += `T:${type};`; + } + + qrString += `S:${encodedSSID};`; + + if (type !== "nopass" && password) { + qrString += `P:${encodedPassword};`; + } + + qrString += `H:${hidden};`; + + qrString += ";"; + + return qrString; +} + +function escapeWiFiString(str: string): string { + return str.replace(/([\\;:,"])/g, "\\$1"); +}