Added client isolation settings. Fixed mobile UI
Build and Publish / Build and Publish Docker Image (push) Successful in 6m6s
Build and Publish / Build and Publish Docker Image (push) Successful in 6m6s
This commit is contained in:
+113
-13
@@ -9,13 +9,13 @@
|
||||
* { box-sizing: border-box; }
|
||||
[x-cloak] { display: none !important; }
|
||||
body { margin: 0; min-height: 100vh; background: #edf3ef; color: #18232d; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
|
||||
button, input { font: inherit; }
|
||||
button, input, select { font: inherit; }
|
||||
button { min-height: 40px; border: 1px solid #18342f; border-radius: 8px; padding: .55rem .85rem; background: #18342f; color: #fff; cursor: pointer; font-weight: 750; }
|
||||
button.secondary { background: #fff; color: #18342f; border-color: #c7d4ce; }
|
||||
button.ghost { background: transparent; color: #42515d; border-color: transparent; }
|
||||
button.danger { background: #a8312d; border-color: #a8312d; color: #fff; }
|
||||
button:disabled { opacity: .48; cursor: default; }
|
||||
input { width: 100%; min-height: 42px; border: 1px solid #c7d4ce; border-radius: 8px; padding: .55rem .7rem; color: #18232d; background: #fff; }
|
||||
input, select { width: 100%; min-height: 42px; border: 1px solid #c7d4ce; border-radius: 8px; padding: .55rem .7rem; color: #18232d; background: #fff; }
|
||||
code { display: inline-block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; border-radius: 6px; background: #f1f4f2; color: #26313a; padding: .18rem .4rem; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: .82rem; }
|
||||
.portal { min-height: 100vh; display: flex; flex-direction: column; }
|
||||
.topbar { position: sticky; top: 0; z-index: 10; background: rgba(255,255,255,.94); border-bottom: 1px solid #d6dfda; backdrop-filter: blur(12px); }
|
||||
@@ -62,7 +62,8 @@
|
||||
.server-status-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #18232d; font-size: .86rem; font-weight: 850; }
|
||||
.server-status-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #69777f; font-size: .74rem; font-weight: 750; }
|
||||
.server-status-muted { color: #69777f; font-size: .82rem; }
|
||||
.create { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: .65rem; padding: 0 1rem 1rem; align-items: end; }
|
||||
.create { display: flex; justify-content: flex-end; padding: 0 1rem 1rem; }
|
||||
.create-plus { width: 44px; min-width: 44px; height: 44px; padding: 0; font-size: 1.55rem; line-height: 1; }
|
||||
.field { display: grid; gap: .32rem; min-width: 0; }
|
||||
.field label { color: #34424b; font-size: .83rem; font-weight: 800; }
|
||||
.notice { border: 1px solid #d6dfda; border-left-width: 4px; border-radius: 8px; background: #fff; padding: .75rem .85rem; display: grid; gap: .25rem; box-shadow: 0 8px 24px rgba(24, 52, 47, .06); }
|
||||
@@ -87,7 +88,7 @@
|
||||
.meta-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .55rem; }
|
||||
.meta-item { min-width: 0; display: grid; gap: .24rem; }
|
||||
.meta-label { color: #69777f; font-size: .77rem; font-weight: 800; }
|
||||
.key-actions { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; gap: .5rem; align-items: center; }
|
||||
.key-actions { display: grid; grid-template-columns: minmax(0, 1fr) auto auto auto; gap: .5rem; align-items: center; }
|
||||
.key-actions .server-button { justify-self: stretch; }
|
||||
.key-updated { color: #69777f; font-size: .82rem; overflow-wrap: anywhere; }
|
||||
.backdrop { position: fixed; inset: 0; z-index: 30; display: grid; place-items: center; padding: 1rem; background: rgba(16, 28, 36, .42); }
|
||||
@@ -115,12 +116,15 @@
|
||||
.modal-actions { display: flex; justify-content: flex-end; gap: .5rem; flex-wrap: wrap; }
|
||||
.telegram-sheet { width: min(480px, 100%); }
|
||||
.telegram-copy { color: #53616c; line-height: 1.45; margin: 0; }
|
||||
.form-stack { display: grid; gap: .8rem; }
|
||||
.field-hint { margin: 0; color: #69777f; font-size: .82rem; line-height: 1.4; }
|
||||
.warning-box { border: 1px solid #e5c76c; border-radius: 8px; background: #fff8df; color: #5f4911; padding: .7rem; font-size: .86rem; line-height: 1.4; }
|
||||
.guide-list { margin: 0; padding-left: 1.2rem; color: #34424b; display: grid; gap: .45rem; line-height: 1.4; }
|
||||
.guide-list a { color: #18342f; font-weight: 850; }
|
||||
.guide-list a.disabled { color: #69777f; pointer-events: none; text-decoration: none; }
|
||||
.secret-box { display: grid; gap: .35rem; border: 1px solid #d6dfda; border-radius: 8px; background: #f7faf8; padding: .7rem; }
|
||||
.secret-box code { display: block; padding: .55rem .65rem; font-size: .92rem; white-space: normal; overflow-wrap: anywhere; }
|
||||
@media (max-width: 720px) {
|
||||
@media (max-width: 900px) {
|
||||
.topbar-inner { align-items: flex-start; flex-direction: column; }
|
||||
.top-actions { width: 100%; justify-content: space-between; }
|
||||
.user-pill { max-width: 100%; }
|
||||
@@ -219,11 +223,7 @@
|
||||
</template>
|
||||
</div>
|
||||
<div class="create">
|
||||
<div class="field">
|
||||
<label for="client-name">{{ t.configs_new_key }}</label>
|
||||
<input id="client-name" x-model="newName" @keydown.enter.prevent="createClient()" placeholder="{{ t.configs_name_placeholder }}">
|
||||
</div>
|
||||
<button type="button" @click="createClient()" :disabled="busy">{{ t.configs_create }}</button>
|
||||
<button type="button" class="create-plus" @click="openCreateClient()" :disabled="busy" title="{{ t.configs_new_key }}" aria-label="{{ t.configs_new_key }}">+</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -265,10 +265,15 @@
|
||||
<span class="meta-label">{{ t.configs_public_key }}</span>
|
||||
<code x-text="shortKey(client.public_key)"></code>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">{{ t.configs_group }}</span>
|
||||
<code x-text="client.group_name || '{{ t.configs_group_isolated }}'"></code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="key-actions">
|
||||
<button type="button" class="server-button" @click="openServers(client)" :disabled="busy || !client.enabled">{{ t.configs_choose_server }}</button>
|
||||
<button type="button" class="secondary" @click="openGroupEditor(client)" :disabled="busy">{{ t.configs_change_group }}</button>
|
||||
<button type="button" class="secondary" @click="setEnabled(client, !client.enabled)" :disabled="busy" x-text="client.enabled ? '{{ t.configs_disable }}' : '{{ t.configs_enable }}'"></button>
|
||||
<button type="button" class="danger" @click="deleteClient(client)" :disabled="busy">{{ t.users_delete }}</button>
|
||||
</div>
|
||||
@@ -280,6 +285,45 @@
|
||||
</template>
|
||||
</main>
|
||||
|
||||
<template x-if="clientModal">
|
||||
<div class="backdrop" x-cloak @click.self="closeClientModal()">
|
||||
<section class="sheet">
|
||||
<div class="sheet-head">
|
||||
<div class="sheet-title">
|
||||
<h2 x-text="clientModal.mode === 'create' ? '{{ t.configs_new_key }}' : '{{ t.configs_change_group }}'"></h2>
|
||||
<span x-show="clientModal.client" x-text="clientModal.client ? clientModal.client.name : ''"></span>
|
||||
</div>
|
||||
<button type="button" class="ghost" @click="closeClientModal()">{{ t.admin_close }}</button>
|
||||
</div>
|
||||
<div class="form-stack">
|
||||
<template x-if="clientModal.mode === 'create'">
|
||||
<div class="field">
|
||||
<label for="client-modal-name">{{ t.configs_name }}</label>
|
||||
<input id="client-modal-name" x-model="clientModal.name" placeholder="{{ t.configs_name_placeholder }}">
|
||||
</div>
|
||||
</template>
|
||||
<div class="field">
|
||||
<label for="client-modal-group">{{ t.configs_group }}</label>
|
||||
<input id="client-modal-group" x-model="clientModal.groupName" list="vpn-client-groups" placeholder="{{ t.configs_group_placeholder }}">
|
||||
<datalist id="vpn-client-groups">
|
||||
<template x-for="group in groups" :key="group">
|
||||
<option :value="group"></option>
|
||||
</template>
|
||||
</datalist>
|
||||
<p class="field-hint">{{ t.configs_group_hint }}</p>
|
||||
</div>
|
||||
<template x-if="clientModal.mode === 'group'">
|
||||
<div class="warning-box">{{ t.configs_group_rotation_warning }}</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="secondary" @click="closeClientModal()">{{ t.admin_close }}</button>
|
||||
<button type="button" @click="clientModal.mode === 'create' ? createClient() : changeClientGroup()" :disabled="busy">{{ t.settings_save }}</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="serverModal">
|
||||
<div class="backdrop" x-cloak @click.self="closeServers()">
|
||||
<section class="sheet">
|
||||
@@ -433,7 +477,8 @@
|
||||
function clientPortal() {
|
||||
return {
|
||||
clients: [],
|
||||
newName: '',
|
||||
groups: [],
|
||||
clientModal: null,
|
||||
busy: false,
|
||||
status: '',
|
||||
error: '',
|
||||
@@ -493,6 +538,7 @@ function clientPortal() {
|
||||
try {
|
||||
const data = await this.request('/api/vpn-clients');
|
||||
this.clients = data.clients || [];
|
||||
this.groups = data.groups || [];
|
||||
} catch (e) {
|
||||
this.showError(e);
|
||||
} finally {
|
||||
@@ -680,17 +726,42 @@ function clientPortal() {
|
||||
if (this.telegram.linked) return '{{ t.telegram_connected_message }}';
|
||||
return '{{ t.telegram_not_connected_message }}';
|
||||
},
|
||||
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 }}');
|
||||
} catch (e) {
|
||||
this.showError(e);
|
||||
@@ -698,6 +769,35 @@ function clientPortal() {
|
||||
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 }}');
|
||||
} catch (e) {
|
||||
this.showError(e);
|
||||
} finally {
|
||||
this.busy = false;
|
||||
}
|
||||
},
|
||||
async setEnabled(client, enabled) {
|
||||
this.clearNotice();
|
||||
this.busy = true;
|
||||
|
||||
+164
-25
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user