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:
tajniak81
2026-08-17 17:38:54 +02:00
co-authored by Claude Opus 5
parent cd16d4383f
commit e373497958
10 changed files with 387 additions and 151 deletions
-246
View File
@@ -1,246 +0,0 @@
<script setup>
import { ref, computed, onMounted } from "vue";
import { api } from "../api";
import { state } from "../auth";
import { formatDate } from "../lib/format.js";
import { t } from "../i18n";
import Modal from "../components/Modal.vue";
const users = ref([]);
const loading = ref(true);
const error = ref("");
// Create-user modal.
const showCreate = ref(false);
const createForm = ref({ email: "", name: "", password: "", role: "user" });
const creating = ref(false);
const createError = ref("");
// Reset-password modal.
const pwUser = ref(null);
const newPassword = ref("");
const savingPw = ref(false);
const pwError = ref("");
const myId = state.user?.id;
const myRole = computed(() => state.profile?.role || state.user?.role || "user");
const isSuperadmin = computed(() => myRole.value === "superadmin");
// Roles this viewer may hand out. Only a superadmin can mint another one; the
// server rejects it either way, this just doesn't offer a doomed option.
const assignableRoles = computed(() =>
isSuperadmin.value ? ["user", "admin", "superadmin"] : ["user", "admin"],
);
// Whether the destructive/role controls should be disabled for a row, with a
// reason. These mirror the server's guards so the UI doesn't offer an action
// that is going to come back as a 400/403.
function deleteBlockedReason(u) {
if (u.id === myId) return t("admin.cantDeleteSelf");
if (u.role === "superadmin" && !isSuperadmin.value) return t("admin.onlySuperadminDeletes");
return "";
}
function roleLockReason(u) {
if (u.id === myId) return t("admin.cantChangeOwnRole");
if (u.role === "superadmin" && !isSuperadmin.value) return t("admin.onlySuperadminEdits");
return "";
}
async function load() {
loading.value = true;
error.value = "";
try {
users.value = await api.listUsers();
} catch (e) {
error.value = e.message;
} finally {
loading.value = false;
}
}
async function changeRole(u, role) {
if (role === u.role) return;
error.value = "";
try {
const updated = await api.updateUser(u.id, { role });
Object.assign(u, updated);
} catch (e) {
error.value = e.message;
await load(); // resync the select back to the true value
}
}
async function submitCreate() {
creating.value = true;
createError.value = "";
try {
await api.createUser({
email: createForm.value.email.trim(),
name: createForm.value.name.trim(),
password: createForm.value.password,
role: createForm.value.role,
});
showCreate.value = false;
createForm.value = { email: "", name: "", password: "", role: "user" };
await load();
} catch (e) {
createError.value = e.message;
} finally {
creating.value = false;
}
}
function openResetPassword(u) {
pwUser.value = u;
newPassword.value = "";
pwError.value = "";
}
async function submitResetPassword() {
savingPw.value = true;
pwError.value = "";
try {
await api.setUserPassword(pwUser.value.id, newPassword.value);
pwUser.value = null;
} catch (e) {
pwError.value = e.message;
} finally {
savingPw.value = false;
}
}
async function removeUser(u) {
if (!confirm(t("admin.confirmDelete", { name: u.name || u.email }))) return;
error.value = "";
try {
await api.deleteUser(u.id);
await load();
} catch (e) {
error.value = e.message;
}
}
onMounted(load);
</script>
<template>
<div>
<div class="mb-6 flex items-end justify-between">
<div>
<p class="eyebrow">{{ t("admin.eyebrow") }}</p>
<h1 class="text-3xl font-bold tracking-[-0.03em] text-strong">{{ t("admin.title") }}</h1>
<p class="mt-1 text-sm text-muted">
{{ isSuperadmin ? t("admin.subtitleAll") : t("admin.subtitleOrg") }}
{{ t("admin.subtitleOrgsNote") }}
</p>
</div>
<button class="dh-btn dh-btn-primary" @click="showCreate = true">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" class="h-4 w-4"><path stroke-linecap="round" stroke-linejoin="round" d="M12 5v14M5 12h14" /></svg>
{{ t("admin.addUser") }}
</button>
</div>
<p v-if="error" class="mb-4 rounded-control bg-danger-soft px-4 py-3 text-sm font-medium text-danger">{{ error }}</p>
<p v-if="loading" class="text-muted">{{ t("common.loading") }}</p>
<div v-else class="dh-card overflow-x-auto p-0">
<table class="min-w-full text-sm">
<thead class="bg-sunken text-left">
<tr class="[&>th]:eyebrow [&>th]:px-4 [&>th]:py-3">
<th>{{ t("admin.colEmail") }}</th>
<th>{{ t("admin.colName") }}</th>
<th>{{ t("admin.colOrganization") }}</th>
<th>{{ t("admin.colRole") }}</th>
<th>{{ t("admin.colCreated") }}</th>
<th></th>
</tr>
</thead>
<tbody class="divide-y divide-subtle">
<tr v-for="u in users" :key="u.id" class="transition-colors hover:bg-sunken">
<td class="px-4 py-3 font-medium text-strong">
{{ u.email }}
<span v-if="u.id === myId" class="ml-1 text-xs text-muted">{{ t("admin.you") }}</span>
</td>
<td class="px-4 py-3 text-body">{{ u.name || t("common.empty") }}</td>
<td class="px-4 py-3 text-body">{{ u.organizationName || t("common.empty") }}</td>
<td class="px-4 py-3">
<select
:value="u.role"
:disabled="!!roleLockReason(u)"
:title="roleLockReason(u)"
class="dh-input w-auto !py-1 !text-xs disabled:opacity-60"
@change="changeRole(u, $event.target.value)"
>
<option v-for="r in assignableRoles" :key="r" :value="r">{{ t(`admin.roles.${r}`) }}</option>
<!-- Keep the current role selectable even when this viewer can't assign it. -->
<option v-if="!assignableRoles.includes(u.role)" :value="u.role">{{ t(`admin.roles.${u.role}`) }}</option>
</select>
</td>
<td class="px-4 py-3 data text-muted">{{ formatDate(u.created) }}</td>
<td class="whitespace-nowrap px-4 py-3 text-right">
<button class="text-xs font-medium text-brandtext hover:underline" @click="openResetPassword(u)">{{ t("admin.resetPassword") }}</button>
<button
class="ml-3 text-xs font-medium text-danger hover:underline disabled:cursor-not-allowed disabled:text-muted disabled:no-underline"
:disabled="!!deleteBlockedReason(u)"
:title="deleteBlockedReason(u)"
@click="removeUser(u)"
>
{{ t("common.delete") }}
</button>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Create user -->
<Modal v-if="showCreate" :title="t('admin.createTitle')" @close="showCreate = false">
<p v-if="createError" class="mb-3 rounded-control bg-danger-soft px-3 py-2 text-sm font-medium text-danger">{{ createError }}</p>
<form class="space-y-3" @submit.prevent="submitCreate">
<div>
<label class="dh-label">{{ t("admin.emailRequired") }}</label>
<input v-model="createForm.email" type="email" required autocomplete="off" class="dh-input" />
</div>
<div>
<label class="dh-label">{{ t("admin.colName") }}</label>
<input v-model="createForm.name" autocomplete="off" class="dh-input" />
</div>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="dh-label">{{ t("admin.passwordRequired") }} <span class="text-muted">{{ t("admin.minChars") }}</span></label>
<input v-model="createForm.password" type="text" required minlength="8" autocomplete="new-password" class="dh-input data" />
</div>
<div>
<label class="dh-label">{{ t("admin.colRole") }}</label>
<select v-model="createForm.role" class="dh-input">
<option v-for="r in assignableRoles" :key="r" :value="r">{{ t(`admin.roles.${r}`) }}</option>
</select>
</div>
</div>
<div class="mt-4 flex justify-end gap-2">
<button type="button" class="dh-btn dh-btn-ghost" @click="showCreate = false">{{ t("common.cancel") }}</button>
<button type="submit" :disabled="creating" class="dh-btn dh-btn-primary">
{{ creating ? t("admin.creating") : t("admin.createUser") }}
</button>
</div>
</form>
</Modal>
<!-- Reset password -->
<Modal v-if="pwUser" :title="t('admin.resetTitle', { email: pwUser.email })" @close="pwUser = null">
<p v-if="pwError" class="mb-3 rounded-control bg-danger-soft px-3 py-2 text-sm font-medium text-danger">{{ pwError }}</p>
<form class="space-y-3" @submit.prevent="submitResetPassword">
<div>
<label class="dh-label">{{ t("admin.newPassword") }} <span class="text-muted">{{ t("admin.minChars") }}</span></label>
<input v-model="newPassword" type="text" required minlength="8" autocomplete="new-password" class="dh-input data" />
</div>
<div class="mt-4 flex justify-end gap-2">
<button type="button" class="dh-btn dh-btn-ghost" @click="pwUser = null">{{ t("common.cancel") }}</button>
<button type="submit" :disabled="savingPw" class="dh-btn dh-btn-primary">
{{ savingPw ? t("common.saving") : t("admin.setPassword") }}
</button>
</div>
</form>
</Modal>
</div>
</template>
+111 -94
View File
@@ -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>