Reworked user portal. devided admin and user UI
Build and Publish / Build and Publish Docker Image (push) Successful in 3m7s

This commit is contained in:
Ultradesu
2026-06-30 17:02:56 +03:00
parent c3db347da6
commit 3def3afeda
8 changed files with 1059 additions and 93 deletions
+150 -56
View File
@@ -14,7 +14,7 @@
.sidebar a { display: block; text-decoration: none; color: #d8dee6; padding: .5rem .55rem; border-radius: 4px; margin-bottom: .2rem; }
.sidebar a:hover, .sidebar a.active { background: #263544; color: #fff; }
.main-wrap { min-width: 0; display: flex; flex-direction: column; }
.topbar { min-height: 48px; display: flex; align-items: center; justify-content: flex-end; gap: 1rem; padding: .5rem 1.25rem; border-bottom: 1px solid #dde2e6; background: #fff; }
.topbar { min-height: 48px; display: flex; align-items: center; justify-content: flex-end; gap: 1rem; flex-wrap: wrap; padding: .5rem 1.25rem; border-bottom: 1px solid #dde2e6; background: #fff; }
.user-info { font-size: .875rem; color: #53606d; }
.app-version { font-size: .75rem; color: #9aa4ae; white-space: nowrap; }
.logout-link, .lang-switch a { font-size: .875rem; text-decoration: none; color: #53606d; padding: .25rem .45rem; border-radius: 4px; }
@@ -42,6 +42,14 @@
.status { margin: .75rem 0; min-height: 1.25rem; color: #53606d; }
.error { color: #9d2323; }
.empty { background: #fff; border: 1px solid #dde2e6; border-radius: 6px; padding: 1rem; color: #53606d; }
.notice { margin: .75rem 0; border: 1px solid #dde2e6; border-left-width: 4px; border-radius: 6px; background: #fff; padding: .7rem .8rem; display: grid; gap: .25rem; }
.notice strong { color: #1d252d; }
.notice span { color: #53606d; font-size: .9rem; }
.notice details { color: #6a7682; font-size: .82rem; }
.notice summary { cursor: pointer; font-weight: 700; }
.notice.tone-success { border-left-color: #2f8f4e; }
.notice.tone-warning { border-left-color: #d39a00; }
.notice.tone-error { border-left-color: #9d2323; }
.rollout { margin: 1rem 0 1.25rem; }
.rollout-meta { color: #53606d; font-size: .88rem; margin-bottom: .5rem; }
.rollout-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: .55rem; }
@@ -84,9 +92,10 @@
.modal-subtitle { color: #53606d; font-size: .86rem; overflow-wrap: anywhere; }
.server-filter { width: 100%; }
.server-picker-list { display: grid; gap: .6rem; overflow: auto; padding-right: .15rem; }
.server-card { border: 1px solid #dde2e6; border-radius: 6px; padding: .7rem; display: grid; grid-template-columns: minmax(160px, 1fr) auto; gap: .65rem; align-items: center; }
.server-card { border: 1px solid #dde2e6; border-radius: 8px; padding: .8rem; display: grid; gap: .7rem; align-items: start; }
.server-card-main { display: grid; gap: .25rem; min-width: 0; }
.server-card-actions { display: flex; gap: .45rem; align-items: center; flex-wrap: wrap; justify-content: flex-end; }
.server-card-actions { display: grid; grid-template-columns: repeat(3, minmax(118px, 1fr)); gap: .45rem; align-items: center; }
.server-card-actions button { min-width: 0; }
.detail-list { display: grid; gap: .55rem; overflow: auto; }
.detail-row { display: grid; grid-template-columns: minmax(120px, .45fr) minmax(0, 1fr); gap: .75rem; align-items: start; padding: .55rem 0; border-bottom: 1px solid #edf0f2; }
.detail-row:last-child { border-bottom: 0; }
@@ -107,8 +116,7 @@
table { display: block; overflow-x: auto; }
.row-actions { align-items: stretch; }
.servers-modal { width: 100%; max-height: calc(100vh - 2rem); }
.server-card { grid-template-columns: 1fr; }
.server-card-actions { justify-content: stretch; }
.server-card-actions { grid-template-columns: 1fr; }
.server-card-actions button { flex: 1 1 100%; }
.detail-row { grid-template-columns: 1fr; gap: .2rem; }
.rollout-grid { grid-template-columns: 1fr; }
@@ -149,7 +157,16 @@
</div>
</div>
<div class="status" :class="{ 'error': error }" x-text="error || status"></div>
<template x-if="notice">
<section class="notice" :class="`tone-${notice.kind}`">
<strong x-text="notice.title"></strong>
<span x-text="notice.message"></span>
<details x-show="notice.detail">
<summary>{{ t.notice_detail }}</summary>
<span x-text="notice.detail"></span>
</details>
</section>
</template>
<section class="rollout">
<div class="panel-head">
@@ -334,6 +351,7 @@ function configsPage() {
busy: false,
status: '',
error: '',
notice: null,
rollout: null,
rolloutBusy: false,
rolloutError: '',
@@ -342,10 +360,13 @@ function configsPage() {
serverConfigs: {},
serverModal: null,
qrModal: null,
isAdmin: {% if is_admin %}true{% else %}false{% endif %},
init() {
this.load();
this.loadRolloutStatus();
this.rolloutTimer = setInterval(() => this.loadRolloutStatus(), 10000);
if (this.isAdmin) {
this.loadRolloutStatus();
this.rolloutTimer = setInterval(() => this.loadRolloutStatus(), 10000);
}
},
async request(url, options = {}) {
const response = await fetch(url, {
@@ -354,7 +375,12 @@ function configsPage() {
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(data.error || response.statusText);
const error = new Error(data.error || this.httpStatusMessage(response.status));
error.title = data.title || this.httpStatusTitle(response.status);
error.detail = data.detail || '';
error.code = data.code || '';
error.status = response.status;
throw error;
}
return data;
},
@@ -371,8 +397,7 @@ function configsPage() {
}
},
async createClient() {
this.error = '';
this.status = '';
this.clearNotice();
this.busy = true;
try {
const data = await this.request('/api/vpn-clients', {
@@ -382,17 +407,16 @@ function configsPage() {
this.clients.push(data.client);
delete this.serverConfigs[data.client.id];
this.newName = '';
this.status = data.sync.message;
await this.loadRolloutStatus();
this.applyResponseNotice(data, '{{ t.notice_create_success }}');
if (this.isAdmin) await this.loadRolloutStatus();
} catch (e) {
this.error = e.message;
this.showError(e);
} finally {
this.busy = false;
}
},
async setEnabled(client, enabled) {
this.error = '';
this.status = '';
this.clearNotice();
this.busy = true;
try {
const data = await this.request(`/api/vpn-clients/${client.id}/enabled`, {
@@ -402,18 +426,17 @@ function configsPage() {
const index = this.clients.findIndex((item) => item.id === client.id);
if (index !== -1) this.clients[index] = data.client;
delete this.serverConfigs[client.id];
this.status = data.sync.message;
await this.loadRolloutStatus();
this.applyResponseNotice(data, '{{ t.notice_update_success }}');
if (this.isAdmin) await this.loadRolloutStatus();
} catch (e) {
this.error = e.message;
this.showError(e);
} finally {
this.busy = false;
}
},
async deleteClient(client) {
if (!confirm('{{ t.users_delete_confirm }}')) return;
this.error = '';
this.status = '';
this.clearNotice();
this.busy = true;
try {
const data = await this.request(`/api/vpn-clients/${client.id}`, {
@@ -421,41 +444,53 @@ function configsPage() {
});
this.clients = this.clients.filter((item) => item.id !== client.id);
delete this.serverConfigs[client.id];
this.status = data.sync.message;
await this.loadRolloutStatus();
this.applyResponseNotice(data, '{{ t.notice_delete_success }}');
if (this.isAdmin) await this.loadRolloutStatus();
} catch (e) {
this.error = e.message;
this.showError(e);
} finally {
this.busy = false;
}
},
async openServers(client) {
this.error = '';
this.status = '';
this.clearNotice();
const cached = this.serverConfigs[client.id];
this.serverModal = {
client,
filter: '',
loading: !cached,
loading: !cached || cached.loading,
servers: cached ? cached.servers : [],
};
if (cached) return;
this.serverConfigs[client.id] = { loading: true, servers: [] };
try {
const data = await this.request(`/api/vpn-clients/${client.id}/config`);
this.serverConfigs[client.id] = { loading: false, servers: data.servers || [] };
const servers = await this.loadClientServers(client);
if (this.serverModal && this.serverModal.client.id === client.id) {
this.serverModal.loading = false;
this.serverModal.servers = data.servers || [];
this.serverModal.servers = servers;
}
} catch (e) {
this.serverConfigs[client.id] = { loading: false, servers: [] };
if (this.serverModal && this.serverModal.client.id === client.id) {
this.serverModal.loading = false;
this.serverModal.servers = [];
}
this.error = e.message;
this.showError(e, {
title: '{{ t.notice_server_list_error_title }}',
message: '{{ t.notice_server_list_error_message }}',
});
}
},
async loadClientServers(client) {
const cached = this.serverConfigs[client.id];
if (cached && !cached.loading) return cached.servers || [];
this.serverConfigs[client.id] = { loading: true, servers: [] };
try {
const data = await this.request(`/api/vpn-clients/${client.id}/config`);
const servers = data.servers || [];
this.serverConfigs[client.id] = { loading: false, servers };
return servers;
} catch (e) {
this.serverConfigs[client.id] = { loading: false, servers: [] };
throw e;
}
},
closeServers() {
@@ -472,35 +507,36 @@ function configsPage() {
});
},
async downloadConfig(client, server) {
this.error = '';
this.status = '';
this.clearNotice();
this.busy = true;
try {
const blob = new Blob([server.config], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${this.fileSlug(client.name || 'amnezia-client')}-${this.fileSlug(server.endpoint_name)}.conf`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
this.status = `${server.endpoint_name}: ${server.endpoint}`;
this.saveConfigFile(client, server);
} catch (e) {
this.error = e.message;
this.showError(e);
} finally {
this.busy = false;
}
},
saveConfigFile(client, server) {
const blob = new Blob([server.config], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${this.fileSlug(client.name || 'amnezia-client')}-${this.fileSlug(server.endpoint_name)}.conf`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
this.setNotice('success', '{{ t.notice_success_title }}', `${server.endpoint_name}: ${server.endpoint}`);
},
async copyVpnUrl(client, server) {
this.error = '';
this.status = '';
this.clearNotice();
this.busy = true;
try {
await this.copyText(server.vpn_url);
this.status = `${server.endpoint_name}: {{ t.configs_vpn_url_copied }}`;
this.setNotice('success', '{{ t.notice_success_title }}', `${server.endpoint_name}: {{ t.configs_vpn_url_copied }}`);
} catch (e) {
this.error = e.message;
this.showError(e);
} finally {
this.busy = false;
}
@@ -535,20 +571,20 @@ function configsPage() {
}
},
async sync() {
this.error = '';
this.status = '';
this.clearNotice();
this.busy = true;
try {
const data = await this.request('/api/vpn-clients/sync', { method: 'POST' });
this.status = data.message;
this.setNotice('success', '{{ t.notice_success_title }}', data.message);
await this.loadRolloutStatus();
} catch (e) {
this.error = e.message;
this.showError(e);
} finally {
this.busy = false;
}
},
async loadRolloutStatus() {
if (!this.isAdmin) return;
this.rolloutError = '';
this.rolloutBusy = true;
try {
@@ -601,6 +637,64 @@ function configsPage() {
fileSlug(value) {
return String(value || 'amnezia').trim().replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'amnezia';
},
clearNotice() {
this.error = '';
this.status = '';
this.notice = null;
},
setNotice(kind, title, message, detail = '') {
this.notice = { kind, title, message, detail };
this.error = kind === 'error' ? message : '';
this.status = kind === 'error' ? '' : message;
},
applyResponseNotice(data, fallbackMessage) {
if (data.notice) {
const notice = this.normalizeNotice(data.notice);
this.setNotice(notice.kind, notice.title, notice.message, notice.detail || '');
return;
}
this.setNotice('success', '{{ t.notice_success_title }}', (data.sync && data.sync.message) || fallbackMessage);
},
normalizeNotice(notice) {
if (notice.code === 'secret_sync_failed') {
return {
kind: 'warning',
title: '{{ t.notice_sync_warning_title }}',
message: '{{ t.notice_sync_warning_message }}',
detail: notice.detail || '',
};
}
return {
kind: notice.kind || 'warning',
title: notice.title || '{{ t.notice_warning_title }}',
message: notice.message || '',
detail: notice.detail || '',
};
},
showError(error, fallback = {}) {
this.setNotice(
'error',
fallback.title || error.title || '{{ t.notice_error_title }}',
fallback.message || error.message || '{{ t.notice_error_title }}',
error.detail || ''
);
},
httpStatusTitle(status) {
if (status === 401) return '{{ t.login_heading }}';
if (status === 403) return '{{ t.notice_error_title }}';
if (status === 404) return '{{ t.configs_empty }}';
if (status === 409) return '{{ t.notice_warning_title }}';
if (status === 503) return '{{ t.notice_server_list_error_title }}';
return '{{ t.notice_error_title }}';
},
httpStatusMessage(status) {
if (status === 401) return 'not authenticated';
if (status === 403) return 'access denied';
if (status === 404) return 'not found';
if (status === 409) return '{{ t.configs_no_servers }}';
if (status === 503) return '{{ t.notice_server_list_error_message }}';
return '{{ t.notice_error_title }}';
},
rolloutStatusLabel(value) {
const labels = {
applied: '{{ t.configs_status_applied }}',