Compare commits

..

2 Commits

Author SHA1 Message Date
Christoph Haas
a399613b85 fix broken translation files 2026-03-30 23:36:58 +02:00
LeC-D
4d5152532c 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>
2026-03-30 13:19:13 -04:00
19 changed files with 1362 additions and 1761 deletions

View File

@@ -1,8 +1,7 @@
# 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
@@ -52,11 +51,6 @@ 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" vite-ignore></script> <script src="/api/v0/config/frontend.js"></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.2.0", "@fortawesome/fontawesome-free": "^7.1.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.3.0", "@simplewebauthn/browser": "^13.2.2",
"@vojtechlanka/vue-tags-input": "^3.1.2", "@vojtechlanka/vue-tags-input": "^3.1.1",
"bootstrap": "^5.3.8", "bootstrap": "^5.3.8",
"bootswatch": "^5.3.8", "bootswatch": "^5.3.8",
"cidr-tools": "^11.3.2", "cidr-tools": "^11.0.3",
"flag-icons": "^7.5.0", "flag-icons": "^7.5.0",
"ip-address": "^10.1.0", "ip-address": "^10.1.0",
"is-cidr": "^6.0.3", "is-cidr": "^6.0.1",
"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.31", "vue": "^3.5.25",
"vue-i18n": "^11.3.0", "vue-i18n": "^11.2.2",
"vue-prism-component": "github:h44z/vue-prism-component", "vue-prism-component": "github:h44z/vue-prism-component",
"vue-router": "^5.0.4" "vue-router": "^4.6.3"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^6.0.5", "@vitejs/plugin-vue": "^6.0.2",
"sass-embedded": "^1.98.0", "sass-embedded": "^1.93.3",
"vite": "^8.0.3" "vite": "^7.2.7"
} }
} }

View File

@@ -26,13 +26,13 @@
display:block; display:block;
} }
.modal.show { .modal.show {
opacity: 1.0; opacity: 1;
} }
.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.0 !important; opacity: 1 !important;
} }
</style> </style>

View File

@@ -1,121 +0,0 @@
<script setup>
import { computed } from 'vue';
const props = defineProps({
currentPage: {
type: Number,
required: true
},
totalCount: {
type: Number,
required: true
},
pageSize: {
type: Number,
required: true
},
onGotoPage: {
type: Function,
required: true
},
onNextPage: {
type: Function,
required: true
},
onPrevPage: {
type: Function,
required: true
},
hasNextPage: {
type: Boolean,
required: true
},
hasPrevPage: {
type: Boolean,
required: true
}
});
const totalPages = computed(() => Math.ceil(props.totalCount / props.pageSize));
const pages = computed(() => {
const current = props.currentPage;
const last = totalPages.value;
const delta = 2; // Number of pages to show before and after current page
const range = [];
const rangeWithDots = [];
// If total pages is small, just show all pages
if (last <= 7) {
for (let i = 1; i <= last; i++) {
rangeWithDots.push({ type: 'page', value: i });
}
return rangeWithDots;
}
// Calculate the range around the current page
let start = Math.max(2, current - delta);
let end = Math.min(last - 1, current + delta);
// Adjust range to always show a consistent number of pages if possible
if (current <= delta + 2) {
end = 2 + delta * 2;
} else if (current >= last - delta - 1) {
start = last - delta * 2 - 1;
}
// Add dots before the range if needed
if (start > 2) {
rangeWithDots.push({ type: 'page', value: 1 });
rangeWithDots.push({ type: 'dots', value: 'dots-start' });
} else {
for (let i = 1; i < start; i++) {
rangeWithDots.push({ type: 'page', value: i });
}
}
// Add the central range
for (let i = start; i <= end; i++) {
rangeWithDots.push({ type: 'page', value: i });
}
// Add dots after the range if needed
if (end < last - 1) {
rangeWithDots.push({ type: 'dots', value: 'dots-end' });
rangeWithDots.push({ type: 'page', value: last });
} else {
for (let i = end + 1; i <= last; i++) {
rangeWithDots.push({ type: 'page', value: i });
}
}
return rangeWithDots;
});
</script>
<template>
<ul class="pagination pagination-sm mb-0" v-if="totalPages > 1">
<li :class="{ disabled: !hasPrevPage }" class="page-item">
<a class="page-link" href="#" @click.prevent="hasPrevPage && onPrevPage()">&laquo;</a>
</li>
<li v-for="item in pages" :key="item.type === 'page' ? item.value : item.value" :class="{ active: currentPage === item.value, disabled: item.type === 'dots' }" class="page-item">
<a v-if="item.type === 'page'" class="page-link" href="#" @click.prevent="onGotoPage(item.value)">{{ item.value }}</a>
<span v-else class="page-link">...</span>
</li>
<li :class="{ disabled: !hasNextPage }" class="page-item">
<a class="page-link" href="#" @click.prevent="hasNextPage && onNextPage()">&raquo;</a>
</li>
</ul>
</template>
<style scoped>
.page-link {
cursor: pointer;
}
.page-item.disabled .page-link {
cursor: default;
}
</style>

View File

@@ -1,6 +1,7 @@
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'
@@ -19,6 +20,11 @@ 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

@@ -11,6 +11,7 @@ export const auditStore = defineStore('audit', {
filter: "", filter: "",
pageSize: 10, pageSize: 10,
pageOffset: 0, pageOffset: 0,
pages: [],
fetching: false, fetching: false,
}), }),
getters: { getters: {
@@ -40,22 +41,33 @@ export const auditStore = defineStore('audit', {
afterPageSizeChange() { afterPageSizeChange() {
// reset pageOffset to avoid problems with new page sizes // reset pageOffset to avoid problems with new page sizes
this.pageOffset = 0 this.pageOffset = 0
this.calculatePages()
},
calculatePages() {
let pageCounter = 1;
this.pages = []
for (let i = 0; i < this.FilteredCount; i+=this.pageSize) {
this.pages.push(pageCounter++)
}
}, },
gotoPage(page) { gotoPage(page) {
this.pageOffset = (page-1) * this.pageSize this.pageOffset = (page-1) * this.pageSize
this.calculatePages()
}, },
nextPage() { nextPage() {
if (this.hasNextPage) {
this.pageOffset += this.pageSize this.pageOffset += this.pageSize
}
this.calculatePages()
}, },
previousPage() { previousPage() {
if (this.hasPrevPage) {
this.pageOffset -= this.pageSize this.pageOffset -= this.pageSize
}
this.calculatePages()
}, },
setEntries(entries) { setEntries(entries) {
this.entries = entries this.entries = entries
this.calculatePages()
this.fetching = false this.fetching = false
}, },
async LoadEntries() { async LoadEntries() {

View File

@@ -19,6 +19,7 @@ export const peerStore = defineStore('peers', {
filter: "", filter: "",
pageSize: 10, pageSize: 10,
pageOffset: 0, pageOffset: 0,
pages: [],
fetching: false, fetching: false,
sortKey: 'IsConnected', // Default sort key sortKey: 'IsConnected', // Default sort key
sortOrder: -1, // 1 for ascending, -1 for descending sortOrder: -1, // 1 for ascending, -1 for descending
@@ -86,22 +87,33 @@ export const peerStore = defineStore('peers', {
afterPageSizeChange() { afterPageSizeChange() {
// reset pageOffset to avoid problems with new page sizes // reset pageOffset to avoid problems with new page sizes
this.pageOffset = 0 this.pageOffset = 0
this.calculatePages()
},
calculatePages() {
let pageCounter = 1;
this.pages = []
for (let i = 0; i < this.FilteredCount; i+=this.pageSize) {
this.pages.push(pageCounter++)
}
}, },
gotoPage(page) { gotoPage(page) {
this.pageOffset = (page-1) * this.pageSize this.pageOffset = (page-1) * this.pageSize
this.calculatePages()
}, },
nextPage() { nextPage() {
if (this.hasNextPage) {
this.pageOffset += this.pageSize this.pageOffset += this.pageSize
}
this.calculatePages()
}, },
previousPage() { previousPage() {
if (this.hasPrevPage) {
this.pageOffset -= this.pageSize this.pageOffset -= this.pageSize
}
this.calculatePages()
}, },
setPeers(peers) { setPeers(peers) {
this.peers = peers this.peers = peers
this.calculatePages()
this.fetching = false this.fetching = false
this.trafficStats = {} this.trafficStats = {}
}, },

View File

@@ -20,6 +20,7 @@ export const profileStore = defineStore('profile', {
filter: "", filter: "",
pageSize: 10, pageSize: 10,
pageOffset: 0, pageOffset: 0,
pages: [],
fetching: false, fetching: false,
sortKey: 'IsConnected', // Default sort key sortKey: 'IsConnected', // Default sort key
sortOrder: -1, // 1 for ascending, -1 for descending sortOrder: -1, // 1 for ascending, -1 for descending
@@ -79,19 +80,29 @@ export const profileStore = defineStore('profile', {
afterPageSizeChange() { afterPageSizeChange() {
// reset pageOffset to avoid problems with new page sizes // reset pageOffset to avoid problems with new page sizes
this.pageOffset = 0 this.pageOffset = 0
this.calculatePages()
},
calculatePages() {
let pageCounter = 1;
this.pages = []
for (let i = 0; i < this.FilteredPeerCount; i+=this.pageSize) {
this.pages.push(pageCounter++)
}
}, },
gotoPage(page) { gotoPage(page) {
this.pageOffset = (page-1) * this.pageSize this.pageOffset = (page-1) * this.pageSize
this.calculatePages()
}, },
nextPage() { nextPage() {
if (this.hasNextPage) {
this.pageOffset += this.pageSize this.pageOffset += this.pageSize
}
this.calculatePages()
}, },
previousPage() { previousPage() {
if (this.hasPrevPage) {
this.pageOffset -= this.pageSize this.pageOffset -= this.pageSize
}
this.calculatePages()
}, },
setPeers(peers) { setPeers(peers) {
this.peers = peers this.peers = peers

View File

@@ -12,6 +12,7 @@ export const userStore = defineStore('users', {
filter: "", filter: "",
pageSize: 10, pageSize: 10,
pageOffset: 0, pageOffset: 0,
pages: [],
fetching: false, fetching: false,
}), }),
getters: { getters: {
@@ -42,22 +43,33 @@ export const userStore = defineStore('users', {
afterPageSizeChange() { afterPageSizeChange() {
// reset pageOffset to avoid problems with new page sizes // reset pageOffset to avoid problems with new page sizes
this.pageOffset = 0 this.pageOffset = 0
this.calculatePages()
},
calculatePages() {
let pageCounter = 1;
this.pages = []
for (let i = 0; i < this.FilteredCount; i+=this.pageSize) {
this.pages.push(pageCounter++)
}
}, },
gotoPage(page) { gotoPage(page) {
this.pageOffset = (page-1) * this.pageSize this.pageOffset = (page-1) * this.pageSize
this.calculatePages()
}, },
nextPage() { nextPage() {
if (this.hasNextPage) {
this.pageOffset += this.pageSize this.pageOffset += this.pageSize
}
this.calculatePages()
}, },
previousPage() { previousPage() {
if (this.hasPrevPage) {
this.pageOffset -= this.pageSize this.pageOffset -= this.pageSize
}
this.calculatePages()
}, },
setUsers(users) { setUsers(users) {
this.users = users this.users = users
this.calculatePages()
this.fetching = false this.fetching = false
}, },
setUserPeers(peers) { setUserPeers(peers) {

View File

@@ -1,7 +1,6 @@
<script setup> <script setup>
import { onMounted } from "vue"; import { onMounted } from "vue";
import {auditStore} from "@/stores/audit"; import {auditStore} from "@/stores/audit";
import Pagination from "@/components/Pagination.vue";
const audit = auditStore() const audit = auditStore()
@@ -61,24 +60,28 @@ onMounted(async () => {
</table> </table>
</div> </div>
<hr> <hr>
<div class="mt-3">
<div class="row"> <div class="row">
<div class="col-12 col-md-6"> <div class="col-6">
<Pagination <ul class="pagination pagination-sm">
:currentPage="audit.currentPage" <li :class="{disabled:audit.pageOffset===0}" class="page-item">
:totalCount="audit.FilteredCount" <a class="page-link" @click="audit.previousPage">&laquo;</a>
:pageSize="audit.pageSize" </li>
:hasNextPage="audit.hasNextPage"
:hasPrevPage="audit.hasPrevPage" <li v-for="page in audit.pages" :key="page" :class="{active:audit.currentPage===page}" class="page-item">
:onGotoPage="audit.gotoPage" <a class="page-link" @click="audit.gotoPage(page)">{{page}}</a>
:onNextPage="audit.nextPage" </li>
:onPrevPage="audit.previousPage"
/> <li :class="{disabled:!audit.hasNextPage}" class="page-item">
<a class="page-link" @click="audit.nextPage">&raquo;</a>
</li>
</ul>
</div> </div>
<div class="col-12 col-md-6"> <div class="col-6">
<div class="form-group row"> <div class="form-group row">
<label class="col-sm-6 col-form-label text-md-end" for="paginationSelector">{{ $t('general.pagination.size') }}:</label> <label class="col-sm-6 col-form-label text-end" for="paginationSelector">{{ $t('general.pagination.size') }}:</label>
<div class="col-sm-6"> <div class="col-sm-6">
<select id="paginationSelector" v-model.number="audit.pageSize" class="form-select" @change="audit.afterPageSizeChange()"> <select id="paginationSelector" v-model.number="audit.pageSize" class="form-select" @click="audit.afterPageSizeChange()">
<option value="10">10</option> <option value="10">10</option>
<option value="25">25</option> <option value="25">25</option>
<option value="50">50</option> <option value="50">50</option>
@@ -89,4 +92,5 @@ onMounted(async () => {
</div> </div>
</div> </div>
</div> </div>
</div>
</template> </template>

View File

@@ -1,10 +1,9 @@
<script setup> <script setup>
import PeerViewModal from "@/components/PeerViewModal.vue"; import PeerViewModal from "../components/PeerViewModal.vue";
import PeerEditModal from "@/components/PeerEditModal.vue"; import PeerEditModal from "../components/PeerEditModal.vue";
import PeerMultiCreateModal from "@/components/PeerMultiCreateModal.vue"; import PeerMultiCreateModal from "../components/PeerMultiCreateModal.vue";
import InterfaceEditModal from "@/components/InterfaceEditModal.vue"; import InterfaceEditModal from "../components/InterfaceEditModal.vue";
import InterfaceViewModal from "@/components/InterfaceViewModal.vue"; import InterfaceViewModal from "../components/InterfaceViewModal.vue";
import Pagination from "@/components/Pagination.vue";
import {computed, onMounted, ref} from "vue"; import {computed, onMounted, ref} from "vue";
import {peerStore} from "@/stores/peers"; import {peerStore} from "@/stores/peers";
@@ -483,23 +482,26 @@ onMounted(async () => {
<hr v-if="interfaces.Count!==0"> <hr v-if="interfaces.Count!==0">
<div v-if="interfaces.Count!==0" class="mt-3"> <div v-if="interfaces.Count!==0" class="mt-3">
<div class="row"> <div class="row">
<div class="col-12 col-md-6"> <div class="col-6">
<Pagination <ul class="pagination pagination-sm">
:currentPage="peers.currentPage" <li :class="{disabled:peers.pageOffset===0}" class="page-item">
:totalCount="peers.FilteredCount" <a class="page-link" @click="peers.previousPage">&laquo;</a>
:pageSize="peers.pageSize" </li>
:hasNextPage="peers.hasNextPage"
:hasPrevPage="peers.hasPrevPage" <li v-for="page in peers.pages" :key="page" :class="{active:peers.currentPage===page}" class="page-item">
:onGotoPage="peers.gotoPage" <a class="page-link" @click="peers.gotoPage(page)">{{page}}</a>
:onNextPage="peers.nextPage" </li>
:onPrevPage="peers.previousPage"
/> <li :class="{disabled:!peers.hasNextPage}" class="page-item">
<a class="page-link" @click="peers.nextPage">&raquo;</a>
</li>
</ul>
</div> </div>
<div class="col-12 col-md-6"> <div class="col-6">
<div class="form-group row"> <div class="form-group row">
<label class="col-sm-6 col-form-label text-md-end" for="paginationSelector">{{ $t('general.pagination.size') }}:</label> <label class="col-sm-6 col-form-label text-end" for="paginationSelector">{{ $t('general.pagination.size') }}:</label>
<div class="col-sm-6"> <div class="col-sm-6">
<select id="paginationSelector" v-model.number="peers.pageSize" class="form-select" @change="peers.afterPageSizeChange()"> <select id="paginationSelector" v-model.number="peers.pageSize" class="form-select" @click="peers.afterPageSizeChange()">
<option value="10">10</option> <option value="10">10</option>
<option value="25">25</option> <option value="25">25</option>
<option value="50">50</option> <option value="50">50</option>

View File

@@ -6,7 +6,6 @@ import { useI18n } from "vue-i18n";
import { profileStore } from "@/stores/profile"; import { profileStore } from "@/stores/profile";
import { peerStore } from "@/stores/peers"; import { peerStore } from "@/stores/peers";
import UserPeerEditModal from "@/components/UserPeerEditModal.vue"; import UserPeerEditModal from "@/components/UserPeerEditModal.vue";
import Pagination from "@/components/Pagination.vue";
import { settingsStore } from "@/stores/settings"; import { settingsStore } from "@/stores/settings";
import { humanFileSize } from "@/helpers/utils"; import { humanFileSize } from "@/helpers/utils";
@@ -67,6 +66,7 @@ onMounted(async () => {
await profile.LoadPeers() await profile.LoadPeers()
await profile.LoadStats() await profile.LoadStats()
await profile.LoadInterfaces() await profile.LoadInterfaces()
await profile.calculatePages(); // Forces to show initial page number
}) })
</script> </script>
@@ -185,25 +185,28 @@ onMounted(async () => {
<hr> <hr>
<div class="mt-3"> <div class="mt-3">
<div class="row"> <div class="row">
<div class="col-12 col-md-6"> <div class="col-6">
<Pagination <ul class="pagination pagination-sm">
:currentPage="profile.currentPage" <li :class="{ disabled: profile.pageOffset === 0 }" class="page-item">
:totalCount="profile.FilteredPeerCount" <a class="page-link" @click="profile.previousPage">&laquo;</a>
:pageSize="profile.pageSize" </li>
:hasNextPage="profile.hasNextPage"
:hasPrevPage="profile.hasPrevPage" <li v-for="page in profile.pages" :key="page" :class="{ active: profile.currentPage === page }" class="page-item">
:onGotoPage="profile.gotoPage" <a class="page-link" @click="profile.gotoPage(page)">{{ page }}</a>
:onNextPage="profile.nextPage" </li>
:onPrevPage="profile.previousPage"
/> <li :class="{ disabled: !profile.hasNextPage }" class="page-item">
<a class="page-link" @click="profile.nextPage">&raquo;</a>
</li>
</ul>
</div> </div>
<div class="col-12 col-md-6"> <div class="col-6">
<div class="form-group row"> <div class="form-group row">
<label class="col-sm-6 col-form-label text-md-end" for="paginationSelector"> <label class="col-sm-6 col-form-label text-end" for="paginationSelector">
{{ $t('general.pagination.size')}}: {{ $t('general.pagination.size')}}:
</label> </label>
<div class="col-sm-6"> <div class="col-sm-6">
<select id="paginationSelector" v-model.number="profile.pageSize" class="form-select" @change="profile.afterPageSizeChange()"> <select id="paginationSelector" v-model.number="profile.pageSize" class="form-select" @click="profile.afterPageSizeChange()">
<option value="10">10</option> <option value="10">10</option>
<option value="25">25</option> <option value="25">25</option>
<option value="50">50</option> <option value="50">50</option>

View File

@@ -1,9 +1,8 @@
<script setup> <script setup>
import {userStore} from "@/stores/users"; import {userStore} from "@/stores/users";
import {ref, onMounted, computed} from "vue"; import {ref, onMounted, computed} from "vue";
import UserEditModal from "@/components/UserEditModal.vue"; import UserEditModal from "../components/UserEditModal.vue";
import UserViewModal from "@/components/UserViewModal.vue"; import UserViewModal from "../components/UserViewModal.vue";
import Pagination from "@/components/Pagination.vue";
import {useI18n} from "vue-i18n"; import {useI18n} from "vue-i18n";
const users = userStore() const users = userStore()
@@ -166,24 +165,28 @@ onMounted(() => {
</table> </table>
</div> </div>
<hr> <hr>
<div class="mt-3">
<div class="row"> <div class="row">
<div class="col-12 col-md-6"> <div class="col-6">
<Pagination <ul class="pagination pagination-sm">
:currentPage="users.currentPage" <li :class="{disabled:users.pageOffset===0}" class="page-item">
:totalCount="users.FilteredCount" <a class="page-link" @click="users.previousPage">&laquo;</a>
:pageSize="users.pageSize" </li>
:hasNextPage="users.hasNextPage"
:hasPrevPage="users.hasPrevPage" <li v-for="page in users.pages" :key="page" :class="{active:users.currentPage===page}" class="page-item">
:onGotoPage="users.gotoPage" <a class="page-link" @click="users.gotoPage(page)">{{page}}</a>
:onNextPage="users.nextPage" </li>
:onPrevPage="users.previousPage"
/> <li :class="{disabled:!users.hasNextPage}" class="page-item">
<a class="page-link" @click="users.nextPage">&raquo;</a>
</li>
</ul>
</div> </div>
<div class="col-12 col-md-6"> <div class="col-6">
<div class="form-group row"> <div class="form-group row">
<label class="col-sm-6 col-form-label text-md-end" for="paginationSelector">{{ $t('general.pagination.size') }}:</label> <label class="col-sm-6 col-form-label text-end" for="paginationSelector">{{ $t('general.pagination.size') }}:</label>
<div class="col-sm-6"> <div class="col-sm-6">
<select id="paginationSelector" v-model.number="users.pageSize" class="form-select" @change="users.afterPageSizeChange()"> <select id="paginationSelector" v-model.number="users.pageSize" class="form-select" @click="users.afterPageSizeChange()">
<option value="10">10</option> <option value="10">10</option>
<option value="25">25</option> <option value="25">25</option>
<option value="50">50</option> <option value="50">50</option>
@@ -194,4 +197,5 @@ onMounted(() => {
</div> </div>
</div> </div>
</div> </div>
</div>
</template> </template>

View File

@@ -232,19 +232,21 @@ 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{}))
var existingSysStat SysStat 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
existingSysStat, err = r.addMigration(schemaVersion) // ensure that follow-up checks test against the latest version sysStat := SysStat{
if err != nil { MigratedAt: time.Now(),
return err SchemaVersion: schemaVersion,
}
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
@@ -260,10 +262,14 @@ 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)
} }
existingSysStat, err = r.addMigration(schemaVersion) // ensure that follow-up checks test against the latest version sysStat := SysStat{
if err != nil { MigratedAt: time.Now(),
return err SchemaVersion: schemaVersion,
} }
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
@@ -301,43 +307,17 @@ 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)
} }
existingSysStat, err = r.addMigration(schemaVersion) // ensure that follow-up checks test against the latest version
if err != nil {
return 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
}
}
return nil
}
func (r *SqlRepo) addMigration(schemaVersion uint64) (SysStat, error) {
sysStat := SysStat{ sysStat := SysStat{
MigratedAt: time.Now(), MigratedAt: time.Now(),
SchemaVersion: schemaVersion, SchemaVersion: schemaVersion,
} }
if err := r.db.Create(&sysStat).Error; err != nil { 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 fmt.Errorf("failed to write sysstat entry for schema version %d: %w", schemaVersion, err)
} }
return sysStat, nil existingSysStat = sysStat // ensure that follow-up checks test against the latest version
}
return nil
} }
// region interfaces // region interfaces

View File

@@ -1,168 +0,0 @@
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

@@ -533,7 +533,6 @@ 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" gorm:"serializer:encstr"` ApiToken string `form:"api_token" binding:"omitempty"`
ApiTokenCreated *time.Time ApiTokenCreated *time.Time
LinkedPeerCount int `gorm:"-"` LinkedPeerCount int `gorm:"-"`