Settings: fold Users and Organization into the Settings tabs
The left rail is back to the three places you actually go — Garage, Charging, Settings — and user management moves inside Settings as an admin-only tab, next to a new Organization tab that used to be a card buried in the personal settings. Tab order is Personal settings, Users, Organization, Integrations. /admin redirects to /settings?tab=users so old links keep working, and ?tab= picks the starting tab in general. AdminUsers moves from views/ to components/ since it is a panel now, not a route, and its page header becomes a section header like its neighbours. The personal panel was split in two around the integrations markup, which left no gap between the Profile and Privacy cards; it is one block again. Creating a user gets an organization picker for superadmins, defaulting to "no organization" so an org-less account stays a deliberate choice. Admins see no picker: the server pins their members to their own org regardless, which users_test.go now covers along with both superadmin paths. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
cd16d4383f
commit
e373497958
10 files changed
+387
-151
No files matched your search
@@ -1,23 +1,36 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted, onBeforeUnmount } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { api } from "../api";
|
||||
import { state, logout, refreshProfile } from "../auth";
|
||||
import { state, isAdmin, logout, refreshProfile } from "../auth";
|
||||
import { prefs, applyProfilePrefs } from "../prefs";
|
||||
import { formatDate, formatMoney } from "../lib/format.js";
|
||||
import { t, tSplit, TRANSLATED_LANGUAGES } from "../i18n";
|
||||
import OrgManager from "../components/OrgManager.vue";
|
||||
import AdminUsers from "../components/AdminUsers.vue";
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
const loading = ref(true);
|
||||
const loadError = ref("");
|
||||
const profile = ref(null);
|
||||
|
||||
// Settings is split into two tabs: personal account settings and external
|
||||
// integrations. The panels stay mounted (v-show) so their loaded state and
|
||||
// in-flight edits survive a tab switch.
|
||||
const activeTab = ref("personal"); // "personal" | "integrations"
|
||||
// Settings is split into tabs: personal account settings, — for admins — user
|
||||
// management, the organization, and external integrations. The panels stay
|
||||
// mounted (v-show) so their loaded state and in-flight edits survive a tab
|
||||
// switch.
|
||||
//
|
||||
// `?tab=` picks the starting tab, which is what /admin redirects to.
|
||||
const ALL_TABS = ["personal", "users", "organization", "integrations"];
|
||||
const tabs = computed(() => ALL_TABS.filter((tab) => tab !== "users" || isAdmin.value));
|
||||
const activeTab = ref(ALL_TABS.includes(route.query.tab) ? route.query.tab : "personal");
|
||||
|
||||
// The admin gate only settles once the profile is loaded, so a non-admin who
|
||||
// asked for ?tab=users lands back on the personal tab rather than on nothing.
|
||||
watch(tabs, (list) => {
|
||||
if (!list.includes(activeTab.value)) activeTab.value = "personal";
|
||||
});
|
||||
|
||||
// Each integration card folds open/closed, like the plugin rows in the API
|
||||
// Server panel. Collapsed by default so the Integrations tab reads as a compact
|
||||
@@ -735,10 +748,10 @@ onBeforeUnmount(() => {
|
||||
<p v-if="loading" class="text-muted">{{ t("common.loading") }}</p>
|
||||
|
||||
<div v-else-if="profile">
|
||||
<!-- Tabs: personal settings vs. integrations -->
|
||||
<!-- Tabs: personal settings, integrations, and users (admins only) -->
|
||||
<div class="mb-6 flex gap-2 border-b border-subtle">
|
||||
<button
|
||||
v-for="tab in ['personal', 'integrations']"
|
||||
v-for="tab in tabs"
|
||||
:key="tab"
|
||||
class="-mb-px border-b-2 px-1 pb-3 text-sm font-semibold transition-colors"
|
||||
:class="activeTab === tab
|
||||
@@ -920,6 +933,87 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Privacy & Security -->
|
||||
<section class="dh-card p-6">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h2 class="text-lg font-bold tracking-[-0.02em] text-strong">{{ t("settings.privacy.title") }}</h2>
|
||||
<button class="text-sm font-medium text-danger hover:underline" @click="onLogout">
|
||||
{{ t("settings.privacy.signOut") }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-muted">{{ t("settings.privacy.body") }}</p>
|
||||
</section>
|
||||
|
||||
<!-- Advanced / Danger Zone -->
|
||||
<section class="dh-card p-6">
|
||||
<h2 class="mb-4 text-lg font-bold tracking-[-0.02em] text-strong">{{ t("settings.advanced.title") }}</h2>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-strong">{{ t("settings.advanced.exportTitle") }}</p>
|
||||
<p class="text-xs text-muted">{{ t("settings.advanced.exportBody") }}</p>
|
||||
</div>
|
||||
<button class="dh-btn dh-btn-ghost !px-3 !py-1.5 shrink-0" :disabled="exporting" @click="exportData">
|
||||
{{ exporting ? t("settings.advanced.preparing") : t("settings.advanced.exportAction") }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="exportError" class="mt-2 text-sm text-danger">{{ exportError }}</p>
|
||||
|
||||
<div class="mt-4 flex items-center justify-between gap-3 border-t border-subtle pt-4">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-strong">{{ t("settings.advanced.importTitle") }}</p>
|
||||
<p class="text-xs text-muted">{{ t("settings.advanced.importBody") }}</p>
|
||||
</div>
|
||||
<button class="dh-btn dh-btn-ghost !px-3 !py-1.5 shrink-0" :disabled="importing" @click="pickImportFile">
|
||||
{{ importing ? t("settings.advanced.importing") : t("settings.advanced.importAction") }}
|
||||
</button>
|
||||
<input ref="importFileInput" type="file" accept="application/json,.json" class="hidden" @change="onImportFileChosen" />
|
||||
</div>
|
||||
<p v-if="importResult" class="mt-2 text-sm font-medium text-success">
|
||||
{{ t("settings.advanced.imported", { cars: importResult.carsImported, services: importResult.servicesImported, parts: importResult.partsImported }) }}
|
||||
</p>
|
||||
<p v-if="importError" class="mt-2 text-sm text-danger">{{ importError }}</p>
|
||||
</section>
|
||||
|
||||
<section class="rounded-card border border-danger/30 bg-danger-soft p-6">
|
||||
<h2 class="mb-2 text-lg font-bold tracking-[-0.02em] text-danger">{{ t("settings.danger.title") }}</h2>
|
||||
|
||||
<template v-if="!deletionPending">
|
||||
<p class="mb-3 text-sm text-danger/90">{{ t("settings.danger.body") }}</p>
|
||||
<button class="dh-btn !border !border-danger/40 !bg-transparent !text-danger hover:!bg-danger/10" @click="showDeleteConfirm = true">
|
||||
{{ t("settings.danger.deleteAccount") }}
|
||||
</button>
|
||||
|
||||
<div v-if="showDeleteConfirm" class="mt-4 rounded-control border border-danger/30 bg-card p-4">
|
||||
<label class="dh-label">
|
||||
{{ tSplit("settings.danger.typeToConfirm", "email").before
|
||||
}}<span class="data text-strong">{{ profile.email }}</span>{{ tSplit("settings.danger.typeToConfirm", "email").after }}
|
||||
</label>
|
||||
<input v-model="deleteConfirmEmail" :placeholder="profile.email" class="dh-input mb-3 max-w-sm" />
|
||||
<div class="flex gap-2">
|
||||
<button class="dh-btn dh-btn-ghost" @click="showDeleteConfirm = false; deleteConfirmEmail = ''">{{ t("common.cancel") }}</button>
|
||||
<button :disabled="!canRequestDelete || deleteRequesting" class="dh-btn dh-btn-danger" @click="requestDeletion">
|
||||
{{ deleteRequesting ? t("settings.danger.requesting") : t("settings.danger.requestDeletion") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<p class="mb-3 text-sm text-danger/90">
|
||||
{{ t("settings.danger.requestedOn", { date: formatDate(profile.deletionRequestedAt) }) }}
|
||||
{{ cooldownElapsed ? t("settings.danger.cooldownPassed") : t("settings.danger.canStillCancel") }}
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
<button class="dh-btn dh-btn-ghost !bg-card" @click="cancelDeletion">{{ t("settings.danger.cancelRequest") }}</button>
|
||||
<button v-if="cooldownElapsed" class="dh-btn dh-btn-danger" @click="finalizeDeletion">
|
||||
{{ t("settings.danger.finalize") }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<p v-if="deleteError" class="mt-3 text-sm font-medium text-danger">{{ deleteError }}</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Integrations -->
|
||||
@@ -1257,93 +1351,16 @@ onBeforeUnmount(() => {
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Personal settings (continued) -->
|
||||
<div v-show="activeTab === 'personal'" class="space-y-6">
|
||||
<!-- Organization: create your own (becoming its admin), or manage it -->
|
||||
<OrgManager />
|
||||
|
||||
<!-- Privacy & Security -->
|
||||
<section class="dh-card p-6">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h2 class="text-lg font-bold tracking-[-0.02em] text-strong">{{ t("settings.privacy.title") }}</h2>
|
||||
<button class="text-sm font-medium text-danger hover:underline" @click="onLogout">
|
||||
{{ t("settings.privacy.signOut") }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-muted">{{ t("settings.privacy.body") }}</p>
|
||||
</section>
|
||||
|
||||
<!-- Advanced / Danger Zone -->
|
||||
<section class="dh-card p-6">
|
||||
<h2 class="mb-4 text-lg font-bold tracking-[-0.02em] text-strong">{{ t("settings.advanced.title") }}</h2>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-strong">{{ t("settings.advanced.exportTitle") }}</p>
|
||||
<p class="text-xs text-muted">{{ t("settings.advanced.exportBody") }}</p>
|
||||
</div>
|
||||
<button class="dh-btn dh-btn-ghost !px-3 !py-1.5 shrink-0" :disabled="exporting" @click="exportData">
|
||||
{{ exporting ? t("settings.advanced.preparing") : t("settings.advanced.exportAction") }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="exportError" class="mt-2 text-sm text-danger">{{ exportError }}</p>
|
||||
|
||||
<div class="mt-4 flex items-center justify-between gap-3 border-t border-subtle pt-4">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-strong">{{ t("settings.advanced.importTitle") }}</p>
|
||||
<p class="text-xs text-muted">{{ t("settings.advanced.importBody") }}</p>
|
||||
</div>
|
||||
<button class="dh-btn dh-btn-ghost !px-3 !py-1.5 shrink-0" :disabled="importing" @click="pickImportFile">
|
||||
{{ importing ? t("settings.advanced.importing") : t("settings.advanced.importAction") }}
|
||||
</button>
|
||||
<input ref="importFileInput" type="file" accept="application/json,.json" class="hidden" @change="onImportFileChosen" />
|
||||
</div>
|
||||
<p v-if="importResult" class="mt-2 text-sm font-medium text-success">
|
||||
{{ t("settings.advanced.imported", { cars: importResult.carsImported, services: importResult.servicesImported, parts: importResult.partsImported }) }}
|
||||
</p>
|
||||
<p v-if="importError" class="mt-2 text-sm text-danger">{{ importError }}</p>
|
||||
</section>
|
||||
|
||||
<section class="rounded-card border border-danger/30 bg-danger-soft p-6">
|
||||
<h2 class="mb-2 text-lg font-bold tracking-[-0.02em] text-danger">{{ t("settings.danger.title") }}</h2>
|
||||
|
||||
<template v-if="!deletionPending">
|
||||
<p class="mb-3 text-sm text-danger/90">{{ t("settings.danger.body") }}</p>
|
||||
<button class="dh-btn !border !border-danger/40 !bg-transparent !text-danger hover:!bg-danger/10" @click="showDeleteConfirm = true">
|
||||
{{ t("settings.danger.deleteAccount") }}
|
||||
</button>
|
||||
|
||||
<div v-if="showDeleteConfirm" class="mt-4 rounded-control border border-danger/30 bg-card p-4">
|
||||
<label class="dh-label">
|
||||
{{ tSplit("settings.danger.typeToConfirm", "email").before
|
||||
}}<span class="data text-strong">{{ profile.email }}</span>{{ tSplit("settings.danger.typeToConfirm", "email").after }}
|
||||
</label>
|
||||
<input v-model="deleteConfirmEmail" :placeholder="profile.email" class="dh-input mb-3 max-w-sm" />
|
||||
<div class="flex gap-2">
|
||||
<button class="dh-btn dh-btn-ghost" @click="showDeleteConfirm = false; deleteConfirmEmail = ''">{{ t("common.cancel") }}</button>
|
||||
<button :disabled="!canRequestDelete || deleteRequesting" class="dh-btn dh-btn-danger" @click="requestDeletion">
|
||||
{{ deleteRequesting ? t("settings.danger.requesting") : t("settings.danger.requestDeletion") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<p class="mb-3 text-sm text-danger/90">
|
||||
{{ t("settings.danger.requestedOn", { date: formatDate(profile.deletionRequestedAt) }) }}
|
||||
{{ cooldownElapsed ? t("settings.danger.cooldownPassed") : t("settings.danger.canStillCancel") }}
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
<button class="dh-btn dh-btn-ghost !bg-card" @click="cancelDeletion">{{ t("settings.danger.cancelRequest") }}</button>
|
||||
<button v-if="cooldownElapsed" class="dh-btn dh-btn-danger" @click="finalizeDeletion">
|
||||
{{ t("settings.danger.finalize") }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<p v-if="deleteError" class="mt-3 text-sm font-medium text-danger">{{ deleteError }}</p>
|
||||
</section>
|
||||
<!-- Users (admins + superadmins) -->
|
||||
<div v-if="isAdmin" v-show="activeTab === 'users'">
|
||||
<AdminUsers />
|
||||
</div>
|
||||
|
||||
<!-- Organization: create your own (becoming its admin), or manage it -->
|
||||
<div v-show="activeTab === 'organization'">
|
||||
<OrgManager />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Reference in new issue
Block a user