Added client isolation settings. Fixed mobile UI
Build and Publish / Build and Publish Docker Image (push) Successful in 6m6s

This commit is contained in:
Ultradesu
2026-07-30 23:03:18 +01:00
parent e8873f61a5
commit 9614ff8a60
8 changed files with 545 additions and 44 deletions
+164 -25
View File
@@ -42,6 +42,21 @@
button.danger { background: #9d2323; border-color: #9d2323; }
button:disabled { opacity: .55; cursor: default; }
.config-table { width: 100%; border-collapse: collapse; background: #fff; border: 1px solid #dde2e6; border-radius: 6px; overflow: hidden; }
.mobile-client-list { display: none; }
.mobile-owner-group { display: grid; gap: .55rem; }
.mobile-owner-head { display: flex; align-items: center; justify-content: space-between; gap: .5rem; color: #34414f; font-size: .86rem; font-weight: 750; }
.mobile-client-card { min-width: 0; border: 1px solid #dde2e6; border-radius: 8px; background: #fff; padding: .75rem; display: grid; gap: .65rem; box-shadow: 0 4px 16px rgba(23, 32, 42, .05); }
.mobile-client-head { min-width: 0; display: flex; align-items: flex-start; justify-content: space-between; gap: .55rem; }
.mobile-client-name { min-width: 0; font-weight: 800; overflow-wrap: anywhere; }
.mobile-client-state { flex: 0 0 auto; border-radius: 999px; padding: .15rem .45rem; background: #e8ecef; color: #53606d; font-size: .72rem; font-weight: 750; }
.mobile-client-state.enabled { background: #dcefe4; color: #175c31; }
.mobile-client-meta { min-width: 0; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .55rem; }
.mobile-meta-item { min-width: 0; display: grid; gap: .18rem; }
.mobile-meta-item.full { grid-column: 1 / -1; }
.mobile-meta-label { color: #6a7682; font-size: .72rem; font-weight: 700; }
.mobile-meta-value { min-width: 0; overflow-wrap: anywhere; }
.mobile-client-actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .4rem; }
.mobile-client-actions button { width: 100%; min-width: 0; padding-inline: .4rem; white-space: normal; }
th, td { text-align: left; padding: .65rem .75rem; border-bottom: 1px solid #edf0f2; font-size: .92rem; vertical-align: middle; }
th { background: #eef1f4; font-weight: 650; color: #34414f; }
tr:last-child td { border-bottom: 0; }
@@ -120,7 +135,7 @@
.guide-list a.disabled { color: #6a7682; pointer-events: none; text-decoration: none; }
.secret-box { display: grid; gap: .35rem; border: 1px solid #dde2e6; border-radius: 6px; background: #f8fafb; padding: .7rem; }
.secret-box code { display: block; padding: .55rem .65rem; font-size: .92rem; white-space: normal; overflow-wrap: anywhere; }
@media (max-width: 760px) {
@media (max-width: 980px) {
.shell { grid-template-columns: 1fr; }
.sidebar { display: flex; align-items: center; gap: .75rem; flex-wrap: wrap; overflow-x: visible; }
.sidebar h1 { margin: 0; white-space: nowrap; }
@@ -129,23 +144,9 @@
.actions { align-items: stretch; }
.actions input, .actions button { width: 100%; }
.main { padding: 1rem; }
.config-table { display: block; border: 0; border-radius: 0; background: transparent; overflow: visible; }
.config-table thead { display: none; }
.config-table tbody { display: grid; gap: .65rem; margin-bottom: .75rem; }
.config-table tr { display: grid; min-width: 0; border: 1px solid #dde2e6; border-radius: 8px; background: #fff; overflow: hidden; box-shadow: 0 4px 16px rgba(23, 32, 42, .05); }
.config-table tr.owner-row { border: 0; border-radius: 0; background: transparent; box-shadow: none; margin: .2rem 0 -.2rem; }
.config-table tr.owner-row td { display: flex; align-items: center; gap: .25rem; border: 0; padding: .15rem .1rem; background: transparent; color: #34414f; }
.config-table tr.owner-row td::before { display: none; }
.config-table td { display: grid; grid-template-columns: minmax(84px, .4fr) minmax(0, 1fr); gap: .55rem; align-items: center; min-width: 0; padding: .55rem .65rem; }
.config-table td::before { content: attr(data-label); min-width: 0; color: #53606d; font-size: .78rem; font-weight: 750; }
.config-table td:last-child { border-bottom: 0; }
.config-table code { max-width: 100%; white-space: normal; overflow-wrap: anywhere; }
.client-name-cell { display: block !important; padding: .7rem .65rem .55rem !important; color: #1d252d; font-size: 1rem; font-weight: 800; overflow-wrap: anywhere; }
.client-name-cell::before, .client-actions-cell::before { display: none; }
.client-actions-cell { display: block !important; padding: .65rem !important; }
.row-actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); align-items: stretch; width: 100%; }
.row-actions button { width: 100%; min-width: 0; white-space: normal; }
.row-actions button:first-child { grid-column: 1 / -1; }
.desktop-client-table { display: none; }
.mobile-client-list { display: grid; gap: .9rem; min-width: 0; }
.mobile-client-card code { display: block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.servers-modal { width: 100%; max-height: calc(100vh - 2rem); }
.server-card-actions { grid-template-columns: 1fr; }
.server-card-actions button { flex: 1 1 100%; }
@@ -184,8 +185,7 @@
<div class="toolbar">
<h2>{{ t.configs_heading }}</h2>
<div class="actions">
<input x-model="newName" placeholder="{{ t.configs_name_placeholder }}">
<button @click="createClient()" :disabled="busy">{{ t.configs_create }}</button>
<button @click="openCreateClient()" :disabled="busy">+ {{ t.configs_create }}</button>
{% if is_admin %}
<button class="secondary" @click="sync()" :disabled="busy">{{ t.configs_sync }}</button>
{% endif %}
@@ -247,7 +247,7 @@
</template>
<template x-if="clients.length > 0">
<table class="config-table">
<table class="config-table desktop-client-table">
<thead>
<tr>
<th>{{ t.configs_name }}</th>
@@ -256,6 +256,7 @@
{% endif %}
<th>{{ t.configs_address }}</th>
<th>{{ t.configs_public_key }}</th>
<th>{{ t.configs_group }}</th>
<th>{{ t.configs_enabled }}</th>
<th>{{ t.users_actions }}</th>
</tr>
@@ -264,7 +265,7 @@
<tbody>
{% if is_admin %}
<tr class="owner-row">
<td colspan="6">
<td colspan="7">
<span x-text="group.label"></span>
<span class="owner-meta" x-text="`(${group.clients.length})`"></span>
</td>
@@ -278,10 +279,12 @@
{% endif %}
<td data-label="{{ t.configs_address }}"><code x-text="client.address + '/32'"></code></td>
<td data-label="{{ t.configs_public_key }}"><code x-text="shortKey(client.public_key)"></code></td>
<td data-label="{{ t.configs_group }}" x-text="client.group_name || '{{ t.configs_group_isolated }}'"></td>
<td data-label="{{ t.configs_enabled }}" x-text="client.enabled ? '{{ t.configs_yes }}' : '{{ t.configs_no }}'"></td>
<td class="client-actions-cell" data-label="{{ t.users_actions }}">
<div class="row-actions">
<button class="secondary" @click="openServers(client)" :disabled="busy">{{ t.configs_servers }}</button>
<button class="secondary" @click="openGroupEditor(client)" :disabled="busy">{{ t.configs_change_group }}</button>
<button class="secondary" @click="setEnabled(client, !client.enabled)" :disabled="busy" x-text="client.enabled ? '{{ t.configs_disable }}' : '{{ t.configs_enable }}'"></button>
<button class="danger" @click="deleteClient(client)" :disabled="busy">{{ t.users_delete }}</button>
</div>
@@ -291,10 +294,89 @@
</tbody>
</template>
</table>
<div class="mobile-client-list">
<template x-for="ownerGroup in groupedClients()" :key="`mobile-${ownerGroup.key}`">
<section class="mobile-owner-group">
<div class="mobile-owner-head">
<span x-text="ownerGroup.label"></span>
<span x-text="ownerGroup.clients.length"></span>
</div>
<template x-for="client in ownerGroup.clients" :key="`mobile-client-${client.id}`">
<article class="mobile-client-card">
<div class="mobile-client-head">
<span class="mobile-client-name" x-text="client.name"></span>
<span class="mobile-client-state" :class="{ enabled: client.enabled }" x-text="client.enabled ? '{{ t.configs_enabled_state }}' : '{{ t.configs_disabled_state }}'"></span>
</div>
<div class="mobile-client-meta">
<div class="mobile-meta-item">
<span class="mobile-meta-label">{{ t.configs_address }}</span>
<code class="mobile-meta-value" x-text="client.address + '/32'"></code>
</div>
<div class="mobile-meta-item">
<span class="mobile-meta-label">{{ t.configs_group }}</span>
<span class="mobile-meta-value" x-text="client.group_name || '{{ t.configs_group_isolated }}'"></span>
</div>
<div class="mobile-meta-item full">
<span class="mobile-meta-label">{{ t.configs_public_key }}</span>
<code class="mobile-meta-value" x-text="shortKey(client.public_key)"></code>
</div>
</div>
<div class="mobile-client-actions">
<button class="secondary" @click="openServers(client)" :disabled="busy">{{ t.configs_servers }}</button>
<button class="secondary" @click="openGroupEditor(client)" :disabled="busy">{{ t.configs_change_group }}</button>
<button class="secondary" @click="setEnabled(client, !client.enabled)" :disabled="busy" x-text="client.enabled ? '{{ t.configs_disable }}' : '{{ t.configs_enable }}'"></button>
<button class="danger" @click="deleteClient(client)" :disabled="busy">{{ t.users_delete }}</button>
</div>
</article>
</template>
</section>
</template>
</div>
</template>
</main>
</div>
<template x-if="clientModal">
<div class="modal-backdrop" @click.self="closeClientModal()">
<div class="modal">
<div class="modal-head">
<div class="modal-title">
<h3 x-text="clientModal.mode === 'create' ? '{{ t.configs_new_key }}' : '{{ t.configs_change_group }}'"></h3>
<span class="modal-subtitle" x-show="clientModal.client" x-text="clientModal.client ? clientModal.client.name : ''"></span>
</div>
<button class="secondary" @click="closeClientModal()">{{ t.admin_close }}</button>
</div>
<template x-if="clientModal.mode === 'create'">
<div class="detail-row">
<label class="detail-label" for="admin-client-name">{{ t.configs_name }}</label>
<input id="admin-client-name" x-model="clientModal.name" placeholder="{{ t.configs_name_placeholder }}">
</div>
</template>
<div class="detail-row">
<label class="detail-label" for="admin-client-group">{{ t.configs_group }}</label>
<div>
<input id="admin-client-group" x-model="clientModal.groupName" list="admin-vpn-client-groups" placeholder="{{ t.configs_group_placeholder }}">
<datalist id="admin-vpn-client-groups">
<template x-for="group in groups" :key="group">
<option :value="group"></option>
</template>
</datalist>
</div>
</div>
<template x-if="clientModal.mode === 'group'">
<div class="notice tone-warning">
<span>{{ t.configs_group_rotation_warning }}</span>
</div>
</template>
<div class="modal-actions">
<button class="secondary" @click="closeClientModal()">{{ t.admin_close }}</button>
<button @click="clientModal.mode === 'create' ? createClient() : changeClientGroup()" :disabled="busy">{{ t.settings_save }}</button>
</div>
</div>
</div>
</template>
<template x-if="rolloutModal">
<div class="modal-backdrop" @click.self="closeRollout()">
<div class="modal servers-modal">
@@ -466,7 +548,8 @@
function configsPage() {
return {
clients: [],
newName: '',
groups: [],
clientModal: null,
busy: false,
status: '',
error: '',
@@ -700,23 +783,49 @@ function configsPage() {
try {
const data = await this.request('/api/vpn-clients');
this.clients = data.clients;
this.groups = data.groups || [];
} catch (e) {
this.error = e.message;
} finally {
this.busy = false;
}
},
openCreateClient() {
this.clientModal = { mode: 'create', name: '', groupName: '', client: null };
},
openGroupEditor(client) {
this.clientModal = {
mode: 'group',
name: client.name || '',
groupName: client.group_name || '',
client,
};
},
closeClientModal() {
if (!this.busy) this.clientModal = null;
},
rememberGroup(groupName) {
const group = String(groupName || '').trim();
if (group && !this.groups.includes(group)) {
this.groups.push(group);
this.groups.sort((left, right) => left.localeCompare(right));
}
},
async createClient() {
if (!this.clientModal || this.clientModal.mode !== 'create') return;
this.clearNotice();
this.busy = true;
try {
const name = this.clientModal.name;
const groupName = this.clientModal.groupName.trim() || null;
const data = await this.request('/api/vpn-clients', {
method: 'POST',
body: JSON.stringify({ name: this.newName }),
body: JSON.stringify({ name, group_name: groupName }),
});
this.clients.push(data.client);
delete this.serverConfigs[data.client.id];
this.newName = '';
this.rememberGroup(data.client.group_name);
this.clientModal = null;
this.applyResponseNotice(data, '{{ t.notice_create_success }}');
if (this.isAdmin) await this.loadRolloutStatus();
} catch (e) {
@@ -725,6 +834,36 @@ function configsPage() {
this.busy = false;
}
},
async changeClientGroup() {
if (!this.clientModal || this.clientModal.mode !== 'group') return;
const client = this.clientModal.client;
const groupName = this.clientModal.groupName.trim() || null;
if ((client.group_name || null) === groupName) {
this.clientModal = null;
return;
}
if (!confirm('{{ t.configs_group_rotation_confirm }}')) return;
this.clearNotice();
this.busy = true;
try {
const data = await this.request(`/api/vpn-clients/${client.id}/group`, {
method: 'POST',
body: JSON.stringify({ group_name: groupName }),
});
const index = this.clients.findIndex((item) => item.id === client.id);
if (index !== -1) this.clients[index] = data.client;
delete this.serverConfigs[client.id];
this.rememberGroup(data.client.group_name);
this.clientModal = null;
this.applyResponseNotice(data, '{{ t.configs_group_changed }}');
if (this.isAdmin) await this.loadRolloutStatus();
} catch (e) {
this.showError(e);
} finally {
this.busy = false;
}
},
async setEnabled(client, enabled) {
this.clearNotice();
this.busy = true;