Merge pull request #61 from glenndehaan/feature/v5

v5
This commit is contained in:
Glenn de Haan
2025-02-25 20:40:18 +01:00
committed by GitHub
17 changed files with 1352 additions and 1479 deletions

View File

@@ -5,7 +5,7 @@
#
# Define OS
#
FROM alpine:3.20 AS dependencies
FROM alpine:3.21 AS dependencies
#
# Basic OS management
@@ -34,7 +34,7 @@ RUN npm ci --only=production && npm cache clean --force
#
# Define OS
#
FROM alpine:3.20 AS css
FROM alpine:3.21 AS css
#
# Basic OS management
@@ -69,7 +69,7 @@ RUN npm run build
#
# Define OS
#
FROM alpine:3.20
FROM alpine:3.21
#
# Basic OS management

290
README.md
View File

@@ -6,7 +6,7 @@ UniFi Voucher Site is a web-based platform for generating and managing UniFi net
![Vouchers Overview - Desktop](.docs/images/desktop_1.png)
> Upgrading from 3.x to 4.x? Please take a look at the [migration guide](#migration-from-3x-to-4x)
> Upgrading from 4.x to 5.x? Please take a look at the [migration guide](#migration-from-4x-to-5x)
## Features
@@ -152,7 +152,7 @@ To install the UniFi Voucher Site add-on for Home Assistant, follow these steps:
## Development
- Install Node.js 20.0 or higher.
- Install Node.js 22.0 or higher.
- Run `npm ci` in the root folder
- Run `npm start` & `npm run tailwind` in the root folder
@@ -181,20 +181,36 @@ the different endpoints available in the API:
- Access: Open
- Response Example:
```json
{
"error": null,
"data": {
"message": "OK",
"endpoints": [
"/api",
"/api/types",
"/api/voucher/:type",
"/api/vouchers"
]
}
}
```
```json
{
"error": null,
"data": {
"message": "OK",
"endpoints": [
{
"method": "GET",
"endpoint": "/api"
},
{
"method": "GET",
"endpoint": "/api/types"
},
{
"method": "GET",
"endpoint": "/api/languages"
},
{
"method": "GET",
"endpoint": "/api/vouchers"
},
{
"method": "POST",
"endpoint": "/api/voucher"
}
]
}
}
```
2. **`/api/types`**
- Method: GET
@@ -203,44 +219,55 @@ the different endpoints available in the API:
- Access: Open
- Response Example:
```json
{
"error": null,
"data": {
"message": "OK",
"types": [
{
"expiration": "480",
"usage": "0",
"raw": "480,0,,,"
```json
{
"error": null,
"data": {
"message": "OK",
"types": [
{
"expiration": "480",
"usage": "0",
"raw": "480,0,,,"
}
]
}
]
}
}
```
}
```
3. **`/api/voucher/:type`**
3. **`/api/languages`**
- Method: GET
- Description: Generates a voucher of the specified type.
- Parameters:
- `type` (string): The type of voucher to generate.
- Description: Retrieves a list of available languages supported by the system.
- Response Format: JSON
- Access: Protected by Bearer Token
- Access: Open
- Response Example:
```json
{
"error": null,
"data": {
"message": "OK",
"voucher": "12345-67890"
}
}
```
> This endpoint is protected by a security mechanism. To access it, users need to include a bearer token in the
request authorization header. The token must match the value of the `AUTH_INTERNAL_BEARER_TOKEN` environment variable. Without
this token, access to the endpoint will be denied.
```json
{
"error": null,
"data": {
"message": "OK",
"languages": [
{
"code": "en",
"name": "English"
},
{
"code": "de",
"name": "German"
},
{
"code": "nl",
"name": "Dutch"
},
{
"code": "pl",
"name": "Polish"
}
]
}
}
```
4. **`/api/vouchers`**
- Method: GET
@@ -249,37 +276,88 @@ the different endpoints available in the API:
- Access: Protected by Bearer Token
- Response Example:
```json
{
"error": null,
"data": {
"message": "OK",
"vouchers": [
{
"code": "15695-53133",
"type": "multi",
"duration": 60,
"data_limit": "200",
"download_limit": "5000",
"upload_limit": "2000"
},
{
"code": "03004-59449",
"type": "single",
"duration": 480,
"data_limit": null,
"download_limit": null,
"upload_limit": null
```json
{
"error": null,
"data": {
"message": "OK",
"vouchers": [
{
"id": "67bded6766f89f2a7ba6731f",
"code": "15695-53133",
"type": "multi",
"duration": 60,
"data_limit": "200",
"download_limit": "5000",
"upload_limit": "2000"
},
{
"id": "67bdecd166f89f2a7ba67317",
"code": "03004-59449",
"type": "single",
"duration": 480,
"data_limit": null,
"download_limit": null,
"upload_limit": null
}
],
"updated": 1712934667937
}
],
"updated": 1712934667937
}
}
```
}
```
> This endpoint is protected by a security mechanism. To access it, users need to include a bearer token in the
request authorization header. The token must match the value of the `AUTH_INTERNAL_BEARER_TOKEN` environment variable. Without
this token, access to the endpoint will be denied.
> This endpoint is protected by a security mechanism. To access it, users need to include a bearer token in the
request authorization header. The token must match the value of the `AUTH_INTERNAL_BEARER_TOKEN` environment variable. Without
this token, access to the endpoint will be denied.
5. **`/api/voucher`**
- Method: POST
- Description: Generates a voucher of the specified type. Optionally sends an email.
- Response Format: JSON
- Access: Protected by Bearer Token
- Body:
- Generate Voucher:
```json
{
"type": "480,0,,,"
}
```
- Generate Voucher and Send Email *(**Warning**: Email module needs to be setup!)*:
```json
{
"type": "480,0,,,",
"email": {
"language": "en",
"address": "user@example.com"
}
}
```
- Response Example:
```json
{
"error": null,
"data": {
"message": "OK",
"voucher": {
"id": "67bdf77b66f89f2a7ba678f7",
"code": "02791-97992"
},
"email": {
"status": "SENT",
"address": "user@example.com"
}
}
}
```
> This endpoint is protected by a security mechanism. To access it, users need to include a bearer token in the
request authorization header. The token must match the value of the `AUTH_INTERNAL_BEARER_TOKEN` environment variable. Without
this token, access to the endpoint will be denied.
## Authentication
@@ -518,6 +596,64 @@ Detailed information on the changes in each release can be found on the [GitHub
## Migration Guide
### Migration from 4.x to 5.x
When upgrading from 4.x to 5.x, the following changes need to be made:
1. **Updated `/api` Endpoint Response**
- The `/api` endpoint now returns a structured response that includes the HTTP method for each available endpoint. Update API integrations to handle the new response structure.
**Previous Response:**
```json
{
"error": null,
"data": {
"message": "OK",
"endpoints": [
"/api",
"/api/types",
"/api/voucher/:type",
"/api/vouchers"
]
}
}
```
**New Response:**
```json
{
"error": null,
"data": {
"message": "OK",
"endpoints": [
{ "method": "GET", "endpoint": "/api" },
{ "method": "GET", "endpoint": "/api/types" },
{ "method": "GET", "endpoint": "/api/languages" },
{ "method": "GET", "endpoint": "/api/vouchers" },
{ "method": "POST", "endpoint": "/api/voucher" }
]
}
}
```
2. **`/api/voucher/:type` Endpoint Replacement**
- The **`/api/voucher/:type`** endpoint has been replaced by **`/api/voucher`**.
- The method has changed from **`GET`** to **`POST`**.
- The `:type` URL parameter is now passed in the **JSON body**.
**Example Request:**
```http
POST /api/voucher
Content-Type: application/json
{
"type": "480,0,,,"
}
```
### Migration from 3.x to 4.x
When upgrading from 3.x to 4.x, the following changes need to be made:

View File

@@ -1,3 +1,33 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@import 'tailwindcss';
@plugin '@tailwindcss/forms';
/*
The default border color has changed to `currentColor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
/*
The default cursor has changed to `default` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an cursor
utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentColor);
}
button:not(:disabled),
[role="button"]:not(:disabled) {
cursor: pointer;
}
}

View File

@@ -1,4 +1,3 @@
version: '3'
services:
app:
build: .

2177
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,18 +5,19 @@
"private": true,
"scripts": {
"start": "LOG_LEVEL=trace node server.js",
"dev": "nodemon --watch . --exec 'LOG_LEVEL=trace node server.js'",
"dev": "LOG_LEVEL=trace node --watch server.js",
"tailwind": "tailwindcss -i ./css/style.css -o ./public/dist/style.css --watch",
"build": "tailwindcss -i ./css/style.css -o ./public/dist/style.css --minify"
},
"engines": {
"node": ">=20.0.0"
"node": ">=22.0.0"
},
"author": "Glenn de Haan",
"license": "MIT",
"overrides": {
"node-unifi@^2.5.1": {
"axios": "1.7.4"
"axios": "1.7.4",
"tough-cookie": "5.1.1"
},
"express-openid-connect@^2.17.1": {
"cookie": "0.7.0"
@@ -31,15 +32,15 @@
"js-logger": "^1.6.1",
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
"node-thermal-printer": "^4.4.3",
"node-thermal-printer": "^4.4.4",
"node-unifi": "^2.5.1",
"nodemailer": "^6.9.16",
"nodemailer": "^6.10.0",
"pdfkit": "^0.16.0",
"qrcode": "^1.5.4"
},
"devDependencies": {
"@tailwindcss/cli": "^4.0.9",
"@tailwindcss/forms": "^0.5.10",
"nodemon": "^3.1.9",
"tailwindcss": "^3.4.17"
"tailwindcss": "^4.0.9"
}
}

184
server.js
View File

@@ -106,6 +106,11 @@ app.use(locale({
"default": "en-GB"
}));
/**
* Enable JSON
*/
app.use(express.json());
/**
* Enable multer
*/
@@ -591,10 +596,26 @@ if(variables.serviceApi) {
data: {
message: 'OK',
endpoints: [
'/api',
'/api/types',
'/api/voucher/:type',
'/api/vouchers'
{
method: 'GET',
endpoint: '/api'
},
{
method: 'GET',
endpoint: '/api/types'
},
{
method: 'GET',
endpoint: '/api/languages'
},
{
method: 'GET',
endpoint: '/api/vouchers'
},
{
method: 'POST',
endpoint: '/api/voucher'
}
]
}
});
@@ -608,36 +629,19 @@ if(variables.serviceApi) {
}
});
});
app.get('/api/voucher/:type', [authorization.api], async (req, res) => {
const typeCheck = (variables.voucherTypes).split(';').includes(req.params.type);
if(!typeCheck) {
res.json({
error: 'Unknown Type!',
data: {}
});
return;
}
// Create voucher code
const voucherCode = await unifi.create(types(req.params.type, true)).catch((e) => {
res.json({
error: e,
data: {}
});
app.get('/api/languages', (req, res) => {
res.json({
error: null,
data: {
message: 'OK',
languages: Object.keys(languages).map(language => {
return {
code: language,
name: languages[language]
}
})
}
});
await updateCache();
if(voucherCode) {
res.json({
error: null,
data: {
message: 'OK',
voucher: voucherCode
}
});
}
});
app.get('/api/vouchers', [authorization.api], async (req, res) => {
res.json({
@@ -646,6 +650,7 @@ if(variables.serviceApi) {
message: 'OK',
vouchers: cache.vouchers.map((voucher) => {
return {
id: voucher._id,
code: `${voucher.code.slice(0, 5)}-${voucher.code.slice(5)}`,
type: voucher.quota === 1 ? 'single' : voucher.quota === 0 ? 'multi' : 'multi',
duration: voucher.duration,
@@ -658,6 +663,119 @@ if(variables.serviceApi) {
}
});
});
app.post('/api/voucher', [authorization.api], async (req, res) => {
// Verify valid body is sent
if(!req.body || !req.body.type) {
res.status(400).json({
error: 'Invalid Body!',
data: {}
});
return;
}
// Check if email body is set
if(req.body.email) {
// Check if email module is enabled
if(variables.smtpFrom === '' || variables.smtpHost === '' || variables.smtpPort === '') {
res.status(400).json({
error: 'Email Not Configured!',
data: {}
});
return;
}
// Check if email body is correct
if(!req.body.email.language || !req.body.email.address) {
res.status(400).json({
error: 'Invalid Body!',
data: {}
});
return;
}
// Check if language is available
if(!Object.keys(languages).includes(req.body.email.language)) {
res.status(400).json({
error: 'Unknown Language!',
data: {}
});
return;
}
}
// Check if type is implemented and valid
const typeCheck = (variables.voucherTypes).split(';').includes(req.body.type);
if(!typeCheck) {
res.status(400).json({
error: 'Unknown Type!',
data: {}
});
return;
}
// Create voucher code
const voucherCode = await unifi.create(types(req.body.type, true)).catch((e) => {
res.status(500).json({
error: e,
data: {}
});
});
// Update application cache
await updateCache();
if(voucherCode) {
// Locate voucher data within cache
const voucherData = cache.vouchers.find(voucher => voucher.code === voucherCode.replaceAll('-', ''));
if(!voucherData) {
res.status(500).json({
error: 'Invalid application cache!',
data: {}
});
return;
}
// Check if we should send and email
if(req.body.email) {
// Send mail
const emailResult = await mail.send(req.body.email.address, voucherData, req.body.email.language).catch((e) => {
res.status(500).json({
error: e,
data: {}
});
});
// Verify is the email was sent successfully
if(emailResult) {
res.json({
error: null,
data: {
message: 'OK',
voucher: {
id: voucherData._id,
code: voucherCode
},
email: {
status: 'SENT',
address: req.body.email.address
}
}
});
}
} else {
res.json({
error: null,
data: {
message: 'OK',
voucher: {
id: voucherData._id,
code: voucherCode
}
}
});
}
}
});
}
/**

View File

@@ -1,14 +1,4 @@
module.exports = {
mode: 'jit',
content: ["./template/**/*.{html,js,ejs}"],
darkMode: 'media',
theme: {
extend: {}
},
variants: {
extend: {}
},
plugins: [
require('@tailwindcss/forms')
]
};
/*
This file is present to satisfy a requirement of the Tailwind CSS IntelliSense
The rest of this file is intentionally empty.
*/

View File

@@ -34,7 +34,7 @@
<h1 class="mt-5 text-3xl font-bold tracking-tight text-gray-900 dark:text-white sm:text-5xl">Page not found</h1>
<p class="mt-4 text-base leading-7 text-gray-600 dark:text-gray-400">Sorry, we couldnt find the page youre looking for.</p>
<div class="mt-10 flex items-center justify-center gap-x-6">
<a href="<%= baseUrl %>/" class="rounded-md bg-sky-700 px-3.5 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-sky-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-700">Go back home</a>
<a href="<%= baseUrl %>/" class="rounded-md bg-sky-700 px-3.5 py-2.5 text-sm font-semibold text-white shadow-xs hover:bg-sky-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-700">Go back home</a>
</div>
<p class="mt-10 text-center text-sm text-gray-500 dark:text-gray-400">
<a href="https://github.com/glenndehaan/unifi-voucher-site" aria-label="GitHub Project Link" target="_blank" rel="noreferrer noopener" class="hover:text-gray-600 dark:hover:text-gray-500">

View File

@@ -33,11 +33,11 @@
<img class="mx-auto h-24 w-auto" width="96" height="96" alt="UniFi Voucher Site Logo" src="<%= baseUrl %>/images/logo.png">
<h1 class="mt-5 text-3xl font-bold tracking-tight text-gray-900 dark:text-white sm:text-5xl">Error</h1>
<p class="mt-4 text-base leading-7 text-gray-600 dark:text-gray-400">Sorry, an unexpected error occurred.</p>
<pre class="max-w-72 sm:max-w-lg mt-4 text-left text-xs text-gray-600 dark:text-gray-400 overflow-x-auto rounded-md border bg-white dark:bg-white/5 border-gray-300 dark:border-white/10 px-3 py-2 placeholder-gray-400 shadow-sm">
<pre class="max-w-72 sm:max-w-lg mt-4 text-left text-xs text-gray-600 dark:text-gray-400 overflow-x-auto rounded-md border bg-white dark:bg-white/5 border-gray-300 dark:border-white/10 px-3 py-2 placeholder-gray-400 shadow-xs">
<%= error %>
</pre>
<div class="mt-10 flex items-center justify-center gap-x-6">
<a href="<%= baseUrl %>/" class="rounded-md bg-sky-700 px-3.5 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-sky-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-700">Go back home</a>
<a href="<%= baseUrl %>/" class="rounded-md bg-sky-700 px-3.5 py-2.5 text-sm font-semibold text-white shadow-xs hover:bg-sky-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-700">Go back home</a>
</div>
<p class="mt-10 text-center text-sm text-gray-500 dark:text-gray-400">
<a href="https://github.com/glenndehaan/unifi-voucher-site" aria-label="GitHub Project Link" target="_blank" rel="noreferrer noopener" class="hover:text-gray-600 dark:hover:text-gray-500">

View File

@@ -1,5 +1,5 @@
<div class="relative z-40" role="dialog" aria-modal="true">
<div id="overlay" class="fixed inset-0 bg-gray-500 bg-opacity-75"></div>
<div id="overlay" class="fixed inset-0 bg-gray-500/75"></div>
<div class="fixed overflow-hidden">
<div class="absolute inset-0 overflow-hidden">
@@ -21,7 +21,7 @@
<div>
<label for="language" class="block text-sm font-medium leading-6 text-gray-900 dark:text-white">Language</label>
<div class="mt-2">
<select id="language" name="language" class="mt-2 block w-full rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 dark:text-white dark:bg-white/5 ring-1 ring-inset ring-gray-300 dark:ring-white/10 focus:ring-2 focus:ring-sky-600 sm:text-sm sm:leading-6 [&_*]:text-black">
<select id="language" name="language" class="mt-2 block w-full rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 dark:text-white dark:bg-white/5 ring-1 ring-inset ring-gray-300 dark:ring-white/10 focus:ring-2 focus:ring-sky-600 sm:text-sm sm:leading-6 **:text-black">
<% Object.keys(languages).forEach((language) => { %>
<option value="<%= language %>"<%= language === defaultLanguage ? ' selected' : '' %>><%= languages[language] %> (<%= language %>)</option>
<% }); %>
@@ -33,7 +33,7 @@
<ul role="list" class="max-h-96 h-96 overflow-y-auto mt-2 rounded-md border-0 divide-y divide-black/5 dark:divide-white/5 dark:bg-white/5 ring-1 ring-inset ring-gray-300 dark:ring-white/10">
<% vouchers.forEach((voucher) => { %>
<li class="relative flex items-center space-x-4 px-4 py-4">
<input id="voucher-<%= voucher._id %>" aria-describedby="voucher-<%= voucher._id %>" name="vouchers" type="checkbox" value="<%= voucher._id %>" class="col-start-1 row-start-1 appearance-none rounded border-0 dark:bg-white/5 ring-1 ring-inset ring-gray-300 dark:ring-white/10 focus:ring-2 focus:ring-sky-600 focus:ring-offset-0">
<input id="voucher-<%= voucher._id %>" aria-describedby="voucher-<%= voucher._id %>" name="vouchers" type="checkbox" value="<%= voucher._id %>" class="col-start-1 row-start-1 appearance-none rounded-sm border-0 dark:bg-white/5 ring-1 ring-inset ring-gray-300 dark:ring-white/10 focus:ring-2 focus:ring-sky-600 focus:ring-offset-0">
<label for="voucher-<%= voucher._id %>" class="voucher min-w-0 flex-auto">
<div class="flex items-center gap-x-3">
<h2 class="min-w-0 text-sm font-semibold leading-6 text-gray-900 dark:text-white">
@@ -96,9 +96,9 @@
</div>
</div>
</div>
<div class="flex flex-shrink-0 justify-end px-4 py-4">
<button id="close" type="button" class="rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50">Cancel</button>
<button type="submit" class="ml-4 inline-flex justify-center rounded-md bg-sky-700 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-sky-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-700">Print</button>
<div class="flex shrink-0 justify-end px-4 py-4">
<button id="close" type="button" class="rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-xs ring-1 ring-inset ring-gray-300 hover:bg-gray-50">Cancel</button>
<button type="submit" class="ml-4 inline-flex justify-center rounded-md bg-sky-700 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-sky-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-700">Print</button>
</div>
</form>
</div>

View File

@@ -1,5 +1,5 @@
<div class="relative z-40" role="dialog" aria-modal="true">
<div id="overlay" class="fixed inset-0 bg-gray-500 bg-opacity-75"></div>
<div id="overlay" class="fixed inset-0 bg-gray-500/75"></div>
<div class="fixed overflow-hidden">
<div class="absolute inset-0 overflow-hidden">
@@ -97,7 +97,7 @@
<% } else { %>
<% guests.forEach((guest) => { %>
<div class="lg:col-start-3 lg:row-end-1">
<div class="rounded-lg bg-gray-50 dark:bg-gray-800 shadow-sm ring-1 ring-gray-900/5 dark:ring-gray-50/25">
<div class="rounded-lg bg-gray-50 dark:bg-gray-800 shadow-xs ring-1 ring-gray-900/5 dark:ring-gray-50/25">
<dl class="flex flex-wrap">
<div class="flex-auto pl-6 pt-6">
<dt class="text-sm font-semibold leading-6 text-gray-900 dark:text-white"><%= guest.mac %></dt>
@@ -158,8 +158,8 @@
</div>
</div>
</div>
<div class="flex flex-shrink-0 justify-end px-4 py-4">
<button id="close" type="button" class="rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50">Close</button>
<div class="flex shrink-0 justify-end px-4 py-4">
<button id="close" type="button" class="rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-xs ring-1 ring-inset ring-gray-300 hover:bg-gray-50">Close</button>
</div>
</div>
</div>

View File

@@ -1,5 +1,5 @@
<div class="relative z-40" role="dialog" aria-modal="true">
<div id="overlay" class="fixed inset-0 bg-gray-500 bg-opacity-75"></div>
<div id="overlay" class="fixed inset-0 bg-gray-500/75"></div>
<div class="fixed overflow-hidden">
<div class="absolute inset-0 overflow-hidden">
@@ -27,7 +27,7 @@
<div>
<label for="language" class="block text-sm font-medium leading-6 text-gray-900 dark:text-white">Language</label>
<div class="mt-2">
<select id="language" name="language" class="mt-2 block w-full rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 dark:text-white dark:bg-white/5 ring-1 ring-inset ring-gray-300 dark:ring-white/10 focus:ring-2 focus:ring-sky-600 sm:text-sm sm:leading-6 [&_*]:text-black">
<select id="language" name="language" class="mt-2 block w-full rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 dark:text-white dark:bg-white/5 ring-1 ring-inset ring-gray-300 dark:ring-white/10 focus:ring-2 focus:ring-sky-600 sm:text-sm sm:leading-6 **:text-black">
<% Object.keys(languages).forEach((language) => { %>
<option value="<%= language %>"<%= language === defaultLanguage ? ' selected' : '' %>><%= languages[language] %> (<%= language %>)</option>
<% }); %>
@@ -38,9 +38,9 @@
</div>
</div>
</div>
<div class="flex flex-shrink-0 justify-end px-4 py-4">
<button id="close" type="button" class="rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50">Cancel</button>
<button type="submit" class="ml-4 inline-flex justify-center rounded-md bg-sky-700 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-sky-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-700">Send</button>
<div class="flex shrink-0 justify-end px-4 py-4">
<button id="close" type="button" class="rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-xs ring-1 ring-inset ring-gray-300 hover:bg-gray-50">Cancel</button>
<button type="submit" class="ml-4 inline-flex justify-center rounded-md bg-sky-700 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-sky-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-700">Send</button>
</div>
</form>
</div>

View File

@@ -1,5 +1,5 @@
<div class="relative z-40" role="dialog" aria-modal="true">
<div id="overlay" class="fixed inset-0 bg-gray-500 bg-opacity-75"></div>
<div id="overlay" class="fixed inset-0 bg-gray-500/75"></div>
<div class="fixed overflow-hidden">
<div class="absolute inset-0 overflow-hidden">
@@ -21,7 +21,7 @@
<div>
<label for="language" class="block text-sm font-medium leading-6 text-gray-900 dark:text-white">Language</label>
<div class="mt-2">
<select id="language" name="language" class="mt-2 block w-full rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 dark:text-white dark:bg-white/5 ring-1 ring-inset ring-gray-300 dark:ring-white/10 focus:ring-2 focus:ring-sky-600 sm:text-sm sm:leading-6 [&_*]:text-black">
<select id="language" name="language" class="mt-2 block w-full rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 dark:text-white dark:bg-white/5 ring-1 ring-inset ring-gray-300 dark:ring-white/10 focus:ring-2 focus:ring-sky-600 sm:text-sm sm:leading-6 **:text-black">
<% Object.keys(languages).forEach((language) => { %>
<option value="<%= language %>"<%= language === defaultLanguage ? ' selected' : '' %>><%= languages[language] %> (<%= language %>)</option>
<% }); %>
@@ -32,9 +32,9 @@
</div>
</div>
</div>
<div class="flex flex-shrink-0 justify-end px-4 py-4">
<button id="close" type="button" class="rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50">Cancel</button>
<button type="submit" class="ml-4 inline-flex justify-center rounded-md bg-sky-700 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-sky-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-700">Print</button>
<div class="flex shrink-0 justify-end px-4 py-4">
<button id="close" type="button" class="rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-xs ring-1 ring-inset ring-gray-300 hover:bg-gray-50">Cancel</button>
<button type="submit" class="ml-4 inline-flex justify-center rounded-md bg-sky-700 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-sky-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-700">Print</button>
</div>
</form>
</div>

View File

@@ -36,7 +36,7 @@
<% if(error) { %>
<div class="mt-5 rounded-md bg-red-700 p-4">
<div class="flex">
<div class="flex-shrink-0">
<div class="shrink-0">
<svg class="h-5 w-5 text-white" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z" clip-rule="evenodd" />
</svg>
@@ -55,12 +55,12 @@
<div>
<label for="password" class="block text-sm font-medium leading-6 text-gray-900 dark:text-white">Password</label>
<div class="mt-2">
<input type="password" id="password" name="password" required class="block w-full rounded-md border-0 dark:bg-white/5 py-1.5 text-gray-900 dark:text-white shadow-sm ring-1 ring-inset ring-gray-300 dark:ring-white/10 focus:ring-2 focus:ring-inset focus:ring-sky-700 sm:text-sm sm:leading-6">
<input type="password" id="password" name="password" required class="block w-full rounded-md border-0 dark:bg-white/5 py-1.5 text-gray-900 dark:text-white shadow-xs ring-1 ring-inset ring-gray-300 dark:ring-white/10 focus:ring-2 focus:ring-inset focus:ring-sky-700 sm:text-sm sm:leading-6">
</div>
</div>
<div>
<button type="submit" class="flex w-full justify-center rounded-md bg-sky-700 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-sky-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-700">Sign in</button>
<button type="submit" class="flex w-full justify-center rounded-md bg-sky-700 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-xs hover:bg-sky-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-700">Sign in</button>
</div>
</form>
<% } %>
@@ -79,7 +79,7 @@
<% } %>
<div class="<%= internalAuth ? 'mt-6' : '' %>">
<a href="<%= baseUrl %>/oidc/login" class="flex w-full items-center justify-center gap-3 rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus-visible:ring-transparent">
<a href="<%= baseUrl %>/oidc/login" class="flex w-full items-center justify-center gap-3 rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-xs ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus-visible:ring-transparent">
<svg class="h-5 w-5" viewBox="0 0 64 64" aria-hidden="true">
<path fill="#f7931e" d="M29.1 6.4v54.5l9.7-4.5V1.7l-9.7 4.7z"></path>
<path fill="#b2b2b2" d="M62.7 22.4L64 36.3l-18.7-4.1"></path>

View File

@@ -1,15 +1,15 @@
<nav class="sticky top-0 z-40 bg-white shadow-sm dark:bg-gray-800">
<nav class="sticky top-0 z-40 bg-white shadow-xs dark:bg-gray-800">
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div class="flex h-16 justify-between">
<div class="flex">
<% if(uiBackButton) { %>
<button aria-label="Back to Previous Page" onclick="window.history.go(-1);">
<svg xmlns="http://www.w3.org/2000/svg" class="text-gray-900 dark:text-white hover:text-gray-700 hover:dark:text-gray-100 w-10 h-10 mr-4" viewBox="0 0 24 24" fill="currentColor">
<svg xmlns="http://www.w3.org/2000/svg" class="text-gray-900 dark:text-white hover:text-gray-700 dark:hover:text-gray-100 w-10 h-10 mr-4" viewBox="0 0 24 24" fill="currentColor">
<path fill-rule="evenodd" d="M12 2.25c-5.385 0-9.75 4.365-9.75 9.75s4.365 9.75 9.75 9.75 9.75-4.365 9.75-9.75S17.385 2.25 12 2.25Zm-4.28 9.22a.75.75 0 0 0 0 1.06l3 3a.75.75 0 1 0 1.06-1.06l-1.72-1.72h5.69a.75.75 0 0 0 0-1.5h-5.69l1.72-1.72a.75.75 0 0 0-1.06-1.06l-3 3Z" clip-rule="evenodd" />
</svg>
</button>
<% } %>
<a href="<%= baseUrl %>/vouchers" class="flex flex-shrink-0 items-center">
<a href="<%= baseUrl %>/vouchers" class="flex shrink-0 items-center">
<img class="h-12 w-auto" width="48" height="48" alt="UniFi Voucher Site Logo" src="<%= baseUrl %>/images/logo.png">
<div class="hidden sm:block ml-4 text-2xl font-semibold leading-7 text-gray-900 dark:text-white">
UniFi Voucher
@@ -18,8 +18,8 @@
</div>
<div class="flex items-center">
<% if(new_voucher_button) { %>
<div class="flex-shrink-0">
<button id="create-button-header" type="button" class="relative inline-flex items-center gap-x-1.5 rounded-md bg-sky-700 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-sky-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-700">
<div class="shrink-0">
<button id="create-button-header" type="button" class="relative inline-flex items-center gap-x-1.5 rounded-md bg-sky-700 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-sky-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-700">
<svg class="-ml-0.5 h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path d="M10.75 4.75a.75.75 0 00-1.5 0v4.5h-4.5a.75.75 0 000 1.5h4.5v4.5a.75.75 0 001.5 0v-4.5h4.5a.75.75 0 000-1.5h-4.5v-4.5z" />
</svg>
@@ -27,9 +27,9 @@
</button>
</div>
<% } %>
<div class="ml-4 flex flex-shrink-0 items-center">
<div class="ml-4 flex shrink-0 items-center">
<div class="relative">
<button id="user-menu-button" class="flex items-center focus:outline-none focus:ring-2 focus:ring-sky-600 rounded-full">
<button id="user-menu-button" class="flex items-center focus:outline-hidden focus:ring-2 focus:ring-sky-600 rounded-full">
<span class="absolute -inset-1.5"></span>
<span class="sr-only">Open user menu</span>
<% if(userIcon !== '') { %>
@@ -40,7 +40,7 @@
</svg>
<% } %>
</button>
<div id="user-dropdown" class="hidden absolute bg-white dark:bg-gray-800 right-0 mt-2 w-64 rounded-md shadow-lg py-1 ring-1 ring-black ring-opacity-5">
<div id="user-dropdown" class="hidden absolute bg-white dark:bg-gray-800 right-0 mt-2 w-64 rounded-md shadow-lg py-1 ring-1 ring-black/25">
<div class="px-4 py-2 text-sm text-gray-600 dark:text-gray-400 border-b border-black/5 dark:border-white/5">
Logged in as:<br>
<span class="font-medium"><%= user.email %></span>

View File

@@ -35,7 +35,7 @@
<div class="mx-6">
<div class="max-w-7xl mx-auto mt-5 rounded-md bg-red-700 p-4">
<div class="flex">
<div class="flex-shrink-0">
<div class="shrink-0">
<svg class="h-5 w-5 text-white" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z" clip-rule="evenodd" />
</svg>
@@ -52,7 +52,7 @@
<div class="mx-6">
<div class="max-w-7xl mx-auto mt-5 rounded-md bg-green-700 p-4">
<div class="flex">
<div class="flex-shrink-0">
<div class="shrink-0">
<svg class="h-5 w-5 text-white" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm3.857-9.809a.75.75 0 0 0-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 1 0-1.06 1.061l2.5 2.5a.75.75 0 0 0 1.137-.089l4-5.5Z" clip-rule="evenodd" />
</svg>
@@ -72,7 +72,7 @@
<div class="flex flex-col md:flex-row md:items-end md:space-x-4 space-y-2 md:space-y-0">
<div class="flex flex-col">
<label for="status" class="text-xs text-gray-900 dark:text-white mb-1">Status</label>
<select id="status" name="status" class="rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 dark:text-white dark:bg-white/5 ring-1 ring-inset ring-gray-300 dark:ring-white/10 focus:ring-2 focus:ring-sky-600 sm:text-sm sm:leading-6 [&_*]:text-black" aria-label="Filter by status">
<select id="status" name="status" class="rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 dark:text-white dark:bg-white/5 ring-1 ring-inset ring-gray-300 dark:ring-white/10 focus:ring-2 focus:ring-sky-600 sm:text-sm sm:leading-6 **:text-black" aria-label="Filter by status">
<option value="all"<%= filters.status === 'all' ? ' selected' : '' %>>All</option>
<option value="available"<%= filters.status === 'available' ? ' selected' : '' %>>Available</option>
<option value="in-use"<%= filters.status === 'in-use' ? ' selected' : '' %>>In Use</option>
@@ -81,7 +81,7 @@
</div>
<div class="flex flex-col">
<label for="quota" class="text-xs text-gray-900 dark:text-white mb-1">Quota</label>
<select id="quota" name="quota" class="rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 dark:text-white dark:bg-white/5 ring-1 ring-inset ring-gray-300 dark:ring-white/10 focus:ring-2 focus:ring-sky-600 sm:text-sm sm:leading-6 [&_*]:text-black" aria-label="Filter by quota">
<select id="quota" name="quota" class="rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 dark:text-white dark:bg-white/5 ring-1 ring-inset ring-gray-300 dark:ring-white/10 focus:ring-2 focus:ring-sky-600 sm:text-sm sm:leading-6 **:text-black" aria-label="Filter by quota">
<option value="all"<%= filters.quota === 'all' ? ' selected' : '' %>>All</option>
<option value="multi-use"<%= filters.quota === 'multi-use' ? ' selected' : '' %>>Multi-use</option>
<option value="single-use"<%= filters.quota === 'single-use' ? ' selected' : '' %>>Single-use</option>
@@ -89,7 +89,7 @@
</div>
<div class="flex flex-col">
<label for="sort" class="text-xs text-gray-900 dark:text-white mb-1">Sort</label>
<select id="sort" name="sort" class="rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 dark:text-white dark:bg-white/5 ring-1 ring-inset ring-gray-300 dark:ring-white/10 focus:ring-2 focus:ring-sky-600 sm:text-sm sm:leading-6 [&_*]:text-black" aria-label="Sort by">
<select id="sort" name="sort" class="rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 dark:text-white dark:bg-white/5 ring-1 ring-inset ring-gray-300 dark:ring-white/10 focus:ring-2 focus:ring-sky-600 sm:text-sm sm:leading-6 **:text-black" aria-label="Sort by">
<option value="date"<%= sort === 'date' ? ' selected' : '' %>>Date</option>
<option value="code"<%= sort === 'code' ? ' selected' : '' %>>Code</option>
<option value="note"<%= sort === 'note' ? ' selected' : '' %>>Notes</option>
@@ -106,7 +106,7 @@
<span class="mb-1 text-xs text-gray-900 dark:text-white<%= !printer_enabled ? ' hidden' : '' %>">
&nbsp;
</span>
<button id="bulk-print" class="w-fit justify-self-end relative inline-flex items-center gap-x-1.5 rounded-md bg-sky-700 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-sky-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-700<%= !printer_enabled ? ' hidden' : '' %>">
<button id="bulk-print" class="<%= !printer_enabled ? 'hidden' : 'inline-flex' %> w-fit justify-self-end relative items-center gap-x-1.5 rounded-md bg-sky-700 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-sky-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-700">
<svg class="-ml-0.5 h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M6.72 13.829c-.24.03-.48.062-.72.096m.72-.096a42.415 42.415 0 0 1 10.56 0m-10.56 0L6.34 18m10.94-4.171c.24.03.48.062.72.096m-.72-.096L17.66 18m0 0 .229 2.523a1.125 1.125 0 0 1-1.12 1.227H7.231c-.662 0-1.18-.568-1.12-1.227L6.34 18m11.318 0h1.091A2.25 2.25 0 0 0 21 15.75V9.456c0-1.081-.768-2.015-1.837-2.175a48.055 48.055 0 0 0-1.913-.247M6.34 18H5.25A2.25 2.25 0 0 1 3 15.75V9.456c0-1.081.768-2.015 1.837-2.175a48.041 48.041 0 0 1 1.913-.247m10.5 0a48.536 48.536 0 0 0-10.5 0m10.5 0V3.375c0-.621-.504-1.125-1.125-1.125h-8.25c-.621 0-1.125.504-1.125 1.125v3.659M18 10.5h.008v.008H18V10.5Zm-3 0h.008v.008H15V10.5Z" />
</svg>
@@ -117,7 +117,7 @@
<span class="mb-1 text-xs text-gray-900 dark:text-white">
Last Sync: <%= new Intl.DateTimeFormat('en-GB', {day: "numeric", month: "numeric", hour: "numeric", minute: "numeric", hour12: false}).format(new Date(updated)) %>
</span>
<a href="<%= baseUrl %>/vouchers?refresh=true" id="reload-vouchers" type="button" class="w-fit justify-self-end relative inline-flex items-center gap-x-1.5 rounded-md bg-sky-700 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-sky-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-700">
<a href="<%= baseUrl %>/vouchers?refresh=true" id="reload-vouchers" type="button" class="w-fit justify-self-end relative inline-flex items-center gap-x-1.5 rounded-md bg-sky-700 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-sky-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-700">
<svg class="-ml-0.5 h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M15.312 11.424a5.5 5.5 0 0 1-9.201 2.466l-.312-.311h2.433a.75.75 0 0 0 0-1.5H3.989a.75.75 0 0 0-.75.75v4.242a.75.75 0 0 0 1.5 0v-2.43l.31.31a7 7 0 0 0 11.712-3.138.75.75 0 0 0-1.449-.39Zm1.23-3.723a.75.75 0 0 0 .219-.53V2.929a.75.75 0 0 0-1.5 0V5.36l-.31-.31A7 7 0 0 0 3.239 8.188a.75.75 0 1 0 1.448.389A5.5 5.5 0 0 1 13.89 6.11l.311.31h-2.432a.75.75 0 0 0 0 1.5h4.243a.75.75 0 0 0 .53-.219Z" clip-rule="evenodd" />
</svg>
@@ -133,7 +133,7 @@
<h3 class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">No vouchers</h3>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">Get started by creating a new voucher.</p>
<div class="mt-6">
<button id="create-button-info" type="button" class="inline-flex items-center rounded-md bg-sky-700 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-sky-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-700">
<button id="create-button-info" type="button" class="inline-flex items-center rounded-md bg-sky-700 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-sky-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-700">
<svg class="-ml-0.5 mr-1.5 h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path d="M10.75 4.75a.75.75 0 00-1.5 0v4.5h-4.5a.75.75 0 000 1.5h4.5v4.5a.75.75 0 001.5 0v-4.5h4.5a.75.75 0 000-1.5h-4.5v-4.5z" />
</svg>
@@ -243,7 +243,7 @@
<div id="bulk-print-dialog"></div>
<div id="create-dialog" class="hidden relative z-40" role="dialog" aria-modal="true">
<div id="create-dialog-overlay" class="fixed inset-0 bg-gray-500 bg-opacity-75"></div>
<div id="create-dialog-overlay" class="fixed inset-0 bg-gray-500/75"></div>
<div class="fixed overflow-hidden">
<div class="absolute inset-0 overflow-hidden">
@@ -265,7 +265,7 @@
<div>
<label for="voucher-type" class="block text-sm font-medium leading-6 text-gray-900 dark:text-white">Preset Voucher Type</label>
<div class="mt-2">
<select id="voucher-type" name="voucher-type" class="mt-2 block w-full rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 dark:text-white dark:bg-white/5 ring-1 ring-inset ring-gray-300 dark:ring-white/10 focus:ring-2 focus:ring-sky-600 sm:text-sm sm:leading-6 [&_*]:text-black">
<select id="voucher-type" name="voucher-type" class="mt-2 block w-full rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 dark:text-white dark:bg-white/5 ring-1 ring-inset ring-gray-300 dark:ring-white/10 focus:ring-2 focus:ring-sky-600 sm:text-sm sm:leading-6 **:text-black">
<% voucher_types.forEach((type) => { %>
<option value="<%= type.raw %>"><%= timeConvert(type.expiration) %>, <%= type.usage === '1' ? 'single-use' : type.usage === '0' ? 'multi-use (unlimited)' : `multi-use (${type.usage}x)` %><%= typeof type.upload === "undefined" && typeof type.download === "undefined" && typeof type.megabytes === "undefined" ? ', no limits' : '' %><%= typeof type.upload !== "undefined" ? `, upload bandwidth limit: ${type.upload} kb/s` : '' %><%= typeof type.download !== "undefined" ? `, download bandwidth limit: ${type.download} kb/s` : '' %><%= typeof type.megabytes !== "undefined" ? `, quota limit: ${type.megabytes} mb` : '' %></option>
<% }); %>
@@ -291,7 +291,7 @@
<label for="voucher-duration" class="mt-4 block text-sm font-medium leading-6 text-gray-900 dark:text-white">Duration</label>
<div class="mt-2 grid grid-cols-2 gap-2">
<input type="number" min="1" step="1" value="8" id="voucher-duration" name="voucher-duration" required class="block w-full rounded-md border-0 py-1.5 text-gray-900 dark:text-white dark:bg-white/5 ring-1 ring-inset ring-gray-300 dark:ring-white/10 focus:ring-2 focus:ring-sky-600 sm:text-sm sm:leading-6">
<select id="voucher-duration-type" name="voucher-duration-type" class="block w-full rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 dark:text-white dark:bg-white/5 ring-1 ring-inset ring-gray-300 dark:ring-white/10 focus:ring-2 focus:ring-sky-600 sm:text-sm sm:leading-6 [&_*]:text-black">
<select id="voucher-duration-type" name="voucher-duration-type" class="block w-full rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 dark:text-white dark:bg-white/5 ring-1 ring-inset ring-gray-300 dark:ring-white/10 focus:ring-2 focus:ring-sky-600 sm:text-sm sm:leading-6 **:text-black">
<option value="minute">Minute(s)</option>
<option value="hour" selected>Hour(s)</option>
<option value="day">Day(s)</option>
@@ -301,7 +301,7 @@
<div class="custom-voucher-field hidden">
<label for="voucher-usage" class="block text-sm font-medium leading-6 text-gray-900 dark:text-white">Voucher Usage</label>
<div class="mt-2">
<select id="voucher-usage" name="voucher-usage" class="mt-2 block w-full rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 dark:text-white dark:bg-white/5 ring-1 ring-inset ring-gray-300 dark:ring-white/10 focus:ring-2 focus:ring-sky-600 sm:text-sm sm:leading-6 [&_*]:text-black">
<select id="voucher-usage" name="voucher-usage" class="mt-2 block w-full rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 dark:text-white dark:bg-white/5 ring-1 ring-inset ring-gray-300 dark:ring-white/10 focus:ring-2 focus:ring-sky-600 sm:text-sm sm:leading-6 **:text-black">
<option value="0">Multi-use (Unlimited)</option>
<option value="-1">Multi-use (Limited/Quota)</option>
<option value="1">Single-use</option>
@@ -336,9 +336,9 @@
</div>
</div>
</div>
<div class="flex flex-shrink-0 justify-end px-4 py-4">
<button id="cancel" type="button" class="rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50">Cancel</button>
<button type="submit" class="ml-4 inline-flex justify-center rounded-md bg-sky-700 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-sky-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-700">Create</button>
<div class="flex shrink-0 justify-end px-4 py-4">
<button id="cancel" type="button" class="rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-xs ring-1 ring-inset ring-gray-300 hover:bg-gray-50">Cancel</button>
<button type="submit" class="ml-4 inline-flex justify-center rounded-md bg-sky-700 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-sky-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-700">Create</button>
</div>
</form>
</div>
@@ -393,10 +393,10 @@
<div aria-live="assertive" class="z-40 pointer-events-none fixed inset-0 flex items-end px-4 py-6">
<div id="copy-notification" style="display: none;" class="flex w-full flex-col items-center space-y-4">
<div class="pointer-events-auto w-full max-w-sm overflow-hidden rounded-lg bg-white dark:bg-gray-800 shadow-lg ring-1 ring-black ring-opacity-5">
<div class="pointer-events-auto w-full max-w-sm overflow-hidden rounded-lg bg-white dark:bg-gray-800 shadow-lg ring-1 ring-black/5">
<div class="p-4">
<div class="flex items-start">
<div class="flex-shrink-0">
<div class="shrink-0">
<svg class="h-6 w-6 text-green-400" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>