Merge pull request #4 from etiennecollin/feat/qr-code

Release 1.2.0
This commit is contained in:
Etienne Collin
2025-08-14 13:02:06 -04:00
committed by etiennecollin
26 changed files with 620 additions and 219 deletions

View File

@@ -1,8 +1,17 @@
README.md
.env
compose.yaml
assets
**
Dockerfile
.git
.gitignore
!backend/.cargo/**
!backend/src/**
!backend/Cargo.lock
!backend/Cargo.toml
!frontend/next-env.d.ts
!frontend/next.config.ts
!frontend/package-lock.json
!frontend/package.json
!frontend/postcss.config.mjs
!frontend/public/**
!frontend/src/**
!frontend/tsconfig.json
!scripts/**

View File

@@ -81,6 +81,10 @@ WORKDIR /app
RUN addgroup -g 1001 -S appgroup && \
adduser -S appuser -u 1001 -G appgroup
# Copy entrypoint script
COPY ./scripts/entrypoint.sh ./
RUN chmod +x entrypoint.sh
# Copy run wrapper script
COPY ./scripts/run_wrapper.sh ./
RUN chmod +x run_wrapper.sh
@@ -108,4 +112,5 @@ ENV BACKEND_BIND_PORT="8080"
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
CMD /usr/local/bin/healthcheck.sh
ENTRYPOINT ["./entrypoint.sh"]
CMD ["./run_wrapper.sh"]

View File

@@ -11,7 +11,7 @@ Perfect for businesses, cafes, hotels, and home networks that need to provide gu
<!-- vim-markdown-toc GFM -->
- [✨ Features](#-features)
- [🎫 Voucher Management](#-voucher-management)
- [🎫 Voucher Management & WiFi QR Code](#-voucher-management--wifi-qr-code)
- [🎨 Modern Interface](#-modern-interface)
- [🔧 Technical Features](#-technical-features)
- [🚀 Quick Start](#-quick-start)
@@ -28,7 +28,7 @@ Perfect for businesses, cafes, hotels, and home networks that need to provide gu
## ✨ Features
### 🎫 Voucher Management
### 🎫 Voucher Management & WiFi QR Code
- **Quick Create** - Generate guest vouchers with preset durations (1 hour to 1 week)
- **Custom Create** - Full control over voucher parameters:
@@ -41,6 +41,7 @@ Perfect for businesses, cafes, hotels, and home networks that need to provide gu
- **Search Vouchers** - Search vouchers by name
- **Bulk Operations** - Select and delete multiple vouchers
- **Auto-cleanup** - Remove expired vouchers with a single click
- **QR Code** - Easily connect guests to your network
### 🎨 Modern Interface
@@ -110,18 +111,26 @@ Perfect for businesses, cafes, hotels, and home networks that need to provide gu
### Environment Variables
| Variable | Type | Description | Example (default if optional) |
| ------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------- | -------------------------------- |
| `UNIFI_CONTROLLER_URL` | Required | URL to your UniFi controller with protocol. | `https://unifi.example.com:8443` |
| `UNIFI_API_KEY` | Required | API Key for your UniFi controller. | `abc123...` |
| `UNIFI_SITE_ID` | Optional | Site ID of your UniFi controller. Using the value `default`, the backend will try to fetch the ID of the default site. | `default` (default) |
| `FRONTEND_BIND_HOST` | Optional | Address on which the frontend server binds. | `0.0.0.0` (default) |
| `FRONTEND_BIND_PORT` | Optional | Port on which the frontend server binds. | `3000` (default) |
| `FRONTEND_TO_BACKEND_URL` | Optional | URL where the frontend will make its API requests to the backend. | `http://127.0.0.1` (default) |
| `BACKEND_BIND_HOST` | Optional | Address on which the server binds. | `127.0.0.1` (default) |
| `BACKEND_BIND_PORT` | Optional | Port on which the backend server binds. | `8080` (default) |
| `BACKEND_LOG_LEVEL` | Optional | Log level of the Rust backend. | `info`(default) |
| `TIMEZONE` | Optional | Server [timezone](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List). | `UTC` (default) |
Make sure to configure the required variables. The optional variables generally have default values that you should not have to change.
To configure the WiFi QR code, you are required to configure the `WIFI_SSID` and `WIFI_PASSWORD` variables.
| Variable | Type | Description | Example | Type |
| ------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------- | -------------------------------- | ------------------------------------------------------------------------------- |
| `UNIFI_CONTROLLER_URL` | Required | URL to your UniFi controller with protocol. | `https://unifi.example.com:8443` | `string` |
| `UNIFI_API_KEY` | Required | API Key for your UniFi controller. | `abc123...` | `string` |
| `UNIFI_SITE_ID` | Optional | Site ID of your UniFi controller. Using the value `default`, the backend will try to fetch the ID of the default site. | `default` (default) | `string` |
| `FRONTEND_BIND_HOST` | Optional | Address on which the frontend server binds. | `0.0.0.0` (default) | `IPv4` |
| `FRONTEND_BIND_PORT` | Optional | Port on which the frontend server binds. | `3000` (default) | `u16` |
| `FRONTEND_TO_BACKEND_URL` | Optional | URL where the frontend will make its API requests to the backend. | `http://127.0.0.1` (default) | `URL` |
| `BACKEND_BIND_HOST` | Optional | Address on which the server binds. | `127.0.0.1` (default) | `IPv4` |
| `BACKEND_BIND_PORT` | Optional | Port on which the backend server binds. | `8080` (default) | `u16` |
| `BACKEND_LOG_LEVEL` | Optional | Log level of the Rust backend. | `info`(default) | `trace\|debug\|info\|warn\|error` |
| `TIMEZONE` | Optional | [Timezone](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List) used to format dates and time. | `UTC` (default) | [`timezone`](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List) |
| `WIFI_SSID` | Optional | WiFi SSID used for the QR code. (required for QR code to be generated) | `My WiFi SSID` | `string` |
| `WIFI_PASSWORD` | Optional | WiFi password used for the QR code (required for QR code to be generated) | `My WiFi Password` | `string` |
| `WIFI_TYPE` | Optional | WiFi security type used. Defaults to `WPA` if a password is provided and `nopass` otherwise. | `WPA` | `WPA\|WEP\|nopass` |
| `WIFI_HIDDEN` | Optional | Whether the WiFi SSID is hidden or broadcasted. | `false` (default) | `bool` |
### Getting UniFi API Credentials
@@ -144,6 +153,9 @@ Perfect for businesses, cafes, hotels, and home networks that need to provide gu
- Check all environment variables are set
- Verify Docker container has network access to UniFi controller
- Check logs: `docker compose logs unifi-voucher-manager`
- **The WiFi QR code button is seems disabled**
- Check the [Environment Variables](#environment-variables) section and make sure you configured the variables required for the WiFi QR code.
- Check the browser console for variable configuration errors (generally by hitting `F12` and going to the 'console' tab).
### Getting Help

View File

@@ -1,5 +0,0 @@
target
README.md
.dockerignore
.gitignore

View File

@@ -1,7 +0,0 @@
node_modules
npm-debug.log
README.md
.next
.dockerignore
.gitignore

View File

@@ -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",

View File

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

2
frontend/public/qr.svg Normal file
View File

@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M3 9h6V3H3zm1-5h4v4H4zm1 1h2v2H5zm10 4h6V3h-6zm1-5h4v4h-4zm1 1h2v2h-2zM3 21h6v-6H3zm1-5h4v4H4zm1 1h2v2H5zm15 2h1v2h-2v-3h1zm0-3h1v1h-1zm0-1v1h-1v-1zm-10 2h1v4h-1v-4zm-4-7v2H4v-1H3v-1h3zm4-3h1v1h-1zm3-3v2h-1V3h2v1zm-3 0h1v1h-1zm10 8h1v2h-2v-1h1zm-1-2v1h-2v2h-2v-1h1v-2h3zm-7 4h-1v-1h-1v-1h2v2zm6 2h1v1h-1zm2-5v1h-1v-1zm-9 3v1h-1v-1zm6 5h1v2h-2v-2zm-3 0h1v1h-1v1h-2v-1h1v-1zm0-1v-1h2v1zm0-5h1v3h-1v1h-1v1h-1v-2h-1v-1h3v-1h-1v-1zm-9 0v1H4v-1zm12 4h-1v-1h1zm1-2h-2v-1h2zM8 10h1v1H8v1h1v2H8v-1H7v1H6v-2h1v-2zm3 0V8h3v3h-2v-1h1V9h-1v1zm0-4h1v1h-1zm-1 4h1v1h-1zm3-3V6h1v1z"/><path fill="none" d="M0 0h24v24H0z"/></svg>

After

Width:  |  Height:  |  Size: 828 B

11
frontend/public/unifi.svg Normal file
View File

@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" >
<path fill="url(#a)" d="M0 10c0-3.5 0-5.25.681-6.587A6.25 6.25 0 0 1 3.413.68C4.75 0 6.5 0 10 0h12c3.5 0 5.25 0 6.587.681a6.25 6.25 0 0 1 2.732 2.732C32 4.75 32 6.5 32 10v12c0 3.5 0 5.25-.681 6.587a6.25 6.25 0 0 1-2.732 2.732C27.25 32 25.5 32 22 32H10c-3.5 0-5.25 0-6.587-.681A6.25 6.25 0 0 1 .68 28.587C0 27.25 0 25.5 0 22V10Z"/>
<path fill="#fff" d="M23.5 8.88h-1v1h1v-1Zm-3.499 7.003v-2.004h2v2H24v.634c0 .733-.062 1.601-.206 2.283-.08.38-.201.76-.344 1.123a7.834 7.834 0 0 1-1.335 2.241l-.017.02-.028.033c-.077.09-.153.18-.237.267a7.888 7.888 0 0 1-.302.302 7.95 7.95 0 0 1-4.69 2.179 11 11 0 0 1-.841.044 11.84 11.84 0 0 1-.841-.044 7.954 7.954 0 0 1-4.69-2.179 7.888 7.888 0 0 1-.302-.302c-.088-.091-.167-.184-.248-.279l-.034-.04a7.834 7.834 0 0 1-1.335-2.242 7.132 7.132 0 0 1-.345-1.123C8.062 18.113 8 17.246 8 16.513V9.005h3.999v6.877s0 .528.006.7l.002.04c.008.224.017.442.04.66.066.618.202 1.203.484 1.699.081.143.164.282.263.414a4.022 4.022 0 0 0 2.658 1.572c.136.02.41.037.548.037.137 0 .412-.017.547-.037a4.022 4.022 0 0 0 2.66-1.572c.099-.132.18-.27.262-.414.282-.496.418-1.081.484-1.699.024-.218.032-.437.04-.66l.002-.04c.006-.172.006-.7.006-.7Z"/>
<path fill="#fff" d="M20.5 10.38H22v1.5h2v2h-2v-2h-1.5v-1.5Z"/>
<defs>
<radialGradient id="a" cx="0" cy="0" r="1" gradientTransform="matrix(0 32 -32 0 16 0)" gradientUnits="userSpaceOnUse">
<stop stop-color="#006FFF"/>
<stop offset="1" stop-color="#003C9E"/>
</radialGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -79,11 +79,15 @@
--shadow-elevation-dark: 0 4px 20px rgba(0, 0, 0, 0.4);
}
/* -------------------------------------------------- */
/* 0. Semantic Color Utilities - Theme System */
/* -------------------------------------------------- */
/* ========================================================================== */
/* 0. Semantic Color Utilities */
/* ========================================================================== */
/* Text Colors - Semantic */
/* ---------------------------------- */
/* Base */
/* ---------------------------------- */
/* Text */
@utility text-primary {
@apply text-neutral-900 dark:text-neutral-50;
}
@@ -100,7 +104,58 @@
@apply text-primary-700 dark:text-primary-300;
}
/* Status Text Colors */
/* Background */
@utility bg-page {
@apply bg-neutral-50 dark:bg-neutral-900;
}
@utility bg-surface {
@apply bg-white dark:bg-neutral-800;
}
@utility bg-overlay {
@apply bg-black/50 dark:bg-black/70;
}
@utility bg-disabled {
@apply bg-neutral-400 dark:bg-neutral-600;
}
/* Borders */
@utility border-default {
@apply border-neutral-200 dark:border-neutral-700;
}
@utility border-subtle {
@apply border-neutral-100 dark:border-neutral-800;
}
@utility border-intense {
@apply border-neutral-400 dark:border-neutral-500;
}
@utility border-accent {
@apply border-primary-500 dark:border-primary-500;
}
@utility border-emphasis {
@apply border-primary-600 dark:border-primary-300;
}
/* Focus */
@utility focus-accent {
@apply focus:outline-none focus:ring-2 focus:ring-primary-500;
}
@utility focus-danger {
@apply focus:outline-none focus:ring-2 focus:ring-danger-500;
}
/* Selected */
@utility selected-accent {
@apply border-accent bg-primary-600;
}
@utility unselected-neutral {
@apply border-default bg-surface;
}
/* ---------------------------------- */
/* Status */
/* ---------------------------------- */
/* Text */
@utility text-status-success {
@apply text-success-700 dark:text-success-400;
}
@@ -114,21 +169,7 @@
@apply text-primary-700 dark:text-primary-400;
}
/* Background Colors - Semantic */
@utility bg-page {
@apply bg-neutral-50 dark:bg-neutral-900;
}
@utility bg-surface {
@apply bg-white dark:bg-neutral-800;
}
@utility bg-surface-elevated {
@apply bg-white dark:bg-neutral-700;
}
@utility bg-overlay {
@apply bg-black/50 dark:bg-black/70;
}
/* Status Backgrounds */
/* Background */
@utility bg-status-success {
@apply bg-success-100 dark:bg-success-900/30;
}
@@ -142,32 +183,7 @@
@apply bg-primary-100 dark:bg-primary-900/30;
}
/* Interactive Backgrounds */
@utility bg-interactive {
@apply bg-neutral-50 dark:bg-neutral-700;
}
@utility bg-interactive-hover {
@apply hover:bg-neutral-100 dark:hover:bg-neutral-600;
}
@utility bg-interactive-active {
@apply bg-neutral-200 dark:bg-neutral-600;
}
/* Border Colors - Semantic */
@utility border-default {
@apply border-neutral-200 dark:border-neutral-700;
}
@utility border-subtle {
@apply border-neutral-100 dark:border-neutral-800;
}
@utility border-accent {
@apply border-primary-500 dark:border-primary-400;
}
@utility border-emphasis {
@apply border-primary-600 dark:border-primary-300;
}
/* Status Borders */
/* Borders */
@utility border-status-success {
@apply border-success-600;
}
@@ -181,56 +197,35 @@
@apply border-primary-600;
}
/* Focus States */
@utility focus-accent {
@apply focus:outline-none focus:ring-2 focus:ring-primary-500;
/* ---------------------------------- */
/* Interactive */
/* ---------------------------------- */
@utility bg-interactive {
@apply bg-neutral-50 dark:bg-neutral-800;
}
@utility focus-danger {
@apply focus:outline-none focus:ring-2 focus:ring-danger-500;
@utility bg-interactive-hover {
@apply hover:bg-neutral-100 dark:hover:bg-neutral-700;
}
@utility interactive-disabled {
@apply disabled:opacity-50 disabled:bg-disabled disabled:hover:bg-disabled disabled:text-primary disabled:border-default disabled:cursor-default;
}
/* Tab States */
@utility tab-active {
@apply text-brand font-semibold border-b-3 border-accent;
}
@utility tab-inactive {
@apply text-secondary bg-interactive-hover border-b-3 border-transparent hover:border-default;
}
/* Selection States */
@utility selected-accent {
@apply border-accent bg-primary-600;
}
@utility unselected-neutral {
@apply border-default bg-surface;
}
/* -------------------------------------------------- */
/* 1. Semantic Animation Utilities - Motion System */
/* -------------------------------------------------- */
/* ========================================================================== */
/* 1. Semantic Animation Utilities */
/* ========================================================================== */
/* Hover Effects */
@utility hover-lift {
@apply hover:-translate-y-1 hover:shadow-elevation hover:dark:shadow-elevation-dark;
}
@utility hover-subtle {
@apply hover:bg-interactive-hover;
@apply bg-interactive-hover;
}
@utility hover-scale {
@apply hover:scale-105;
}
/* Transition Speeds */
@utility transition-fast {
transition: all 0.15s ease;
}
@utility transition-smooth {
transition: all 0.2s ease;
}
@utility transition-slow {
transition: all 0.3s ease;
}
/* Slide Animations */
@utility slide-in-right {
@apply transform transition-transform duration-500 ease-out translate-x-full;
@@ -238,18 +233,6 @@
@utility slide-in-right-visible {
@apply translate-x-0;
}
@utility slide-in-left {
@apply transform transition-transform duration-300 ease-out -translate-x-full;
}
@utility slide-in-left-visible {
@apply translate-x-0;
}
@utility slide-in-up {
@apply transform transition-transform duration-300 ease-out translate-y-full;
}
@utility slide-in-up-visible {
@apply translate-y-0;
}
/* Fade Animations */
@utility fade-in {
@@ -267,10 +250,8 @@
/* Interactive States */
@utility interactive-element {
@apply transition-smooth cursor-pointer focus-accent;
}
@utility card-interactive {
@apply interactive-element hover-lift;
transition: all 0.2s ease;
@apply cursor-pointer focus-accent;
}
/* Focus Animations */
@@ -281,9 +262,22 @@
@apply focus:outline-none focus:ring-2 focus:ring-danger-500 focus:ring-offset-2 transition-all duration-200;
}
/* -------------------------------------------------- */
/* 2. Base layer: resets, defaults, theming, scrollbar */
/* -------------------------------------------------- */
/* ========================================================================== */
/* 2. Utilities */
/* ========================================================================== */
@utility flex-center {
@apply flex items-center justify-center;
}
@utility flex-center-between {
@apply flex items-center justify-between;
}
@utility inline-flex-center {
@apply inline-flex items-center justify-center;
}
/* ========================================================================== */
/* 3. Base layer: resets, defaults, theming, scrollbar */
/* ========================================================================== */
@layer base {
* {
@apply transition duration-200 ease-in-out;
@@ -331,7 +325,7 @@
}
select {
@apply appearance-none bg-surface border border-default cursor-pointer focus-accent px-3 py-2 rounded-lg w-full;
@apply appearance-none bg-surface bg-interactive-hover border border-default cursor-pointer focus-accent px-3 py-2 rounded-lg w-full;
}
input {
@apply bg-surface border border-default focus-accent px-3 py-2 rounded-lg w-full;
@@ -350,53 +344,50 @@
}
}
/* -------------------------------------------------- */
/* 3. Components layer: reusable patterns */
/* -------------------------------------------------- */
/* ========================================================================== */
/* 4. Components layer: reusable patterns */
/* ========================================================================== */
@utility btn {
@apply inline-flex items-center justify-center font-medium rounded-lg focus:outline-none focus:ring-2 focus:ring-offset-2 cursor-pointer px-4 py-2 disabled:opacity-50 disabled:bg-neutral-500 disabled:hover:bg-neutral-600 text-white;
@apply inline-flex-center bg-interactive-hover font-medium rounded-lg focus:ring-2 cursor-pointer px-4 py-2 border interactive-disabled border-default text-primary;
}
@layer components {
/* Buttons */
.btn-primary {
@apply btn bg-primary-500 hover:bg-primary-600 focus:ring-primary-300;
@apply btn text-white bg-primary-500 border-primary-600 hover:bg-primary-600 focus:ring-primary-300;
}
.btn-secondary {
@apply btn bg-secondary-500 hover:bg-secondary-600 focus:ring-secondary-300;
@apply btn text-white bg-secondary-500 border-secondary-600 hover:bg-secondary-600 focus:ring-secondary-300;
}
.btn-success {
@apply btn bg-success-500 hover:bg-success-600 focus:ring-success-300;
@apply btn text-white bg-success-500 border-success-600 hover:bg-success-600 focus:ring-success-300;
}
.btn-danger {
@apply btn bg-danger-500 hover:bg-danger-600 focus:ring-danger-300;
@apply btn text-white bg-danger-500 border-danger-600 hover:bg-danger-600 focus:ring-danger-300;
}
.btn-warning {
@apply btn bg-warning-500 hover:bg-warning-600 focus:ring-warning-300;
@apply btn text-white bg-warning-500 border-warning-600 hover:bg-warning-600 focus:ring-warning-300;
}
/* Cards */
.card {
@apply bg-surface border border-default rounded-xl shadow-soft dark:shadow-soft-dark p-4 border-1 w-full;
}
/* Custom styled select with arrow */
.icon-button {
@apply inline-flex items-center justify-center w-9 h-9 text-sm rounded-lg bg-surface bg-interactive-hover border border-default focus-accent;
.card-interactive {
@apply interactive-element hover-lift;
}
.voucher-code {
@apply font-bold text-brand font-mono tracking-wider;
}
}
/* -------------------------------------------------- */
/* 4. Utilities layer: small helpers */
/* -------------------------------------------------- */
@layer utilities {
/* Flex center shortcuts */
.flex-center {
@apply flex items-center justify-center;
.tab-active {
@apply text-brand font-semibold border-b-3 border-accent;
}
.tab-inactive {
@apply text-secondary bg-interactive-hover border-b-3 border-transparent hover:border-intense;
}
.sticky-tabs {
top: var(--header-height);
}
}

View File

@@ -18,6 +18,10 @@ export default function RootLayout({
}>) {
return (
<html lang="en" suppressHydrationWarning>
<head>
{/* Load runtime config */}
<script src="/runtime-config.js"></script>
</head>
<body className={`antialiased`}>{children}</body>
</html>
);

View File

@@ -1,16 +1,78 @@
"use client";
import { useEffect, useRef, useState } from "react";
import ThemeSwitcher from "@/components/utils/ThemeSwitcher";
import WifiQrModal from "@/components/modals/WifiQrModal";
import { generateWifiConfig, WifiConfig } from "@/utils/wifi";
export default function Header() {
const [showWifi, setShowWifi] = useState(false);
const [wifiConfig, setWifiConfig] = useState<WifiConfig | null>(null);
const headerRef = useRef<HTMLElement>(null);
useEffect(() => {
// Set initial height and update on resize
function updateHeaderHeight() {
if (headerRef.current) {
document.documentElement.style.setProperty(
"--header-height",
`${headerRef.current.offsetHeight}px`,
);
}
}
updateHeaderHeight();
window.addEventListener("resize", updateHeaderHeight);
return () => window.removeEventListener("resize", updateHeaderHeight);
}, []);
useEffect(() => {
const config: WifiConfig | null = (() => {
try {
return generateWifiConfig();
} catch (e) {
console.warn(`Could not generate WiFi configuration: ${e}`);
return null;
}
})();
setWifiConfig(config);
}, [generateWifiConfig]);
return (
<header className="bg-surface border-b border-default sticky top-0 z-7000">
<div className="max-w-95/100 mx-auto flex items-center justify-between px-4 py-4">
<header
ref={headerRef}
className="bg-surface border-b border-default sticky top-0 z-7000"
>
<div className="max-w-95/100 mx-auto flex-center-between px-4 py-4">
<h1 className="text-xl md:text-2xl font-semibold text-brand">
UniFi Voucher Manager
</h1>
<div className="flex items-center gap-3">
<div className="flex-center gap-3">
<button
onClick={() => setShowWifi(true)}
className="btn p-1"
disabled={!wifiConfig}
aria-label="Open WiFi QR code"
title="Open WiFi QR code"
>
<img
src="/qr.svg"
width={45}
height={45}
className="dark:invert"
alt="QR code icon"
/>
</button>
<ThemeSwitcher />
</div>
</div>
{showWifi && wifiConfig && (
<WifiQrModal
wifiConfig={wifiConfig}
onClose={() => setShowWifi(false)}
/>
)}
</header>
);
}

View File

@@ -1,17 +1,12 @@
import { Voucher } from "@/types/voucher";
import {
formatCode,
formatDate,
formatDuration,
formatGuestUsage,
} from "@/utils/format";
import { formatCode, formatDuration, formatGuestUsage } from "@/utils/format";
import { memo } from "react";
type Props = {
voucher: Voucher;
selected: boolean;
editMode: boolean;
onClick: () => void;
onClick?: () => void;
};
const VoucherCard = ({ voucher, selected, editMode, onClick }: Props) => {
@@ -29,7 +24,7 @@ const VoucherCard = ({ voucher, selected, editMode, onClick }: Props) => {
{editMode && (
<div className="absolute top-3 right-3 z-1000">
<div
className={`w-6 h-6 rounded-full border-2 flex items-center justify-center transition-smooth
className={`w-6 h-6 rounded-full border-2 flex-center
${selected ? "selected-accent" : "unselected-neutral"}`}
>
{selected && <div className="w-3 h-3 bg-white rounded-full" />}
@@ -62,20 +57,18 @@ const VoucherCard = ({ voucher, selected, editMode, onClick }: Props) => {
{voucher.activatedAt && (
<div className="flex justify-between">
<span>First Used:</span>
<span className="text-xs">{formatDate(voucher.activatedAt)}</span>
<span className="text-xs">{voucher.activatedAt}</span>
</div>
)}
<div className="flex justify-between items-center">
<div className="flex-center-between">
<span
className={`px-2 py-1 rounded-lg text-xs font-semibold uppercase ${statusClass}`}
>
{voucher.expired ? "Expired" : "Active"}
</span>
{voucher.expiresAt && (
<span className="text-xs">
Expires: {formatDate(voucher.expiresAt)}
</span>
<span className="text-xs">Expires: {voucher.expiresAt}</span>
)}
</div>
</div>

View File

@@ -1,17 +1,19 @@
"use client";
import { ReactNode, useEffect } from "react";
import { ReactNode, useEffect, RefObject } from "react";
type Props = {
onClose: () => void;
/** Extra classes for the content container */
contentClassName?: string;
ref?: RefObject<HTMLDivElement | null>;
children: ReactNode;
};
export default function Modal({
onClose,
contentClassName = "",
ref,
children,
}: Props) {
// lock scroll + handle Escape
@@ -41,6 +43,7 @@ export default function Modal({
>
<div
className={`bg-surface border border-default flex flex-col max-h-9/10 max-w-lg overflow-hidden relative rounded-xl shadow-2xl w-full ${contentClassName}`}
ref={ref}
>
<button
onClick={onClose}

View File

@@ -6,7 +6,6 @@ import { api } from "@/utils/api";
import { useEffect, useRef, useState } from "react";
import {
formatBytes,
formatDate,
formatDuration,
formatGuestUsage,
formatSpeed,
@@ -64,13 +63,11 @@ export default function VoucherModal({ voucher, onClose }: Props) {
[
["Status", details.expired ? "Expired" : "Active"],
["Name", details.name || "No note"],
["Created", formatDate(details.createdAt)],
["Created", details.createdAt],
...(details.activatedAt
? [["Activated", formatDate(details.activatedAt)]]
: []),
...(details.expiresAt
? [["Expires", formatDate(details.expiresAt)]]
? [["Activated", details.activatedAt]]
: []),
...(details.expiresAt ? [["Expires", details.expiresAt]] : []),
["Duration", formatDuration(details.timeLimitMinutes)],
[
"Guest Usage",
@@ -92,7 +89,7 @@ export default function VoucherModal({ voucher, onClose }: Props) {
).map(([label, value]) => (
<div
key={label}
className="flex justify-between items-center p-4 bg-interactive border border-subtle rounded-xl space-x-4"
className="flex-center-between p-4 bg-interactive border border-subtle rounded-xl space-x-4"
>
<span className="font-semibold text-primary">{label}:</span>
<span className="text-secondary">{value}</span>

View File

@@ -0,0 +1,74 @@
"use client";
import { useRef, useState, useEffect, useMemo } 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<HTMLDivElement | null>(null);
const [qrSize, setQrSize] = useState(220);
const wifiString = useMemo(
() => wifiConfig && generateWiFiQRString(wifiConfig),
[wifiConfig],
);
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 (
<Modal ref={modalRef} onClose={onClose} contentClassName="max-w-md">
<div className="flex-center flex-col gap-4">
<h2 className="text-2xl font-bold text-primary text-center">
WiFi QR Code
</h2>
{wifiString ? (
<>
<QRCodeSVG
value={wifiString}
size={qrSize}
level="H"
bgColor="transparent"
fgColor="currentColor"
marginSize={4}
title="Wi-Fi Access QR Code"
imageSettings={{
src: "/unifi.svg",
height: qrSize / 4,
width: qrSize / 4,
excavate: true,
}}
/>
<p className="text-sm text-muted">
Scan this QR code to join the network:{" "}
<strong>{wifiConfig.ssid}</strong>
</p>
</>
) : (
<p className="text-sm text-muted text-center">
No WiFi credentials configured.
</p>
)}
</div>
</Modal>
);
}

View File

@@ -35,6 +35,8 @@ export default function NotificationItem({ id, message, type, onDone }: Props) {
return "border-status-success text-status-success";
case "error":
return "border-status-danger text-status-danger";
case "warning":
return "border-status-warning text-status-warning";
default:
return "border-status-info text-status-info";
}

View File

@@ -43,7 +43,7 @@ export default function Tabs() {
return (
<>
<nav className="bg-surface border-b border-default flex sticky top-16 z-2000 shadow-soft dark:shadow-soft-dark">
<nav className="bg-surface border-b border-default flex sticky sticky-tabs z-2000 shadow-soft dark:shadow-soft-dark">
{enabledTabs.map((tabConfig) => (
<button
key={tabConfig.id}

View File

@@ -2,8 +2,13 @@
import { notify } from "@/utils/notifications";
import Spinner from "@/components/utils/Spinner";
import VoucherCard from "../VoucherCard";
import VoucherModal from "@/components/modals/VoucherModal";
import { useCallback, useState } from "react";
import { Voucher } from "@/types/voucher";
export default function TestTab() {
const [viewVoucher, setViewVoucher] = useState<Voucher | null>(null);
const sendInfo = () => notify("This is an info notification", "info");
const sendSuccess = () => notify("Operation succeeded!", "success");
const sendError = () => notify("Something went wrong!", "error");
@@ -13,31 +18,107 @@ export default function TestTab() {
setTimeout(() => notify("Third message", "error"), 1000);
};
const closeModal = useCallback(() => {
setViewVoucher(null);
}, []);
const v: Voucher = {
id: "test-voucher",
createdAt: "2025-12-31",
name: "Test Voucher",
code: "TEST123",
authorizedGuestCount: 0,
authorizedGuestLimit: null,
expired: false,
timeLimitMinutes: 1440,
activatedAt: null,
expiresAt: "2025-12-31",
dataUsageLimitMBytes: null,
rxRateLimitKbps: null,
txRateLimitKbps: null,
};
return (
<div className="flex-center flex-col space-y-6">
<div className="card max-w-lg space-y-4">
<h2 className="text-lg font-semibold text-primary">
Notification Tester
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<button onClick={sendInfo} className="btn-primary">
Send Info
</button>
<button onClick={sendSuccess} className="btn-success">
Send Success
</button>
<button onClick={sendError} className="btn-danger">
Send Error
</button>
<button onClick={sendMultiple} className="btn-primary">
Send Multiple
</button>
<>
<div className="flex-center flex-col space-y-6">
<div className="card max-w-lg space-y-4">
<h2 className="text-lg font-semibold text-primary">
Notification Tester
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<button onClick={sendInfo} className="btn-primary">
Send Info
</button>
<button onClick={sendSuccess} className="btn-success">
Send Success
</button>
<button onClick={sendError} className="btn-danger">
Send Error
</button>
<button onClick={sendMultiple} className="btn-primary">
Send Multiple
</button>
</div>
</div>
<div className="card max-w-lg">
<h2 className="text-lg font-semibold text-primary">
Spinner Example
</h2>
<Spinner />
</div>
<div className="flex-center flex-row gap-4 w-full">
<VoucherCard
key={123}
voucher={{
id: "abc123",
createdAt: "2025-12-31",
name: "test voucher",
code: "1234567890",
authorizedGuestCount: 0,
expired: false,
timeLimitMinutes: 1440,
}}
editMode={false}
selected={false}
onClick={() => setViewVoucher(v)}
/>
<VoucherCard
key={456}
voucher={{
id: "abc123",
createdAt: "2025-12-31",
name: "test voucher",
code: "1234567890",
authorizedGuestCount: 0,
expired: false,
timeLimitMinutes: 1440,
}}
editMode={true}
selected={true}
onClick={() => setViewVoucher(v)}
/>
<VoucherCard
key={789}
voucher={{
id: "abc123",
createdAt: "2025-12-31",
name: "test voucher",
code: "1234567890",
authorizedGuestCount: 1,
expired: true,
timeLimitMinutes: 1440,
expiresAt: "2025-12-31",
}}
editMode={true}
selected={false}
onClick={() => setViewVoucher(v)}
/>
</div>
</div>
<div className="card max-w-lg">
<h2 className="text-lg font-semibold text-primary">Spinner Example</h2>
<Spinner />
</div>
</div>
{viewVoucher && (
<VoucherModal voucher={viewVoucher} onClose={closeModal} />
)}
</>
);
}

View File

@@ -0,0 +1,7 @@
declare global {
interface Window {
__RUNTIME_CONFIG__?: { [key: string]: string | undefined };
}
}
export {};

View File

@@ -2,10 +2,6 @@ export function formatCode(code: string) {
return code.length === 10 ? code.replace(/(.{5})(.{5})/, "$1-$2") : code;
}
export function formatDate(d: string) {
return new Date(d).toLocaleString();
}
export function formatDuration(m: number | null | undefined) {
if (!m) return "Unlimited";
const days = Math.floor(m / 1440),

View File

@@ -1,4 +1,4 @@
export type NotificationType = "success" | "error" | "info";
export type NotificationType = "success" | "error" | "warning" | "info";
export interface NotificationPayload {
id: string;

View File

@@ -0,0 +1,4 @@
export const getRuntimeConfig = (): Record<string, string | undefined> => {
if (typeof window === "undefined") return {};
return window.__RUNTIME_CONFIG__ ?? {};
};

125
frontend/src/utils/wifi.ts Normal file
View File

@@ -0,0 +1,125 @@
import { getRuntimeConfig } from "@/utils/runtimeConfig";
// Derive the type from the array
const validWifiTypes = ["WPA", "WEP", "nopass"] as const;
type WifiType = (typeof validWifiTypes)[number];
export interface WifiConfig {
ssid: string;
password: string;
type: WifiType;
hidden?: boolean;
}
export function generateWifiConfig(): WifiConfig {
const {
WIFI_SSID: ssid,
WIFI_PASSWORD: password,
WIFI_TYPE: type,
WIFI_HIDDEN: hidden,
} = getRuntimeConfig();
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:
throw `Invalid WiFi type provided: ${type}. Valid types: ${validWifiTypes.join(
" | ",
)}`;
}
}
if (password && type_parsed === "nopass") {
throw "Incoherent WiFi configuration: password provided, but type set to 'nopass'";
} else if (!password && type_parsed !== "nopass") {
throw "Incoherent WiFi configuration: password not provided, but type implies a password";
}
let hidden_parsed: boolean;
if (hidden == null) {
hidden_parsed = false;
console.log(
`WiFi Configuration: hidden state not provided, defaulting to 'hidden=${hidden_parsed}' `,
);
} else {
switch (hidden.trim().toLowerCase()) {
case "true":
case "1":
hidden_parsed = true;
break;
case "false":
case "0":
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");
}

25
scripts/entrypoint.sh Normal file
View File

@@ -0,0 +1,25 @@
#!/usr/bin/env sh
set -e
# Set variables accessible by the frontend
mkdir -p /app/frontend/public
# Build runtime-config.js containing only env vars that are defined and non-empty.
node - <<'NODE'
const fs = require('fs');
const outPath = '/app/frontend/public/runtime-config.js';
const keys = ['WIFI_SSID','WIFI_PASSWORD','WIFI_TYPE','WIFI_HIDDEN'];
const cfg = {};
for (const k of keys) {
const v = process.env[k];
if (v !== undefined) {
cfg[k] = v;
}
}
fs.writeFileSync(outPath, 'window.__RUNTIME_CONFIG__ = ' + JSON.stringify(cfg) + ';', 'utf8');
console.log('WROTE', outPath, 'keys=', Object.keys(cfg));
NODE
# exec the original command
exec "$@"

View File

@@ -17,8 +17,7 @@ sleep 3
# Start frontend in foreground
echo "Starting frontend..."
NEXT_TELEMETRY_DISABLED="1" NODE_ENV="production" \
HOSTNAME=${FRONTEND_BIND_HOST} PORT="${FRONTEND_BIND_PORT}" \
UNIFI_CONTROLLER_URL="" UNIFI_API_KEY="" UNIFI_SITE_ID="" \
HOSTNAME="${FRONTEND_BIND_HOST}" PORT="${FRONTEND_BIND_PORT}" \
node ./frontend/server.js &
FRONTEND_PID=$!