Initial commit

fix: double api call to get voucher details

style: removed ugly tab background
This commit is contained in:
etiennecollin
2025-08-05 15:40:56 +02:00
commit d4d093e779
55 changed files with 6813 additions and 0 deletions

8
.dockerignore Normal file
View File

@@ -0,0 +1,8 @@
README.md
.env
compose.yaml
assets
Dockerfile
.git
.gitignore

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
.env
.DS_Store

111
Dockerfile Normal file
View File

@@ -0,0 +1,111 @@
# ==============================================================================
# Base images
# ==============================================================================
FROM node:24.3-alpine AS node-base
FROM rust:1.88-alpine AS rust-base
# ==============================================================================
# Backend dependencies
# ==============================================================================
FROM rust-base AS rust-deps
RUN apk add --no-cache musl-dev pkgconfig openssl-dev openssl-libs-static
WORKDIR /app
COPY ./backend/Cargo.toml ./backend/Cargo.lock ./
# Create dummy src to satisfy cargo
RUN mkdir ./src && echo "fn main() {}" > ./src/main.rs
ENV OPENSSL_STATIC=1
# Build dependencies only (cache them)
RUN cargo build --release && rm -rf ./src/
# ==============================================================================
# Backend build
# ==============================================================================
FROM rust-deps AS rust-builder
# Now copy real source
COPY ./backend/src ./src
# Build only our code (dependencies already built)
RUN cargo build --release
# ==============================================================================
# Frontend dependencies
# ==============================================================================
FROM node-base AS node-deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Install dependencies based on the preferred package manager
COPY ./frontend/package.json ./frontend/yarn.lock* ./frontend/package-lock.json* ./frontend/pnpm-lock.yaml* ./frontend/.npmrc* ./
RUN \
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
elif [ -f package-lock.json ]; then npm ci; \
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
else echo "Lockfile not found." && exit 1; \
fi
# ==============================================================================
# Frontend build
# ==============================================================================
FROM node-base AS node-builder
WORKDIR /app
COPY --from=node-deps /app/node_modules ./node_modules
COPY ./frontend ./
ENV NEXT_TELEMETRY_DISABLED=1
RUN \
if [ -f yarn.lock ]; then yarn run build; \
elif [ -f package-lock.json ]; then npm run build; \
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \
else echo "Lockfile not found." && exit 1; \
fi
# ==============================================================================
# Runner
# ==============================================================================
FROM alpine:3.22 AS runtime
RUN apk add --no-cache ca-certificates wget nodejs
WORKDIR /app
RUN addgroup -g 1001 -S appgroup && \
adduser -S appuser -u 1001 -G appgroup
# Copy run wrapper script
COPY ./scripts/run_wrapper.sh ./
RUN chmod +x run_wrapper.sh
# Create healthcheck script
COPY ./scripts/healthcheck.sh /usr/local/bin/healthcheck.sh
RUN chmod +x /usr/local/bin/healthcheck.sh
# Copy backend
COPY --from=rust-builder --chown=appuser:appgroup /app/target/release/backend ./backend
# Copy frontend
COPY --from=node-builder --chown=appuser:appgroup /app/public* ./frontend/public
COPY --from=node-builder --chown=appuser:appgroup /app/.next/standalone ./frontend
COPY --from=node-builder --chown=appuser:appgroup /app/.next/static ./frontend/.next/static
USER appuser
ENV FRONTEND_BIND_HOST="0.0.0.0"
ENV FRONTEND_BIND_PORT="3000"
ENV FRONTEND_TO_BACKEND_URL="http://127.0.0.1"
ENV BACKEND_BIND_HOST="127.0.0.1"
ENV BACKEND_BIND_PORT="8080"
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
CMD /usr/local/bin/healthcheck.sh
CMD ["./run_wrapper.sh"]

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Etienne Collin
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

201
README.md Normal file
View File

@@ -0,0 +1,201 @@
# WiFi Voucher Manager
[![Docker Image Version (latest by date)](https://img.shields.io/docker/v/etiennecollin/unifi-voucher-manager?sort=semver&label=Version&logo=docker&color=blue)]
A modern, touch-friendly web application for managing WiFi vouchers on UniFi controllers.
Perfect for businesses, cafes, hotels, and home networks that need to provide guest WiFi access.
![WiFi Voucher Manager](./assets/frontend.png)
<!-- vim-markdown-toc GFM -->
- [✨ Features](#-features)
- [🎫 Voucher Management](#-voucher-management)
- [🎨 Modern Interface](#-modern-interface)
- [🔧 Technical Features](#-technical-features)
- [🏗 Architecture](#-architecture)
- [🚀 Quick Start](#-quick-start)
- [🐳 Using Docker Compose (Recommended)](#-using-docker-compose-recommended)
- [⚙️ Without Docker](#-without-docker)
- [⚙️ Configuration](#-configuration)
- [Environment Variables](#environment-variables)
- [Getting UniFi API Credentials](#getting-unifi-api-credentials)
- [🤝 Contributing](#-contributing)
- [Development Guidelines](#development-guidelines)
- [Code Style](#code-style)
- [🐛 Troubleshooting](#-troubleshooting)
- [Common Issues](#common-issues)
- [Vouchers not appearing or connection issue to UniFi controller](#vouchers-not-appearing-or-connection-issue-to-unifi-controller)
- [Application won't start](#application-wont-start)
- [Getting Help](#getting-help)
<!-- vim-markdown-toc -->
## ✨ Features
### 🎫 Voucher Management
- **Quick Create** - Generate guest vouchers with preset durations (1 hour to 1 week)
- **Custom Create** - Full control over voucher parameters:
- Custom name
- Duration (minutes to days)
- Guest count limits
- Data usage limits
- Upload/download speed limits
- **View All Vouchers** - Browse and search existing vouchers by name
- **Search Vouchers** - Search vouchers by name
- **Bulk Operations** - Select and delete multiple vouchers
- **Auto-cleanup** - Remove expired vouchers with a single click
### 🎨 Modern Interface
- **Touch-Friendly** Optimized for tablet, mobile, and desktop.
- **Dark/Light Mode** Follows system preference, with manual override.
- **Responsive Design** - Works seamlessly across all screen sizes
- **Smooth Animations** Semantic transitions for polished UX.
- **Real-time Notifications** - Instant feedback for all operations
### 🔧 Technical Features
- **Docker Ready** - Easy deployment with Docker Compose and included healthcheck
- **UniFi Integration** - Direct API connection to UniFi controllers
#### 🏗 Architecture
This application is built with a clear separation of concerns:
1. **Frontend**: A Next.js 14 application (TypeScript + Tailwind CSS) that provides a responsive, touch-friendly UI.
2. **Backend**: A Rust service powered by [Axum](https://github.com/tokio-rs/axum) that exposes a JSON API.
3. **UniFi Controller**: The Axum backend securely communicates with your UniFi controllers API, isolating API keys from the user-facing frontend.
```text
{ DOCKER }
[User Browser] < HTTP/HTTPS > [Next.js Frontend] < HTTP/HTTPS > [Axum Rust Backend] < HTTPS > [UniFi Controller]
```
- The frontend only knows about the backend API endpoint.
- All UniFi credentials and site IDs are stored on the backend.
- This isolation limits the scope of user actions and protects sensitive API keys.
## 🚀 Quick Start
### 🐳 Using Docker Compose (Recommended)
1. **Create the configuration files**
```bash
# Download the compose file
curl -o compose.yaml https://raw.githubusercontent.com/etiennecollin/unifi-voucher-manager/main/compose.yaml
```
2. **Configure your environment**
Set the required environment variables (see [Environment Variables](#environment-variables)) in the `compose.yaml` file.
3. **Start the application**
```bash
docker compose up -d --force-recreate
```
4. **Access the interface**
Open your browser to `http://localhost:3000`.
### ⚙️ Without Docker
1. **Install the dependencies**
- `rust >= 1.88.0`
- `nodejs >= 24.3.0`
- `npm >= 11.4.2`
2. **Configure your environment**
In your shell, set the required environment variables (see [Environment Variables](#environment-variables))
3. **Start the frontend and backend**
```bash
# Backend
cd backend && cargo run -r
# Frontend (development)
cd frontend && npm install && npm run dev
# Frontend (release)
cd frontend && npm ci && npm run build && npm run start
```
## ⚙️ Configuration
### 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) |
| `TIMEZONE` | Optional | Server [timezone](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List). | `UTC` (default) |
| `RUST_LOG` | Optional | Log level of the Rust backend. | `info`(default) |
### Getting UniFi API Credentials
1. **Access your UniFi Controller**
2. **Navigate to Settings -> Control Plane -> Integration**
3. **Create a new API key** by giving it a name and an expiration.
4. **Find your Site ID** in the controller URL or on [unifi.ui.com](https://unifi.ui.com)
## 🤝 Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
### Development Guidelines
1. **Fork the repository**
2. **Create a feature branch**: `git checkout -b feature/amazing-feature`
3. **Make your changes** with proper TypeScript types
4. **Update documentation** as needed
5. **Commit changes**: `git commit -m 'feat: Add amazing feature'`
6. **Push to branch**: `git push origin feature/amazing-feature`
7. **Open a Pull Request**
### Code Style
- Use TypeScript for all new code
- Follow the existing Tailwind CSS semantic utility patterns
- Ensure components are touch-friendly and responsive
- Add proper error handling and user feedback
## 🐛 Troubleshooting
### Common Issues
#### Vouchers not appearing or connection issue to UniFi controller
- Verify `UNIFI_CONTROLLER_URL` is correct and accessible
- Verify `UNIFI_SITE_ID` matches your controller's site
- Check if UniFi controller is running and reachable
- Ensure API key is valid
- Ensure the site has hotspot/guest portal enabled
#### Application won't start
- Check all environment variables are set
- Verify Docker container has network access to UniFi controller
- Check logs: `docker compose logs unifi-voucher-manager`
### Getting Help
- Check the [Issues](https://github.com/etiennecollin/unifi-voucher-manager/issues) page
- Create a new issue with detailed information about your problem
- Include relevant logs and environment details (redact sensitive information)
---
**⭐ If this project helped you, please consider giving it a star!**

BIN
assets/frontend.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 311 KiB

View File

@@ -0,0 +1,2 @@
[build]
rustflags = ["-C", "target-cpu=native"]

5
backend/.dockerignore Normal file
View File

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

1
backend/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
target

2193
backend/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

25
backend/Cargo.toml Normal file
View File

@@ -0,0 +1,25 @@
[package]
name = "backend"
version = "0.1.0"
edition = "2024"
[dependencies]
axum = "0.8.4"
chrono = { version = "0.4.41" }
chrono-tz = "0.10.4"
reqwest = { version = "0.12.22", features = ["json", "rustls-tls"] }
serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.141"
tokio = { version = "1.47.0", features = ["full"] }
tower = "0.5.2" # Remove??
tower-http = { version = "0.6.6", features = ["cors"] }
tracing = "0.1.41"
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
[profile.release]
opt-level = "z"
codegen-units = 1
lto = "fat"
panic = "abort"
strip = "symbols"

90
backend/src/handlers.rs Normal file
View File

@@ -0,0 +1,90 @@
use crate::{models::*, unifi_api::*};
use axum::{extract::Query, http::StatusCode, response::Json};
use tracing::{debug, error};
pub async fn get_vouchers_handler() -> Result<Json<GetVouchersResponse>, StatusCode> {
debug!("Received request to get vouchers");
let client = UNIFI_API.get().expect("UnifiAPI not initialized");
match client.get_all_vouchers().await {
Ok(response) => Ok(Json(response)),
Err(e) => {
error!("Failed to get vouchers: {}", e);
Err(e)
}
}
}
pub async fn get_newest_voucher_handler() -> Result<Json<Option<Voucher>>, StatusCode> {
debug!("Received request to get newest voucher");
let client = UNIFI_API.get().expect("UnifiAPI not initialized");
match client.get_newest_voucher().await {
Ok(voucher) => Ok(Json(voucher)),
Err(e) => {
error!("Failed to get newest voucher: {}", e);
Err(e)
}
}
}
pub async fn get_voucher_details_handler(
Query(params): Query<DetailsRequest>,
) -> Result<Json<Voucher>, StatusCode> {
debug!("Received request to get voucher details");
let client = UNIFI_API.get().expect("UnifiAPI not initialized");
match client.get_voucher_details(params.id).await {
Ok(voucher) => Ok(Json(voucher)),
Err(e) => {
error!("Failed to get voucher details: {}", e);
Err(e)
}
}
}
pub async fn create_voucher_handler(
Json(request): Json<CreateVoucherRequest>,
) -> Result<Json<CreateVoucherResponse>, StatusCode> {
debug!("Received request to create voucher");
let client = UNIFI_API.get().expect("UnifiAPI not initialized");
match client.create_voucher(request.clone()).await {
Ok(response) => Ok(Json(response)),
Err(e) => {
error!("Failed to create voucher: {}", e);
Err(e)
}
}
}
pub async fn delete_selected_handler(
Query(params): Query<DeleteRequest>,
) -> Result<Json<DeleteResponse>, StatusCode> {
debug!("Received request to delete selected vouchers");
let client = UNIFI_API.get().expect("UnifiAPI not initialized");
let ids = params.ids.split(',').map(|s| s.to_string()).collect();
match client.delete_vouchers_by_ids(ids).await {
Ok(response) => Ok(Json(response)),
Err(e) => {
error!("Failed to delete selected vouchers: {}", e);
Err(e)
}
}
}
pub async fn delete_expired_handler() -> Result<Json<DeleteResponse>, StatusCode> {
debug!("Received request to delete expired vouchers");
let client = UNIFI_API.get().expect("UnifiAPI not initialized");
match client.delete_expired_vouchers().await {
Ok(response) => Ok(Json(response)),
Err(e) => {
error!("Failed to delete expired vouchers: {}", e);
Err(e)
}
}
}
pub async fn health_check_handler() -> Result<Json<HealthCheckResponse>, StatusCode> {
debug!("Received health check request");
let response = HealthCheckResponse {
status: "ok".to_string(),
};
Ok(Json(response))
}

70
backend/src/lib.rs Normal file
View File

@@ -0,0 +1,70 @@
use std::{env, sync::OnceLock};
use chrono_tz::Tz;
use tracing::{error, info};
pub mod handlers;
pub mod models;
pub mod unifi_api;
const DEFAULT_BACKEND_BIND_HOST: &str = "127.0.0.1";
const DEFAULT_BACKEND_BIND_PORT: u16 = 8080;
const DEFAULT_UNIFI_SITE_ID: &str = "default";
pub static ENVIRONMENT: OnceLock<Environment> = OnceLock::new();
#[derive(Debug, Clone)]
pub struct Environment {
pub unifi_controller_url: String,
pub unifi_site_id: String,
pub unifi_api_key: String,
pub backend_bind_host: String,
pub backend_bind_port: u16,
pub timezone: Tz,
}
impl Environment {
pub fn try_new() -> Result<Self, String> {
let unifi_controller_url: String =
env::var("UNIFI_CONTROLLER_URL").map_err(|e| format!("UNIFI_CONTROLLER_URL: {e}"))?;
let unifi_api_key: String =
env::var("UNIFI_API_KEY").map_err(|e| format!("UNIFI_API_KEY: {e}"))?;
let unifi_site_id: String =
env::var("UNIFI_SITE_ID").unwrap_or(DEFAULT_UNIFI_SITE_ID.to_owned());
let backend_bind_host: String =
env::var("BACKEND_BIND_HOST").unwrap_or(DEFAULT_BACKEND_BIND_HOST.to_owned());
let backend_bind_port: u16 = match env::var("BACKEND_BIND_PORT") {
Ok(port_str) => port_str
.parse()
.map_err(|e| format!("Invalid BACKEND_BIND_PORT: {e}"))?,
Err(_) => DEFAULT_BACKEND_BIND_PORT,
};
let timezone: Tz = match env::var("TIMEZONE") {
Ok(s) => match s.parse() {
Ok(tz) => {
info!("Using timezone: {}", s);
tz
}
Err(_) => {
error!("Using UTC, could not parse timezone: {}", s);
Tz::UTC
}
},
Err(_) => {
info!("TIMEZONE environment variable not set, defaulting to UTC");
Tz::UTC
}
};
Ok(Self {
unifi_controller_url,
unifi_site_id,
unifi_api_key,
backend_bind_host,
backend_bind_port,
timezone,
})
}
}

70
backend/src/main.rs Normal file
View File

@@ -0,0 +1,70 @@
use axum::{
Router,
http::{self, Method},
routing::{delete, get, post},
};
use backend::{
ENVIRONMENT, Environment,
handlers::*,
unifi_api::{UNIFI_API, UnifiAPI},
};
use tower_http::cors::{Any, CorsLayer};
use tracing::{error, info, level_filters::LevelFilter};
use tracing_subscriber::EnvFilter;
#[tokio::main]
async fn main() {
// Initialize tracing
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::builder()
.with_default_directive(LevelFilter::INFO.into())
.from_env_lossy(), // use RUST_LOG env variable when running
)
.init();
let env = match Environment::try_new() {
Ok(env) => env,
Err(e) => {
error!("Failed to load environment variables: {e}");
std::process::exit(1);
}
};
ENVIRONMENT
.set(env)
.expect("Failed to set environment variables");
let unifi_api = match UnifiAPI::new().await {
Ok(api) => api,
Err(_) => {
error!("Failed to initialize UnifiAPI wrapper");
std::process::exit(1);
}
};
UNIFI_API.set(unifi_api).expect("Failed to set UnifiAPI");
let cors = CorsLayer::new()
.allow_headers([http::header::CONTENT_TYPE])
.allow_methods([Method::POST, Method::GET, Method::DELETE])
.allow_origin(Any);
let app = Router::new()
.route("/api/health", get(health_check_handler))
.route("/api/vouchers", get(get_vouchers_handler))
.route("/api/vouchers", post(create_voucher_handler))
.route("/api/vouchers/details", get(get_voucher_details_handler))
.route("/api/vouchers/expired", delete(delete_expired_handler))
.route("/api/vouchers/newest", get(get_newest_voucher_handler))
.route("/api/vouchers/selected", delete(delete_selected_handler))
.layer(cors);
let environment = ENVIRONMENT.get().expect("Environment not set");
let bind_address = format!(
"{}:{}",
environment.backend_bind_host, environment.backend_bind_port
);
let listener = tokio::net::TcpListener::bind(&bind_address).await.unwrap();
info!("Server running on http://{}", bind_address);
axum::serve(listener, app).await.unwrap();
}

108
backend/src/models.rs Normal file
View File

@@ -0,0 +1,108 @@
#![allow(dead_code)]
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Voucher {
id: String,
#[serde(rename = "createdAt")]
pub created_at: String,
name: String,
code: String,
#[serde(rename = "authorizedGuestLimit")]
authorized_guest_limit: Option<u64>,
#[serde(rename = "authorizedGuestCount")]
authorized_guest_count: u64,
#[serde(rename = "activatedAt")]
pub activated_at: Option<String>,
#[serde(rename = "expiresAt")]
pub expires_at: Option<String>,
expired: bool,
#[serde(rename = "timeLimitMinutes")]
time_limit_minutes: u64,
#[serde(rename = "dataUsageLimitMBytes")]
data_usage_limit_mbytes: Option<u64>,
#[serde(rename = "rxRateLimitKbps")]
rx_rate_limit_kbps: Option<u64>,
#[serde(rename = "txRateLimitKbps")]
tx_rate_limit_kbps: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateVoucherRequest {
count: u32,
name: String,
#[serde(rename = "authorizedGuestLimit")]
authorized_guest_limit: Option<u64>,
#[serde(rename = "timeLimitMinutes")]
time_limit_minutes: u64,
#[serde(rename = "dataUsageLimitMBytes")]
data_usage_limit_mbytes: Option<u64>,
#[serde(rename = "rxRateLimitKbps")]
rx_rate_limit_kbps: Option<u64>,
#[serde(rename = "txRateLimitKbps")]
tx_rate_limit_kbps: Option<u64>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct CreateVoucherResponse {
pub vouchers: Vec<Voucher>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct GetVouchersResponse {
pub data: Vec<Voucher>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct DeleteResponse {
#[serde(rename = "vouchersDeleted")]
pub vouchers_deleted: u32,
}
#[derive(Debug, Serialize)]
pub struct HealthCheckResponse {
pub status: String,
}
#[derive(Debug, Deserialize)]
pub struct DeleteRequest {
pub ids: String,
}
#[derive(Debug, Deserialize)]
pub struct DetailsRequest {
pub id: String,
}
#[derive(Debug, Deserialize)]
pub struct Site {
pub id: String,
#[serde(rename = "internalReference")]
internal_reference: String,
pub name: String,
}
#[derive(Debug, Deserialize)]
pub struct GetSitesResponse {
offset: u64,
limit: u32,
count: u32,
#[serde(rename = "totalCount")]
total_count: u32,
pub data: Vec<Site>,
}
#[derive(Debug, Deserialize)]
pub struct ErrorResponse {
#[serde(rename = "statusCode")]
pub status_code: u32,
#[serde(rename = "statusName")]
pub status_name: String,
pub message: String,
timestamp: String,
#[serde(rename = "requestPath")]
request_path: String,
#[serde(rename = "requestId")]
request_id: String,
}

278
backend/src/unifi_api.rs Normal file
View File

@@ -0,0 +1,278 @@
use chrono::DateTime;
use chrono_tz::Tz;
use reqwest::{Client, ClientBuilder, StatusCode};
use std::{sync::OnceLock, time::Duration};
use tracing::{error, info, warn};
use crate::{
ENVIRONMENT, Environment,
models::{
CreateVoucherRequest, CreateVoucherResponse, DeleteResponse, ErrorResponse,
GetSitesResponse, GetVouchersResponse, Voucher,
},
};
const UNIFI_API_ROUTE: &str = "proxy/network/integration/v1/sites";
const DATE_TIME_FORMAT: &str = "%Y-%m-%d %H:%M:%S";
pub static UNIFI_API: OnceLock<UnifiAPI> = OnceLock::new();
enum RequestType {
Get,
Post,
Delete,
}
#[derive(Debug, Clone)]
pub struct UnifiAPI<'a> {
client: Client,
sites_api_url: String,
voucher_api_url: String,
environment: &'a Environment,
}
impl<'a> UnifiAPI<'a> {
pub async fn new() -> Result<Self, ()> {
let environment: &Environment = ENVIRONMENT.get().expect("Environment not set");
let mut headers = reqwest::header::HeaderMap::with_capacity(2);
headers.insert(
reqwest::header::CONTENT_TYPE,
reqwest::header::HeaderValue::from_static("application/json"),
);
headers.insert(
"X-API-Key",
environment
.unifi_api_key
.parse()
.expect("Could not parse API Key"),
);
let client = ClientBuilder::new()
.timeout(Duration::from_secs(30))
.connect_timeout(Duration::from_secs(10))
.default_headers(headers)
.use_rustls_tls()
.build()
.expect("Failed to build UniFi reqwest client");
let mut unifi_api = Self {
client,
sites_api_url: format!("{}/{}", environment.unifi_controller_url, UNIFI_API_ROUTE),
voucher_api_url: String::new(),
environment,
};
let site_id = match environment.unifi_site_id.to_lowercase().as_str() {
"default" => {
info!("Trying to fetch the default site ID from UniFi controller...");
let id = match unifi_api.get_default_site_id().await {
Ok(id) => id,
Err(e) => {
error!("Failed to fetch default site ID: {}", e);
return Err(());
}
};
info!("Default site ID found: {}", id);
id
}
_ => environment.unifi_site_id.clone(),
};
unifi_api.voucher_api_url =
format!("{}/{}/hotspot/vouchers", unifi_api.sites_api_url, site_id);
Ok(unifi_api)
}
async fn make_request<
T: serde::ser::Serialize + Sized,
U: serde::de::DeserializeOwned + Sized,
>(
&self,
request_type: RequestType,
url: &str,
body: Option<&T>,
) -> Result<U, StatusCode> {
// Make request
let response_result = match request_type {
RequestType::Get => self.client.get(url).send().await,
RequestType::Post => {
if let Some(b) = body {
self.client.post(url).json(b).send().await
} else {
error!("Body is required for POST requests");
return Err(StatusCode::BAD_REQUEST);
}
}
RequestType::Delete => self.client.delete(url).send().await,
};
// Check if the request was successful
let response = match response_result {
Ok(resp) => resp,
Err(e) => {
let status = e.status().unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
error!("Request failed with status {}: {}", status, e.without_url());
return Err(status);
}
};
// The request was successful, now check the status code
let clean_response = match response.error_for_status_ref().is_ok() {
true => response,
false => {
let status = response.status();
error!("Request failed with status: {}", status);
if let Ok(body) = response.json::<ErrorResponse>().await {
error!("Error response message: {}", body.message);
} else {
error!("Failed to parse error response body");
}
return Err(status);
}
};
// It's a successful response, now parse the JSON
clean_response.json::<U>().await.map_err(|e| {
error!("Failed to parse response JSON: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})
}
fn process_voucher(&self, voucher: &mut Voucher) {
voucher.created_at = format_unifi_date(&voucher.created_at, &self.environment.timezone);
if let Some(activated_at) = &mut voucher.activated_at {
*activated_at = format_unifi_date(activated_at, &self.environment.timezone);
}
if let Some(expires_at) = &mut voucher.expires_at {
*expires_at = format_unifi_date(expires_at, &self.environment.timezone);
}
}
fn process_vouchers(&self, mut vouchers: Vec<Voucher>) -> Vec<Voucher> {
vouchers.iter_mut().for_each(|voucher| {
self.process_voucher(voucher);
});
vouchers
}
async fn get_default_site_id(&self) -> Result<String, StatusCode> {
let url = format!(
"{}?filter=or(internalReference.eq('default'),name.eq('Default'))",
self.sites_api_url
);
let result: GetSitesResponse = self
.make_request(RequestType::Get, &url, None::<&()>)
.await?;
if result.data.is_empty() {
error!("No default site found on the UniFi controller");
error!(
"Please manually set the `UNIFI_SITE_ID` environment variable to a valid site ID."
);
return Err(StatusCode::NOT_FOUND);
}
let id = result.data[0].id.to_owned();
Ok(id)
}
pub async fn get_all_vouchers(&self) -> Result<GetVouchersResponse, StatusCode> {
let mut result: GetVouchersResponse = self
.make_request(RequestType::Get, &self.voucher_api_url, None::<&()>)
.await?;
result.data = self.process_vouchers(result.data);
Ok(result)
}
pub async fn get_newest_voucher(&self) -> Result<Option<Voucher>, StatusCode> {
let response = self.get_all_vouchers().await?;
if response.data.is_empty() {
warn!("No vouchers found when fetching the newest voucher");
return Err(StatusCode::NOT_FOUND);
}
// Find the newest voucher
let newest = response
.data
.iter()
.max_by_key(|voucher| {
DateTime::parse_from_str(&voucher.created_at, DATE_TIME_FORMAT)
.unwrap_or_else(|_| DateTime::UNIX_EPOCH.fixed_offset())
})
.cloned();
Ok(newest)
}
pub async fn get_voucher_details(&self, id: String) -> Result<Voucher, StatusCode> {
let url = format!("{}/{}", self.voucher_api_url, id);
let mut result: Voucher = self
.make_request(RequestType::Get, &url, None::<&()>)
.await?;
self.process_voucher(&mut result);
Ok(result)
}
pub async fn create_voucher(
&self,
request: CreateVoucherRequest,
) -> Result<CreateVoucherResponse, StatusCode> {
let mut result: CreateVoucherResponse = self
.make_request(RequestType::Post, &self.voucher_api_url, Some(&request))
.await?;
result.vouchers = self.process_vouchers(result.vouchers);
Ok(result)
}
pub async fn delete_vouchers_by_ids(
&self,
ids: Vec<String>,
) -> Result<DeleteResponse, StatusCode> {
if ids.is_empty() || (ids.len() == 1 && ids[0].is_empty()) {
return Ok(DeleteResponse {
vouchers_deleted: 0,
});
}
let filter_expr = ids
.iter()
.map(|id| format!("id.eq({id})"))
.collect::<Vec<_>>()
.join(",");
let url = if ids.len() == 1 {
format!("{}?filter={}", self.voucher_api_url, filter_expr)
} else {
format!("{}?filter=or({})", self.voucher_api_url, filter_expr)
};
self.make_request(RequestType::Delete, &url, None::<&()>)
.await
}
pub async fn delete_expired_vouchers(&self) -> Result<DeleteResponse, StatusCode> {
let url = format!("{}?filter=expired.eq(true)", self.voucher_api_url);
self.make_request(RequestType::Delete, &url, None::<&()>)
.await
}
}
fn format_unifi_date(rfc3339_string: &str, target_timezone: &Tz) -> String {
match DateTime::parse_from_rfc3339(rfc3339_string) {
Ok(dt) => {
let local_time = dt.with_timezone(target_timezone);
local_time.format(DATE_TIME_FORMAT).to_string()
}
Err(_) => {
error!("Failed to parse RFC3339 date: {}", rfc3339_string);
rfc3339_string.to_string()
}
}
}

20
compose.yaml Normal file
View File

@@ -0,0 +1,20 @@
---
services:
unifi-voucher-manager:
image: etiennecollin/unifi-voucher-manager
# To build the image yourself
# build:
# context: "./"
# target: "runtime"
# dockerfile: "./Dockerfile"
container_name: "unifi-voucher-manager"
restart: "unless-stopped"
ports:
- "3000:3000"
# To put the environment variables in a `.env` file, uncomment the env_file
# section and comment the environment section.
# env_file:
# - ".env"
environment:
UNIFI_CONTROLLER_URL: "URL to your Unifi controller with protocol. Example: 'https://unifi.example.com:8443' or 'http://192.168.0.1'"
UNIFI_API_KEY: "API Key for your Unifi controller"

7
frontend/.dockerignore Normal file
View File

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

41
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

19
frontend/README.md Normal file
View File

@@ -0,0 +1,19 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
The page auto-updates as you edit files.

7
frontend/next.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
};
export default nextConfig;

1735
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

24
frontend/package.json Normal file
View File

@@ -0,0 +1,24 @@
{
"name": "unifi-voucher-manager",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"react": "19.1.0",
"react-dom": "19.1.0",
"next": "15.4.5"
},
"devDependencies": {
"typescript": "^5",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@tailwindcss/postcss": "^4",
"tailwindcss": "^4"
}
}

View File

@@ -0,0 +1,5 @@
const config = {
plugins: ["@tailwindcss/postcss"],
};
export default config;

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

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

@@ -0,0 +1,391 @@
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
@theme {
/* Font Families */
--font-system:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, ui-sans-serif,
system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
"Segoe UI Symbol", "Noto Color Emoji";
--font-mono:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
"Courier New", monospace;
/* Primary Brand Colors */
--color-primary-50: #eff6ff;
--color-primary-100: #dbeafe;
--color-primary-200: #bfdbfe;
--color-primary-300: #93c5fd;
--color-primary-400: #60a5fa;
--color-primary-500: #3b82f6;
--color-primary-600: #2563eb;
--color-primary-700: #1d4ed8;
--color-primary-800: #1e40af;
--color-primary-900: #1e3a8a;
/* Secondary Colors */
--color-secondary-50: #f0f9ff;
--color-secondary-100: #e0f2fe;
--color-secondary-200: #bae6fd;
--color-secondary-300: #7dd3fc;
--color-secondary-400: #38bdf8;
--color-secondary-500: #0ea5e9;
--color-secondary-600: #0284c7;
--color-secondary-700: #0369a1;
--color-secondary-800: #075985;
--color-secondary-900: #0c4a6e;
/* Success Colors */
--color-success-50: #f0fdf4;
--color-success-100: #dcfce7;
--color-success-200: #bbf7d0;
--color-success-300: #86efac;
--color-success-400: #4ade80;
--color-success-500: #22c55e;
--color-success-600: #16a34a;
--color-success-700: #15803d;
--color-success-800: #166534;
--color-success-900: #14532d;
/* Danger Colors */
--color-danger-50: #fef2f2;
--color-danger-100: #fee2e2;
--color-danger-200: #fecaca;
--color-danger-300: #fca5a5;
--color-danger-400: #f87171;
--color-danger-500: #ef4444;
--color-danger-600: #dc2626;
--color-danger-700: #b91c1c;
--color-danger-800: #991b1b;
--color-danger-900: #7f1d1d;
/* Warning Colors */
--color-warning-50: #fffbeb;
--color-warning-100: #fef3c7;
--color-warning-200: #fde68a;
--color-warning-300: #fcd34d;
--color-warning-400: #fbbf24;
--color-warning-500: #f59e0b;
--color-warning-600: #d97706;
--color-warning-700: #b45309;
--color-warning-800: #92400e;
--color-warning-900: #78350f;
/* Custom Box Shadows */
--shadow-soft: 0 2px 10px rgba(0, 0, 0, 0.1);
--shadow-soft-dark: 0 2px 10px rgba(0, 0, 0, 0.3);
--shadow-elevation: 0 4px 20px rgba(0, 0, 0, 0.15);
--shadow-elevation-dark: 0 4px 20px rgba(0, 0, 0, 0.4);
}
/* -------------------------------------------------- */
/* 0. Semantic Color Utilities - Theme System */
/* -------------------------------------------------- */
/* Text Colors - Semantic */
@utility text-primary {
@apply text-neutral-900 dark:text-neutral-50;
}
@utility text-secondary {
@apply text-neutral-600 dark:text-neutral-400;
}
@utility text-muted {
@apply text-neutral-500 dark:text-neutral-500;
}
@utility text-brand {
@apply text-primary-600 dark:text-primary-400;
}
@utility text-brand-emphasis {
@apply text-primary-700 dark:text-primary-300;
}
/* Status Text Colors */
@utility text-status-success {
@apply text-success-700 dark:text-success-400;
}
@utility text-status-danger {
@apply text-danger-700 dark:text-danger-400;
}
@utility text-status-warning {
@apply text-warning-700 dark:text-warning-400;
}
@utility text-status-info {
@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 */
@utility bg-status-success {
@apply bg-success-100 dark:bg-success-900/30;
}
@utility bg-status-danger {
@apply bg-danger-100 dark:bg-danger-900/30;
}
@utility bg-status-warning {
@apply bg-warning-100 dark:bg-warning-900/30;
}
@utility bg-status-info {
@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 */
@utility border-status-success {
@apply border-success-600;
}
@utility border-status-danger {
@apply border-danger-600;
}
@utility border-status-warning {
@apply border-warning-600;
}
@utility border-status-info {
@apply border-primary-600;
}
/* Focus States */
@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;
}
/* 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 */
/* -------------------------------------------------- */
/* 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;
}
@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;
}
@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 {
@apply transition-opacity duration-300 ease-out opacity-0;
}
@utility fade-in-visible {
@apply opacity-100;
}
@utility fade-out {
@apply transition-opacity duration-200 ease-in opacity-100;
}
@utility fade-out-hidden {
@apply opacity-0;
}
/* Interactive States */
@utility interactive-element {
@apply transition-smooth cursor-pointer focus-accent;
}
@utility card-interactive {
@apply interactive-element hover-lift;
}
/* Focus Animations */
@utility focus-ring {
@apply focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 transition-all duration-200;
}
@utility focus-ring-danger {
@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 */
/* -------------------------------------------------- */
@layer base {
html {
font-family: var(--font-system);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
scroll-behavior: smooth;
}
* {
@apply transition duration-200 ease-in-out;
}
.transition-disabled * {
@apply transition-none;
}
body {
@apply bg-page text-primary min-h-screen flex flex-col;
}
/* touch feedback */
button,
a {
-webkit-tap-highlight-color: transparent;
cursor: pointer;
}
select {
@apply appearance-none bg-surface 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;
}
/* Scrollbars */
*::-webkit-scrollbar {
width: 8px;
height: 8px;
}
*::-webkit-scrollbar-track {
background: transparent;
}
*::-webkit-scrollbar-thumb {
@apply bg-neutral-300 dark:bg-neutral-700 rounded-lg;
}
}
/* -------------------------------------------------- */
/* 3. 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;
}
@layer components {
/* Buttons */
.btn-primary {
@apply btn bg-primary-500 hover:bg-primary-600 focus:ring-primary-300;
}
.btn-secondary {
@apply btn bg-secondary-500 hover:bg-secondary-600 focus:ring-secondary-300;
}
.btn-success {
@apply btn bg-success-500 hover:bg-success-600 focus:ring-success-300;
}
.btn-danger {
@apply btn bg-danger-500 hover:bg-danger-600 focus:ring-danger-300;
}
.btn-warning {
@apply btn bg-warning-500 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;
}
.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;
}
/* Safe area padding for iOS */
.safe-top {
padding-top: env(safe-area-inset-top);
}
.safe-bottom {
padding-bottom: env(safe-area-inset-bottom);
}
}

View File

@@ -0,0 +1,24 @@
import "./globals.css";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "UniFi Voucher Manager",
description: "Manage WiFi vouchers with ease",
authors: [{ name: "etiennecollin", url: "https://etiennecollin.com" }],
creator: "Etienne Collin",
robots: {
index: false,
},
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" suppressHydrationWarning>
<body className={`antialiased`}>{children}</body>
</html>
);
}

13
frontend/src/app/page.tsx Normal file
View File

@@ -0,0 +1,13 @@
import Header from "@/components/Header";
import NotificationContainer from "@/components/notifications/NotificationContainer";
import Tabs from "@/components/tabs/Tabs";
export default function Home() {
return (
<div className="h-screen flex flex-col bg-page">
<NotificationContainer />
<Header />
<Tabs />
</div>
);
}

View File

@@ -0,0 +1,16 @@
import ThemeSwitcher from "@/components/utils/ThemeSwitcher";
export default function Header() {
return (
<header className="bg-surface border-b border-default safe-top sticky top-0 z-7000">
<div className="max-w-95/100 mx-auto flex items-center justify-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">
<ThemeSwitcher />
</div>
</div>
</header>
);
}

View File

@@ -0,0 +1,86 @@
import { Voucher } from "@/types/voucher";
import {
formatCode,
formatDate,
formatDuration,
formatGuestUsage,
} from "@/utils/format";
import { memo } from "react";
type Props = {
voucher: Voucher;
selected: boolean;
editMode: boolean;
onClick: () => void;
};
const VoucherCard = ({ voucher, selected, editMode, onClick }: Props) => {
const statusClass = voucher.expired
? "bg-status-danger text-status-danger"
: "bg-status-success text-status-success";
return (
<div
onClick={onClick}
className={`card card-interactive
${selected ? "border-accent" : ""}
${editMode ? "relative" : ""}`}
>
{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
${selected ? "selected-accent" : "unselected-neutral"}`}
>
{selected && <div className="w-3 h-3 bg-white rounded-full" />}
</div>
</div>
)}
{/* Primary Information */}
<div className="mb-2">
<div className="text-xl voucher-code">{formatCode(voucher.code)}</div>
<div className="text-lg font-semibold truncate">{voucher.name}</div>
</div>
<div className="space-y-1 text-sm text-secondary">
<div className="flex justify-between">
<span>Guests Used:</span>
<span>
{formatGuestUsage(
voucher.authorizedGuestCount,
voucher.authorizedGuestLimit,
)}
</span>
</div>
<div className="flex justify-between">
<span>Session Time:</span>
<span>{formatDuration(voucher.timeLimitMinutes)}</span>
</div>
{voucher.activatedAt && (
<div className="flex justify-between">
<span>First Used:</span>
<span className="text-xs">{formatDate(voucher.activatedAt)}</span>
</div>
)}
<div className="flex justify-between items-center">
<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>
)}
</div>
</div>
</div>
);
};
export default memo(VoucherCard);

View File

@@ -0,0 +1,56 @@
"use client";
import { ReactNode, useEffect } from "react";
type Props = {
onClose: () => void;
/** Extra classes for the content container */
contentClassName?: string;
children: ReactNode;
};
export default function Modal({
onClose,
contentClassName = "",
children,
}: Props) {
// lock scroll + handle Escape
useEffect(() => {
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => {
document.body.style.overflow = prevOverflow;
window.removeEventListener("keydown", onKey);
};
}, [onClose]);
const onBackdropClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (e.target === e.currentTarget) onClose();
};
return (
<div
className="fixed inset-0 bg-overlay flex-center z-8000"
onClick={onBackdropClick}
>
<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}`}
>
<button
onClick={onClose}
className="absolute top-0 right-2 text-secondary text-2xl hover:text-primary"
aria-label="Close"
>
&times;
</button>
<div className="overflow-y-auto mr-3 mt-8 mb-2 p-6">{children}</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,20 @@
"use client";
import Modal from "@/components/modals/Modal";
import CopyCode from "@/components/utils/CopyCode";
type Props = {
code: string;
onClose: () => void;
};
export default function SuccessModal({ code: rawCode, onClose }: Props) {
return (
<Modal onClose={onClose} contentClassName="max-w-sm">
<h2 className="text-2xl font-bold text-primary mb-4 text-center">
Voucher Created!
</h2>
<CopyCode rawCode={rawCode} />
</Modal>
);
}

View File

@@ -0,0 +1,105 @@
"use client";
import Modal from "@/components/modals/Modal";
import Spinner from "@/components/utils/Spinner";
import { api } from "@/utils/api";
import { useEffect, useRef, useState } from "react";
import {
formatBytes,
formatDate,
formatDuration,
formatGuestUsage,
formatSpeed,
} from "@/utils/format";
import CopyCode from "@/components/utils/CopyCode";
import { Voucher } from "@/types/voucher";
type Props = {
voucher: Voucher;
onClose: () => void;
};
export default function VoucherModal({ voucher, onClose }: Props) {
const [details, setDetails] = useState<Voucher | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const lastFetchedId = useRef<string | null>(null);
useEffect(() => {
// Only fetch if we haven't already fetched this voucher's details
if (voucher.id === lastFetchedId.current) {
return;
}
(async () => {
setLoading(true);
setError(false);
lastFetchedId.current = voucher.id;
try {
const res = await api.getVoucherDetails(voucher.id);
setDetails(res);
} catch {
setError(true);
} finally {
setLoading(false);
}
})();
}, [voucher.id]);
const rawCode = details?.code ?? voucher.code;
return (
<Modal onClose={onClose}>
<CopyCode rawCode={rawCode} contentClassName="mb-8" />
{loading ? (
<Spinner />
) : error || details == null ? (
<div className="card text-status-danger text-center">
Failed to load detailed information
</div>
) : (
<div className="space-y-4">
{(
[
["Status", details.expired ? "Expired" : "Active"],
["Name", details.name || "No note"],
["Created", formatDate(details.createdAt)],
...(details.activatedAt
? [["Activated", formatDate(details.activatedAt)]]
: []),
...(details.expiresAt
? [["Expires", formatDate(details.expiresAt)]]
: []),
["Duration", formatDuration(details.timeLimitMinutes)],
[
"Guest Usage",
formatGuestUsage(
details.authorizedGuestCount,
details.authorizedGuestLimit,
),
],
[
"Data Limit",
details.dataUsageLimitMBytes
? formatBytes(details.dataUsageLimitMBytes * 1024 * 1024)
: "Unlimited",
],
["Download Speed", formatSpeed(details.rxRateLimitKbps)],
["Upload Speed", formatSpeed(details.txRateLimitKbps)],
["ID", details.id],
] as [string, any][]
).map(([label, value]) => (
<div
key={label}
className="flex justify-between items-center 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>
</div>
))}
</div>
)}
</Modal>
);
}

View File

@@ -0,0 +1,30 @@
"use client";
import NotificationItem from "@/components/notifications/NotificationItem";
import { NotificationPayload } from "@/utils/notifications";
import { useEffect, useState, useCallback } from "react";
export default function NotificationContainer() {
const [notes, setNotes] = useState<NotificationPayload[]>([]);
useEffect(() => {
const handler = (e: Event) => {
const detail = (e as CustomEvent<NotificationPayload>).detail;
setNotes((prev) => [...prev, detail]);
};
window.addEventListener("notify", handler as any);
return () => window.removeEventListener("notify", handler as any);
}, []);
const remove = useCallback((id: string) => {
setNotes((prev) => prev.filter((n) => n.id !== id));
}, []);
return (
<div className="fixed bottom-4 right-4 flex flex-col space-y-2 z-9000 overflow-visible">
{notes.map((n) => (
<NotificationItem key={n.id} {...n} onDone={remove} />
))}
</div>
);
}

View File

@@ -0,0 +1,54 @@
"use client";
import { NotificationPayload } from "@/utils/notifications";
import { useEffect, useState } from "react";
type Props = NotificationPayload & {
onDone: (id: string) => void;
};
const NOTIFICATION_DURATION_MS = 4000;
export default function NotificationItem({ id, message, type, onDone }: Props) {
const [visible, setVisible] = useState(false);
useEffect(() => {
// slide in next tick
const showTimer = setTimeout(() => setVisible(true), 10);
// slide out after 4s
const hideTimer = setTimeout(
() => setVisible(false),
NOTIFICATION_DURATION_MS,
);
// remove after animation
const removeTimer = setTimeout(
() => onDone(id),
NOTIFICATION_DURATION_MS + 500,
);
return () => [showTimer, hideTimer, removeTimer].forEach(clearTimeout);
}, [id, onDone]);
const getNotificationClasses = () => {
switch (type) {
case "success":
return "border-status-success text-status-success";
case "error":
return "border-status-danger text-status-danger";
default:
return "border-status-info text-status-info";
}
};
return (
<div
className={`
slide-in-right ${visible ? "slide-in-right-visible" : ""}
bg-surface shadow-lg border px-4 py-3 rounded-lg max-w-xs
${getNotificationClasses()}
`}
>
<span className="text-sm font-medium">{message}</span>
</div>
);
}

View File

@@ -0,0 +1,112 @@
"use client";
import SuccessModal from "@/components/modals/SuccessModal";
import { VoucherCreateData } from "@/types/voucher";
import { api } from "@/utils/api";
import { map } from "@/utils/functional";
import { notify } from "@/utils/notifications";
import { useCallback, useState, FormEvent } from "react";
export default function CustomCreateTab() {
const [loading, setLoading] = useState(false);
const [newCode, setNewCode] = useState<string | null>(null);
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
setLoading(true);
const parseNumber = (x: FormDataEntryValue) =>
x !== "" ? Number(x) : null;
const form = e.currentTarget;
const data = new FormData(form);
const payload: VoucherCreateData = {
count: Number(data.get("count")),
name: String(data.get("name")),
timeLimitMinutes: Number(data.get("duration")),
authorizedGuestLimit: map(data.get("guests"), parseNumber),
dataUsageLimitMBytes: map(data.get("data"), parseNumber),
rxRateLimitKbps: map(data.get("download"), parseNumber),
txRateLimitKbps: map(data.get("upload"), parseNumber),
};
try {
const res = await api.createVoucher(payload);
const code = res.vouchers?.[0]?.code;
if (code) {
setNewCode(code);
form.reset();
} else {
notify("Voucher created, but code not available", "error");
}
} catch {
notify("Failed to create voucher", "error");
}
setLoading(false);
};
const closeModal = useCallback(() => {
setNewCode(null);
}, []);
return (
<div>
<form onSubmit={handleSubmit} className="card max-w-lg mx-auto space-y-6">
{[
{
label: "Number",
name: "count",
type: "number",
props: { required: true, min: 1, max: 10, defaultValue: 1 },
},
{
label: "Name",
name: "name",
type: "text",
props: { required: true, defaultValue: "Custom Voucher" },
},
{
label: "Duration (min)",
name: "duration",
type: "number",
props: { required: true, min: 1, max: 525600, defaultValue: 1440 },
},
{
label: "Guest Limit",
name: "guests",
type: "number",
props: { min: 1, max: 5, placeholder: "Unlimited" },
},
{
label: "Data Limit (MB)",
name: "data",
type: "number",
props: { min: 1, max: 1048576, placeholder: "Unlimited" },
},
{
label: "Download Kbps",
name: "download",
type: "number",
props: { min: 2, max: 100000, placeholder: "Unlimited" },
},
{
label: "Upload Kbps",
name: "upload",
type: "number",
props: { min: 2, max: 100000, placeholder: "Unlimited" },
},
].map(({ label, name, type, props }) => (
<div key={name}>
<label className="block font-medium mb-1">{label}</label>
<input name={name} type={type} {...(props as any)} />
</div>
))}
<button type="submit" disabled={loading} className="btn-primary w-full">
{loading ? "Creating…" : "Create Custom Voucher"}
</button>
</form>
{newCode && <SuccessModal code={newCode} onClose={closeModal} />}
</div>
);
}

View File

@@ -0,0 +1,69 @@
"use client";
import SuccessModal from "@/components/modals/SuccessModal";
import { VoucherCreateData } from "@/types/voucher";
import { api } from "@/utils/api";
import { notify } from "@/utils/notifications";
import { useCallback, useState, FormEvent } from "react";
export default function QuickCreateTab() {
const [loading, setLoading] = useState<boolean>(false);
const [newCode, setNewCode] = useState<string | null>(null);
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
setLoading(true);
const form = e.currentTarget;
const data = new FormData(form);
const payload: VoucherCreateData = {
count: 1,
name: String(data.get("name")),
timeLimitMinutes: Number(data.get("duration")),
authorizedGuestLimit: 1,
};
try {
const res = await api.createVoucher(payload);
const code = res.vouchers?.[0]?.code;
if (code) {
setNewCode(code);
form.reset();
} else {
notify("Voucher created, but code not available", "error");
}
} catch {
notify("Failed to create voucher", "error");
}
setLoading(false);
};
const closeModal = useCallback(() => {
setNewCode(null);
}, []);
return (
<div>
<form onSubmit={handleSubmit} className="card max-w-lg mx-auto space-y-6">
<label className="block font-medium mb-1">Duration</label>
<select name="duration" defaultValue="1440" required>
<option value={60}>1 Hour</option>
<option value={240}>4 Hours</option>
<option value={1440}>24 Hours</option>
<option value={4320}>3 Days</option>
<option value={10080}>1 Week</option>
</select>
<label className="block font-medium mb-1">Name</label>
<input name="name" defaultValue="Quick Voucher" required />
<button type="submit" disabled={loading} className="btn-primary w-full">
{loading ? "Creating…" : "Create Voucher"}
</button>
</form>
{newCode && <SuccessModal code={newCode} onClose={closeModal} />}
</div>
);
}

View File

@@ -0,0 +1,74 @@
"use client";
import CustomCreateTab from "@/components/tabs/CustomCreateTab";
import TestTab from "@/components/tabs/TestTab";
import QuickCreateTab from "@/components/tabs/QuickCreateTab";
import VouchersTab from "@/components/tabs/VouchersTab";
import { useState } from "react";
const TAB_CONFIG = [
{
id: "vouchers",
label: "View Vouchers",
component: VouchersTab,
enabled: true,
},
{
id: "quick",
label: "Quick Create",
component: QuickCreateTab,
enabled: true,
},
{
id: "custom",
label: "Custom Create",
component: CustomCreateTab,
enabled: true,
},
{
id: "test",
label: "Test",
component: TestTab,
enabled: false,
},
] as const;
// Get enabled tabs and derive types
const enabledTabs = TAB_CONFIG.filter((tab) => tab.enabled);
const tabIds = enabledTabs.map((tab) => tab.id);
type TabId = (typeof tabIds)[number];
export default function Tabs() {
const [tab, setTab] = useState<TabId>(tabIds[0]);
return (
<>
<nav className="bg-surface border-b border-default flex sticky top-16 z-2000 shadow-soft dark:shadow-soft-dark">
{enabledTabs.map((tabConfig) => (
<button
key={tabConfig.id}
className={`flex-1 px-4 py-3 ${
tab === tabConfig.id ? "tab-active" : "tab-inactive"
}`}
onClick={() => setTab(tabConfig.id)}
>
{tabConfig.label}
</button>
))}
</nav>
<div className="p-4 overflow-y-auto">
{enabledTabs.map((tabConfig) => {
const Component = tabConfig.component;
return (
<div
key={tabConfig.id}
className={tab === tabConfig.id ? "" : "hidden"}
>
<Component />
</div>
);
})}
</div>
</>
);
}

View File

@@ -0,0 +1,43 @@
"use client";
import { notify } from "@/utils/notifications";
import Spinner from "@/components/utils/Spinner";
export default function TestTab() {
const sendInfo = () => notify("This is an info notification", "info");
const sendSuccess = () => notify("Operation succeeded!", "success");
const sendError = () => notify("Something went wrong!", "error");
const sendMultiple = () => {
notify("First message", "info");
setTimeout(() => notify("Second message", "success"), 500);
setTimeout(() => notify("Third message", "error"), 1000);
};
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>
</div>
<div className="card max-w-lg">
<h2 className="text-lg font-semibold text-primary">Spinner Example</h2>
<Spinner />
</div>
</div>
);
}

View File

@@ -0,0 +1,216 @@
"use client";
import Spinner from "@/components/utils/Spinner";
import VoucherCard from "@/components/VoucherCard";
import VoucherModal from "@/components/modals/VoucherModal";
import { Voucher } from "@/types/voucher";
import { api } from "@/utils/api";
import { notify } from "@/utils/notifications";
import { useMemo, useEffect, useCallback, useState } from "react";
export default function VouchersTab() {
const [loading, setLoading] = useState(true);
const [vouchers, setVouchers] = useState<Voucher[]>([]);
const [viewVoucher, setViewVoucher] = useState<Voucher | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const [editMode, setEditMode] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [busy, setBusy] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const res = await api.getAllVouchers();
setVouchers(res.data || []);
} catch {
notify("Failed to load vouchers", "error");
}
setLoading(false);
}, []);
const startEdit = () => {
setSelected(new Set());
setEditMode(true);
};
const cancelEdit = useCallback(() => {
setSelected(new Set());
setEditMode(false);
}, []);
useEffect(() => {
load();
window.addEventListener("vouchersUpdated", load);
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") cancelEdit();
};
window.addEventListener("keydown", onKey);
return () => {
window.removeEventListener("vouchersUpdated", load);
window.removeEventListener("keydown", onKey);
};
}, [load, cancelEdit]);
const filteredVouchers = useMemo(() => {
if (!searchQuery.trim()) return vouchers;
const query = searchQuery.toLowerCase().trim();
return vouchers.filter((voucher) =>
voucher.name?.toLowerCase().includes(query),
);
}, [vouchers, searchQuery]);
const expiredVouchers = useMemo(
() => filteredVouchers.filter((v) => v.expired).map((v) => v.id),
[filteredVouchers],
);
const toggleSelect = useCallback((id: string) => {
setSelected((p) => {
const s = new Set(p);
s.has(id) ? s.delete(id) : s.add(id);
return s;
});
}, []);
const selectAll = () => {
if (selected.size === filteredVouchers.length) {
setSelected(new Set());
} else {
setSelected(new Set(filteredVouchers.map((v) => v.id)));
}
};
const closeModal = useCallback(() => {
setViewVoucher(null);
}, []);
const deleteVouchers = useCallback(
async (kind: "selected" | "expired") => {
setBusy(true);
const kind_word = kind === "selected" ? "" : "expired";
try {
const res =
kind === "selected"
? await api.deleteSelected([...selected])
: await api.deleteSelected([...expiredVouchers]);
const count = res.vouchersDeleted || 0;
if (count > 0) {
notify(
`Successfully deleted ${count} ${kind_word} voucher${count === 1 ? "" : "s"}`,
"success",
);
setSelected(new Set());
} else {
notify(`No ${kind_word} vouchers were deleted`, "info");
}
} catch {
notify(`Failed to delete ${kind_word} vouchers`, "error");
}
setBusy(false);
},
[selected],
);
return (
<div className="flex-1">
<div className="mb-2">
<div className="relative">
<input
type="text"
placeholder="Search vouchers by name..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
{searchQuery && (
<button
onClick={() => setSearchQuery("")}
className="absolute right-3 top-1/2 -translate-y-1/2 text-secondary text-2xl hover:text-primary"
>
&times;
</button>
)}
</div>
</div>
<div className="mb-4 flex flex-wrap items-center gap-3">
{!editMode ? (
<>
<button onClick={startEdit} className="btn-primary">
Edit Mode
</button>
<button onClick={load} className="btn-secondary">
Refresh
</button>
</>
) : (
<>
<button
onClick={selectAll}
disabled={!filteredVouchers.length}
className="btn-secondary"
>
Select All
</button>
<button
onClick={() => deleteVouchers("selected")}
disabled={busy || !selected.size}
className="btn-danger"
>
Delete Selected
</button>
<button
onClick={() => deleteVouchers("expired")}
disabled={busy || !expiredVouchers.length}
className="btn-warning"
>
Delete Expired
</button>
<button onClick={cancelEdit} className="btn-secondary">
Cancel
</button>
{busy ? <Spinner /> : <></>}
<span className="text-sm text-secondary font-bold ml-auto">
{selected.size} selected
</span>
</>
)}
</div>
{searchQuery && (
<div className="mb-4 text-sm text-secondary">
Showing {filteredVouchers.length} of {vouchers.length} vouchers
</div>
)}
{loading ? (
<Spinner />
) : !filteredVouchers.length ? (
<div className="text-center py-8 text-secondary">
{searchQuery
? "No vouchers found matching your search"
: "No vouchers found"}
</div>
) : (
<div className="grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{filteredVouchers.map((v) => (
<VoucherCard
key={v.id}
voucher={v}
editMode={editMode}
selected={selected.has(v.id)}
onClick={() =>
editMode ? toggleSelect(v.id) : setViewVoucher(v)
}
/>
))}
</div>
)}
{viewVoucher && (
<VoucherModal voucher={viewVoucher} onClose={closeModal} />
)}
</div>
);
}

View File

@@ -0,0 +1,41 @@
"use client";
import { copyText } from "@/utils/clipboard";
import { formatCode } from "@/utils/format";
import { notify } from "@/utils/notifications";
import { useState } from "react";
type Props = {
rawCode: string;
contentClassName?: string;
};
export default function CopyCode({ rawCode, contentClassName = "" }: Props) {
const code = formatCode(rawCode);
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
if (await copyText(rawCode)) {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
notify("Code copied to clipboard!", "success");
} else {
notify("Failed to copy code", "error");
}
};
return (
<div className={`text-center ${contentClassName}`}>
<div
onClick={handleCopy}
className="cursor-pointer mb-4 text-3xl voucher-code"
>
{code}
</div>
<button onClick={handleCopy} className="btn-success w-2/3">
{copied ? "Copied" : "Copy Code"}
</button>
</div>
);
}

View File

@@ -0,0 +1,10 @@
export default function Spinner() {
return (
<div className="flex-center py-4">
<div className="relative w-8 h-8">
<div className="absolute inset-0 rounded-full border-3 border-default" />
<div className="absolute inset-0 rounded-full border-3 border-accent border-t-transparent animate-spin" />
</div>
</div>
);
}

View File

@@ -0,0 +1,64 @@
"use client";
import { useEffect, useState } from "react";
import { notify } from "@/utils/notifications";
export default function ThemeSwitcher() {
type themeType = "system" | "light" | "dark";
const [theme, setTheme] = useState<themeType>("system");
// Load saved theme
useEffect(() => {
const stored = localStorage.getItem("theme") as themeType | null;
setTheme(stored || "system");
}, []);
// Apply theme class
useEffect(() => {
const html = document.documentElement;
const mql = window.matchMedia("(prefers-color-scheme: dark)");
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
const apply = () => {
// Only disable transitions on Safari
if (isSafari) {
html.classList.add("transition-disabled");
notify("Theme changed, reloading for Safari compatibility", "info");
}
const isDark = theme === "dark" || (theme === "system" && mql.matches);
html.classList.toggle("dark", isDark);
localStorage.setItem("theme", theme);
// Re-enable transitions after a brief delay (Safari only)
if (isSafari) {
requestAnimationFrame(() => {
setTimeout(() => {
html.classList.remove("transition-disabled");
}, 50);
});
}
};
apply();
// For system mode, listen to changes
mql.addEventListener("change", apply);
return () => {
mql.removeEventListener("change", apply);
};
}, [theme]);
return (
<select
value={theme}
onChange={(e) => setTheme(e.target.value as any)}
className="text-sm"
>
<option value="system"> System</option>
<option value="light">🌞 Light</option>
<option value="dark">🌙 Dark</option>
</select>
);
}

View File

@@ -0,0 +1,17 @@
import { NextResponse, NextRequest } from "next/server";
export const config = {
matcher: "/api/:path*",
};
const DEFAULT_FRONTEND_TO_BACKEND_URL = "http://127.0.0.1";
const DEFAULT_BACKEND_BIND_PORT = "8080";
export function middleware(request: NextRequest) {
return NextResponse.rewrite(
new URL(
`${process.env.FRONTEND_TO_BACKEND_URL || DEFAULT_FRONTEND_TO_BACKEND_URL}:${process.env.BACKEND_BIND_PORT || DEFAULT_BACKEND_BIND_PORT}${request.nextUrl.pathname}${request.nextUrl.search}`,
),
{ request },
);
}

View File

@@ -0,0 +1,37 @@
export interface Voucher {
id: string;
createdAt: string;
name: string;
code: string;
authorizedGuestLimit?: number | null;
authorizedGuestCount: number;
activatedAt?: string | null;
expiresAt?: string | null;
expired: boolean;
timeLimitMinutes: number;
dataUsageLimitMBytes?: number | null;
rxRateLimitKbps?: number | null;
txRateLimitKbps?: number | null;
}
export interface VoucherCreateData
extends Omit<
Voucher,
| "id"
| "createdAt"
| "code"
| "authorizedGuestCount"
| "activatedAt"
| "expiresAt"
| "expired"
> {
count: number;
}
export interface VoucherDeletedResponse {
vouchersDeleted: number;
}
export interface VoucherCreatedResponse {
vouchers: Voucher[];
}

64
frontend/src/utils/api.ts Normal file
View File

@@ -0,0 +1,64 @@
import {
Voucher,
VoucherCreateData,
VoucherCreatedResponse,
VoucherDeletedResponse,
} from "@/types/voucher";
function removeNullUndefined<T extends Record<string, any>>(obj: T): T {
return Object.fromEntries(
Object.entries(obj).filter(
([_, value]) => value !== null && value !== undefined,
),
) as T;
}
async function call<T>(endpoint: string, opts: RequestInit = {}) {
const res = await fetch(`/api/${endpoint}`, {
headers: { "Content-Type": "application/json" },
...opts,
});
if (!res.ok) throw new Error(res.statusText);
return res.json() as Promise<T>;
}
function notifyVouchersUpdated() {
window.dispatchEvent(new CustomEvent("vouchersUpdated"));
}
export const api = {
getAllVouchers: () => call<{ data: Voucher[] }>("/vouchers"),
getVoucherDetails: (id: string) =>
call<Voucher>(`/vouchers/details?id=${encodeURIComponent(id)}`),
createVoucher: async (data: VoucherCreateData) => {
const filteredData = removeNullUndefined(data);
const result = await call<VoucherCreatedResponse>("/vouchers", {
method: "POST",
body: JSON.stringify(filteredData),
});
notifyVouchersUpdated();
return result;
},
deleteExpired: async () => {
const result = await call<VoucherDeletedResponse>("/vouchers/expired", {
method: "DELETE",
});
notifyVouchersUpdated();
return result;
},
deleteSelected: async (ids: string[]) => {
const qs = ids.map(encodeURIComponent).join(",");
const result = await call<VoucherDeletedResponse>(
`/vouchers/selected?ids=${qs}`,
{
method: "DELETE",
},
);
notifyVouchersUpdated();
return result;
},
};

View File

@@ -0,0 +1,34 @@
/**
* Copy the given text to clipboard.
* Tries the modern Clipboard API first; on failure, falls back to execCommand.
* Returns true if the text was successfully copied.
*/
export async function copyText(text: string): Promise<boolean> {
// Try Clipboard API
if (navigator.clipboard && navigator.clipboard.writeText) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
// fall through to fallback
}
}
// Fallback to textarea + execCommand
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.setAttribute("readonly", "");
textarea.style.position = "absolute";
textarea.style.left = "-9999px";
document.body.appendChild(textarea);
textarea.select();
let success = false;
try {
success = document.execCommand("copy");
} catch {
success = false;
}
document.body.removeChild(textarea);
return success;
}

View File

@@ -0,0 +1,49 @@
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),
hours = Math.floor((m % 1440) / 60),
mins = m % 60;
return (
[
days > 0 ? days + "d" : "",
hours > 0 ? hours + "h" : "",
mins > 0 ? mins + "m" : "",
]
.filter(Boolean)
.join(" ") || "0m"
);
}
export function formatBytes(b: number | null | undefined) {
if (!b) return "Unlimited";
const units = ["B", "KB", "MB", "GB", "TB"];
let size = b,
i = 0;
while (size >= 1024 && i < units.length - 1) {
size /= 1024;
i++;
}
return `${size.toFixed(size < 10 ? 1 : 0)} ${units[i]}`;
}
export function formatSpeed(kbps: number | null | undefined) {
if (!kbps) return "Unlimited";
return kbps >= 1024
? `${(kbps / 1024).toFixed(kbps < 10240 ? 1 : 0)} Mbps`
: `${kbps} Kbps`;
}
export function formatGuestUsage(
usage: number,
limit: number | null | undefined,
) {
return limit ? `${usage}/${limit}` : `${usage}/∞`;
}

View File

@@ -0,0 +1,6 @@
export function map<T, U>(value: T | null, fn: (v: T) => U): U | null {
/**
* Returns the result of applying fn to value if value is non-null, otherwise returns null.
*/
return value != null ? fn(value) : null;
}

View File

@@ -0,0 +1,43 @@
export type NotificationType = "success" | "error" | "info";
export interface NotificationPayload {
id: string;
message: string;
type: NotificationType;
}
/** Generate a RFC-4122 v4 UUID */
function generateUUID(): string {
// use crypto.randomUUID() when available
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
// @ts-ignore
return crypto.randomUUID();
}
// fallback to crypto.getRandomValues
let d = new Date().getTime();
let d2 = (performance && performance.now && performance.now() * 1000) || 0;
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
const r =
Math.random() * 16 +
// use high-res entropy if available
(crypto && crypto.getRandomValues
? crypto.getRandomValues(new Uint8Array(1))[0]
: 0);
const v = c === "x" ? r % 16 | 0 : (r % 16 & 0x3) | 0x8;
return v.toString(16);
});
}
/**
* Dispatch a notification event. Listeners (e.g. NotificationContainer)
* will pick this up and render it.
*/
export function notify(message: string, type: NotificationType = "info") {
const id = generateUUID();
window.dispatchEvent(
new CustomEvent<NotificationPayload>("notify", {
detail: { id, message, type },
}),
);
}

27
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

19
scripts/healthcheck.sh Normal file
View File

@@ -0,0 +1,19 @@
#!/bin/sh
set -e
# Check backend health
echo "Checking backend on port $BACKEND_BIND_PORT..."
wget --no-verbose --tries=1 --spider --timeout=5 "http://localhost:$BACKEND_BIND_PORT/api/health" 2>/dev/null || {
echo "Backend health check failed on port $BACKEND_BIND_PORT"
exit 1
}
# Check frontend health
echo "Checking frontend on port $FRONTEND_BIND_PORT..."
wget --no-verbose --tries=1 --spider --timeout=5 "http://localhost:$FRONTEND_BIND_PORT" 2>/dev/null || {
echo "Frontend health check failed on port $FRONTEND_BIND_PORT"
exit 1
}
echo "Both services are healthy"
exit 0

39
scripts/run_wrapper.sh Normal file
View File

@@ -0,0 +1,39 @@
#!/usr/bin/env sh
echo "================================================================"
echo "Starting services..."
echo "Frontend will listen on: ${FRONTEND_BIND_HOST}:${FRONTEND_BIND_PORT}"
echo "Backend will listen on: ${BACKEND_BIND_HOST}:${BACKEND_BIND_PORT}"
echo "================================================================"
# Start backend in background
echo "Starting backend..."
./backend &
BACKEND_PID=$!
# Wait for backend to initialize
sleep 3
# Start frontend in foreground
echo "Starting frontend..."
NEXT_TELEMETRY_DISABLED="1" NODE_ENV="production" HOSTNAME=${FRONTEND_BIND_HOST} PORT="${FRONTEND_BIND_PORT}" node ./frontend/server.js &
FRONTEND_PID=$!
cleanup() {
echo "================================================================"
echo "Shutting down services..."
kill $BACKEND_PID $FRONTEND_PID 2>/dev/null
wait $BACKEND_PID $FRONTEND_PID 2>/dev/null
echo "Frontend and Backend services have been shut down."
echo "================================================================"
exit 0
}
# Set up signal handlers
trap cleanup SIGTERM SIGINT
# Wait for any process to exit
wait $BACKEND_PID $FRONTEND_PID
# Exit with status of process that exited first
exit $?