Reworked user portal. devided admin and user UI
Build and Publish / Build and Publish Docker Image (push) Successful in 3m7s
Build and Publish / Build and Publish Docker Image (push) Successful in 3m7s
This commit is contained in:
+150
-56
@@ -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 }}',
|
||||
|
||||
Reference in New Issue
Block a user