Compare commits

..

27 Commits

Author SHA1 Message Date
Christoph Haas
cc472216b4 Merge branch 'master' into stable 2026-03-22 22:34:48 +01:00
Christoph
95394628d3 Merge branch 'master' into stable 2026-03-03 17:02:52 +01:00
Christoph Haas
b553375c43 Merge branch 'master' into stable 2026-02-28 22:32:43 +01:00
htiryaki-oe24
0a8ec71b3f Fixes minor chart issues #629 (#630)
(cherry picked from commit 3e0ffec07c)
2026-02-24 22:41:28 +01:00
h44z
fe4485037a Merge commit from fork 2026-02-24 22:40:13 +01:00
Arnaud Rocher
6e47d8c3e9 fix: parity of Base64/URL encoding between frontend and backend (#611)
Signed-off-by: Arnaud Rocher <arnaud.roche3@gmail.com>
(cherry picked from commit 5d58df8a19)
2026-01-29 22:39:33 +01:00
h44z
eb28492539 Merge commit from fork
* fix: prevent open redirect in OAuth return URL validation

* reformat check

---------

Co-authored-by: Arne Cools <arne.cools@intigriti.com>
(cherry picked from commit e62db0d62e)
2026-01-29 22:38:42 +01:00
Christoph
d1a4ddde10 Merge branch 'master' into stable 2025-11-23 21:00:12 +01:00
Christoph Haas
b1637b0c4e Merge branch 'master' into stable
# Conflicts:
#	internal/domain/peer.go
2025-10-19 13:25:07 +02:00
h44z
0cc7ebb83e ensure hooks run after restart (#494) (#497)
(cherry picked from commit 99df4ca3cd)
2025-09-03 22:48:45 +02:00
h44z
eb6a787cfc ensure that LDAP filter values are escaped (#512)
(cherry picked from commit 0cbca61c15)
2025-09-03 22:47:40 +02:00
Christoph Haas
b546eec4ed fix multi-peer generation, fix prefix handling (#491)
(cherry picked from commit c20f17cddf)
2025-08-12 21:25:48 +02:00
h44z
9be2133220 fix migration tool (#495) (#496)
(cherry picked from commit 9884d8c002)
2025-08-12 21:23:30 +02:00
Christoph Haas
b05837b2d9 ensure that v2 (or just 2) tags are only published for stable releases (#493)
(cherry picked from commit b099e8abfa)
2025-08-12 21:23:28 +02:00
Christoph Haas
08c8f8eac0 backport username display bugfix (#456) 2025-06-12 19:11:25 +02:00
Christoph Haas
d864e24145 improve logging of OAuth login issues, decrease auth-code exchange timeout (#451)
(cherry picked from commit e3b65ca337)
2025-06-12 19:07:46 +02:00
Christoph Haas
5b56e58fe9 fix self-provisioned peer-generation (#452)
(cherry picked from commit 61d8aa6589)
2025-06-09 17:41:29 +02:00
Christoph Haas
930ef7b573 Merge branch 'master' into stable 2025-05-16 09:58:14 +02:00
Christoph Haas
18296673d7 Merge branch 'master' into stable 2025-05-13 20:25:27 +02:00
Christoph Haas
4ccc59c109 Merge branch 'master' into stable
# Conflicts:
#	.github/workflows/docker-publish.yml
#	README.md
#	assets/tpl/admin_index.html
#	assets/tpl/user_index.html
#	cmd/wg-portal/main.go
#	docker-compose.yml
#	go.mod
#	go.sum
#	internal/common/util.go
#	internal/server/docs/docs.go
#	internal/server/handlers_common.go
#	internal/server/server.go
#	internal/wireguard/peermanager.go
2025-05-04 20:38:55 +02:00
onyx-flame
e6b01a9903 Feature (v1): add latest handshake data to API response (#203)
* feature: updated handshake-related fields type

* feature: updated handshake representations in templates

* feature: added handshake field to Swagger schema
2023-12-23 12:56:52 +01:00
Christoph Haas
2f79dd04c0 adopt github actions 2023-10-26 11:29:34 +02:00
Christoph Haas
e5ed9736b3 update docker build settings, move to new docker hub repository, use stable branch and major version tags 2023-10-26 11:22:58 +02:00
Christoph Haas
c8353b85ae Merge branch 'replace_ext_lib' into stable 2023-10-26 10:40:06 +02:00
Christoph Haas
6142031387 update gin 2023-10-26 10:39:01 +02:00
Christoph Haas
dd86d0ff49 replace inaccessible external lib 2023-10-26 10:31:29 +02:00
Christoph Haas
bdd426a679 populate peer device type (#170) 2023-10-26 10:20:08 +02:00
33 changed files with 2059 additions and 2558 deletions

View File

@@ -1,8 +1,7 @@
# Go parameters
GOCMD=go
GOVERSION=1.25
MODULENAME=github.com/h44z/wg-portal
GOFILES=$(shell go list ./... | grep -v /vendor/)
GOFILES:=$(shell go list ./... | grep -v /vendor/)
BUILDDIR=dist
BINARIES=$(subst cmd/,,$(wildcard cmd/*))
IMAGE=h44z/wg-portal
@@ -52,11 +51,6 @@ format:
.PHONY: test
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
.PHONY: test-vet
test-vet: build-dependencies

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

@@ -382,8 +382,7 @@
"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.",
"confirm-delete": "Are you sure you want to delete user '{id}'?"
"sync-warning": "To modify this synchronized user, enable local change persistence. Otherwise, your changes will be overwritten during the next synchronization."
},
"interface-view": {
"headline": "Config for Interface:"
@@ -504,8 +503,8 @@
"placeholder": "Persistent Keepalive (0 = default)"
}
},
"button-apply-defaults": "Apply Peer Defaults",
"confirm-delete": "Are you sure you want to delete interface '{id}'?"
"button-apply-defaults": "Apply Peer Defaults"
},
"peer-view": {
"headline-peer": "Peer:",
@@ -627,8 +626,7 @@
},
"expires-at": {
"label": "Expiry date"
},
"confirm-delete": "Are you sure you want to delete peer '{id}'?"
}
},
"peer-multi-create": {
"headline-peer": "Create multiple peers",

View File

@@ -365,8 +365,7 @@
},
"admin": {
"label": "Es administrador"
},
"confirm-delete": "Seguro que desea eliminar el usuario '{id}'?"
}
},
"interface-view": {
"headline": "Configuración de la interfaz:"
@@ -494,8 +493,7 @@
"placeholder": "Keepalive Persistente (0 = por defecto)"
}
},
"button-apply-defaults": "Aplicar Valores Predeterminados de peers",
"confirm-delete": "Seguro que desea eliminar la interfaz '{id}'?"
"button-apply-defaults": "Aplicar Valores Predeterminados de peers"
},
"peer-view": {
"headline-peer": "Peer:",
@@ -615,8 +613,7 @@
},
"expires-at": {
"label": "Fecha de expiración"
},
"confirm-delete": "Seguro que desea eliminar el par '{id}'?"
}
},
"peer-multi-create": {
"headline-peer": "Crear múltiples peers",

View File

@@ -126,7 +126,9 @@
"peer-expiring": "Le pair expire le",
"peer-connected": "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": {
"headline": "Administration des utilisateurs",
@@ -262,8 +264,7 @@
},
"admin": {
"label": "Est Admin"
},
"confirm-delete": "Voulez-vous vraiment supprimer l'utilisateur \"{id}\" ?"
}
},
"interface-view": {
"headline": "Configuration pour l'interface :"
@@ -376,8 +377,7 @@
"placeholder": "Persistent Keepalive (0 = par défaut)"
}
},
"button-apply-defaults": "Appliquer les valeurs par défaut des pairs",
"confirm-delete": "Voulez-vous vraiment supprimer l'interface \"{id}\" ?"
"button-apply-defaults": "Appliquer les valeurs par défaut des pairs"
},
"peer-view": {
"headline-peer": "Pair :",
@@ -493,8 +493,7 @@
},
"expires-at": {
"label": "Date d'expiration"
},
"confirm-delete": "Voulez-vous vraiment supprimer le pair \"{id}\" ?"
}
},
"peer-multi-create": {
"headline-peer": "Créer plusieurs pairs",

View File

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

View File

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

View File

@@ -366,8 +366,7 @@
},
"admin": {
"label": "Является администратором"
},
"confirm-delete": "Вы уверены, что хотите удалить пользователя «{id}»?"
}
},
"interface-view": {
"headline": "Конфигурация интерфейса:"
@@ -485,8 +484,7 @@
"placeholder": "Постоянное поддержание активности (0 = значение по умолчанию)"
}
},
"button-apply-defaults": "Применить настройки пира по умолчанию",
"confirm-delete": "Вы уверены, что хотите удалить интерфейс «{id}»?"
"button-apply-defaults": "Применить настройки пира по умолчанию"
},
"peer-view": {
"headline-peer": "Пир:",
@@ -607,8 +605,7 @@
},
"expires-at": {
"label": "Дата истечения срока действия"
},
"confirm-delete": "Вы уверены, что хотите удалить пир «{id}»?"
}
},
"peer-multi-create": {
"headline-peer": "Создать несколько узлов",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,8 @@
<script setup>
import {userStore} from "@/stores/users";
import {ref, onMounted, computed} from "vue";
import UserEditModal from "@/components/UserEditModal.vue";
import UserViewModal from "@/components/UserViewModal.vue";
import Pagination from "@/components/Pagination.vue";
import UserEditModal from "../components/UserEditModal.vue";
import UserViewModal from "../components/UserViewModal.vue";
import {useI18n} from "vue-i18n";
const users = userStore()
@@ -166,24 +165,28 @@ onMounted(() => {
</table>
</div>
<hr>
<div class="mt-3">
<div class="row">
<div class="col-12 col-md-6">
<Pagination
:currentPage="users.currentPage"
:totalCount="users.FilteredCount"
:pageSize="users.pageSize"
:hasNextPage="users.hasNextPage"
:hasPrevPage="users.hasPrevPage"
:onGotoPage="users.gotoPage"
:onNextPage="users.nextPage"
:onPrevPage="users.previousPage"
/>
<div class="col-6">
<ul class="pagination pagination-sm">
<li :class="{disabled:users.pageOffset===0}" class="page-item">
<a class="page-link" @click="users.previousPage">&laquo;</a>
</li>
<li v-for="page in users.pages" :key="page" :class="{active:users.currentPage===page}" class="page-item">
<a class="page-link" @click="users.gotoPage(page)">{{page}}</a>
</li>
<li :class="{disabled:!users.hasNextPage}" class="page-item">
<a class="page-link" @click="users.nextPage">&raquo;</a>
</li>
</ul>
</div>
<div class="col-12 col-md-6">
<div class="col-6">
<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">
<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="25">25</option>
<option value="50">50</option>
@@ -194,4 +197,5 @@ onMounted(() => {
</div>
</div>
</div>
</div>
</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: audit data", "result", r.db.AutoMigrate(&domain.AuditEntry{}))
var existingSysStat SysStat
var err error
existingSysStat := SysStat{}
r.db.Order("schema_version desc").First(&existingSysStat) // get latest version
// Migration: 0 --> 1
if existingSysStat.SchemaVersion == 0 {
const schemaVersion = 1
existingSysStat, err = r.addMigration(schemaVersion) // ensure that follow-up checks test against the latest version
if err != nil {
return err
sysStat := SysStat{
MigratedAt: time.Now(),
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)
existingSysStat = sysStat // ensure that follow-up checks test against the latest version
}
// Migration: 1 --> 2
@@ -260,10 +262,14 @@ func (r *SqlRepo) migrate() error {
}
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
if err != nil {
return err
sysStat := SysStat{
MigratedAt: time.Now(),
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
@@ -301,43 +307,17 @@ func (r *SqlRepo) migrate() error {
if err != nil {
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{
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 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
@@ -502,7 +482,7 @@ func (r *SqlRepo) getOrCreateInterface(
Identifier: id,
}
err := tx.Preload("Addresses").Attrs(interfaceDefaults).FirstOrCreate(&in, id).Error
err := tx.Attrs(interfaceDefaults).FirstOrCreate(&in, id).Error
if err != nil {
return nil, err
}
@@ -711,7 +691,7 @@ func (r *SqlRepo) getOrCreatePeer(ui *domain.ContextUserInfo, tx *gorm.DB, id do
Identifier: id,
}
err := tx.Preload("Addresses").Attrs(interfaceDefaults).FirstOrCreate(&peer, id).Error
err := tx.Attrs(interfaceDefaults).FirstOrCreate(&peer, id).Error
if err != nil {
return nil, err
}

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

@@ -1,73 +0,0 @@
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,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) {
user.CopyCalculatedAttributes(u, false)
return user, 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
// 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
LinkedPeerCount int `gorm:"-"`