Browse Source

Merge pull request #1 from wg-easy/master

rebase
pull/2556/head
Dvorhack 3 weeks ago
committed by GitHub
parent
commit
6a4ad1b795
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 10
      .github/dependabot.yml
  2. 10
      .github/workflows/deploy-development.yml
  3. 10
      .github/workflows/deploy-edge.yml
  4. 10
      .github/workflows/deploy.yml
  5. 4
      .github/workflows/lint.yml
  6. 5
      .gitignore
  7. 5
      .vscode/settings.json
  8. 7
      CHANGELOG.md
  9. 19
      Dockerfile
  10. 4
      Dockerfile.dev
  11. 6
      README.md
  12. 1
      docs/content/advanced/api.md
  13. 16
      docs/content/advanced/config/amnezia.md
  14. 182
      docs/content/advanced/config/external-authentication.md
  15. 13
      docs/content/advanced/config/optional-config.md
  16. 2
      docs/content/advanced/config/unattended-setup.md
  17. 1
      docs/content/advanced/metrics/prometheus.md
  18. 3
      docs/content/contributing/issues-and-pull-requests.md
  19. 6
      docs/content/examples/tutorials/adguard.md
  20. 2
      docs/content/examples/tutorials/basic-installation.md
  21. 1
      docs/content/examples/tutorials/caddy.md
  22. 1
      docs/content/examples/tutorials/reverse-proxyless.md
  23. 1
      docs/content/examples/tutorials/routed.md
  24. 1
      docs/content/examples/tutorials/traefik.md
  25. 28
      docs/content/faq.md
  26. 2
      docs/content/getting-started.md
  27. 2
      docs/content/guides/admin.md
  28. 2
      docs/content/guides/clients.md
  29. 1
      docs/content/index.md
  30. 87
      docs/mkdocs.yml
  31. 6
      docs/requirements.txt
  32. 97
      docs/zensical.toml
  33. 6
      package.json
  34. 10
      pnpm-lock.yaml
  35. 2
      src/.nuxtrc
  36. 2
      src/app/components/Base/Dialog.vue
  37. 26
      src/app/components/Base/FormSecondaryButton.vue
  38. 2
      src/app/components/ClientCard/Charts.vue
  39. 2
      src/app/components/ClientCard/ClientCard.vue
  40. 4
      src/app/components/ClientCard/ExpireDate.vue
  41. 6
      src/app/components/ClientCard/Name.vue
  42. 9
      src/app/components/ClientCard/OneTimeLinkBtn.vue
  43. 18
      src/app/components/ClientCard/Switch.vue
  44. 9
      src/app/components/Clients/CreateDialog.vue
  45. 2
      src/app/components/Clients/Empty.vue
  46. 4
      src/app/components/Clients/New.vue
  47. 2
      src/app/components/Clients/Search.vue
  48. 11
      src/app/components/Clients/Sort.vue
  49. 20
      src/app/components/Form/ArrayField.vue
  50. 4
      src/app/components/Form/Heading.vue
  51. 6
      src/app/components/Form/Label.vue
  52. 20
      src/app/components/Form/NullArrayField.vue
  53. 2
      src/app/components/Form/PrimaryActionField.vue
  54. 34
      src/app/components/Form/SecondaryActionField.vue
  55. 4
      src/app/components/Form/SwitchField.vue
  56. 6
      src/app/components/Header/LangSelector.vue
  57. 2
      src/app/components/Header/Logo.vue
  58. 2
      src/app/components/Header/Update.vue
  59. 23
      src/app/components/Icons/Brands/GitHub.vue
  60. 247
      src/app/components/Icons/Brands/Google.vue
  61. 10
      src/app/components/Icons/Brands/Provider.vue
  62. 10
      src/app/components/Panel/Panel.vue
  63. 2
      src/app/components/Panel/head/Boat.vue
  64. 2
      src/app/components/Panel/head/Head.vue
  65. 10
      src/app/components/Panel/head/Title.vue
  66. 21
      src/app/components/Ui/LoginOauthButton.vue
  67. 9
      src/app/components/Ui/UserMenu.vue
  68. 47
      src/app/composables/useSubmit.ts
  69. 10
      src/app/layouts/default.vue
  70. 6
      src/app/middleware/auth.global.ts
  71. 60
      src/app/pages/admin.vue
  72. 9
      src/app/pages/admin/config.vue
  73. 9
      src/app/pages/admin/general.vue
  74. 9
      src/app/pages/admin/hooks.vue
  75. 35
      src/app/pages/admin/interface.vue
  76. 30
      src/app/pages/clients/[id].vue
  77. 10
      src/app/pages/index.vue
  78. 117
      src/app/pages/login.vue
  79. 108
      src/app/pages/login/2fa.vue
  80. 147
      src/app/pages/login/index.vue
  81. 151
      src/app/pages/me.vue
  82. 9
      src/app/pages/setup/2.vue
  83. 9
      src/app/pages/setup/4.vue
  84. 9
      src/app/pages/setup/migrate.vue
  85. 1
      src/app/stores/auth.ts
  86. 24
      src/app/stores/clients.ts
  87. 2
      src/app/stores/global.ts
  88. 2
      src/app/utils/types.ts
  89. 4
      src/cli/admin/reset.ts
  90. 14
      src/cli/build.js
  91. 2
      src/cli/clients/list.ts
  92. 6
      src/cli/clients/qr.ts
  93. 2
      src/cli/db.ts
  94. 7
      src/cli/index.ts
  95. 7
      src/cli/tsconfig.json
  96. 3
      src/eslint.config.mjs
  97. 6
      src/i18n/i18n.config.ts
  98. 13
      src/i18n/localeDetector.ts
  99. 19
      src/i18n/locales/en.json
  100. 153
      src/i18n/locales/gl.json

10
.github/dependabot.yml

@ -5,13 +5,3 @@ updates:
schedule:
interval: "weekly"
rebase-strategy: "auto"
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
rebase-strategy: "auto"
- package-ecosystem: "npm"
directory: "/src/"
schedule:
interval: "weekly"
rebase-strategy: "auto"

10
.github/workflows/deploy-development.yml

@ -123,8 +123,14 @@ jobs:
- name: Create manifest list and push
working-directory: ${{ runner.temp }}/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf 'ghcr.io/wg-easy/wg-easy@sha256:%s ' *)
n=3
for i in $(seq 1 $n); do
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf 'ghcr.io/wg-easy/wg-easy@sha256:%s ' *) && break
[ "$i" = "$n" ] && exit 1
sleep 10
done
- name: Inspect image
run: |

10
.github/workflows/deploy-edge.yml

@ -132,8 +132,14 @@ jobs:
- name: Create manifest list and push
working-directory: ${{ runner.temp }}/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf 'ghcr.io/wg-easy/wg-easy@sha256:%s ' *)
n=3
for i in $(seq 1 $n); do
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf 'ghcr.io/wg-easy/wg-easy@sha256:%s ' *) && break
[ "$i" = "$n" ] && exit 1
sleep 10
done
- name: Inspect image
run: |

10
.github/workflows/deploy.yml

@ -135,8 +135,14 @@ jobs:
- name: Create manifest list and push
working-directory: ${{ runner.temp }}/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf 'ghcr.io/wg-easy/wg-easy@sha256:%s ' *)
n=3
for i in $(seq 1 $n); do
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf 'ghcr.io/wg-easy/wg-easy@sha256:%s ' *) && break
[ "$i" = "$n" ] && exit 1
sleep 10
done
- name: Inspect image
run: |

4
.github/workflows/lint.yml

@ -16,7 +16,7 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v6
- uses: pnpm/action-setup@v4
- uses: pnpm/action-setup@v6
name: Install pnpm
with:
run_install: false
@ -49,7 +49,7 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v6
- uses: pnpm/action-setup@v4
- uses: pnpm/action-setup@v6
name: Install pnpm
with:
run_install: false

5
.gitignore

@ -1,3 +1,6 @@
.DS_Store
*.swp
node_modules
node_modules
docs/site
docs/.cache

5
.vscode/settings.json

@ -26,5 +26,8 @@
"i18n-ally.sortKeys": false,
"i18n-ally.keepFulfilled": false,
"i18n-ally.keystyle": "nested",
"editor.gotoLocation.multipleDefinitions": "goto"
"editor.gotoLocation.multipleDefinitions": "goto",
"[toml]": {
"editor.defaultFormatter": "tamasfe.even-better-toml"
}
}

7
CHANGELOG.md

@ -14,10 +14,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- CLI: Show QR code (https://github.com/wg-easy/wg-easy/pull/2518)
- Copy QR code to clipboard / save as png (https://github.com/wg-easy/wg-easy/pull/2521)
### Fixed
- Add trailing newline to Prometheus metrics output (https://github.com/wg-easy/wg-easy/pull/2573)
- Correctly use DEBUG env var (https://github.com/wg-easy/wg-easy/pull/2619)
### Changed
- Hooks are now Textareas (https://github.com/wg-easy/wg-easy/pull/2522)
- Update to Node Krypton (24) (https://github.com/wg-easy/wg-easy/pull/2536)
- Mobile UI (https://github.com/wg-easy/wg-easy/pull/2569)
- Prevent enabling client when expired (https://github.com/wg-easy/wg-easy/pull/2594)
## [15.2.2] - 2026-02-06

19
Dockerfile

@ -7,7 +7,7 @@ RUN npm install --global corepack@latest
RUN corepack enable pnpm
# Copy Web UI
COPY src/package.json src/pnpm-lock.yaml ./
COPY src/package.json src/pnpm-lock.yaml src/pnpm-workspace.yaml ./
RUN pnpm install
# Build UI
@ -21,7 +21,12 @@ RUN apk add linux-headers build-base go git && \
cd amneziawg-go && \
make && \
cd ../amneziawg-tools/src && \
make
make && \
sed -i 's|\[\[ $proto == -4 \]\] && cmd sysctl -q net\.ipv4\.conf\.all\.src_valid_mark=1|[[ $proto == -4 ]] \&\& [[ $(sysctl -n net.ipv4.conf.all.src_valid_mark) != 1 ]] \&\& cmd sysctl -q net.ipv4.conf.all.src_valid_mark=1|' ./wg-quick/linux.bash
FROM docker.io/library/node:krypton-alpine AS build-libsql
WORKDIR /app
RUN npm install --no-save --omit=dev libsql
# Copy build result to a new image.
# This saves a lot of disk space.
@ -35,9 +40,8 @@ COPY --from=build /app/.output /app
# Copy migrations
COPY --from=build /app/server/database/migrations /app/server/database/migrations
# libsql (https://github.com/nitrojs/nitro/issues/3328)
RUN cd /app/server && \
npm install --no-save --omit=dev libsql && \
npm cache clean --force
COPY --from=build-libsql /app/node_modules /app/server/node_modules
# cli
COPY --from=build /app/cli/cli.sh /usr/local/bin/cli
RUN chmod +x /usr/local/bin/cli
@ -59,7 +63,8 @@ RUN apk add --no-cache \
kmod \
iptables-legacy \
wireguard-go \
wireguard-tools
wireguard-tools && \
sed -i 's|\[\[ $proto == -4 \]\] && cmd sysctl -q net\.ipv4\.conf\.all\.src_valid_mark=1|[[ $proto == -4 ]] \&\& [[ $(sysctl -n net.ipv4.conf.all.src_valid_mark) != 1 ]] \&\& cmd sysctl -q net.ipv4.conf.all.src_valid_mark=1|' /usr/bin/wg-quick
RUN mkdir -p /etc/amnezia
RUN ln -s /etc/wireguard /etc/amnezia/amneziawg
@ -69,7 +74,7 @@ RUN update-alternatives --install /usr/sbin/iptables iptables /usr/sbin/iptables
RUN update-alternatives --install /usr/sbin/ip6tables ip6tables /usr/sbin/ip6tables-legacy 10 --slave /usr/sbin/ip6tables-restore ip6tables-restore /usr/sbin/ip6tables-legacy-restore --slave /usr/sbin/ip6tables-save ip6tables-save /usr/sbin/ip6tables-legacy-save
# Set Environment
ENV DEBUG=Server,WireGuard,Database,CMD
ENV DEBUG=Server,WireGuard,Database,CMD,Firewall
ENV PORT=51821
ENV HOST=0.0.0.0
ENV INSECURE=false

4
Dockerfile.dev

@ -24,7 +24,7 @@ RUN update-alternatives --install /usr/sbin/iptables iptables /usr/sbin/iptables
RUN update-alternatives --install /usr/sbin/ip6tables ip6tables /usr/sbin/ip6tables-legacy 10 --slave /usr/sbin/ip6tables-restore ip6tables-restore /usr/sbin/ip6tables-legacy-restore --slave /usr/sbin/ip6tables-save ip6tables-save /usr/sbin/ip6tables-legacy-save
# Set Environment
ENV DEBUG=Server,WireGuard,Database,CMD
ENV DEBUG=Server,WireGuard,Database,CMD,Firewall
ENV PORT=51821
ENV HOST=0.0.0.0
ENV INSECURE=true
@ -32,7 +32,7 @@ ENV INIT_ENABLED=false
ENV DISABLE_IPV6=false
# Install Dependencies
COPY src/package.json src/pnpm-lock.yaml ./
COPY src/package.json src/pnpm-lock.yaml src/pnpm-workspace.yaml ./
RUN pnpm install
# Copy Project

6
README.md

@ -1,16 +1,14 @@
# WireGuard Easy
[![Build & Publish latest Image](https://github.com/wg-easy/wg-easy/actions/workflows/deploy.yml/badge.svg?branch=production)](https://github.com/wg-easy/wg-easy/actions/workflows/deploy.yml)
[![Build & Publish latest Image](https://github.com/wg-easy/wg-easy/actions/workflows/deploy.yml/badge.svg)](https://github.com/wg-easy/wg-easy/actions/workflows/deploy.yml)
[![Lint](https://github.com/wg-easy/wg-easy/actions/workflows/lint.yml/badge.svg?branch=master)](https://github.com/wg-easy/wg-easy/actions/workflows/lint.yml)
[![GitHub Stars](https://img.shields.io/github/stars/wg-easy/wg-easy)](https://github.com/wg-easy/wg-easy/stargazers)
[![License](https://img.shields.io/github/license/wg-easy/wg-easy)](LICENSE)
[![GitHub Release](https://img.shields.io/github/v/release/wg-easy/wg-easy)](https://github.com/wg-easy/wg-easy/releases/latest)
[![Image Pulls](https://img.shields.io/badge/image_pulls-12M+-blue)](https://github.com/wg-easy/wg-easy/pkgs/container/wg-easy)
[![Image Pulls](https://img.shields.io/badge/image_pulls-28M+-blue)](https://github.com/wg-easy/wg-easy/pkgs/container/wg-easy)
You have found the easiest way to install & manage WireGuard on any Linux host!
<!-- TOOD: update screenshot -->
<p align="center">
<img src="./assets/screenshot.png" width="802" alt="wg-easy Screenshot" />
</p>

1
docs/content/advanced/api.md

@ -5,6 +5,7 @@ title: API
/// warning | Breaking Changes
This API is not yet stable and may change in the future. The API is currently in development and is subject to change without notice. The API is not yet documented, but we will add documentation as the API stabilizes.
///
You can use the API to interact with the application programmatically. The API is available at `/api` and supports both GET and POST requests. The API is designed to be simple and easy to use, with a focus on providing a consistent interface for all endpoints.

16
docs/content/advanced/config/amnezia.md

@ -39,14 +39,14 @@ If a parameter is not set, it will not be added to the configuration. If all Amn
### Parameter Compatibility Table
| Parameter | Can differ between server and client | Configurable on server | Configurable on client |
| --------- | ------------------------------------ | ---------------------- | ----------------------- |
| Jc | ✅ Yes | ✅ | ✅ |
| Jmin | ✅ Yes | ✅ | ✅ |
| Jmax | ✅ Yes | ✅ | ✅ |
| S1-S4 | ❌ No, must match | ✅ | ❌ (copied from server) |
| H1-H4 | ❌ No, must match | ✅ | ❌ (copied from server) |
| I1-I5 | ✅ Yes | ✅ | ✅ |
| Parameter | Can differ between server and client | Configurable on server | Configurable on client |
| --------- | ------------------------------------ | ---------------------- | ------------------------ |
| Jc | :white_check_mark: Yes | :white_check_mark: | :white_check_mark: |
| Jmin | :white_check_mark: Yes | :white_check_mark: | :white_check_mark: |
| Jmax | :white_check_mark: Yes | :white_check_mark: | :white_check_mark: |
| S1-S4 | :x: No, must match | :white_check_mark: | :x: (copied from server) |
| H1-H4 | :x: No, must match | :white_check_mark: | :x: (copied from server) |
| I1-I5 | :white_check_mark: Yes | :white_check_mark: | :white_check_mark: |
## Client Applications

182
docs/content/advanced/config/external-authentication.md

@ -0,0 +1,182 @@
---
title: External Authentication
---
## OAuth
### Setup
To enable OAuth set the env var `OAUTH_PROVIDERS` to any of the following providers:
| Provider | Value |
| ----------------------------- | -------- |
| [Google](#google) | `google` |
| [GitHub](#github) | `github` |
| [Generic OIDC](#generic-oidc) | `oidc` |
You can enable multiple providers by separating them with a comma:
e.g. `google,github`
### Auto Register
To automatically register users that log in with an OAuth provider, set the following environment variable to `true`:
| Env | Required | Default | Description |
| --------------------- | -------- | ------- | ------------------------ |
| `OAUTH_AUTO_REGISTER` | :x: | `false` | Enable auto-registration |
When enabled:
- If a user logs in with an email address that is not yet registered, a new account will be created for them.
- If a user logs in with an email address that is already registered, their account will be linked to the OAuth provider (if not already linked), regardless of the value of `OAUTH_AUTO_REGISTER`.
/// warning | Security
Users will be created with Admin Permissions, as the permissions system is not yet implemented. Only enable this if you trust all users that can log in with the OAuth provider.
Use [Allowed Domains](#allowed-domains) to restrict which users can log in.
///
### Allowed Domains
To only allow users with an email address from a specific domain to log in, set the following environment variable to the allowed domain.
| Env | Required | Default | Description |
| ----------------------- | -------- | ------- | --------------------- |
| `OAUTH_ALLOWED_DOMAINS` | :x: | - | Allowed email domains |
You can allow multiple domains by separating them with a comma:
e.g. `example.com,example.org`
### Auto Launch
To automatically launch the OAuth login flow when visiting the login page, set the following environment variable to the provider you want to launch:
| Env | Required | Default | Description |
| ------------------- | -------- | ------- | ----------------------------- |
| `OAUTH_AUTO_LAUNCH` | :x: | - | Auto launch an OAuth provider |
When enabled:
- Visiting the login page will automatically redirect to the selected provider's login page
- The user can still access the normal login page by visiting `/login?auto_launch=false`
- You can auto launch any provider by visiting `/login?auto_launch=<provider>`
### Redirect URIs
You have to configure the following redirect URIs in your OAuth provider:
- `https://<your-domain>/api/auth/<provider>/callback`
Used to log in to with the provider
- `https://<your-domain>/api/auth/<provider>/link`
Used to link an existing account to the provider
If your provider does not support multiple redirect URIs (e.g. GitHub) but allows multiple URIs under the same base, then configure:
- `https://<your-domain>/api/auth/<provider>/`
### Provider Configuration
#### Google
| Env | Required | Description |
| ---------------------------- | ------------------ | -------------------- |
| `OAUTH_GOOGLE_CLIENT_ID` | :white_check_mark: | Google Client ID |
| `OAUTH_GOOGLE_CLIENT_SECRET` | :white_check_mark: | Google Client Secret |
<h5>Setup</h5>
1. Go to [Google Cloud Console](https://console.cloud.google.com/apis/credentials)
2. Create an OAuth 2.0 Client ID (Web application)
3. Add Authorized redirect URI: See [Redirect URIs](#redirect-uris)
4. Copy the Client ID and Client Secret to the environment variables
#### GitHub
| Env | Required | Description |
| ---------------------------- | ------------------ | -------------------- |
| `OAUTH_GITHUB_CLIENT_ID` | :white_check_mark: | GitHub Client ID |
| `OAUTH_GITHUB_CLIENT_SECRET` | :white_check_mark: | GitHub Client Secret |
<h5>Setup</h5>
1. Go to [GitHub Developer Settings](https://github.com/settings/developers)
2. Create a new OAuth App
3. Add Authorization callback URL: See [Redirect URIs](#redirect-uris)
4. Create a new client secret
5. Copy the Client ID and Client Secret to the environment variables
#### Generic OIDC
This supports generic OIDC providers like Authelia, Authentik, etc.
The provider needs to support:
- PKCE
- default scopes: `openid email profile`
- Client Secret Authentication `client_secret_post`
The provider needs to be available with HTTPS and have a valid certificate.
| Env | Required | Default | Example | Description |
| -------------------------- | ------------------ | ------- | -------------------------- | ------------------ |
| `OAUTH_OIDC_SERVER` | :white_check_mark: | - | `https://auth.example.com` | OIDC Server |
| `OAUTH_OIDC_CLIENT_ID` | :white_check_mark: | - | - | OIDC Client ID |
| `OAUTH_OIDC_CLIENT_SECRET` | :white_check_mark: | - | - | OIDC Client Secret |
| `OAUTH_OIDC_NAME` | :x: | OIDC | `Authelia` | Provider Name |
##### Authelia Setup
Generate Client ID and Secret:
```shell
# Client ID
docker run --rm authelia/authelia:latest authelia crypto rand --length 72 --charset rfc3986
# Client Secret
docker run --rm authelia/authelia:latest authelia crypto hash generate pbkdf2 --variant sha512 --random --random.length 72 --random.charset rfc3986
```
```yaml
- client_id: '...'
client_name: wg-easy
client_secret: '$pbkdf2-...'
redirect_uris:
- https://<your-domain>/api/auth/oidc/callback
- https://<your-domain>/api/auth/oidc/link
scopes:
- openid
- profile
- email
authorization_policy: one_factor
pre_configured_consent_duration: 1 week
require_pkce: true
token_endpoint_auth_method: client_secret_post
```
#### Generic OAuth
Not currently supported
### Disable Password Authentication
To disable password-based authentication and only allow login via OAuth providers, set the following environment variable to `true`:
| Env | Required | Default | Description |
| ----------------------- | -------- | ------- | ------------------------------- |
| `DISABLE_PASSWORD_AUTH` | :x: | `false` | Disable password authentication |
When enabled:
- Users will not be able to log in with a password
/// warning | Access Recovery
Before disabling password authentication, ensure that at least one OAuth provider is configured and that you have successfully linked an administrator account.
If no login method is available, you will not be able to log in to the application and will need to reset the configuration to regain access.
///

13
docs/content/advanced/config/optional-config.md

@ -4,12 +4,13 @@ title: Optional Configuration
You can set these environment variables to configure the container. They are not required, but can be useful in some cases.
| Env | Default | Example | Description |
| -------------- | --------- | ----------- | ---------------------------------- |
| `PORT` | `51821` | `6789` | TCP port for Web UI. |
| `HOST` | `0.0.0.0` | `localhost` | IP address web UI binds to. |
| `INSECURE` | `false` | `true` | If access over http is allowed |
| `DISABLE_IPV6` | `false` | `true` | If IPv6 support should be disabled |
| Env | Default | Example | Description |
| ----------------------- | --------- | ----------- | --------------------------------------- |
| `PORT` | `51821` | `6789` | TCP port for Web UI. |
| `HOST` | `0.0.0.0` | `localhost` | IP address web UI binds to. |
| `INSECURE` | `false` | `true` | If access over http is allowed |
| `DISABLE_IPV6` | `false` | `true` | If IPv6 support should be disabled |
| `DISABLE_VERSION_CHECK` | `false` | `true` | If wg-easy should check for new updates |
/// note | IPv6 Caveats

2
docs/content/advanced/config/unattended-setup.md

@ -23,6 +23,7 @@ These will only be used during the first start of the container. After that, the
If variables are in the same group, you have to set all of them. For example, if you set `INIT_IPV4_CIDR`, you also have to set `INIT_IPV6_CIDR`.
If you want to skip the setup process, you have to configure group `1`
///
/// note | Security
@ -30,4 +31,5 @@ If you want to skip the setup process, you have to configure group `1`
The initial username and password is not checked for complexity. Make sure to set a long enough username and password. Otherwise, the user won't be able to log in.
It's recommended to remove the variables after the setup is done to prevent the password from being exposed.
///

1
docs/content/advanced/metrics/prometheus.md

@ -39,4 +39,5 @@ You can use the following Grafana dashboard to visualize the metrics:
The Grafana dashboard is not official and is not maintained by the `wg-easy` team. If you have any issues with the dashboard, please contact the author of the dashboard.
See [#1299](https://github.com/wg-easy/wg-easy/pull/1299) for more information.
///

3
docs/content/contributing/issues-and-pull-requests.md

@ -9,6 +9,7 @@ This project is Open Source. That means that you can contribute on enhancements,
/// note | Attention
**Before opening an issue**, read the [`README`][github-file-readme] carefully, study the docs for your version (maybe [latest][docs-latest]) and your search engine you trust. The issue tracker is not meant to be used for unrelated questions!
///
When opening an issue, please provide details use case to let the community reproduce your problem.
@ -16,6 +17,7 @@ When opening an issue, please provide details use case to let the community repr
/// note | Attention
**Use the issue templates** to provide the necessary information. Issues which do not use these templates are not worked on and closed.
///
By raising issues, I agree to these terms and I understand, that the rules set for the issue tracker will help both maintainers as well as everyone to find a solution.
@ -39,6 +41,7 @@ When an option is marked with "not officially supported" / "unsupported", then s
/// question | Motivation
You want to add a feature? Feel free to start creating an issue explaining what you want to do and how you're thinking doing it. Other users may have the same need and collaboration may lead to better results.
///
### Submit a Pull-Request

6
docs/content/examples/tutorials/adguard.md

@ -9,9 +9,11 @@ This tutorial is a follow-up to the official [Traefik tutorial](./traefik.md). I
- A working [wg-easy](./basic-installation.md) and [Traefik](./traefik.md) setup from the previous guides.
/// warning | Important: Following this guide will reset your WireGuard configuration.
The process involves re-creating the `wg-easy` container and its data, which means **all existing WireGuard clients and settings will be deleted.**
You will need to create your clients again after completing this guide.
///
## Add `adguard` configuration
@ -152,13 +154,15 @@ networks:
2. Navigate to `https://adguard.$example.com$` to begin the AdGuard Home setup.
/// warning | Important: Configure AdGuard Home Admin Web Interface Port
During the initial AdGuard Home setup on the `Step 2/5` page, you **must** set the **Admin Web Interface Port** to **3000**. Do not use the default port 80, as it will not work with the Traefik configuration.
After completing the setup, the AdGuard UI might appear unresponsive. This is expected. **Simply reload the page**, and the panel will display correctly.
///
> If you accidentally left it default (80), you will need to manually edit the `docker-compose.yml` file for AdGuard Home (`/etc/docker/containers/adguard/docker-compose.yml`) and change the line `traefik.http.services.adguard.loadbalancer.server.port=3000` to `traefik.http.services.adguard.loadbalancer.server.port=80`. After making this change, restart AdGuard Home by navigating to `/etc/docker/containers/adguard` and running `sudo docker compose up -d`.
///
## Final System Checks
### Firewall

2
docs/content/examples/tutorials/basic-installation.md

@ -2,7 +2,7 @@
title: Basic Installation
---
<!-- TOOD: add docs for pihole, nginx, caddy, traefik -->
<!-- TODO: add docs for pihole, nginx, caddy, traefik -->
## Requirements

1
docs/content/examples/tutorials/caddy.md

@ -5,6 +5,7 @@ title: Caddy
/// note | Opinionated
This guide is opinionated. If you use other conventions or folder layouts, feel free to change the commands and paths.
///
We're using [Caddy](https://caddyserver.com/) here as reverse proxy to serve `wg-easy` on [https://wg-easy.example.com](https://wg-easy.example.com) via TLS.

1
docs/content/examples/tutorials/reverse-proxyless.md

@ -7,6 +7,7 @@ title: No Reverse Proxy
This is insecure. You should use a reverse proxy to secure the connection.
Only use this method if you know what you are doing.
///
If you only allow access to the web UI from your local network, you can skip the reverse proxy setup. This is not recommended, but it is possible.

1
docs/content/examples/tutorials/routed.md

@ -61,6 +61,7 @@ Pick an IPv4 and IPv6 subnet for your clients and add static routes on your rout
/// note | 2001:db8::/32
The _documentation prefix_ `2001:db8::/32` (RFC 3849) used in this example is not meant for production use, replace it with your own ISP-assigned IPv6 prefix (GUA) or local prefix (ULA)
///
I want my WireGuard clients in `192.168.0.0/24` and `2001:db8:abc:0::/64`.

1
docs/content/examples/tutorials/traefik.md

@ -5,6 +5,7 @@ title: Traefik
/// note | Opinionated
This guide is opinionated. If you use other conventions or folder layouts, feel free to change the commands and paths.
///
## Create docker compose project

28
docs/content/faq.md

@ -109,3 +109,31 @@ To resolve this issue, you can try the following steps:
```shell
echo "ip6table_filter" | sudo tee -a /etc/modules
```
## Clients lose connectivity after restarting the container when using multiple networks?
When you attach multiple Docker networks (e.g., `wg` and a reverse proxy network like `traefik` or `nginx`) to the `wg-easy` container, Docker might assign the network interfaces randomly (e.g., swapping `eth0` and `eth1`). Since `wg-easy` expects the wireguard interface to act as `eth0` and configures `POSTROUTING` rules for it, connectivity will break if the interfaces are swapped upon container restart.
To solve this, specify the `interface_name` and `gw_priority` explicitly in your `docker-compose.yml` file to guarantee that the `wg` network always binds to `eth0` and acts as the default gateway.
**Example `docker-compose.yml`:**
```yaml
services:
wg-easy:
# ... other configuration ...
networks:
wg:
interface_name: eth0
gw_priority: 1
ipv4_address: 10.42.42.42
nginx:
interface_name: eth1
gw_priority: 0
networks:
wg:
# ... wg network config ...
nginx:
external: true
```

2
docs/content/getting-started.md

@ -24,6 +24,7 @@ There are a few requirements for a suitable host system:
On the host, you need to have a suitable container runtime (like _Docker_ or _Podman_) installed. We assume [_Docker Compose_][docker-compose] is [installed][docker-compose-installation]. We have aligned file names and configuration conventions with the latest [Docker Compose specification][docker-compose-specification].
If you're using podman, make sure to read the related [documentation][docs-podman].
///
[docker-compose]: https://docs.docker.com/compose/
@ -69,6 +70,7 @@ When publishing a tag we follow the [Semantic Versioning][semver] specification.
/// danger | Use the Correct Commands For Stopping and Starting `wg-easy`
**Use `sudo docker compose up / down`, not `sudo docker compose start / stop`**. Otherwise, the container is not properly destroyed and you may experience problems during startup because of inconsistent state.
///
**That's it! It really is that easy**.

2
docs/content/guides/admin.md

@ -23,7 +23,9 @@ This feature is currently experimental. While functional, it should be thoroughl
- The feature cannot be enabled if these tools are not available
/// note
Most Linux distributions include iptables by default. If you're running in a minimal container environment, you may need to install the `iptables` package on the host system.
///
**Enable this feature if you want to:**

2
docs/content/guides/clients.md

@ -62,7 +62,9 @@ Protocol specifiers (`/tcp` or `/udp`) require a port number. The following form
- **Disable for specific client**: To disable firewall filtering for a single client while keeping it enabled for others, add `0.0.0.0/0, ::/0` to allow all traffic
/// note
To allow clients to reach the VPN server itself (e.g. for DNS), include the server's VPN address in the firewall allowed IPs.
///
**Use Case Examples**:

1
docs/content/index.md

@ -9,6 +9,7 @@ hide:
/// info | This Documentation is Versioned
**Make sure** to select the correct version of this documentation! It should match the version of the image you are using. The default version corresponds to [the most recent stable release][docs-tagging].
///
This documentation provides you not only with the basic setup and configuration of `wg-easy` but also with advanced configuration, elaborate usage scenarios, detailed examples, hints and more.

87
docs/mkdocs.yml

@ -1,87 +0,0 @@
site_name: 'wg-easy'
site_description: 'The easiest way to run WireGuard VPN + Web-based Admin UI.'
site_author: 'WireGuard Easy'
copyright: >
<p>
&copy <a href="https://github.com/wg-easy"><em>Wireguard Easy</em></a><br/>
<span>This project is licensed under AGPL-3.0-only.</span><br/>
<span>This project is not affiliated, associated, authorized, endorsed by, or in any way officially connected with Jason A. Donenfeld, ZX2C4 or Edge Security</span><br/>
<span>"WireGuard" and the "WireGuard" logo are registered trademarks of Jason A. Donenfeld</span>
</p>
repo_url: https://github.com/wg-easy/wg-easy
repo_name: wg-easy
edit_uri: 'edit/master/docs/content'
docs_dir: 'content/'
site_url: https://wg-easy.github.io/wg-easy
theme:
name: material
favicon: assets/logo/favicon.png
logo: assets/logo/logo.png
icon:
repo: fontawesome/brands/github
features:
- navigation.tabs
- navigation.top
- navigation.expand
- navigation.instant
- content.action.edit
- content.action.view
- content.code.annotate
palette:
# Light mode
- media: '(prefers-color-scheme: light)'
scheme: default
primary: grey
accent: red
toggle:
icon: material/weather-night
name: Switch to dark mode
# Dark mode
- media: '(prefers-color-scheme: dark)'
scheme: slate
primary: grey
accent: red
toggle:
icon: material/weather-sunny
name: Switch to light mode
extra:
version:
provider: mike
markdown_extensions:
- toc:
anchorlink: true
- abbr
- attr_list
- pymdownx.blocks.admonition:
types:
- danger
- note
- info
- question
- warning
- pymdownx.details
- pymdownx.superfences:
custom_fences:
- name: mermaid
class: mermaid
format: !!python/name:pymdownx.superfences.fence_code_format
- pymdownx.tabbed:
alternate_style: true
slugify: !!python/object/apply:pymdownx.slugs.slugify
kwds:
case: lower
- pymdownx.tasklist:
custom_checkbox: true
- pymdownx.magiclink
- pymdownx.inlinehilite
- pymdownx.tilde
- pymdownx.emoji:
emoji_index: !!python/name:material.extensions.emoji.twemoji
emoji_generator: !!python/name:material.extensions.emoji.to_svg

6
docs/requirements.txt

@ -1,4 +1,2 @@
mkdocs-material
pillow
cairosvg
mike
zensical==0.0.45
git+https://github.com/squidfunk/mike.git

97
docs/zensical.toml

@ -0,0 +1,97 @@
[project]
site_name = "wg-easy"
site_description = "The easiest way to run WireGuard VPN + Web-based Admin UI."
site_author = "WireGuard Easy"
copyright = """
<p>
&copy <a href="https://github.com/wg-easy"><em>Wireguard Easy</em></a><br/>
<span>This project is licensed under AGPL-3.0-only.</span><br/>
<span>This project is not affiliated, associated, authorized, endorsed by, or in any way officially connected with Jason A. Donenfeld, ZX2C4 or Edge Security</span><br/>
<span>"WireGuard" and the "WireGuard" logo are registered trademarks of Jason A. Donenfeld</span>
</p>
"""
repo_url = "https://github.com/wg-easy/wg-easy"
repo_name = "wg-easy"
edit_uri = "edit/master/docs/content"
docs_dir = "content/"
site_url = "https://wg-easy.github.io/wg-easy"
[project.theme]
variant = "modern"
favicon = "assets/logo/favicon.png"
logo = "assets/logo/logo.png"
features = [
"navigation.instant",
"navigation.tabs",
"navigation.expand",
"navigation.path",
"navigation.top",
"content.action.edit",
"content.action.view",
"content.code.copy",
"content.code.annotate",
]
[project.theme.icon]
repo = "fontawesome/brands/github"
# Palette toggle for automatic mode
[[project.theme.palette]]
media = "(prefers-color-scheme)"
toggle.icon = "lucide/sun-moon"
toggle.name = "Switch to light mode"
# Palette toggle for light mode
[[project.theme.palette]]
media = "(prefers-color-scheme: light)"
scheme = "default"
primary = "grey"
accent = "red"
toggle.icon = "lucide/sun"
toggle.name = "Switch to dark mode"
# Palette toggle for dark mode
[[project.theme.palette]]
media = "(prefers-color-scheme: dark)"
scheme = "slate"
primary = "grey"
accent = "red"
toggle.icon = "lucide/moon"
toggle.name = "Switch to system preference"
# [project.plugins.privacy]
[project.extra.version]
provider = "mike"
[project.markdown_extensions.pymdownx.blocks.admonition]
types = ["danger", "note", "info", "question", "warning"]
[project.markdown_extensions.md_in_html]
[project.markdown_extensions.toc]
permalink = true
[project.markdown_extensions.pymdownx.details]
[project.markdown_extensions.pymdownx.emoji]
emoji_generator = "zensical.extensions.emoji.to_svg"
emoji_index = "zensical.extensions.emoji.twemoji"
[project.markdown_extensions.pymdownx.highlight]
anchor_linenums = true
line_spans = "__span"
pygments_lang_class = true
[project.markdown_extensions.pymdownx.inlinehilite]
[project.markdown_extensions.pymdownx.smartsymbols]
[project.markdown_extensions.pymdownx.superfences]
custom_fences = [
{ name = "mermaid", class = "mermaid", format = "pymdownx.superfences.fence_code_format" },
]

6
package.json

@ -5,13 +5,13 @@
"dev": "docker compose -f docker-compose.dev.yml up wg-easy --build",
"cli:dev": "docker compose -f docker-compose.dev.yml run --build --rm -it wg-easy cli:dev",
"build": "docker build -t wg-easy .",
"docs:preview": "docker run --rm -it -p 8080:8080 -v ./docs:/docs squidfunk/mkdocs-material serve -a 0.0.0.0:8080",
"docs:preview": "docker run --rm -it -p 8080:8080 -v ./docs:/docs zensical/zensical serve -a 0.0.0.0:8080",
"scripts:version": "bash scripts/version.sh",
"scripts:i18n": "bash scripts/i18n.sh",
"format:check:docs": "prettier --check docs"
},
"devDependencies": {
"prettier": "^3.8.1"
"prettier": "^3.8.4"
},
"packageManager": "pnpm@10.32.1"
"packageManager": "pnpm@11.8.0"
}

10
pnpm-lock.yaml

@ -9,16 +9,16 @@ importers:
.:
devDependencies:
prettier:
specifier: ^3.8.1
version: 3.8.1
specifier: ^3.8.4
version: 3.8.4
packages:
[email protected].1:
resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==}
[email protected].4:
resolution: {integrity: sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==}
engines: {node: '>=14'}
hasBin: true
snapshots:
[email protected].1: {}
[email protected].4: {}

2
src/.nuxtrc

@ -1 +1 @@
setups.@nuxt/test-utils="4.0.0"
setups.@nuxt/test-utils="4.0.3"

2
src/app/components/Base/Dialog.vue

@ -18,7 +18,7 @@
>
<slot name="description" />
</DialogDescription>
<div class="mt-6 flex justify-end gap-2">
<div class="mt-6 flex flex-wrap justify-end gap-2">
<slot name="actions" />
</div>
</DialogContent>

26
src/app/components/Base/FormSecondaryButton.vue

@ -0,0 +1,26 @@
<template>
<component
:is="elementType"
role="button"
class="rounded-lg border-2 border-gray-100 text-gray-500 hover:border-red-800 hover:bg-red-800 hover:text-white focus:border-red-800 focus:outline-0 focus:ring-0 disabled:cursor-not-allowed disabled:border-gray-200 disabled:bg-gray-100 disabled:text-gray-400 dark:border-neutral-800 dark:bg-neutral-700 dark:text-neutral-200 dark:placeholder:text-neutral-400 dark:disabled:border-neutral-800 dark:disabled:bg-neutral-700 dark:disabled:text-neutral-400"
v-bind="attrs"
>
<slot />
</component>
</template>
<script setup lang="ts">
const props = defineProps({
as: {
type: String,
default: 'button',
},
});
const elementType = computed(() => props.as);
const attrs = computed(() => {
const { as, ...rest } = props;
return rest;
});
</script>

2
src/app/components/ClientCard/Charts.vue

@ -16,7 +16,7 @@
</template>
<script setup lang="ts">
import type { ApexOptions } from 'apexcharts';
import type { ApexChart, ApexOptions } from 'apexcharts';
defineProps<{
client: LocalClient;

2
src/app/components/ClientCard/ClientCard.vue

@ -9,7 +9,7 @@
<div class="flex flex-grow flex-col gap-1">
<ClientCardName :client="client" />
<div
class="flex flex-col pb-1 text-xs text-gray-500 md:inline-block md:pb-0 dark:text-neutral-400"
class="flex flex-col text-xs text-gray-500 dark:text-neutral-400"
>
<div>
<ClientCardAddress :client="client" />

4
src/app/components/ClientCard/ExpireDate.vue

@ -1,7 +1,5 @@
<template>
<div
class="block pb-1 text-xs text-gray-500 md:inline-block md:pb-0 dark:text-neutral-400"
>
<div class="block text-xs text-gray-500 dark:text-neutral-400">
<span class="inline-block">{{ expiredDateFormat(client.expiresAt) }}</span>
</div>
</template>

6
src/app/components/ClientCard/Name.vue

@ -1,11 +1,9 @@
<template>
<div
class="text-sm text-gray-700 md:text-base dark:text-neutral-200"
class="break-all text-sm text-gray-700 md:text-base dark:text-neutral-200"
:title="$t('client.createdOn') + $d(new Date(client.createdAt))"
>
<span class="border-b-2 border-t-2 border-transparent">
{{ client.name }}
</span>
{{ client.name }}
</div>
</template>

9
src/app/components/ClientCard/OneTimeLinkBtn.vue

@ -14,10 +14,11 @@ const props = defineProps<{ client: LocalClient }>();
const clientsStore = useClientsStore();
const _showOneTimeLink = useSubmit(
`/api/client/${props.client.id}/generateOneTimeLink`,
{
method: 'post',
},
(data) =>
$fetch(`/api/client/${props.client.id}/generateOneTimeLink`, {
method: 'post',
body: data,
}),
{
revert: async () => {
await clientsStore.refresh();

18
src/app/components/ClientCard/Switch.vue

@ -18,10 +18,11 @@ const enabled = ref(props.client.enabled);
const clientsStore = useClientsStore();
const _disableClient = useSubmit(
`/api/client/${props.client.id}/disable`,
{
method: 'post',
},
(data) =>
$fetch(`/api/client/${props.client.id}/disable`, {
method: 'post',
body: data,
}),
{
revert: async () => {
await clientsStore.refresh();
@ -31,10 +32,11 @@ const _disableClient = useSubmit(
);
const _enableClient = useSubmit(
`/api/client/${props.client.id}/enable`,
{
method: 'post',
},
(data) =>
$fetch(`/api/client/${props.client.id}/enable`, {
method: 'post',
body: data,
}),
{
revert: async () => {
await clientsStore.refresh();

9
src/app/components/Clients/CreateDialog.vue

@ -45,10 +45,11 @@ function createClient() {
}
const _createClient = useSubmit(
'/api/client',
{
method: 'post',
},
(data) =>
$fetch('/api/client', {
method: 'post',
body: data,
}),
{
revert: () => clientsStore.refresh(),
successMsg: t('client.created'),

2
src/app/components/Clients/Empty.vue

@ -3,7 +3,7 @@
{{ $t('client.empty') }}<br /><br />
<ClientsCreateDialog>
<BaseSecondaryButton as="span">
<IconsPlus class="w-4 md:mr-2" />
<IconsPlus class="mr-2 w-4" />
<span class="text-sm">{{ $t('client.new') }}</span>
</BaseSecondaryButton>
</ClientsCreateDialog>

4
src/app/components/Clients/New.vue

@ -1,8 +1,8 @@
<template>
<ClientsCreateDialog>
<BaseSecondaryButton as="span">
<IconsPlus class="w-4 md:mr-2" />
<span class="text-sm max-md:hidden">{{ $t('client.newShort') }}</span>
<IconsPlus class="mr-2 w-4" />
<span class="text-sm">{{ $t('client.newShort') }}</span>
</BaseSecondaryButton>
</ClientsCreateDialog>
</template>

2
src/app/components/Clients/Search.vue

@ -1,5 +1,5 @@
<template>
<div class="relative w-60 md:mr-2">
<div class="relative">
<div class="relative flex h-full items-center">
<IconsMagnifyingGlass
class="absolute left-2.5 h-4 w-4 text-gray-400 dark:text-neutral-500"

11
src/app/components/Clients/Sort.vue

@ -1,11 +1,8 @@
<template>
<BasePrimaryButton @click="toggleSort">
<IconsArrowDown
v-if="globalStore.sortClient === true"
class="w-4 md:mr-2"
/>
<IconsArrowUp v-else class="w-4 md:mr-2" />
<span class="text-sm max-md:hidden"> {{ $t('client.sort') }}</span>
<IconsArrowDown v-if="globalStore.sortClient === 'asc'" class="mr-2 w-4" />
<IconsArrowUp v-else class="mr-2 w-4" />
<span class="text-sm">{{ $t('client.sort') }}</span>
</BasePrimaryButton>
</template>
@ -14,7 +11,7 @@ const globalStore = useGlobalStore();
const clientsStore = useClientsStore();
function toggleSort() {
globalStore.sortClient = !globalStore.sortClient;
globalStore.sortClient = globalStore.sortClient === 'asc' ? 'desc' : 'asc';
clientsStore.refresh().catch(console.error);
}
</script>

20
src/app/components/Form/ArrayField.vue

@ -12,23 +12,15 @@
class="rounded-lg border-2 border-gray-100 text-gray-500 focus:border-red-800 focus:outline-0 focus:ring-0 dark:border-neutral-800 dark:bg-neutral-700 dark:text-neutral-200 dark:placeholder:text-neutral-400"
@input="update($event, i)"
/>
<BaseSecondaryButton
as="input"
type="button"
class="rounded-lg"
value="-"
@click="del(i)"
/>
<BaseSecondaryButton type="button" class="rounded-lg" @click="del(i)">
{{ '-' }}
</BaseSecondaryButton>
</div>
</div>
<div class="mt-2">
<BasePrimaryButton
as="input"
type="button"
class="rounded-lg"
:value="$t('form.add')"
@click="add"
/>
<BasePrimaryButton type="button" class="rounded-lg" @click="add">
{{ $t('form.add') }}
</BasePrimaryButton>
</div>
</div>
</template>

4
src/app/components/Form/Heading.vue

@ -1,10 +1,10 @@
<template>
<h4 class="col-span-full flex items-center py-6 text-2xl">
<h3 class="col-span-full flex items-center py-6 text-2xl">
<slot />
<BaseTooltip v-if="description" :text="description">
<IconsInfo class="size-4" />
</BaseTooltip>
</h4>
</h3>
</template>
<script lang="ts" setup>

6
src/app/components/Form/Label.vue

@ -1,7 +1,7 @@
<template>
<RLabel :for="props.for" class="md:align-middle md:leading-10"
><slot
/></RLabel>
<RLabel :for="props.for" class="md:leading-[2.75rem]">
<slot />
</RLabel>
</template>
<script lang="ts" setup>

20
src/app/components/Form/NullArrayField.vue

@ -12,23 +12,15 @@
class="rounded-lg border-2 border-gray-100 text-gray-500 focus:border-red-800 focus:outline-0 focus:ring-0 dark:border-neutral-800 dark:bg-neutral-700 dark:text-neutral-200 dark:placeholder:text-neutral-400"
@input="update($event, i)"
/>
<BaseSecondaryButton
as="input"
type="button"
class="rounded-lg"
value="-"
@click="del(i)"
/>
<BaseSecondaryButton type="button" class="rounded-lg" @click="del(i)">
{{ '-' }}
</BaseSecondaryButton>
</div>
</div>
<div class="mt-2">
<BasePrimaryButton
as="input"
type="button"
class="rounded-lg"
:value="$t('form.add')"
@click="add"
/>
<BasePrimaryButton type="button" class="rounded-lg" @click="add">
{{ $t('form.add') }}
</BasePrimaryButton>
</div>
</div>
</template>

2
src/app/components/Form/PrimaryActionField.vue

@ -2,7 +2,7 @@
<input
:value="label"
:type="type ?? 'button'"
class="col-span-2 rounded-lg border-2 border-red-800 bg-red-800 py-2 text-white hover:border-red-600 hover:bg-red-600 focus:border-red-800 focus:outline-0 focus:ring-0"
class="col-span-2 rounded-lg border-2 border-red-800 bg-red-800 px-4 py-2 text-white hover:border-red-600 hover:bg-red-600 focus:border-red-800 focus:outline-0 focus:ring-0"
/>
</template>

34
src/app/components/Form/SecondaryActionField.vue

@ -1,16 +1,32 @@
<template>
<input
:value="label"
:type="type ?? 'button'"
class="col-span-2 rounded-lg border-2 border-gray-100 py-2 text-gray-500 hover:border-red-800 hover:bg-red-800 hover:text-white focus:border-red-800 focus:outline-0 focus:ring-0 dark:border-neutral-800 dark:bg-neutral-700 dark:text-neutral-200 dark:placeholder:text-neutral-400"
/>
<BaseFormSecondaryButton
:as="as"
:value="isInput ? label : undefined"
:type="isInput ? type : undefined"
class="col-span-2 px-4 py-2"
>
<template v-if="!isInput">
{{ label }}
</template>
</BaseFormSecondaryButton>
</template>
<script lang="ts" setup>
import type { InputTypeHTMLAttribute } from 'vue';
defineProps<{
label: string;
type?: InputTypeHTMLAttribute;
}>();
const props = withDefaults(
defineProps<{
label: string;
type?: InputTypeHTMLAttribute;
as?: string;
}>(),
{
type: 'button',
as: 'input',
}
);
const isInput = computed(() => {
return props.as === 'input';
});
</script>

4
src/app/components/Form/SwitchField.vue

@ -7,7 +7,9 @@
<IconsInfo class="size-4" />
</BaseTooltip>
</div>
<BaseSwitch :id="id" v-model="data" />
<div class="my-auto">
<BaseSwitch :id="id" v-model="data" />
</div>
</template>
<script lang="ts" setup>

6
src/app/components/Header/LangSelector.vue

@ -1,12 +1,14 @@
<template>
<SelectRoot v-model="langProxy" :default-value="locale">
<SelectTrigger
class="inline-flex h-8 items-center justify-around gap-2 rounded bg-gray-200 px-3 text-sm leading-none dark:bg-neutral-700 dark:text-neutral-400"
class="group inline-flex h-8 items-center justify-around gap-2 rounded bg-gray-200 px-3 text-sm leading-none dark:bg-neutral-700 dark:text-neutral-400"
aria-label="Select language"
>
<IconsLanguage class="size-3" />
<SelectValue />
<IconsArrowDown class="size-3" />
<IconsArrowDown
class="size-3 transition-transform group-data-[state=open]:rotate-180"
/>
</SelectTrigger>
<SelectPortal>

2
src/app/components/Header/Logo.vue

@ -1,5 +1,5 @@
<template>
<NuxtLink to="/" class="mb-4">
<NuxtLink to="/" class="max-sm:mb-4">
<h1 class="text-4xl font-medium dark:text-neutral-200">
<img
src="/logo.png"

2
src/app/components/Header/Update.vue

@ -5,7 +5,7 @@
authStore.userData &&
hasPermissions(authStore.userData, 'admin', 'any')
"
class="font-small mb-10 rounded-md bg-red-800 p-4 text-sm text-white shadow-lg dark:bg-red-100 dark:text-red-600"
class="font-small rounded-md bg-red-800 p-4 text-sm text-white shadow-lg dark:bg-red-100 dark:text-red-600"
:title="`v${globalStore.information.currentRelease} → v${globalStore.information.latestRelease.version}`"
>
<div class="container mx-auto flex flex-auto flex-row items-center">

23
src/app/components/Icons/Brands/GitHub.vue

@ -0,0 +1,23 @@
<template>
<!-- https://brand.github.com/foundations/logo -->
<svg
width="98"
height="96"
viewBox="0 0 98 96"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g clip-path="url(#clip0_730_27136)">
<path
d="M41.4395 69.3848C28.8066 67.8535 19.9062 58.7617 19.9062 46.9902C19.9062 42.2051 21.6289 37.0371 24.5 33.5918C23.2559 30.4336 23.4473 23.7344 24.8828 20.959C28.7109 20.4805 33.8789 22.4902 36.9414 25.2656C40.5781 24.1172 44.4062 23.543 49.0957 23.543C53.7852 23.543 57.6133 24.1172 61.0586 25.1699C64.0254 22.4902 69.2891 20.4805 73.1172 20.959C74.457 23.543 74.6484 30.2422 73.4043 33.4961C76.4668 37.1328 78.0937 42.0137 78.0937 46.9902C78.0937 58.7617 69.1934 67.6621 56.3691 69.2891C59.623 71.3945 61.8242 75.9883 61.8242 81.252L61.8242 91.2051C61.8242 94.0762 64.2168 95.7031 67.0879 94.5547C84.4102 87.9512 98 70.6289 98 49.1914C98 22.1074 75.9883 6.69539e-07 48.9043 4.309e-07C21.8203 1.92261e-07 -1.9479e-07 22.1074 -4.3343e-07 49.1914C-6.20631e-07 70.4375 13.4941 88.0469 31.6777 94.6504C34.2617 95.6074 36.75 93.8848 36.75 91.3008L36.75 83.6445C35.4102 84.2188 33.6875 84.6016 32.1562 84.6016C25.8398 84.6016 22.1074 81.1563 19.4277 74.7441C18.375 72.1602 17.2266 70.6289 15.0254 70.3418C13.877 70.2461 13.4941 69.7676 13.4941 69.1934C13.4941 68.0449 15.4082 67.1836 17.3223 67.1836C20.0977 67.1836 22.4902 68.9063 24.9785 72.4473C26.8926 75.2227 28.9023 76.4668 31.2949 76.4668C33.6875 76.4668 35.2187 75.6055 37.4199 73.4043C39.0469 71.7773 40.291 70.3418 41.4395 69.3848Z"
fill="white"
class="fill-black dark:fill-white"
/>
</g>
<defs>
<clipPath id="clip0_730_27136">
<rect width="98" height="96" fill="white" />
</clipPath>
</defs>
</svg>
</template>

247
src/app/components/Icons/Brands/Google.vue

@ -0,0 +1,247 @@
<template>
<!-- By Original: Google Vector: Designism, Liquinoid, and YeBoy371 - Own work based on: Google updating its G icon for the first time in 10 years, Public Domain, https://commons.wikimedia.org/w/index.php?curid=165142698 -->
<svg
version="1.1"
viewBox="0 0 268.1522 273.8827"
overflow="hidden"
xml:space="preserve"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
>
<defs>
<linearGradient id="a">
<stop offset="0" stop-color="#0fbc5c" />
<stop offset="1" stop-color="#0cba65" />
</linearGradient>
<linearGradient id="g">
<stop offset=".2312727" stop-color="#0fbc5f" />
<stop offset=".3115468" stop-color="#0fbc5f" />
<stop offset=".3660131" stop-color="#0fbc5e" />
<stop offset=".4575163" stop-color="#0fbc5d" />
<stop offset=".540305" stop-color="#12bc58" />
<stop offset=".6993464" stop-color="#28bf3c" />
<stop offset=".7712418" stop-color="#38c02b" />
<stop offset=".8605665" stop-color="#52c218" />
<stop offset=".9150327" stop-color="#67c30f" />
<stop offset="1" stop-color="#86c504" />
</linearGradient>
<linearGradient id="h">
<stop offset=".1416122" stop-color="#1abd4d" />
<stop offset=".2475151" stop-color="#6ec30d" />
<stop offset=".3115468" stop-color="#8ac502" />
<stop offset=".3660131" stop-color="#a2c600" />
<stop offset=".4456735" stop-color="#c8c903" />
<stop offset=".540305" stop-color="#ebcb03" />
<stop offset=".6156363" stop-color="#f7cd07" />
<stop offset=".6993454" stop-color="#fdcd04" />
<stop offset=".7712418" stop-color="#fdce05" />
<stop offset=".8605661" stop-color="#ffce0a" />
</linearGradient>
<linearGradient id="f">
<stop offset=".3159041" stop-color="#ff4c3c" />
<stop offset=".6038179" stop-color="#ff692c" />
<stop offset=".7268366" stop-color="#ff7825" />
<stop offset=".884534" stop-color="#ff8d1b" />
<stop offset="1" stop-color="#ff9f13" />
</linearGradient>
<linearGradient id="b">
<stop offset=".2312727" stop-color="#ff4541" />
<stop offset=".3115468" stop-color="#ff4540" />
<stop offset=".4575163" stop-color="#ff4640" />
<stop offset=".540305" stop-color="#ff473f" />
<stop offset=".6993464" stop-color="#ff5138" />
<stop offset=".7712418" stop-color="#ff5b33" />
<stop offset=".8605665" stop-color="#ff6c29" />
<stop offset="1" stop-color="#ff8c18" />
</linearGradient>
<linearGradient id="d">
<stop offset=".4084578" stop-color="#fb4e5a" />
<stop offset="1" stop-color="#ff4540" />
</linearGradient>
<linearGradient id="c">
<stop offset=".1315461" stop-color="#0cba65" />
<stop offset=".2097843" stop-color="#0bb86d" />
<stop offset=".2972969" stop-color="#09b479" />
<stop offset=".3962575" stop-color="#08ad93" />
<stop offset=".4771242" stop-color="#0aa6a9" />
<stop offset=".5684245" stop-color="#0d9cc6" />
<stop offset=".667385" stop-color="#1893dd" />
<stop offset=".7687273" stop-color="#258bf1" />
<stop offset=".8585063" stop-color="#3086ff" />
</linearGradient>
<linearGradient id="e">
<stop offset=".3660131" stop-color="#ff4e3a" />
<stop offset=".4575163" stop-color="#ff8a1b" />
<stop offset=".540305" stop-color="#ffa312" />
<stop offset=".6156363" stop-color="#ffb60c" />
<stop offset=".7712418" stop-color="#ffcd0a" />
<stop offset=".8605665" stop-color="#fecf0a" />
<stop offset=".9150327" stop-color="#fecf08" />
<stop offset="1" stop-color="#fdcd01" />
</linearGradient>
<linearGradient
id="s"
xlink:href="#a"
x1="219.6997"
y1="329.5351"
x2="254.4673"
y2="329.5351"
gradientUnits="userSpaceOnUse"
/>
<radialGradient
id="m"
xlink:href="#b"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(-1.936885,1.043001,1.455731,2.555422,290.5254,-400.6338)"
cx="109.6267"
cy="135.8619"
fx="109.6267"
fy="135.8619"
r="71.46001"
/>
<radialGradient
id="n"
xlink:href="#c"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(-3.512595,-4.45809,-1.692547,1.260616,870.8006,191.554)"
cx="45.25866"
cy="279.2738"
fx="45.25866"
fy="279.2738"
r="71.46001"
/>
<radialGradient
id="l"
xlink:href="#d"
cx="304.0166"
cy="118.0089"
fx="304.0166"
fy="118.0089"
r="47.85445"
gradientTransform="matrix(2.064353,-4.926832e-6,-2.901531e-6,2.592041,-297.6788,-151.7469)"
gradientUnits="userSpaceOnUse"
/>
<radialGradient
id="o"
xlink:href="#e"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(-0.2485783,2.083138,2.962486,0.3341668,-255.1463,-331.1636)"
cx="181.001"
cy="177.2013"
fx="181.001"
fy="177.2013"
r="71.46001"
/>
<radialGradient
id="p"
xlink:href="#f"
cx="207.6733"
cy="108.0972"
fx="207.6733"
fy="108.0972"
r="41.1025"
gradientTransform="matrix(-1.249206,1.343263,-3.896837,-3.425693,880.5011,194.9051)"
gradientUnits="userSpaceOnUse"
/>
<radialGradient
id="r"
xlink:href="#g"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(-1.936885,-1.043001,1.455731,-2.555422,290.5254,838.6834)"
cx="109.6267"
cy="135.8619"
fx="109.6267"
fy="135.8619"
r="71.46001"
/>
<radialGradient
id="j"
xlink:href="#h"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(-0.081402,-1.93722,2.926737,-0.1162508,-215.1345,632.8606)"
cx="154.8697"
cy="145.9691"
fx="154.8697"
fy="145.9691"
r="71.46001"
/>
<filter
id="q"
x="-.04842873"
y="-.0582241"
width="1.096857"
height="1.116448"
color-interpolation-filters="sRGB"
>
<feGaussianBlur stdDeviation="1.700914" />
</filter>
<filter
id="k"
x="-.01670084"
y="-.01009856"
width="1.033402"
height="1.020197"
color-interpolation-filters="sRGB"
>
<feGaussianBlur stdDeviation=".2419367" />
</filter>
<clipPath id="i" clipPathUnits="userSpaceOnUse">
<path
d="M371.3784 193.2406H237.0825v53.4375h77.167c-1.2405 7.5627-4.0259 15.0024-8.1049 21.7862-4.6734 7.7723-10.4511 13.6895-16.373 18.1957-17.7389 13.4983-38.42 16.2584-52.7828 16.2584-36.2824 0-67.2833-23.2865-79.2844-54.9287-.4843-1.1482-.8059-2.3344-1.1975-3.5068-2.652-8.0533-4.101-16.5825-4.101-25.4474 0-9.226 1.5691-18.0575 4.4301-26.3985 11.2851-32.8967 42.9849-57.4674 80.1789-57.4674 7.4811 0 14.6854.8843 21.5173 2.6481 15.6135 4.0309 26.6578 11.9698 33.4252 18.2494l40.834-39.7111c-24.839-22.616-57.2194-36.3201-95.8444-36.3201-30.8782-.00066-59.3863 9.55308-82.7477 25.6992-18.9454 13.0941-34.4833 30.6254-44.9695 50.9861-9.75366 18.8785-15.09441 39.7994-15.09441 62.2934 0 22.495 5.34891 43.6334 15.10261 62.3374v.126c10.3023 19.8567 25.3678 36.9537 43.6783 49.9878 15.9962 11.3866 44.6789 26.5516 84.0307 26.5516 22.6301 0 42.6867-4.0517 60.3748-11.6447 12.76-5.4775 24.0655-12.6217 34.3012-21.8036 13.5247-12.1323 24.1168-27.1388 31.3465-44.4041 7.2297-17.2654 11.097-36.7895 11.097-57.957 0-9.858-.9971-19.8694-2.6881-28.9684Z"
fill="#000"
/>
</clipPath>
</defs>
<g transform="matrix(0.957922,0,0,0.985255,-90.17436,-78.85577)">
<g clip-path="url(#i)">
<path
d="M92.07563 219.9585c.14844 22.14 6.5014 44.983 16.11767 63.4234v.1269c6.9482 13.3919 16.4444 23.9704 27.2604 34.4518l65.326-23.67c-12.3593-6.2344-14.2452-10.0546-23.1048-17.0253-9.0537-9.0658-15.8015-19.4735-20.0038-31.677h-.1693l.1693-.1269c-2.7646-8.0587-3.0373-16.6129-3.1393-25.5029Z"
fill="url(#j)"
filter="url(#k)"
/>
<path
d="M237.0835 79.02491c-6.4568 22.52569-3.988 44.42139 0 57.16129 7.4561.0055 14.6388.8881 21.4494 2.6464 15.6135 4.0309 26.6566 11.97 33.424 18.2496l41.8794-40.7256c-24.8094-22.58904-54.6663-37.2961-96.7528-37.33169Z"
fill="url(#l)"
filter="url(#k)"
/>
<path
d="M236.9434 78.84678c-31.6709-.00068-60.9107 9.79833-84.8718 26.35902-8.8968 6.149-17.0612 13.2521-24.3311 21.1509-1.9045 17.7429 14.2569 39.5507 46.2615 39.3702 15.5284-17.9373 38.4946-29.5427 64.0561-29.5427.0233 0 .046.0019.0693.002l-1.0439-57.33536c-.0472-.00003-.0929-.00406-.1401-.00406Z"
fill="url(#m)"
filter="url(#k)"
/>
<path
d="m341.4751 226.3788-28.2685 19.2848c-1.2405 7.5627-4.0278 15.0023-8.1068 21.7861-4.6734 7.7723-10.4506 13.6898-16.3725 18.196-17.7022 13.4704-38.3286 16.2439-52.6877 16.2553-14.8415 25.1018-17.4435 37.6749 1.0439 57.9342 22.8762-.0167 43.157-4.1174 61.0458-11.7965 12.9312-5.551 24.3879-12.7913 34.7609-22.0964 13.7061-12.295 24.4421-27.5034 31.7688-45.0003 7.3267-17.497 11.2446-37.2822 11.2446-58.7336Z"
fill="url(#n)"
filter="url(#k)"
/>
<path
d="M234.9956 191.2104v57.4981h136.0062c1.1962-7.8745 5.1523-18.0644 5.1523-26.5001 0-9.858-.9963-21.899-2.6873-30.998Z"
fill="#3086ff"
filter="url(#k)"
/>
<path
d="M128.3894 124.3268c-8.393 9.1191-15.5632 19.326-21.2483 30.3646-9.75351 18.8785-15.09402 41.8295-15.09402 64.3235 0 .317.02642.6271.02855.9436 4.31953 8.2244 59.66647 6.6495 62.45617 0-.0035-.3103-.0387-.6128-.0387-.9238 0-9.226 1.5696-16.0262 4.4306-24.3672 3.5294-10.2885 9.0557-19.7628 16.1223-27.9257 1.6019-2.0309 5.8748-6.3969 7.1214-9.0157.4749-.9975-.8621-1.5574-.9369-1.9085-.0836-.3927-1.8762-.0769-2.2778-.3694-1.2751-.9288-3.8001-1.4138-5.3334-1.8449-3.2772-.9215-8.7085-2.9536-11.7252-5.0601-9.5357-6.6586-24.417-14.6122-33.5047-24.2164Z"
fill="url(#o)"
filter="url(#k)"
/>
<path
d="M162.0989 155.8569c22.1123 13.3013 28.4714-6.7139 43.173-12.9771L179.698 90.21568c-9.4075 3.92642-18.2957 8.80465-26.5426 14.50442-12.316 8.5122-23.192 18.8995-32.1763 30.7204Z"
fill="url(#p)"
filter="url(#q)"
/>
<path
d="M171.0987 290.222c-29.6829 10.6413-34.3299 11.023-37.0622 29.2903 5.2213 5.0597 10.8312 9.74 16.7926 13.9835 15.9962 11.3867 46.766 26.5517 86.1178 26.5517.0462 0 .0904-.004.1366-.004v-59.1574c-.0298.0001-.064.002-.0938.002-14.7359 0-26.5113-3.8435-38.5848-10.5273-2.9768-1.6479-8.3775 2.7772-11.1229.799-3.7865-2.7284-12.8991 2.3508-16.1833-.9378Z"
fill="url(#r)"
filter="url(#k)"
/>
<path
d="M219.6997 299.0227v59.9959c5.506.6402 11.2361 1.0289 17.2472 1.0289 6.0259 0 11.8556-.3073 17.5204-.8723v-59.7481c-6.3482 1.0777-12.3272 1.461-17.4776 1.461-5.9318 0-11.7005-.6858-17.29-1.8654Z"
opacity=".5"
fill="url(#s)"
filter="url(#k)"
/>
</g>
</g>
</svg>
</template>

10
src/app/components/Icons/Brands/Provider.vue

@ -0,0 +1,10 @@
<template>
<IconsBrandsGitHub v-if="provider === 'github'" />
<IconsBrandsGoogle v-else-if="provider === 'google'" />
</template>
<script setup lang="ts">
defineProps<{
provider: OAUTH_PROVIDER;
}>();
</script>

10
src/app/components/Panel/Panel.vue

@ -1,7 +1,9 @@
<template>
<div
class="container mx-auto max-w-3xl overflow-hidden rounded-lg bg-white px-3 text-gray-700 shadow-md md:px-0 dark:bg-neutral-700 dark:text-neutral-200"
>
<slot />
<div class="container mx-auto max-w-3xl">
<div
class="mx-3 overflow-hidden rounded-lg bg-white text-gray-700 shadow-md dark:bg-neutral-700 dark:text-neutral-200"
>
<slot />
</div>
</div>
</template>

2
src/app/components/Panel/head/Boat.vue

@ -1,5 +1,5 @@
<template>
<div class="flex flex-shrink-0 items-center space-x-2">
<div class="flex flex-shrink-0 flex-col items-center gap-2 sm:flex-row">
<slot />
</div>
</template>

2
src/app/components/Panel/head/Head.vue

@ -1,6 +1,6 @@
<template>
<div
class="flex flex-auto flex-grow flex-row items-center border-b-2 border-gray-100 p-3 px-5 dark:border-neutral-600"
class="flex flex-col items-center gap-2 border-b-2 border-gray-100 p-3 px-5 sm:flex-row dark:border-neutral-600"
>
<slot />
</div>

10
src/app/components/Panel/head/Title.vue

@ -1,11 +1,5 @@
<template>
<h2 class="flex-1 text-2xl font-medium">
{{ text }}
<h2 class="flex-1 break-all text-2xl font-medium">
<slot />
</h2>
</template>
<script setup lang="ts">
const { text } = defineProps<{
text: string;
}>();
</script>

21
src/app/components/Ui/LoginOauthButton.vue

@ -0,0 +1,21 @@
<template>
<a
:href="`/api/auth/${provider}`"
class="flex items-center justify-center gap-2 rounded border border-gray-300 bg-white py-2 text-sm text-gray-700 shadow-sm transition hover:bg-gray-50 dark:border-neutral-600 dark:bg-neutral-800 dark:text-neutral-200 dark:hover:bg-neutral-700"
>
<IconsBrandsProvider :provider="provider" class="h-4 w-4" />
<span>
{{ $t('login.signInWith', [info.friendlyName]) }}
</span>
</a>
</template>
<script setup lang="ts">
defineProps<{
info: {
enabled: boolean;
friendlyName: string;
};
provider: OAUTH_PROVIDER;
}>();
</script>

9
src/app/components/Ui/UserMenu.vue

@ -70,10 +70,11 @@ const authStore = useAuthStore();
const toggleState = ref(false);
const _submit = useSubmit(
'/api/session',
{
method: 'delete',
},
(data) =>
$fetch('/api/session', {
method: 'delete',
body: data,
}),
{
revert: async () => {
await navigateTo('/login');

47
src/app/composables/useSubmit.ts

@ -1,49 +1,24 @@
import type {
NitroFetchRequest,
NitroFetchOptions,
TypedInternalResponse,
ExtractedRouteMethod,
} from 'nitropack/types';
import { FetchError } from 'ofetch';
type RevertFn<
R extends NitroFetchRequest,
T = unknown,
O extends NitroFetchOptions<R> = NitroFetchOptions<R>,
> = (
success: boolean,
data:
| TypedInternalResponse<
R,
T,
NitroFetchOptions<R> extends O ? 'get' : ExtractedRouteMethod<R, O>
>
| undefined
) => Promise<void>;
type RevertFn<T> = (success: boolean, data: T | undefined) => Promise<void>;
type SubmitOpts<
R extends NitroFetchRequest,
T = unknown,
O extends NitroFetchOptions<R> = NitroFetchOptions<R>,
> = {
revert: RevertFn<R, T, O>;
type SubmitOpts<T> = {
revert: RevertFn<T>;
successMsg?: string;
noSuccessToast?: boolean;
};
export function useSubmit<
R extends NitroFetchRequest,
O extends NitroFetchOptions<R> & { body?: never },
T = unknown,
>(url: R, options: O, opts: SubmitOpts<R, T, O>) {
type Body = Record<string, unknown> | null | undefined;
export function useSubmit<T>(
fetcher: (data: Body) => Promise<T>,
opts: SubmitOpts<T>
) {
const toast = useToast();
return async (data: unknown) => {
return async (data: Body) => {
try {
const res = await $fetch(url, {
...options,
body: data,
});
const res = await fetcher(data);
if (!opts.noSuccessToast) {
toast.showToast({

10
src/app/layouts/default.vue

@ -1,6 +1,8 @@
<template>
<div>
<header class="mx-auto mt-4 flex max-w-3xl flex-col justify-center">
<header
class="mx-auto my-4 flex max-w-3xl flex-col justify-center max-md:px-3"
>
<div
class="mb-5 w-full"
:class="
@ -17,7 +19,7 @@
<UiUserMenu v-if="loggedIn" />
</div>
</div>
<HeaderUpdate class="mt-4" />
<HeaderUpdate class="my-4" />
</header>
<slot />
<UiFooter />
@ -27,5 +29,7 @@
<script setup lang="ts">
const route = useRoute();
const loggedIn = computed(() => route.path !== '/login');
const loggedIn = computed(
() => route.path !== '/login' && route.path !== '/login/2fa'
);
</script>

6
src/app/middleware/auth.global.ts

@ -9,8 +9,10 @@ export default defineNuxtRouteMiddleware(async (to) => {
const authStore = useAuthStore();
authStore.userData = await authStore.getSession(event);
// skip login if already logged in
if (to.path === '/login') {
const isLoginRoute = to.path === '/login' || to.path === '/login/2fa';
// skip login pages if already logged in
if (isLoginRoute) {
if (authStore.userData?.username) {
return navigateTo('/', { redirectCode: 302 });
}

60
src/app/pages/admin.vue

@ -2,35 +2,47 @@
<div>
<div class="container mx-auto p-4">
<div class="flex flex-col gap-4 lg:flex-row">
<div class="rounded-lg bg-white p-4 lg:w-64 dark:bg-neutral-700">
<NuxtLink to="/admin">
<h2 class="mb-4 text-xl font-bold dark:text-neutral-200">
{{ t('pages.admin.panel') }}
</h2>
</NuxtLink>
<div class="flex flex-col space-y-2">
<NuxtLink
v-for="(item, index) in menuItems"
:key="index"
:to="`/admin/${item.id}`"
class="group rounded"
active-class="bg-red-800 active"
>
<BaseSecondaryButton
as="span"
class="w-full font-medium group-[.active]:text-white"
<div
class="overflow-hidden rounded-lg bg-white text-gray-700 shadow-md lg:w-64 dark:bg-neutral-700 dark:text-neutral-200"
>
<PanelHead>
<PanelHeadTitle>
<NuxtLink to="/admin">
{{ t('pages.admin.panel') }}
</NuxtLink>
</PanelHeadTitle>
</PanelHead>
<PanelBody>
<nav class="flex flex-col gap-2">
<NuxtLink
v-for="(item, index) in menuItems"
:key="index"
:to="`/admin/${item.id}`"
class="group rounded"
active-class="bg-red-800 active"
>
{{ item.name }}
</BaseSecondaryButton>
</NuxtLink>
</div>
<BaseSecondaryButton
as="span"
class="w-full font-medium group-[.active]:text-white"
>
{{ item.name }}
</BaseSecondaryButton>
</NuxtLink>
</nav>
</PanelBody>
</div>
<div
class="flex-1 rounded-lg bg-white p-6 dark:bg-neutral-700 dark:text-neutral-200"
class="flex-1 overflow-hidden rounded-lg bg-white text-gray-700 shadow-md dark:bg-neutral-700 dark:text-neutral-200"
>
<h1 class="mb-6 text-3xl font-bold">{{ activeMenuItem.name }}</h1>
<NuxtPage />
<PanelHead>
<PanelHeadTitle>
{{ activeMenuItem.name }}
</PanelHeadTitle>
</PanelHead>
<PanelBody>
<NuxtPage />
</PanelBody>
</div>
</div>
</div>

9
src/app/pages/admin/config.vue

@ -121,10 +121,11 @@ const { data: _data, refresh } = await useFetch(`/api/admin/userconfig`, {
const data = toRef(_data.value);
const _submit = useSubmit(
`/api/admin/userconfig`,
{
method: 'post',
},
(data) =>
$fetch(`/api/admin/userconfig`, {
method: 'post',
body: data,
}),
{ revert }
);

9
src/app/pages/admin/general.vue

@ -46,10 +46,11 @@ const { data: _data, refresh } = await useFetch(`/api/admin/general`, {
const data = toRef(_data.value);
const _submit = useSubmit(
`/api/admin/general`,
{
method: 'post',
},
(data) =>
$fetch(`/api/admin/general`, {
method: 'post',
body: data,
}),
{ revert }
);

9
src/app/pages/admin/hooks.vue

@ -40,10 +40,11 @@ const { data: _data, refresh } = await useFetch(`/api/admin/hooks`, {
const data = toRef(_data.value);
const _submit = useSubmit(
`/api/admin/hooks`,
{
method: 'post',
},
(data) =>
$fetch(`/api/admin/hooks`, {
method: 'post',
body: data,
}),
{ revert }
);

35
src/app/pages/admin/interface.vue

@ -145,8 +145,8 @@
>
<FormSecondaryActionField
:label="$t('admin.interface.changeCidr')"
class="w-full"
tabindex="-1"
class="inline-block w-full"
as="span"
/>
</AdminCidrDialog>
<AdminRestartInterfaceDialog
@ -155,8 +155,8 @@
>
<FormSecondaryActionField
:label="$t('admin.interface.restart')"
class="w-full"
tabindex="-1"
class="inline-block w-full"
as="span"
/>
</AdminRestartInterfaceDialog>
</FormGroup>
@ -176,10 +176,11 @@ const { data: _data, refresh } = await useFetch(`/api/admin/interface`, {
const data = toRef(_data.value);
const _submit = useSubmit(
`/api/admin/interface`,
{
method: 'post',
},
(data) =>
$fetch(`/api/admin/interface`, {
method: 'post',
body: data,
}),
{
revert: async (success) => {
await revert();
@ -201,10 +202,11 @@ async function revert() {
}
const _changeCidr = useSubmit(
`/api/admin/interface/cidr`,
{
method: 'post',
},
(data) =>
$fetch(`/api/admin/interface/cidr`, {
method: 'post',
body: data,
}),
{
revert,
successMsg: t('admin.interface.cidrSuccess'),
@ -216,10 +218,11 @@ async function changeCidr(ipv4Cidr: string, ipv6Cidr: string) {
}
const _restartInterface = useSubmit(
`/api/admin/interface/restart`,
{
method: 'post',
},
(data) =>
$fetch(`/api/admin/interface/restart`, {
method: 'post',
body: data,
}),
{
revert,
successMsg: t('admin.interface.restartSuccess'),

30
src/app/pages/clients/[id].vue

@ -2,7 +2,9 @@
<main v-if="data">
<Panel>
<PanelHead>
<PanelHeadTitle :text="data.name" />
<PanelHeadTitle>
{{ data.name }}
</PanelHeadTitle>
</PanelHead>
<PanelBody>
<FormElement @submit.prevent="submit">
@ -186,9 +188,7 @@
>
<FormSecondaryActionField
:label="$t('client.delete')"
class="w-full"
type="button"
tabindex="-1"
class="inline-block w-full"
as="span"
/>
</ClientsDeleteDialog>
@ -198,9 +198,7 @@
>
<FormSecondaryActionField
:label="$t('client.viewConfig')"
class="w-full"
type="button"
tabindex="-1"
class="inline-block w-full"
as="span"
/>
</ClientsConfigDialog>
@ -223,10 +221,11 @@ const { data: _data, refresh } = await useFetch(`/api/client/${id}`, {
const data = toRef(_data.value);
const _submit = useSubmit(
`/api/client/${id}`,
{
method: 'post',
},
(data) =>
$fetch(`/api/client/${id}`, {
method: 'post',
body: data,
}),
{
revert: async (success) => {
if (success) {
@ -248,10 +247,11 @@ async function revert() {
}
const _deleteClient = useSubmit(
`/api/client/${id}`,
{
method: 'delete',
},
(data) =>
$fetch(`/api/client/${id}`, {
method: 'delete',
body: data,
}),
{
revert: async () => {
await navigateTo('/');

10
src/app/pages/index.vue

@ -2,11 +2,15 @@
<main>
<Panel>
<PanelHead>
<PanelHeadTitle :text="$t('pages.clients')" />
<PanelHeadTitle>
{{ $t('pages.clients') }}
</PanelHeadTitle>
<PanelHeadBoat>
<ClientsSearch />
<ClientsSort />
<ClientsNew />
<div class="flex gap-2">
<ClientsSort />
<ClientsNew />
</div>
</PanelHeadBoat>
</PanelHead>

117
src/app/pages/login.vue

@ -2,9 +2,8 @@
<main>
<UiBanner />
<HeaderInsecure />
<form
<div
class="mx-auto mt-10 flex w-64 flex-col gap-5 overflow-hidden rounded-md bg-white p-5 text-gray-700 shadow dark:bg-neutral-700 dark:text-neutral-200"
@submit.prevent="submit"
>
<!-- Avatar -->
<div
@ -13,117 +12,7 @@
<IconsAvatar class="m-5 h-10 w-10 text-white dark:text-white" />
</div>
<BaseInput
v-model="username"
type="text"
:placeholder="$t('general.username')"
autocomplete="username"
autofocus
name="username"
/>
<BaseInput
v-model="password"
type="password"
name="password"
:placeholder="$t('general.password')"
autocomplete="current-password"
/>
<BaseInput
v-if="totpRequired"
v-model="totp"
type="text"
name="totp"
:placeholder="$t('general.2faCode')"
autocomplete="one-time-code"
inputmode="numeric"
maxlength="6"
pattern="\d{6}"
/>
<label
class="flex gap-2 whitespace-nowrap"
:title="$t('login.rememberMeDesc')"
>
<BaseSwitch v-model="remember" />
<span class="text-sm">{{ $t('login.rememberMe') }}</span>
</label>
<button
class="rounded py-2 text-sm text-white shadow transition dark:text-white"
:class="{
'cursor-pointer bg-red-800 hover:bg-red-700 dark:bg-red-800 dark:hover:bg-red-700':
password && username,
'cursor-not-allowed bg-gray-200 dark:bg-neutral-800':
!password || !username,
}"
>
<IconsLoading v-if="authenticating" class="mx-auto w-5 animate-spin" />
<span v-else>{{ $t('login.signIn') }}</span>
</button>
</form>
<NuxtPage />
</div>
</main>
</template>
<script setup lang="ts">
const toast = useToast();
const { t } = useI18n();
const authenticating = ref(false);
const remember = ref(false);
const username = ref<string>('');
const password = ref<string>('');
const totpRequired = ref(false);
const totp = ref<string>('');
const _submit = useSubmit(
'/api/session',
{
method: 'post',
},
{
revert: async (success, data) => {
if (success) {
if (data?.status === 'success') {
await navigateTo('/');
} else if (data?.status === 'TOTP_REQUIRED') {
authenticating.value = false;
totpRequired.value = true;
toast.showToast({
title: t('general.2fa'),
message: t('login.2faRequired'),
type: 'error',
});
return;
} else if (data?.status === 'INVALID_TOTP_CODE') {
authenticating.value = false;
totp.value = '';
toast.showToast({
title: t('general.2fa'),
message: t('login.2faWrong'),
type: 'error',
});
return;
}
}
authenticating.value = false;
password.value = '';
},
noSuccessToast: true,
}
);
async function submit() {
if (!username.value || !password.value || authenticating.value) return;
authenticating.value = true;
return _submit({
username: username.value,
password: password.value,
remember: remember.value,
totpCode: totpRequired.value ? totp.value : undefined,
});
}
</script>

108
src/app/pages/login/2fa.vue

@ -0,0 +1,108 @@
<template>
<div>
<form class="flex flex-col gap-5" @submit.prevent="submit">
<BaseInput
v-model="totp"
type="text"
name="totp"
:placeholder="$t('general.2faCode')"
autocomplete="one-time-code"
inputmode="numeric"
maxlength="6"
pattern="\d{6}"
autofocus
/>
<button
class="rounded bg-red-800 py-2 text-sm text-white shadow transition hover:bg-red-700 disabled:cursor-not-allowed disabled:bg-gray-200 dark:bg-red-800 dark:text-white dark:hover:bg-red-700 disabled:dark:bg-neutral-800"
:disabled="!totp || authenticating"
>
<IconsLoading v-if="authenticating" class="mx-auto w-5 animate-spin" />
<span v-else>{{ $t('general.continue') }}</span>
</button>
<button
type="button"
class="rounded border-2 border-gray-100 py-2 text-sm text-gray-700 transition hover:border-red-800 hover:bg-red-800 hover:text-white dark:border-neutral-600 dark:text-neutral-200"
@click="cancel"
>
{{ $t('dialog.cancel') }}
</button>
</form>
</div>
</template>
<script setup lang="ts">
const toast = useToast();
const { t } = useI18n();
const authenticating = ref(false);
const totp = ref<string>('');
const { error } = await useFetch('/api/auth/pending');
if (error.value) {
await navigateTo('/login');
}
const _submit = useSubmit(
(data) =>
$fetch('/api/auth/verify-2fa', {
method: 'post',
body: data,
}),
{
revert: async (success, data) => {
if (success) {
if (data?.status === 'success') {
await navigateTo('/');
return;
} else if (data?.status === 'INVALID_TOTP_CODE') {
authenticating.value = false;
totp.value = '';
toast.showToast({
title: t('general.2fa'),
message: t('login.2faWrong'),
type: 'error',
});
return;
} else if (data?.status === 'PENDING_LOGIN_EXPIRED') {
toast.showToast({
title: t('general.2fa'),
message: t('login.loginExpired'),
type: 'error',
});
await navigateTo('/login');
return;
}
}
authenticating.value = false;
},
noSuccessToast: true,
}
);
async function submit() {
if (!totp.value || authenticating.value) return;
authenticating.value = true;
return _submit({ totpCode: totp.value });
}
const _cancel = useSubmit(
(data) =>
$fetch('/api/auth/cancel', {
method: 'post',
body: data,
}),
{
revert: async () => {
await navigateTo('/login');
},
noSuccessToast: true,
}
);
async function cancel() {
return _cancel({});
}
</script>

147
src/app/pages/login/index.vue

@ -0,0 +1,147 @@
<template>
<div class="flex flex-col gap-5">
<div
v-if="authMethods && authMethods.oauthEnabled"
class="flex flex-col gap-5"
>
<UiLoginOauthButton
v-for="(info, provider) in authMethods.providers"
:key="provider"
:provider="provider"
:info="info"
/>
<!-- Divider -->
<div
v-if="authMethods.oauthEnabled && !authMethods.passwordDisabled"
class="flex items-center gap-2"
>
<div class="h-px flex-1 bg-gray-300 dark:bg-neutral-600"></div>
<span class="text-xs text-gray-500 dark:text-neutral-400">
{{ $t('login.or') }}
</span>
<div class="h-px flex-1 bg-gray-300 dark:bg-neutral-600"></div>
</div>
</div>
<!-- Classic Login Form -->
<form
v-if="!authMethods?.passwordDisabled"
class="flex flex-col gap-5"
@submit.prevent="submit"
>
<BaseInput
v-model="username"
type="text"
:placeholder="$t('general.username')"
autocomplete="username"
autofocus
name="username"
/>
<BaseInput
v-model="password"
type="password"
name="password"
:placeholder="$t('general.password')"
autocomplete="current-password"
/>
<label
class="flex gap-2 whitespace-nowrap"
:title="$t('login.rememberMeDesc')"
>
<BaseSwitch v-model="remember" />
<span class="text-sm">{{ $t('login.rememberMe') }}</span>
</label>
<button
class="rounded bg-red-800 py-2 text-sm text-white shadow transition hover:bg-red-700 disabled:cursor-not-allowed disabled:bg-gray-200 dark:bg-red-800 dark:text-white dark:hover:bg-red-700 disabled:dark:bg-neutral-800"
:disabled="!password || !username"
>
<IconsLoading v-if="authenticating" class="mx-auto w-5 animate-spin" />
<span v-else>{{ $t('login.signIn') }}</span>
</button>
</form>
</div>
</template>
<script setup lang="ts">
const toast = useToast();
const { t } = useI18n();
const route = useRoute();
const authenticating = ref(false);
const remember = ref(false);
const username = ref<string>('');
const password = ref<string>('');
const { data: authMethods } = await useFetch('/api/auth/methods');
watchEffect(() => {
const autoLauchQuery =
typeof route.query.auto_launch === 'string' && !!route.query.auto_launch
? route.query.auto_launch
: undefined;
if (authMethods.value?.autoLaunchProvider && !autoLauchQuery) {
navigateTo(`/api/auth/${authMethods.value.autoLaunchProvider}`, {
external: true,
});
}
if (
autoLauchQuery &&
autoLauchQuery !== 'false' &&
authMethods.value?.providers?.[autoLauchQuery as OAUTH_PROVIDER]
) {
navigateTo(`/api/auth/${autoLauchQuery}`, {
external: true,
});
}
});
const _submit = useSubmit(
(data) =>
$fetch('/api/auth/password', {
method: 'post',
body: data,
}),
{
revert: async (success, data) => {
if (success) {
if (data?.status === 'success') {
await navigateTo('/');
} else if (data?.status === 'TOTP_REQUIRED') {
authenticating.value = false;
await navigateTo('/login/2fa');
return;
} else if (data?.status === 'INVALID_TOTP_CODE') {
authenticating.value = false;
toast.showToast({
title: t('general.2fa'),
message: t('login.2faWrong'),
type: 'error',
});
return;
}
}
authenticating.value = false;
password.value = '';
},
noSuccessToast: true,
}
);
async function submit() {
if (!username.value || !password.value || authenticating.value) return;
authenticating.value = true;
return _submit({
username: username.value,
password: password.value,
remember: remember.value,
});
}
</script>

151
src/app/pages/me.vue

@ -2,7 +2,9 @@
<main>
<Panel>
<PanelHead>
<PanelHeadTitle :text="$t('pages.me')" />
<PanelHeadTitle>
{{ $t('pages.me') }}
</PanelHeadTitle>
</PanelHead>
<PanelBody class="dark:text-neutral-200">
<FormElement @submit.prevent="submit">
@ -25,6 +27,7 @@
<FormGroup>
<FormHeading>{{ $t('general.password') }}</FormHeading>
<FormPasswordField
v-if="hasPassword"
id="current-password"
v-model="currentPassword"
autocomplete="current-password"
@ -44,24 +47,25 @@
/>
<FormSecondaryActionField
type="submit"
:label="$t('general.updatePassword')"
:label="
hasPassword
? $t('general.updatePassword')
: $t('general.addPassword')
"
/>
</FormGroup>
</FormElement>
<FormElement @submit.prevent>
<FormGroup>
<FormHeading>{{ $t('general.2fa') }}</FormHeading>
<div
v-if="!authStore.userData?.totpVerified && !twofa"
class="col-span-2 flex flex-col"
>
<template v-if="!authStore.userData?.totpVerified && !twofa">
<FormSecondaryActionField
:label="$t('me.enable2fa')"
@click="setup2fa"
/>
</div>
</template>
<div
v-if="!authStore.userData?.totpVerified && twofa"
v-else-if="!authStore.userData?.totpVerified && twofa"
class="col-span-2"
>
<p class="text-sm text-gray-500 dark:text-gray-400">
@ -91,7 +95,7 @@
</div>
</div>
<div
v-if="authStore.userData?.totpVerified"
v-else-if="authStore.userData?.totpVerified"
class="col-span-2 flex flex-col gap-2"
>
<p class="text-sm text-gray-500 dark:text-gray-400">
@ -111,6 +115,60 @@
</div>
</FormGroup>
</FormElement>
<FormElement v-if="authMethods?.oauthEnabled" @submit.prevent>
<FormGroup>
<FormHeading>{{ $t('general.externalAuth') }}</FormHeading>
<template v-if="oauthProvider && oauthProviderInfo?.enabled">
<div class="flex items-center gap-2">
<IconsBrandsProvider
:provider="oauthProvider"
class="h-4 w-4"
/>
<span>
{{ $t('me.linkedWith', [oauthProviderInfo.friendlyName]) }}
</span>
</div>
<div>
<FormSecondaryActionField
:label="$t('me.unlinkOauth')"
class="w-full"
:disabled="!hasPassword"
@click="unlinkOauth"
/>
</div>
</template>
<template v-else-if="oauthProvider && !oauthProviderInfo?.enabled">
<span class="flex items-center">
{{ $t('me.providerDisabled') }}
</span>
<div>
<FormSecondaryActionField
:label="$t('me.unlinkOauth')"
class="w-full"
:disabled="!hasPassword"
@click="unlinkOauth"
/>
</div>
</template>
<template v-else>
<div class="flex items-center">
<span>{{ $t('me.linkOauth') }}</span>
</div>
<div class="flex flex-col gap-2">
<BaseFormSecondaryButton
v-for="(info, provider) in authMethods.providers"
:key="provider"
class="flex items-center gap-2 p-2"
as="a"
:href="`/api/auth/${provider}?link=true`"
>
<IconsBrandsProvider :provider="provider" class="h-4 w-4" />
<span>{{ info.friendlyName }}</span>
</BaseFormSecondaryButton>
</div>
</template>
</FormGroup>
</FormElement>
</PanelBody>
</Panel>
</main>
@ -121,14 +179,25 @@ import { encodeQR } from 'qr';
const authStore = useAuthStore();
const { data: authMethods } = await useFetch('/api/auth/methods');
const name = ref(authStore.userData?.name);
const email = ref(authStore.userData?.email);
const hasPassword = computed(() => authStore.userData?.hasPassword);
const oauthProvider = computed(() => authStore.userData?.oauthProvider);
const oauthProviderInfo = computed(() => {
if (!authStore.userData?.oauthProvider) {
return null;
}
return authMethods.value?.providers?.[authStore.userData.oauthProvider];
});
const _submit = useSubmit(
`/api/me`,
{
method: 'post',
},
(data) =>
$fetch(`/api/me`, {
method: 'post',
body: data,
}),
{
revert: () => {
return authStore.update();
@ -145,22 +214,24 @@ const newPassword = ref('');
const confirmPassword = ref('');
const _updatePassword = useSubmit(
`/api/me/password`,
{
method: 'post',
},
(data) =>
$fetch(`/api/me/password`, {
method: 'post',
body: data,
}),
{
revert: async () => {
currentPassword.value = '';
newPassword.value = '';
confirmPassword.value = '';
return authStore.update();
},
}
);
function updatePassword() {
return _updatePassword({
currentPassword: currentPassword.value,
currentPassword: hasPassword.value ? currentPassword.value : null,
newPassword: newPassword.value,
confirmPassword: confirmPassword.value,
});
@ -169,10 +240,11 @@ function updatePassword() {
const twofa = ref<{ key: string; qrcode: string } | null>(null);
const _setup2fa = useSubmit(
`/api/me/totp`,
{
method: 'post',
},
(data) =>
$fetch(`/api/me/totp`, {
method: 'post',
body: data,
}),
{
revert: async (success, data) => {
if (success && data?.type === 'setup') {
@ -197,10 +269,11 @@ async function setup2fa() {
const code = ref<string>('');
const _enable2fa = useSubmit(
`/api/me/totp`,
{
method: 'post',
},
(data) =>
$fetch(`/api/me/totp`, {
method: 'post',
body: data,
}),
{
revert: async (success, data) => {
if (success && data?.type === 'created') {
@ -222,10 +295,11 @@ async function enable2fa() {
const disable2faPassword = ref('');
const _disable2fa = useSubmit(
`/api/me/totp`,
{
method: 'post',
},
(data) =>
$fetch(`/api/me/totp`, {
method: 'post',
body: data,
}),
{
revert: async (success, data) => {
if (success && data?.type === 'deleted') {
@ -242,4 +316,21 @@ async function disable2fa() {
currentPassword: disable2faPassword.value,
});
}
const _unlinkOauth = useSubmit(
(data) =>
$fetch(`/api/auth/unlink`, {
method: 'post',
body: data,
}),
{
revert: async () => {
return authStore.update();
},
}
);
async function unlinkOauth() {
return _unlinkOauth({});
}
</script>

9
src/app/pages/setup/2.vue

@ -50,10 +50,11 @@ const password = ref<string>('');
const confirmPassword = ref<string>('');
const _submit = useSubmit(
'/api/setup/2',
{
method: 'post',
},
(data) =>
$fetch('/api/setup/2', {
method: 'post',
body: data,
}),
{
revert: async (success) => {
if (success) {

9
src/app/pages/setup/4.vue

@ -43,10 +43,11 @@ const host = ref<null | string>(null);
const port = ref<number>(51820);
const _submit = useSubmit(
'/api/setup/4',
{
method: 'post',
},
(data) =>
$fetch('/api/setup/4', {
method: 'post',
body: data,
}),
{
revert: async (success) => {
if (success) {

9
src/app/pages/setup/migrate.vue

@ -36,10 +36,11 @@ function onChangeFile(evt: Event) {
}
const _submit = useSubmit(
'/api/setup/migrate',
{
method: 'post',
},
(data) =>
$fetch('/api/setup/migrate', {
method: 'post',
body: data,
}),
{
revert: async (success) => {
if (success) {

1
src/app/stores/auth.ts

@ -1,5 +1,4 @@
import type { H3Event } from 'h3';
import type { SharedPublicUser } from '~~/shared/utils/permissions';
export const useAuthStore = defineStore('Auth', () => {
const userData = useState<SharedPublicUser | null>('user-data', () => null);

24
src/app/stores/clients.ts

@ -31,9 +31,12 @@ export const useClientsStore = defineStore('Clients', () => {
const clients = ref<null | LocalClient[]>(null);
const clientsPersist = ref<Record<string, ClientPersist>>({});
const searchParams = ref({
filter: undefined as string | undefined,
});
const filter = ref<string | undefined>(undefined);
const searchParams = computed(() => ({
filter: filter.value,
sort: globalStore.sortClient,
}));
const { data: _clients, refresh: _refresh } = useFetch('/api/client', {
method: 'get',
@ -43,7 +46,7 @@ export const useClientsStore = defineStore('Clients', () => {
// TODO: rewrite
async function refresh({ updateCharts = false } = {}) {
await _refresh();
let transformedClients = _clients.value?.map((client) => {
const transformedClients = _clients.value?.map((client) => {
let avatar = undefined;
if (client.name.includes('@') && client.name.includes('.')) {
avatar = `https://gravatar.com/avatar/${sha256(client.name.toLowerCase().trim())}.jpg`;
@ -126,21 +129,12 @@ export const useClientsStore = defineStore('Clients', () => {
};
});
// TODO: move sort to backend
if (transformedClients !== undefined) {
transformedClients = sortByProperty(
transformedClients,
'name',
globalStore.sortClient
);
}
clients.value = transformedClients ?? null;
}
function setSearchQuery(filter: string) {
function setSearchQuery(query: string) {
clients.value = null;
searchParams.value.filter = filter || undefined;
filter.value = query || undefined;
}
return { clients, clientsPersist, refresh, _clients, setSearchQuery };

2
src/app/stores/global.ts

@ -6,7 +6,7 @@ export const useGlobalStore = defineStore('Global', () => {
}
);
const sortClient = ref(true); // Sort clients by name, true = asc, false = desc
const sortClient = ref<'asc' | 'desc'>('asc');
const uiShowCharts = useCookie<boolean>('uiShowCharts', {
default: () => false,

2
src/app/utils/types.ts

@ -4,3 +4,5 @@ export type ToastParams = {
title: string;
message: string;
};
export type OAUTH_PROVIDER = 'google' | 'github' | 'oidc';

4
src/cli/admin/reset.ts

@ -2,8 +2,8 @@ import { defineCommand } from 'citty';
import { consola } from 'consola';
import { eq } from 'drizzle-orm';
import { db, schema } from '../db';
import { hashPassword } from '../../server/utils/password';
import { db, schema } from '#cli/db';
import { hashPassword } from '#server/utils/password';
export default defineCommand({
meta: {

14
src/cli/build.js

@ -1,6 +1,7 @@
// @ts-check
import { fileURLToPath } from 'node:url';
import esbuild from 'esbuild';
esbuild.build({
@ -9,17 +10,6 @@ esbuild.build({
outfile: fileURLToPath(new URL('../.output/server/cli.mjs', import.meta.url)),
platform: 'node',
format: 'esm',
plugins: [
{
name: 'make-all-packages-external',
setup(build) {
let filter = /^[^./]|^\.[^./]|^\.\.[^/]/; // Must not start with "/" or "./" or "../"
build.onResolve({ filter }, (args) => ({
path: args.path,
external: true,
}));
},
},
],
packages: 'external',
logLevel: 'info',
});

2
src/cli/clients/list.ts

@ -1,7 +1,7 @@
import { defineCommand } from 'citty';
import { consola } from 'consola';
import { db } from '../db';
import { db } from '#cli/db';
export default defineCommand({
meta: {

6
src/cli/clients/qr.ts

@ -2,9 +2,9 @@ import { defineCommand } from 'citty';
import { consola } from 'consola';
import { eq } from 'drizzle-orm';
import { wg } from '../../server/utils/wgHelper';
import { encodeQRCodeTerm } from '../../server/utils/qr';
import { db, schema } from '../db';
import { db, schema } from '#cli/db';
import { wg } from '#server/utils/wgHelper';
import { encodeQRCodeTerm } from '#server/utils/qr';
export default defineCommand({
meta: {

2
src/cli/db.ts

@ -1,7 +1,7 @@
import { createClient } from '@libsql/client';
import { drizzle } from 'drizzle-orm/libsql';
import * as schema from '../server/database/schema';
import * as schema from '#server/database/schema';
//const client = createClient({ url: 'file:../data/wg-easy.db' });
const client = createClient({ url: 'file:/etc/wireguard/wg-easy.db' });

7
src/cli/index.ts

@ -6,9 +6,10 @@ import { defineCommand, runMain } from 'citty';
import packageJson from '../package.json';
// Commands
import dbAdminReset from './admin/reset';
import clientsList from './clients/list';
import clientsQr from './clients/qr';
import clientsList from '#cli/clients/list';
import clientsQr from '#cli/clients/qr';
import dbAdminReset from '#cli/admin/reset';
const subCommands = [dbAdminReset, clientsList, clientsQr] as const;
// from citty

7
src/cli/tsconfig.json

@ -6,7 +6,12 @@
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"moduleResolution": "bundler"
"moduleResolution": "bundler",
"paths": {
"#cli/*": ["./*"],
"#server/*": ["../server/*"],
"#db/*": ["../server/database/*"]
}
},
"include": ["./**/*.ts"]
}

3
src/eslint.config.mjs

@ -1,10 +1,11 @@
import eslintConfigPrettier from 'eslint-config-prettier';
import withNuxt from './.nuxt/eslint.config.mjs';
export default withNuxt([
{
rules: {
'import/order': 'warn',
'import/order': ['warn', { 'newlines-between': 'always' }],
},
},
eslintConfigPrettier,

6
src/i18n/i18n.config.ts

@ -4,6 +4,7 @@ import uk from './locales/uk.json';
import fr from './locales/fr.json';
import de from './locales/de.json';
import it from './locales/it.json';
import ja from './locales/ja.json';
import ru from './locales/ru.json';
import zhhk from './locales/zh-HK.json';
import zhcn from './locales/zh-CN.json';
@ -17,8 +18,10 @@ import id from './locales/id.json';
import nl from './locales/nl.json';
import nb from './locales/nb.json';
import bg from './locales/bg.json';
import hi from './locales/hi.json';
import gl from './locales/gl.json';
import cs from './locales/cs.json';
import vi from './locales/vi.json';
export default defineI18nConfig(() => ({
legacy: false,
@ -30,6 +33,7 @@ export default defineI18nConfig(() => ({
fr,
de,
it,
ja,
ru,
'zh-HK': zhhk,
'zh-CN': zhcn,
@ -43,7 +47,9 @@ export default defineI18nConfig(() => ({
nl,
nb,
bg,
hi,
gl,
cs,
vi,
},
}));

13
src/i18n/localeDetector.ts

@ -1,4 +1,13 @@
export default defineI18nLocaleDetector((event, config) => {
import {
tryCookieLocale,
tryHeaderLocale,
tryQueryLocale,
} from '@intlify/utils/h3';
import type { H3Event } from 'h3';
// TODO: use defineI18nLocaleDetector
export default (event: H3Event, config: { defaultLocale: string }) => {
const query = tryQueryLocale(event, { lang: '' });
if (query) {
return query.toString();
@ -18,4 +27,4 @@ export default defineI18nLocaleDetector((event, config) => {
}
return config.defaultLocale;
});
};

19
src/i18n/locales/en.json

@ -20,7 +20,11 @@
"2faKey": "TOTP Key",
"2faCodeDesc": "Enter the code from your authenticator app.",
"disable2fa": "Disable Two Factor Authentication",
"disable2faDesc": "Enter your password to disable Two Factor Authentication."
"disable2faDesc": "Enter your password to disable Two Factor Authentication.",
"linkOauth": "Link your account with an external provider",
"unlinkOauth": "Unlink",
"linkedWith": "Linked with {0}",
"providerDisabled": "Your currently linked provider is not enabled"
},
"general": {
"name": "Name",
@ -28,6 +32,7 @@
"password": "Password",
"newPassword": "New Password",
"updatePassword": "Update Password",
"addPassword": "Add Password",
"mtu": "MTU",
"allowedIps": "Allowed IPs",
"dns": "DNS",
@ -41,7 +46,8 @@
"confirmPassword": "Confirm Password",
"loading": "Loading...",
"2fa": "Two Factor Authentication",
"2faCode": "TOTP Code"
"2faCode": "TOTP Code",
"externalAuth": "External Authentication"
},
"setup": {
"welcome": "Welcome to your first setup of wg-easy",
@ -72,11 +78,14 @@
},
"login": {
"signIn": "Sign In",
"signInWith": "Sign in with {0}",
"or": "or",
"rememberMe": "Remember me",
"rememberMeDesc": "Stay logged after closing the browser",
"insecure": "You can't log in with an insecure connection. Use HTTPS.",
"2faRequired": "Two Factor Authentication is required",
"2faWrong": "Two Factor Authentication is wrong"
"2faWrong": "Two Factor Authentication is wrong",
"loginExpired": "Login expired. Try again"
},
"client": {
"empty": "There are no clients yet.",
@ -197,7 +206,9 @@
"validBoolean": "{0} must be a valid boolean",
"validArray": "{0} must be a valid array",
"stringMin": "{0} must be at least {1} Character",
"numberMin": "{0} must be at least {1}"
"stringMax": "{0} must be at most {1} Character",
"numberMin": "{0} must be at least {1}",
"numberMax": "{0} must be at most {1}"
},
"client": {
"id": "Client ID",

153
src/i18n/locales/gl.json

@ -120,7 +120,11 @@
"endpointDesc": "IP do cliente desde a que se establece a conexión WireGuard",
"search": "Buscar clientes...",
"config": "Configuración",
"viewConfig": "Ver configuración"
"viewConfig": "Ver configuración",
"firewallIps": "IPs permitidas do cortalumes",
"firewallIpsDesc": "IPs/CIDRs de destino aos que este cliente pode acceder (aplicado no lado do servidor). Déixao baleiro para usar as IPs permitidas. Admite filtrado opcional por porto e protocolo. Consulta a documentación para a sintaxe.",
"downloadPng": "Descargar PNG",
"copyPng": "Copiar PNG"
},
"dialog": {
"change": "Cambiar",
@ -141,5 +145,152 @@
"noItems": "Sen elementos",
"nullNoItems": "Sen elementos. Usando a configuración global",
"add": "Engadir"
},
"admin": {
"general": {
"sessionTimeout": "Tempo límite da sesión",
"sessionTimeoutDesc": "Duración da sesión para Lembrarme (segundos)",
"metrics": "Métricas",
"metricsPassword": "Contrasinal",
"metricsPasswordDesc": "Contrasinal Bearer para o endpoint de métricas (contrasinal ou hash argon2)",
"json": "JSON",
"jsonDesc": "Ruta para as métricas en formato JSON",
"prometheus": "Prometheus",
"prometheusDesc": "Ruta para as métricas de Prometheus"
},
"config": {
"connection": "Conexión",
"hostDesc": "Nome de host público ao que se conectarán os clientes (invalida a configuración)",
"portDesc": "Porto UDP público ao que se conectarán os clientes (invalida a configuración; probablemente tamén queiras cambiar o Porto da interface)",
"allowedIpsDesc": "IPs permitidas que usarán os clientes (configuración global)",
"dnsDesc": "Servidor DNS que usarán os clientes (configuración global)",
"mtuDesc": "MTU que usarán os clientes (só para clientes novos)",
"persistentKeepaliveDesc": "Intervalo en segundos para enviar keepalives ao servidor. 0 = desactivado (só para clientes novos)",
"suggest": "Suxerir",
"suggestDesc": "Escolle un enderezo IP ou nome de host para o campo Host"
},
"interface": {
"cidrSuccess": "CIDR cambiado",
"device": "Dispositivo",
"deviceDesc": "Dispositivo Ethernet polo que se redirixirá o tráfico de WireGuard",
"mtuDesc": "MTU que usará WireGuard",
"portDesc": "Porto UDP no que WireGuard escoitará (probablemente tamén queiras cambiar o Porto da configuración)",
"changeCidr": "Cambiar CIDR",
"restart": "Reiniciar interface",
"restartDesc": "Reiniciar a interface de WireGuard",
"restartWarn": "Seguro que queres reiniciar a interface? Isto desconectará todos os clientes.",
"restartSuccess": "Interface reiniciada",
"firewall": "Filtrado de tráfico",
"firewallEnabled": "Activar devasa por cliente",
"firewallEnabledDesc": "Restrinxir o tráfico dos clientes a IPs de destino específicas usando iptables. Cando está activado, cada cliente pode configurarse con destinos permitidos."
},
"introText": "Benvido ao panel de administración.\n\nAquí podes xestionar a configuración xeral, a configuración, os axustes da interface e os hooks.\n\nComeza escollendo unha das seccións na barra lateral."
},
"zod": {
"generic": {
"required": "{0} é obrigatorio",
"validNumber": "{0} debe ser un número válido",
"validNumberRange": "{0} debe ser un número válido ou un rango de números",
"validString": "{0} debe ser unha cadea válida",
"validBoolean": "{0} debe ser un booleano válido",
"validArray": "{0} debe ser un array válido",
"stringMin": "{0} debe ter polo menos {1} carácter",
"numberMin": "{0} debe ser polo menos {1}"
},
"client": {
"id": "ID do cliente",
"name": "Nome",
"expiresAt": "Caduca o",
"address4": "Enderezo IPv4",
"address6": "Enderezo IPv6",
"serverAllowedIps": "IPs permitidas do servidor",
"firewallIps": "IPs permitidas da devasa",
"firewallIpsInvalid": "Entrada de IP da devasa non válida. Consulta a documentación para a sintaxe admitida."
},
"user": {
"username": "Nome de usuario",
"password": "Contrasinal",
"remember": "Lembrar",
"name": "Nome",
"email": "Correo electrónico",
"emailInvalid": "O correo electrónico debe ser válido",
"passwordMatch": "Os contrasinais deben coincidir",
"totpEnable": "Activar TOTP",
"totpEnableTrue": "Activar TOTP debe ser verdadeiro",
"totpCode": "Código TOTP"
},
"userConfig": {
"host": "Host"
},
"general": {
"sessionTimeout": "Tempo límite da sesión",
"metricsEnabled": "Métricas",
"metricsPassword": "Contrasinal das métricas"
},
"interface": {
"cidr": "CIDR",
"device": "Dispositivo",
"cidrValid": "O CIDR debe ser válido"
},
"otl": "Ligazón dun só uso",
"stringMalformed": "A cadea está mal formada",
"body": "O corpo debe ser un obxecto válido",
"hook": "Hook",
"enabled": "Activado",
"mtu": "MTU",
"port": "Porto",
"persistentKeepalive": "Keepalive persistente",
"address": "Enderezo IP",
"dns": "DNS",
"allowedIps": "IPs permitidas",
"file": "Ficheiro"
},
"hooks": {
"preUp": "PreUp",
"postUp": "PostUp",
"preDown": "PreDown",
"postDown": "PostDown"
},
"copy": {
"notSupported": "Copiar non é compatible",
"copied": "Copiado!",
"failed": "Erro ao copiar",
"copy": "Copiar"
},
"awg": {
"jCLabel": "Número de paquetes lixo (Jc)",
"jCDescription": "Número de paquetes lixo para enviar (1-128, recomendado: 4-12)",
"jMinLabel": "Tamaño mínimo dos paquetes lixo (Jmin)",
"jMinDescription": "Tamaño mínimo dos paquetes lixo (0-1279*, recomendado: 8, debe ser < Jmax)",
"jMaxLabel": "Tamaño máximo dos paquetes lixo (Jmax)",
"jMaxDescription": "Tamaño máximo dos paquetes lixo (1-1280*, recomendado: 80, debe ser > Jmin)",
"s1Label": "Tamaño lixo do paquete de inicio (S1)",
"s1Description": "Tamaño lixo do paquete de inicio (0-1132[1280* - 148 = 1132], recomendado: 15-150, S1+56 ≠ S2)",
"s2Label": "Tamaño lixo do paquete de resposta (S2)",
"s2Description": "Tamaño lixo do paquete de resposta (0-1188[1280* - 92 = 1188], recomendado: 15-150)",
"s3Label": "Tamaño lixo do paquete de resposta cookie (S3)",
"s3Description": "Tamaño lixo do paquete de resposta cookie",
"s4Label": "Tamaño lixo do paquete de transporte (S4)",
"s4Description": "Tamaño lixo do paquete de transporte",
"h1Label": "Cabeceira máxica de inicio (H1)",
"h1Description": "Valor ou rango da cabeceira do paquete de inicio (X ou X-Y, onde X<Y. Mín 5, máx 2147483647. O valor ou rango non debe solaparse con outras cabeceiras)",
"h2Label": "Cabeceira máxica de resposta (H2)",
"h2Description": "Valor ou rango da cabeceira do paquete de resposta (X ou X-Y, onde X<Y. Mín 5, máx 2147483647. O valor ou rango non debe solaparse con outras cabeceiras)",
"h3Label": "Cabeceira máxica da resposta cookie (H3)",
"h3Description": "Valor ou rango da cabeceira do paquete de resposta cookie (X ou X-Y, onde X<Y. Mín 5, máx 2147483647. O valor ou rango non debe solaparse con outras cabeceiras)",
"h4Label": "Cabeceira máxica de transporte (H4)",
"h4Description": "Valor ou rango da cabeceira do paquete de transporte (X ou X-Y, onde X<Y. Mín 5, máx 2147483647. O valor ou rango non debe solaparse con outras cabeceiras)",
"i1Label": "Paquete lixo especial 1 (I1)",
"i1Description": "Paquete de simulación de protocolo en formato hexadecimal: <b 0x...>",
"i2Label": "Paquete lixo especial 2 (I2)",
"i2Description": "Paquete de simulación de protocolo en formato hexadecimal: <b 0x...>",
"i3Label": "Paquete lixo especial 3 (I3)",
"i3Description": "Paquete de simulación de protocolo en formato hexadecimal: <b 0x...>",
"i4Label": "Paquete lixo especial 4 (I4)",
"i4Description": "Paquete de simulación de protocolo en formato hexadecimal: <b 0x...>",
"i5Label": "Paquete lixo especial 5 (I5)",
"i5Description": "Paquete de simulación de protocolo en formato hexadecimal: <b 0x...>",
"mtuNote": "Os valores dependen da MTU",
"obfuscationParameters": "Parámetros de ofuscación de AmneziaWG"
}
}

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save