Compare commits

..

7 Commits

Author SHA1 Message Date
dependabot[bot]
29dda9fcb4 chore(deps): bump the actions group with 2 updates
Bumps the actions group with 2 updates: [nolar/setup-k3d-k3s](https://github.com/nolar/setup-k3d-k3s) and [docker/login-action](https://github.com/docker/login-action).


Updates `nolar/setup-k3d-k3s` from 1.0.10 to 1.1.0
- [Release notes](https://github.com/nolar/setup-k3d-k3s/releases)
- [Commits](8bf8d22160...62c9d1bd2b)

Updates `docker/login-action` from 4.0.0 to 4.1.0
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](b45d80f862...4907a6ddec)

---
updated-dependencies:
- dependency-name: nolar/setup-k3d-k3s
  dependency-version: 1.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: docker/login-action
  dependency-version: 4.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-06 13:54:41 +00:00
Mykhailo Roit
72f9123592 Add test-in-docker target to Makefile (#659)
Some checks failed
Docker / Build and Push (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
Docker / release (push) Has been cancelled
* Add test-in-docker target to Makefile

Add a target to run tests in Docker for non-Linux environments.

* Add GOVERSION variable to Makefile

* fix: update test-in-docker command to use user permissions

* Fix docker command syntax in Makefile
2026-04-03 22:01:07 +02:00
Mykhailo Roit
0e9e9d697f fix: "created_at" for users (#656)
Some checks failed
Docker / Build and Push (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
Docker / release (push) Has been cancelled
* fix: created_at for users

* added tests for: created_at for users

* cleanup fixes

---------

Co-authored-by: Christoph Haas <christoph.h@sprinternet.at>
2026-04-01 11:58:22 +02:00
Christoph
87bfd5b23a feat: allow encrypting user api token using gorm serializer 2026-04-01 11:42:07 +02:00
h44z
920806b231 chore: update frontend deps (#657)
Some checks failed
Docker / Build and Push (push) Has been cancelled
Docker / release (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
2026-04-01 00:20:35 +02:00
Leandre Chamberland-Dozois
ec08e31eb7 feat(frontend): add confirmation dialog before deleting users, peers, and interfaces (#654)
Some checks failed
Docker / Build and Push (push) Has been cancelled
Docker / release (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
* feat(frontend): add confirmation dialog before deleting users, peers, and interfaces (#652)

Add a browser confirm() dialog to the delete functions in UserEditModal,
PeerEditModal, and InterfaceEditModal to prevent accidental deletions.
The bulk-delete actions in UserView already had this protection; this
change brings single-item deletion in line with that behavior.

Translation keys (confirm-delete) added for all 10 supported locales:
de, en, es, fr, ko, pt, ru, uk, vi, zh.

Signed-off-by: LeC-D <leo.openc@gmail.com>

* fix broken translation files

---------

Signed-off-by: LeC-D <leo.openc@gmail.com>
Co-authored-by: Christoph Haas <christoph.h@sprinternet.at>
2026-03-31 19:49:53 +02:00
Jacopo Clark
c1a7edcc9a fix: prevent interface address clearing during startup (#651)
Some checks failed
Docker / Build and Push (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
Docker / release (push) Has been cancelled
Signed-off-by: jc <37738506+theguy147@users.noreply.github.com>
Co-authored-by: jc <37738506+theguy147@users.noreply.github.com>
2026-03-25 22:08:06 +01:00
26 changed files with 2337 additions and 1899 deletions

View File

@@ -44,7 +44,7 @@ jobs:
- name: Run chart-testing (lint) - name: Run chart-testing (lint)
run: ct lint --config ct.yaml run: ct lint --config ct.yaml
- uses: nolar/setup-k3d-k3s@8bf8d22160e8b1d184dcb780e390d6952a7eec65 # v1.0.10 - uses: nolar/setup-k3d-k3s@62c9d1bd2bc843275c85d2e7dcd696edc1160eee # v1.1.0
with: with:
github-token: ${{ secrets.GITHUB_TOKEN }} github-token: ${{ secrets.GITHUB_TOKEN }}
@@ -62,7 +62,7 @@ jobs:
steps: steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}

View File

@@ -32,14 +32,14 @@ jobs:
- name: Login to Docker Hub - name: Login to Docker Hub
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}

View File

@@ -1,7 +1,8 @@
# Go parameters # Go parameters
GOCMD=go GOCMD=go
GOVERSION=1.25
MODULENAME=github.com/h44z/wg-portal MODULENAME=github.com/h44z/wg-portal
GOFILES:=$(shell go list ./... | grep -v /vendor/) GOFILES=$(shell go list ./... | grep -v /vendor/)
BUILDDIR=dist BUILDDIR=dist
BINARIES=$(subst cmd/,,$(wildcard cmd/*)) BINARIES=$(subst cmd/,,$(wildcard cmd/*))
IMAGE=h44z/wg-portal IMAGE=h44z/wg-portal
@@ -51,6 +52,11 @@ format:
.PHONY: test .PHONY: test
test: test-vet test-race test: test-vet test-race
#> test-in-docker: Run tests in Docker (for non-Linux environments e.g. MacOS)
.PHONY: test-in-docker
test-in-docker:
docker run --rm -u $(shell id -u):$(shell id -g) -e HOME=/tmp -v $(PWD):/app -w /app golang:$(GOVERSION) make test
#< test-vet: Static code analysis #< test-vet: Static code analysis
.PHONY: test-vet .PHONY: test-vet
test-vet: build-dependencies test-vet: build-dependencies

View File

@@ -14,7 +14,7 @@
let WGPORTAL_SITE_TITLE="WireGuard Portal"; let WGPORTAL_SITE_TITLE="WireGuard Portal";
let WGPORTAL_SITE_COMPANY_NAME="WireGuard Portal"; let WGPORTAL_SITE_COMPANY_NAME="WireGuard Portal";
</script> </script>
<script src="/api/v0/config/frontend.js"></script> <script src="/api/v0/config/frontend.js" vite-ignore></script>
</head> </head>
<body class="d-flex flex-column min-vh-100"> <body class="d-flex flex-column min-vh-100">
<noscript> <noscript>

File diff suppressed because it is too large Load Diff

View File

@@ -9,28 +9,28 @@
}, },
"dependencies": { "dependencies": {
"@fontsource/nunito-sans": "^5.2.7", "@fontsource/nunito-sans": "^5.2.7",
"@fortawesome/fontawesome-free": "^7.1.0", "@fortawesome/fontawesome-free": "^7.2.0",
"@kyvg/vue3-notification": "^3.4.2", "@kyvg/vue3-notification": "^3.4.2",
"@popperjs/core": "^2.11.8", "@popperjs/core": "^2.11.8",
"@simplewebauthn/browser": "^13.2.2", "@simplewebauthn/browser": "^13.3.0",
"@vojtechlanka/vue-tags-input": "^3.1.1", "@vojtechlanka/vue-tags-input": "^3.1.2",
"bootstrap": "^5.3.8", "bootstrap": "^5.3.8",
"bootswatch": "^5.3.8", "bootswatch": "^5.3.8",
"cidr-tools": "^11.0.3", "cidr-tools": "^11.3.2",
"flag-icons": "^7.5.0", "flag-icons": "^7.5.0",
"ip-address": "^10.1.0", "ip-address": "^10.1.0",
"is-cidr": "^6.0.1", "is-cidr": "^6.0.3",
"is-ip": "^5.0.1", "is-ip": "^5.0.1",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"prismjs": "^1.30.0", "prismjs": "^1.30.0",
"vue": "^3.5.25", "vue": "^3.5.31",
"vue-i18n": "^11.2.2", "vue-i18n": "^11.3.0",
"vue-prism-component": "github:h44z/vue-prism-component", "vue-prism-component": "github:h44z/vue-prism-component",
"vue-router": "^4.6.3" "vue-router": "^5.0.4"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^6.0.2", "@vitejs/plugin-vue": "^6.0.5",
"sass-embedded": "^1.93.3", "sass-embedded": "^1.98.0",
"vite": "^7.2.7" "vite": "^8.0.3"
} }
} }

View File

@@ -315,6 +315,7 @@ async function applyPeerDefaults() {
async function del() { async function del() {
if (isDeleting.value) return if (isDeleting.value) return
if (!confirm(t('modals.interface-edit.confirm-delete', {id: selectedInterface.value.Identifier}))) return
isDeleting.value = true isDeleting.value = true
try { try {
await interfaces.DeleteInterface(selectedInterface.value.Identifier) await interfaces.DeleteInterface(selectedInterface.value.Identifier)

View File

@@ -26,13 +26,13 @@
display:block; display:block;
} }
.modal.show { .modal.show {
opacity: 1; opacity: 1.0;
} }
.modal-backdrop { .modal-backdrop {
background-color: rgba(0,0,0,0.6) !important; background-color: rgba(0,0,0,0.6) !important;
} }
.modal-backdrop.show { .modal-backdrop.show {
opacity: 1 !important; opacity: 1.0 !important;
} }
</style> </style>

View File

@@ -294,6 +294,7 @@ async function save() {
async function del() { async function del() {
if (isDeleting.value) return if (isDeleting.value) return
if (!confirm(t('modals.peer-edit.confirm-delete', {id: selectedPeer.value.Identifier}))) return
isDeleting.value = true isDeleting.value = true
try { try {
await peers.DeletePeer(selectedPeer.value.Identifier) await peers.DeletePeer(selectedPeer.value.Identifier)

View File

@@ -114,6 +114,7 @@ async function save() {
async function del() { async function del() {
if (isDeleting.value) return if (isDeleting.value) return
if (!confirm(t('modals.user-edit.confirm-delete', {id: selectedUser.value.Identifier}))) return
isDeleting.value = true isDeleting.value = true
try { try {
await users.DeleteUser(selectedUser.value.Identifier) await users.DeleteUser(selectedUser.value.Identifier)

View File

@@ -382,7 +382,8 @@
"persist-local-changes": { "persist-local-changes": {
"label": "Lokale Änderungen speichern" "label": "Lokale Änderungen speichern"
}, },
"sync-warning": "Um diesen synchronisierten Benutzer zu bearbeiten, aktivieren Sie die lokale Änderungsspeicherung. Andernfalls werden Ihre Änderungen bei der nächsten Synchronisierung überschrieben." "sync-warning": "Um diesen synchronisierten Benutzer zu bearbeiten, aktivieren Sie die lokale Änderungsspeicherung. Andernfalls werden Ihre Änderungen bei der nächsten Synchronisierung überschrieben.",
"confirm-delete": "Benutzer '{id}' wirklich löschen?"
}, },
"interface-view": { "interface-view": {
"headline": "Konfiguration für Schnittstelle:" "headline": "Konfiguration für Schnittstelle:"
@@ -503,7 +504,8 @@
"placeholder": "Persistentes Keepalive (0 = Standard)" "placeholder": "Persistentes Keepalive (0 = Standard)"
} }
}, },
"button-apply-defaults": "Peer-Standardeinstellungen anwenden" "button-apply-defaults": "Peer-Standardeinstellungen anwenden",
"confirm-delete": "Interface '{id}' wirklich löschen?"
}, },
"peer-view": { "peer-view": {
"headline-peer": "Peer:", "headline-peer": "Peer:",
@@ -625,7 +627,8 @@
}, },
"expires-at": { "expires-at": {
"label": "Ablaufdatum" "label": "Ablaufdatum"
} },
"confirm-delete": "Peer '{id}' wirklich löschen?"
}, },
"peer-multi-create": { "peer-multi-create": {
"headline-peer": "Mehrere Peers erstellen", "headline-peer": "Mehrere Peers erstellen",

View File

@@ -271,16 +271,16 @@
"headline-preshared-key": "New Preshared Key", "headline-preshared-key": "New Preshared Key",
"button-generate": "Generate", "button-generate": "Generate",
"private-key": { "private-key": {
"label": "Private Key", "label": "Private Key",
"placeholder": "The private key" "placeholder": "The private key"
}, },
"public-key": { "public-key": {
"label": "Public Key", "label": "Public Key",
"placeholder": "The public key" "placeholder": "The public key"
}, },
"preshared-key": { "preshared-key": {
"label": "Preshared Key", "label": "Preshared Key",
"placeholder": "The pre-shared key" "placeholder": "The pre-shared key"
} }
}, },
"calculator": { "calculator": {
@@ -289,18 +289,18 @@
"headline-allowed-ip": "New Allowed IPs", "headline-allowed-ip": "New Allowed IPs",
"button-exclude-private": "Exclude Private IP Ranges", "button-exclude-private": "Exclude Private IP Ranges",
"allowed-ip": { "allowed-ip": {
"label": "Allowed IPs", "label": "Allowed IPs",
"placeholder": "0.0.0.0/0, ::/0", "placeholder": "0.0.0.0/0, ::/0",
"empty": "Value cannot be empty" "empty": "Value cannot be empty"
}, },
"dissallowed-ip": { "dissallowed-ip": {
"label": "Disallowed IPs", "label": "Disallowed IPs",
"placeholder": "10.0.0.0/8, 192.168.0.0/16", "placeholder": "10.0.0.0/8, 192.168.0.0/16",
"invalid": "Invalid address: {addr}" "invalid": "Invalid address: {addr}"
}, },
"new-allowed-ip": { "new-allowed-ip": {
"label": "Allowed IPs", "label": "Allowed IPs",
"placeholder": "" "placeholder": ""
} }
}, },
"modals": { "modals": {
@@ -382,7 +382,8 @@
"persist-local-changes": { "persist-local-changes": {
"label": "Persist local changes" "label": "Persist local changes"
}, },
"sync-warning": "To modify this synchronized user, enable local change persistence. Otherwise, your changes will be overwritten during the next synchronization." "sync-warning": "To modify this synchronized user, enable local change persistence. Otherwise, your changes will be overwritten during the next synchronization.",
"confirm-delete": "Are you sure you want to delete user '{id}'?"
}, },
"interface-view": { "interface-view": {
"headline": "Config for Interface:" "headline": "Config for Interface:"
@@ -503,8 +504,8 @@
"placeholder": "Persistent Keepalive (0 = default)" "placeholder": "Persistent Keepalive (0 = default)"
} }
}, },
"button-apply-defaults": "Apply Peer Defaults",
"button-apply-defaults": "Apply Peer Defaults" "confirm-delete": "Are you sure you want to delete interface '{id}'?"
}, },
"peer-view": { "peer-view": {
"headline-peer": "Peer:", "headline-peer": "Peer:",
@@ -626,7 +627,8 @@
}, },
"expires-at": { "expires-at": {
"label": "Expiry date" "label": "Expiry date"
} },
"confirm-delete": "Are you sure you want to delete peer '{id}'?"
}, },
"peer-multi-create": { "peer-multi-create": {
"headline-peer": "Create multiple peers", "headline-peer": "Create multiple peers",

File diff suppressed because it is too large Load Diff

View File

@@ -126,9 +126,7 @@
"peer-expiring": "Le pair expire le", "peer-expiring": "Le pair expire le",
"peer-connected": "Connecté", "peer-connected": "Connecté",
"peer-not-connected": "Non connecté", "peer-not-connected": "Non connecté",
"peer-handshake": "Dernière négociation :", "peer-handshake": "Dernière négociation :"
"button-show-peer": "Afficher le pair",
"button-edit-peer": "Modifier le pair"
}, },
"users": { "users": {
"headline": "Administration des utilisateurs", "headline": "Administration des utilisateurs",
@@ -264,7 +262,8 @@
}, },
"admin": { "admin": {
"label": "Est Admin" "label": "Est Admin"
} },
"confirm-delete": "Voulez-vous vraiment supprimer l'utilisateur \"{id}\" ?"
}, },
"interface-view": { "interface-view": {
"headline": "Configuration pour l'interface :" "headline": "Configuration pour l'interface :"
@@ -377,7 +376,8 @@
"placeholder": "Persistent Keepalive (0 = par défaut)" "placeholder": "Persistent Keepalive (0 = par défaut)"
} }
}, },
"button-apply-defaults": "Appliquer les valeurs par défaut des pairs" "button-apply-defaults": "Appliquer les valeurs par défaut des pairs",
"confirm-delete": "Voulez-vous vraiment supprimer l'interface \"{id}\" ?"
}, },
"peer-view": { "peer-view": {
"headline-peer": "Pair :", "headline-peer": "Pair :",
@@ -493,7 +493,8 @@
}, },
"expires-at": { "expires-at": {
"label": "Date d'expiration" "label": "Date d'expiration"
} },
"confirm-delete": "Voulez-vous vraiment supprimer le pair \"{id}\" ?"
}, },
"peer-multi-create": { "peer-multi-create": {
"headline-peer": "Créer plusieurs pairs", "headline-peer": "Créer plusieurs pairs",

View File

@@ -282,6 +282,7 @@
"label": "관리자 여부" "label": "관리자 여부"
} }
}, },
"confirm-delete": "사용자 '{id}'를 삭제하시겠습니까?",
"interface-view": { "interface-view": {
"headline": "인터페이스 구성:" "headline": "인터페이스 구성:"
}, },
@@ -393,7 +394,8 @@
"placeholder": "영구 Keepalive (0 = 기본값)" "placeholder": "영구 Keepalive (0 = 기본값)"
} }
}, },
"button-apply-defaults": "피어 기본값 적용" "button-apply-defaults": "피어 기본값 적용",
"confirm-delete": "인터페이스 '{id}'를 삭제하시겠습니까?"
}, },
"peer-view": { "peer-view": {
"headline-peer": "피어:", "headline-peer": "피어:",
@@ -509,7 +511,8 @@
}, },
"expires-at": { "expires-at": {
"label": "만료 날짜" "label": "만료 날짜"
} },
"confirm-delete": "피어 '{id}'를 삭제하시겠습니까?"
}, },
"peer-multi-create": { "peer-multi-create": {
"headline-peer": "여러 피어 생성", "headline-peer": "여러 피어 생성",

View File

@@ -300,7 +300,8 @@
}, },
"admin": { "admin": {
"label": "É Administrador" "label": "É Administrador"
} },
"confirm-delete": "Tem certeza que deseja excluir o utilizador '{id}'?"
}, },
"interface-view": { "interface-view": {
"headline": "Configuração para a Interface:" "headline": "Configuração para a Interface:"
@@ -413,7 +414,8 @@
"placeholder": "Keepalive persistente (0 = padrão)" "placeholder": "Keepalive persistente (0 = padrão)"
} }
}, },
"button-apply-defaults": "Aplicar Padrões de Peer" "button-apply-defaults": "Aplicar Padrões de Peer",
"confirm-delete": "Tem certeza que deseja excluir a interface '{id}'?"
}, },
"peer-view": { "peer-view": {
"headline-peer": "Peer:", "headline-peer": "Peer:",
@@ -530,7 +532,8 @@
}, },
"expires-at": { "expires-at": {
"label": "Data de expiração" "label": "Data de expiração"
} },
"confirm-delete": "Tem certeza que deseja excluir o par '{id}'?"
}, },
"peer-multi-create": { "peer-multi-create": {
"headline-peer": "Criar múltiplos peers", "headline-peer": "Criar múltiplos peers",

View File

@@ -259,16 +259,16 @@
"headline-preshared-key": "Новый общий ключ", "headline-preshared-key": "Новый общий ключ",
"button-generate": "Генерировать", "button-generate": "Генерировать",
"private-key": { "private-key": {
"label": "Приватный ключ", "label": "Приватный ключ",
"placeholder": "Приватный ключ" "placeholder": "Приватный ключ"
}, },
"public-key": { "public-key": {
"label": "Публичный ключ", "label": "Публичный ключ",
"placeholder": "Публичный ключ" "placeholder": "Публичный ключ"
}, },
"preshared-key": { "preshared-key": {
"label": "Общий ключ", "label": "Общий ключ",
"placeholder": "Общий ключ" "placeholder": "Общий ключ"
} }
}, },
"calculator": { "calculator": {
@@ -277,18 +277,18 @@
"headline-allowed-ip": "Новые разрешенные IP-адреса", "headline-allowed-ip": "Новые разрешенные IP-адреса",
"button-exclude-private": "Исключить частные диапазоны IP-адресов", "button-exclude-private": "Исключить частные диапазоны IP-адресов",
"allowed-ip": { "allowed-ip": {
"label": "Разрешенные IP-адреса", "label": "Разрешенные IP-адреса",
"placeholder": "0.0.0.0/0, ::/0", "placeholder": "0.0.0.0/0, ::/0",
"empty": "Поле ввода не должно быть пустым" "empty": "Поле ввода не должно быть пустым"
}, },
"dissallowed-ip": { "dissallowed-ip": {
"label": "Запрещенные IP-адреса", "label": "Запрещенные IP-адреса",
"placeholder": "10.0.0.0/8, 192.168.0.0/16", "placeholder": "10.0.0.0/8, 192.168.0.0/16",
"invalid": "Некорректный адрес: {addr}" "invalid": "Некорректный адрес: {addr}"
}, },
"new-allowed-ip": { "new-allowed-ip": {
"label": "Разрешенные IP-адреса", "label": "Разрешенные IP-адреса",
"placeholder": "" "placeholder": ""
} }
}, },
"modals": { "modals": {
@@ -366,7 +366,8 @@
}, },
"admin": { "admin": {
"label": "Является администратором" "label": "Является администратором"
} },
"confirm-delete": "Вы уверены, что хотите удалить пользователя «{id}»?"
}, },
"interface-view": { "interface-view": {
"headline": "Конфигурация интерфейса:" "headline": "Конфигурация интерфейса:"
@@ -484,7 +485,8 @@
"placeholder": "Постоянное поддержание активности (0 = значение по умолчанию)" "placeholder": "Постоянное поддержание активности (0 = значение по умолчанию)"
} }
}, },
"button-apply-defaults": "Применить настройки пира по умолчанию" "button-apply-defaults": "Применить настройки пира по умолчанию",
"confirm-delete": "Вы уверены, что хотите удалить интерфейс «{id}»?"
}, },
"peer-view": { "peer-view": {
"headline-peer": "Пир:", "headline-peer": "Пир:",
@@ -605,7 +607,8 @@
}, },
"expires-at": { "expires-at": {
"label": "Дата истечения срока действия" "label": "Дата истечения срока действия"
} },
"confirm-delete": "Вы уверены, что хотите удалить пир «{id}»?"
}, },
"peer-multi-create": { "peer-multi-create": {
"headline-peer": "Создать несколько узлов", "headline-peer": "Создать несколько узлов",

View File

@@ -151,7 +151,6 @@
"admin": "Користувач має адміністративні привілеї", "admin": "Користувач має адміністративні привілеї",
"no-admin": "Користувач не має адміністративних привілеїв" "no-admin": "Користувач не має адміністративних привілеїв"
}, },
"profile": { "profile": {
"headline": "Мої VPN-піри", "headline": "Мої VPN-піри",
"table-heading": { "table-heading": {
@@ -189,7 +188,6 @@
"api-link": "Документація API" "api-link": "Документація API"
} }
}, },
"modals": { "modals": {
"user-view": { "user-view": {
"headline": "Обліковий запис користувача:", "headline": "Обліковий запис користувача:",
@@ -264,7 +262,8 @@
}, },
"admin": { "admin": {
"label": "Адміністратор" "label": "Адміністратор"
} },
"confirm-delete": "Ви впевнені, що хочете видалити користувача «{id}»?"
}, },
"interface-view": { "interface-view": {
"headline": "Конфігурація для інтерфейсу:" "headline": "Конфігурація для інтерфейсу:"
@@ -377,7 +376,8 @@
"placeholder": "Постійний Keepalive (0 = за замовчуванням)" "placeholder": "Постійний Keepalive (0 = за замовчуванням)"
} }
}, },
"button-apply-defaults": "Застосувати значення за замовчуванням для пірів" "button-apply-defaults": "Застосувати значення за замовчуванням для пірів",
"confirm-delete": "Ви впевнені, що хочете видалити інтерфейс «{id}»?"
}, },
"peer-view": { "peer-view": {
"headline-peer": "Пір:", "headline-peer": "Пір:",
@@ -493,7 +493,8 @@
}, },
"expires-at": { "expires-at": {
"label": "Дата закінчення терміну дії" "label": "Дата закінчення терміну дії"
} },
"confirm-delete": "Ви впевнені, що хочете видалити пір «{id}»?"
}, },
"peer-multi-create": { "peer-multi-create": {
"headline-peer": "Створити декілька пір", "headline-peer": "Створити декілька пір",

View File

@@ -240,7 +240,8 @@
}, },
"admin": { "admin": {
"label": "Là Quản trị viên" "label": "Là Quản trị viên"
} },
"confirm-delete": "Ban co chac muon xoa nguoi dung '{id}' khong?"
}, },
"interface-view": { "interface-view": {
"headline": "Cấu hình cho Giao diện:" "headline": "Cấu hình cho Giao diện:"
@@ -353,8 +354,8 @@
"placeholder": "Giữ kết nối liên tục (0 = mặc định)" "placeholder": "Giữ kết nối liên tục (0 = mặc định)"
} }
}, },
"button-apply-defaults": "Áp dụng Cài đặt Mặc định của Peer",
"button-apply-defaults": "Áp dụng Cài đặt Mặc định của Peer" "confirm-delete": "Ban co chac muon xoa giao dien '{id}' khong?"
}, },
"peer-view": { "peer-view": {
"headline-peer": "Peer:", "headline-peer": "Peer:",
@@ -470,7 +471,8 @@
}, },
"expires-at": { "expires-at": {
"label": "Ngày hết hạn" "label": "Ngày hết hạn"
} },
"confirm-delete": "Ban co chac muon xoa peer '{id}' khong?"
}, },
"peer-multi-create": { "peer-multi-create": {
"headline-peer": "Tạo nhiều peer", "headline-peer": "Tạo nhiều peer",

View File

@@ -242,6 +242,7 @@
"label": "管理员" "label": "管理员"
} }
}, },
"confirm-delete": "确定要删除用户“{id}”吗?",
"interface-view": { "interface-view": {
"headline": "接口配置: " "headline": "接口配置: "
}, },
@@ -353,7 +354,8 @@
"placeholder": "持久保持连接 (0 = 默认)" "placeholder": "持久保持连接 (0 = 默认)"
} }
}, },
"button-apply-defaults": "应用节点默认值" "button-apply-defaults": "应用节点默认值",
"confirm-delete": "确定要删除接口“{id}”吗?"
}, },
"peer-view": { "peer-view": {
"headline-peer": "节点: ", "headline-peer": "节点: ",
@@ -469,7 +471,8 @@
}, },
"expires-at": { "expires-at": {
"label": "过期日期" "label": "过期日期"
} },
"confirm-delete": "确定要删除对等点“{id}”吗?"
}, },
"peer-multi-create": { "peer-multi-create": {
"headline-peer": "创建多个节点", "headline-peer": "创建多个节点",

View File

@@ -1,7 +1,6 @@
import {createRouter, createWebHashHistory} from 'vue-router' import {createRouter, createWebHashHistory} from 'vue-router'
import HomeView from '../views/HomeView.vue' import HomeView from '../views/HomeView.vue'
import LoginView from '../views/LoginView.vue' import LoginView from '../views/LoginView.vue'
import InterfaceView from '../views/InterfaceView.vue'
import {authStore} from '@/stores/auth' import {authStore} from '@/stores/auth'
import {securityStore} from '@/stores/security' import {securityStore} from '@/stores/security'
@@ -20,11 +19,6 @@ const router = createRouter({
name: 'login', name: 'login',
component: LoginView component: LoginView
}, },
{
path: '/interface',
name: 'interface',
component: InterfaceView
},
{ {
path: '/interfaces', path: '/interfaces',
name: 'interfaces', name: 'interfaces',

View File

@@ -232,21 +232,19 @@ func (r *SqlRepo) migrate() error {
slog.Debug("running migration: interface status", "result", r.db.AutoMigrate(&domain.InterfaceStatus{})) slog.Debug("running migration: interface status", "result", r.db.AutoMigrate(&domain.InterfaceStatus{}))
slog.Debug("running migration: audit data", "result", r.db.AutoMigrate(&domain.AuditEntry{})) slog.Debug("running migration: audit data", "result", r.db.AutoMigrate(&domain.AuditEntry{}))
existingSysStat := SysStat{} var existingSysStat SysStat
var err error
r.db.Order("schema_version desc").First(&existingSysStat) // get latest version r.db.Order("schema_version desc").First(&existingSysStat) // get latest version
// Migration: 0 --> 1 // Migration: 0 --> 1
if existingSysStat.SchemaVersion == 0 { if existingSysStat.SchemaVersion == 0 {
const schemaVersion = 1 const schemaVersion = 1
sysStat := SysStat{ existingSysStat, err = r.addMigration(schemaVersion) // ensure that follow-up checks test against the latest version
MigratedAt: time.Now(), if err != nil {
SchemaVersion: schemaVersion, return err
}
if err := r.db.Create(&sysStat).Error; err != nil {
return fmt.Errorf("failed to write sysstat entry for schema version %d: %w", schemaVersion, err)
} }
slog.Debug("sys-stat entry written", "schema_version", schemaVersion) slog.Debug("sys-stat entry written", "schema_version", schemaVersion)
existingSysStat = sysStat // ensure that follow-up checks test against the latest version
} }
// Migration: 1 --> 2 // Migration: 1 --> 2
@@ -262,14 +260,10 @@ func (r *SqlRepo) migrate() error {
} }
slog.Debug("migrated interface create_default_peer flags", "schema_version", schemaVersion) slog.Debug("migrated interface create_default_peer flags", "schema_version", schemaVersion)
} }
sysStat := SysStat{ existingSysStat, err = r.addMigration(schemaVersion) // ensure that follow-up checks test against the latest version
MigratedAt: time.Now(), if err != nil {
SchemaVersion: schemaVersion, return err
} }
if err := r.db.Create(&sysStat).Error; err != nil {
return fmt.Errorf("failed to write sysstat entry for schema version %d: %w", schemaVersion, err)
}
existingSysStat = sysStat // ensure that follow-up checks test against the latest version
} }
// Migration: 2 --> 3 // Migration: 2 --> 3
@@ -307,19 +301,45 @@ func (r *SqlRepo) migrate() error {
if err != nil { if err != nil {
return fmt.Errorf("failed to migrate to multi-auth: %w", err) return fmt.Errorf("failed to migrate to multi-auth: %w", err)
} }
sysStat := SysStat{ existingSysStat, err = r.addMigration(schemaVersion) // ensure that follow-up checks test against the latest version
MigratedAt: time.Now(), if err != nil {
SchemaVersion: schemaVersion, return err
} }
if err := r.db.Create(&sysStat).Error; err != nil { }
return fmt.Errorf("failed to write sysstat entry for schema version %d: %w", schemaVersion, err)
// Migration: 3 --> 4
if existingSysStat.SchemaVersion == 3 {
const schemaVersion = 4
cutoff := time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC)
// Fix zero created_at timestamps for users. Set the to the last known update timestamp.
err := r.db.Model(&domain.User{}).Where("created_at < ?", cutoff).
Update("created_at", gorm.Expr("updated_at")).Error
if err != nil {
slog.Warn("failed to fix zero created_at for users", "error", err)
}
slog.Debug("fixed zero created_at timestamps for users", "schema_version", schemaVersion)
existingSysStat, err = r.addMigration(schemaVersion) // ensure that follow-up checks test against the latest version
if err != nil {
return err
} }
existingSysStat = sysStat // ensure that follow-up checks test against the latest version
} }
return nil return nil
} }
func (r *SqlRepo) addMigration(schemaVersion uint64) (SysStat, error) {
sysStat := SysStat{
MigratedAt: time.Now(),
SchemaVersion: schemaVersion,
}
if err := r.db.Create(&sysStat).Error; err != nil {
return SysStat{}, fmt.Errorf("failed to write sysstat entry for schema version %d: %w", schemaVersion, err)
}
return sysStat, nil
}
// region interfaces // region interfaces
// GetInterface returns the interface with the given id. // GetInterface returns the interface with the given id.
@@ -482,7 +502,7 @@ func (r *SqlRepo) getOrCreateInterface(
Identifier: id, Identifier: id,
} }
err := tx.Attrs(interfaceDefaults).FirstOrCreate(&in, id).Error err := tx.Preload("Addresses").Attrs(interfaceDefaults).FirstOrCreate(&in, id).Error
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -691,7 +711,7 @@ func (r *SqlRepo) getOrCreatePeer(ui *domain.ContextUserInfo, tx *gorm.DB, id do
Identifier: id, Identifier: id,
} }
err := tx.Attrs(interfaceDefaults).FirstOrCreate(&peer, id).Error err := tx.Preload("Addresses").Attrs(interfaceDefaults).FirstOrCreate(&peer, id).Error
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@@ -0,0 +1,168 @@
package adapters
import (
"context"
"testing"
"time"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"github.com/h44z/wg-portal/internal/config"
"github.com/h44z/wg-portal/internal/domain"
)
func newTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
require.NoError(t, err)
return db
}
func TestUpsertUser_SetsCreatedAtWhenZero(t *testing.T) {
db := newTestDB(t)
require.NoError(t, db.AutoMigrate(&domain.User{}, &domain.UserAuthentication{}, &domain.UserWebauthnCredential{}))
repo := &SqlRepo{db: db, cfg: &config.Config{}}
ui := domain.SystemAdminContextUserInfo()
user := &domain.User{
Identifier: "test-user",
Email: "test@example.com",
// CreatedAt is zero
}
err := repo.upsertUser(ui, db, user)
require.NoError(t, err)
assert.False(t, user.CreatedAt.IsZero(), "CreatedAt should be set when it was zero")
assert.Equal(t, ui.UserId(), user.UpdatedBy, "UpdatedBy should be set when it was empty")
assert.WithinDuration(t, user.UpdatedAt, user.CreatedAt, time.Second,
"CreatedAt should be close to UpdatedAt for new user")
}
func TestUpsertUser_PreservesExistingCreatedAt(t *testing.T) {
db := newTestDB(t)
require.NoError(t, db.AutoMigrate(&domain.User{}, &domain.UserAuthentication{}, &domain.UserWebauthnCredential{}))
repo := &SqlRepo{db: db, cfg: &config.Config{}}
ui := domain.SystemAdminContextUserInfo()
originalTime := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC)
user := &domain.User{
Identifier: "test-user",
Email: "test@example.com",
BaseModel: domain.BaseModel{
CreatedAt: originalTime,
CreatedBy: "original-creator",
},
}
err := repo.upsertUser(ui, db, user)
require.NoError(t, err)
assert.Equal(t, originalTime, user.CreatedAt, "CreatedAt should not be overwritten")
assert.Equal(t, "original-creator", user.CreatedBy, "CreatedBy should not be overwritten")
}
func TestSaveUser_NewUserGetsCreatedAt(t *testing.T) {
db := newTestDB(t)
require.NoError(t, db.AutoMigrate(&domain.User{}, &domain.UserAuthentication{}, &domain.UserWebauthnCredential{}))
repo := &SqlRepo{db: db, cfg: &config.Config{}}
ctx := domain.SetUserInfo(context.Background(), domain.SystemAdminContextUserInfo())
before := time.Now().Add(-time.Second)
err := repo.SaveUser(ctx, "new-user", func(u *domain.User) (*domain.User, error) {
u.Email = "new@example.com"
return u, nil
})
require.NoError(t, err)
var saved domain.User
require.NoError(t, db.First(&saved, "identifier = ?", "new-user").Error)
assert.False(t, saved.CreatedAt.IsZero(), "CreatedAt should not be zero")
assert.True(t, saved.CreatedAt.After(before), "CreatedAt should be recent")
assert.NotEmpty(t, saved.CreatedBy, "CreatedBy should be set")
}
func TestMigration_FixesZeroCreatedAt(t *testing.T) {
db := newTestDB(t)
// Manually create tables and seed schema version 3
require.NoError(t, db.AutoMigrate(
&SysStat{},
&domain.User{},
&domain.UserAuthentication{},
&domain.Interface{},
&domain.Cidr{},
&domain.Peer{},
&domain.AuditEntry{},
&domain.UserWebauthnCredential{},
))
// Insert schema versions 1, 2, 3 so migration starts at 3
for v := uint64(1); v <= 3; v++ {
require.NoError(t, db.Create(&SysStat{SchemaVersion: v, MigratedAt: time.Now()}).Error)
}
updatedAt := time.Date(2025, 6, 15, 10, 0, 0, 0, time.UTC)
// Insert a user with zero created_at but valid updated_at
require.NoError(t, db.Exec(
"INSERT INTO users (identifier, email, created_at, updated_at) VALUES (?, ?, ?, ?)",
"zero-user", "zero@example.com", time.Time{}, updatedAt,
).Error)
// Run migration
repo := &SqlRepo{db: db, cfg: &config.Config{}}
require.NoError(t, repo.migrate())
// Verify created_at was backfilled from updated_at
var user domain.User
require.NoError(t, db.First(&user, "identifier = ?", "zero-user").Error)
assert.Equal(t, updatedAt, user.CreatedAt, "created_at should be backfilled from updated_at")
// Verify schema version advanced to 4
var latest SysStat
require.NoError(t, db.Order("schema_version DESC").First(&latest).Error)
assert.Equal(t, uint64(4), latest.SchemaVersion)
}
func TestMigration_DoesNotTouchValidCreatedAt(t *testing.T) {
db := newTestDB(t)
require.NoError(t, db.AutoMigrate(
&SysStat{},
&domain.User{},
&domain.UserAuthentication{},
&domain.Interface{},
&domain.Cidr{},
&domain.Peer{},
&domain.AuditEntry{},
&domain.UserWebauthnCredential{},
))
for v := uint64(1); v <= 3; v++ {
require.NoError(t, db.Create(&SysStat{SchemaVersion: v, MigratedAt: time.Now()}).Error)
}
createdAt := time.Date(2024, 3, 1, 8, 0, 0, 0, time.UTC)
updatedAt := time.Date(2025, 6, 15, 10, 0, 0, 0, time.UTC)
require.NoError(t, db.Exec(
"INSERT INTO users (identifier, email, created_at, updated_at) VALUES (?, ?, ?, ?)",
"valid-user", "valid@example.com", createdAt, updatedAt,
).Error)
repo := &SqlRepo{db: db, cfg: &config.Config{}}
require.NoError(t, repo.migrate())
var user domain.User
require.NoError(t, db.First(&user, "identifier = ?", "valid-user").Error)
assert.Equal(t, createdAt, user.CreatedAt, "valid created_at should not be modified")
}

View File

@@ -0,0 +1,73 @@
package adapters
import (
"context"
"reflect"
"testing"
"github.com/glebarez/sqlite"
"github.com/h44z/wg-portal/internal/config"
"github.com/h44z/wg-portal/internal/domain"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"gorm.io/gorm/schema"
)
func init() {
schema.RegisterSerializer("encstr", dummySerializer{})
}
type dummySerializer struct{}
func (dummySerializer) Scan(ctx context.Context, field *schema.Field, dst reflect.Value, dbValue any) error {
return nil
}
func (dummySerializer) Value(ctx context.Context, field *schema.Field, dst reflect.Value, fieldValue any) (any, error) {
if fieldValue == nil {
return nil, nil
}
if v, ok := fieldValue.(string); ok {
return v, nil
}
if v, ok := fieldValue.(domain.PreSharedKey); ok {
return string(v), nil
}
return fieldValue, nil
}
func TestSqlRepo_SaveInterface_Simple(t *testing.T) {
// Initialize in-memory database
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
require.NoError(t, err)
// Migrate only what's needed for this test (avoids Peer and its encstr serializer)
require.NoError(t, db.AutoMigrate(&domain.Interface{}, &domain.Cidr{}))
repo := &SqlRepo{db: db, cfg: &config.Config{}}
ctx := domain.SetUserInfo(context.Background(), domain.SystemAdminContextUserInfo())
ifaceId := domain.InterfaceIdentifier("wg0")
// 1. Create an interface with one address
addr, _ := domain.CidrFromString("10.0.0.1/24")
initialIface := &domain.Interface{
Identifier: ifaceId,
Addresses: []domain.Cidr{addr},
}
require.NoError(t, db.Create(initialIface).Error)
// 2. Perform a "partial" update using SaveInterface (this is the buggy path)
err = repo.SaveInterface(ctx, ifaceId, func(in *domain.Interface) (*domain.Interface, error) {
in.DisplayName = "New Name"
return in, nil
})
require.NoError(t, err)
// 3. Verify that the address was NOT deleted
var finalIface domain.Interface
require.NoError(t, db.Preload("Addresses").First(&finalIface, "identifier = ?", ifaceId).Error)
require.Equal(t, "New Name", finalIface.DisplayName)
require.Len(t, finalIface.Addresses, 1, "Address list should still have 1 entry!")
require.Equal(t, "10.0.0.1/24", finalIface.Addresses[0].Cidr)
}

View File

@@ -533,6 +533,7 @@ func (m Manager) create(ctx context.Context, user *domain.User) (*domain.User, e
} }
err = m.users.SaveUser(ctx, user.Identifier, func(u *domain.User) (*domain.User, error) { err = m.users.SaveUser(ctx, user.Identifier, func(u *domain.User) (*domain.User, error) {
user.CopyCalculatedAttributes(u, false)
return user, nil return user, nil
}) })
if err != nil { if err != nil {

View File

@@ -68,7 +68,7 @@ type User struct {
WebAuthnCredentialList []UserWebauthnCredential `gorm:"foreignKey:user_identifier"` // the webauthn credentials of the user, used for webauthn authentication WebAuthnCredentialList []UserWebauthnCredential `gorm:"foreignKey:user_identifier"` // the webauthn credentials of the user, used for webauthn authentication
// API token for REST API access // API token for REST API access
ApiToken string `form:"api_token" binding:"omitempty"` ApiToken string `form:"api_token" binding:"omitempty" gorm:"serializer:encstr"`
ApiTokenCreated *time.Time ApiTokenCreated *time.Time
LinkedPeerCount int `gorm:"-"` LinkedPeerCount int `gorm:"-"`