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:
co-authored by
Claude Opus 5
parent
cd16d4383f
commit
e373497958
@@ -0,0 +1,194 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"drivervault/apiserver/internal/config"
|
||||
"drivervault/apiserver/internal/pb"
|
||||
)
|
||||
|
||||
// These tests cover who a newly created account belongs to, through the real
|
||||
// Handler + middleware chain: an admin's members are pinned to the admin's own
|
||||
// organization no matter what the client sends, while a superadmin picks the
|
||||
// organization freely — including omitting it to create an org-less account.
|
||||
// A stand-in PocketBase (userFakePB) serves the calls those paths hit.
|
||||
|
||||
const (
|
||||
userTestCallerID = "u1"
|
||||
userTestBearer = "user-bearer-token"
|
||||
userTestNewID = "newuser1"
|
||||
userTestOrgA = "orgA" // the caller's own organization
|
||||
userTestOrgB = "orgB" // some other organization
|
||||
)
|
||||
|
||||
// userFakePB answers the identity and user-create calls, recording the create
|
||||
// payload so tests can assert on the organization that was actually written.
|
||||
type userFakePB struct {
|
||||
mu sync.Mutex
|
||||
|
||||
// Identity returned by auth-refresh.
|
||||
callerRole string
|
||||
callerOrg string
|
||||
|
||||
// Recorded effects.
|
||||
createdUsers []map[string]any
|
||||
}
|
||||
|
||||
func (f *userFakePB) handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// Service-account auth (pb.Client.Authenticate).
|
||||
mux.HandleFunc("POST /api/collections/_superusers/auth-with-password", func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, 200, map[string]any{"token": "svc-token"})
|
||||
})
|
||||
|
||||
// Identify the bearer (withAuth → identify → AuthRefresh).
|
||||
mux.HandleFunc("POST /api/collections/users/auth-refresh", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") == "" {
|
||||
writeJSON(w, 401, map[string]any{})
|
||||
return
|
||||
}
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
writeJSON(w, 200, map[string]any{"record": map[string]any{
|
||||
"id": userTestCallerID, "email": "boss@test.local", "name": "Boss",
|
||||
"role": f.callerRole, "organization": f.callerOrg,
|
||||
}})
|
||||
})
|
||||
|
||||
// User create.
|
||||
mux.HandleFunc("POST /api/collections/users/records", func(w http.ResponseWriter, r *http.Request) {
|
||||
var body map[string]any
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
f.mu.Lock()
|
||||
f.createdUsers = append(f.createdUsers, body)
|
||||
f.mu.Unlock()
|
||||
org, _ := body["organization"].(string)
|
||||
writeJSON(w, 200, map[string]any{
|
||||
"id": userTestNewID, "email": body["email"], "name": body["name"],
|
||||
"role": body["role"], "verified": true, "organization": org,
|
||||
"created": "2026-08-17 10:00:00Z",
|
||||
})
|
||||
})
|
||||
|
||||
// Organization name lookup for the created record.
|
||||
mux.HandleFunc("GET /api/collections/organizations/records/{id}", func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, 200, map[string]any{"id": r.PathValue("id"), "name": "Org " + r.PathValue("id")})
|
||||
})
|
||||
|
||||
return mux
|
||||
}
|
||||
|
||||
// newUserTestServer wires the fake PocketBase to a real Handler and returns the
|
||||
// app's base URL.
|
||||
func newUserTestServer(t *testing.T, f *userFakePB) string {
|
||||
t.Helper()
|
||||
pbSrv := httptest.NewServer(f.handler())
|
||||
t.Cleanup(pbSrv.Close)
|
||||
|
||||
s := New(config.Config{UsersCollection: "users"}, pb.New(pbSrv.URL, "admin@test.local", "pw"))
|
||||
appSrv := httptest.NewServer(s.Handler())
|
||||
t.Cleanup(appSrv.Close)
|
||||
return appSrv.URL
|
||||
}
|
||||
|
||||
// An admin's new members join the admin's own organization, even when the
|
||||
// request asks for a different one.
|
||||
func TestCreateUserAdminForcesOwnOrganization(t *testing.T) {
|
||||
f := &userFakePB{callerRole: roleAdmin, callerOrg: userTestOrgA}
|
||||
base := newUserTestServer(t, f)
|
||||
|
||||
status, body := orgReq(t, base, http.MethodPost, "/api/users", map[string]any{
|
||||
"email": "member@test.local", "password": "hunter2hunter2",
|
||||
"name": "Member", "role": "user", "organization": userTestOrgB,
|
||||
})
|
||||
if status != http.StatusCreated {
|
||||
t.Fatalf("status = %d, want 201 (body %v)", status, body)
|
||||
}
|
||||
if len(f.createdUsers) != 1 {
|
||||
t.Fatalf("created users = %d, want 1 (%v)", len(f.createdUsers), f.createdUsers)
|
||||
}
|
||||
if got := f.createdUsers[0]["organization"]; got != userTestOrgA {
|
||||
t.Errorf("created organization = %v, want the admin's own org %q", got, userTestOrgA)
|
||||
}
|
||||
}
|
||||
|
||||
// An admin with no organization has nowhere to put a member, so the create is
|
||||
// refused rather than producing a stray org-less account.
|
||||
func TestCreateUserAdminWithoutOrganizationRejected(t *testing.T) {
|
||||
f := &userFakePB{callerRole: roleAdmin, callerOrg: ""}
|
||||
base := newUserTestServer(t, f)
|
||||
|
||||
status, body := orgReq(t, base, http.MethodPost, "/api/users", map[string]any{
|
||||
"email": "member@test.local", "password": "hunter2hunter2",
|
||||
})
|
||||
if status != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want 403 (body %v)", status, body)
|
||||
}
|
||||
if len(f.createdUsers) != 0 {
|
||||
t.Errorf("created users = %v, want none", f.createdUsers)
|
||||
}
|
||||
}
|
||||
|
||||
// A superadmin picks the organization, and it is passed through untouched.
|
||||
func TestCreateUserSuperadminHonoursChosenOrganization(t *testing.T) {
|
||||
f := &userFakePB{callerRole: roleSuperadmin, callerOrg: ""}
|
||||
base := newUserTestServer(t, f)
|
||||
|
||||
status, body := orgReq(t, base, http.MethodPost, "/api/users", map[string]any{
|
||||
"email": "member@test.local", "password": "hunter2hunter2",
|
||||
"name": "Member", "role": "admin", "organization": userTestOrgB,
|
||||
})
|
||||
if status != http.StatusCreated {
|
||||
t.Fatalf("status = %d, want 201 (body %v)", status, body)
|
||||
}
|
||||
if len(f.createdUsers) != 1 {
|
||||
t.Fatalf("created users = %d, want 1 (%v)", len(f.createdUsers), f.createdUsers)
|
||||
}
|
||||
if got := f.createdUsers[0]["organization"]; got != userTestOrgB {
|
||||
t.Errorf("created organization = %v, want %q", got, userTestOrgB)
|
||||
}
|
||||
user, _ := body["user"].(map[string]any)
|
||||
if user["organization"] != userTestOrgB {
|
||||
t.Errorf("returned organization = %v, want %q", user["organization"], userTestOrgB)
|
||||
}
|
||||
if user["organizationName"] != "Org "+userTestOrgB {
|
||||
t.Errorf("returned organizationName = %v, want the resolved name", user["organizationName"])
|
||||
}
|
||||
}
|
||||
|
||||
// Leaving the picker empty is a deliberate choice: the account is created with
|
||||
// no organization at all, rather than the field being rejected or defaulted.
|
||||
func TestCreateUserSuperadminCanOmitOrganization(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
body map[string]any
|
||||
}{
|
||||
{"field absent", map[string]any{"email": "solo@test.local", "password": "hunter2hunter2"}},
|
||||
{"field empty", map[string]any{"email": "solo@test.local", "password": "hunter2hunter2", "organization": ""}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
f := &userFakePB{callerRole: roleSuperadmin, callerOrg: ""}
|
||||
base := newUserTestServer(t, f)
|
||||
|
||||
status, body := orgReq(t, base, http.MethodPost, "/api/users", tc.body)
|
||||
if status != http.StatusCreated {
|
||||
t.Fatalf("status = %d, want 201 (body %v)", status, body)
|
||||
}
|
||||
if len(f.createdUsers) != 1 {
|
||||
t.Fatalf("created users = %d, want 1 (%v)", len(f.createdUsers), f.createdUsers)
|
||||
}
|
||||
if got, ok := f.createdUsers[0]["organization"]; ok {
|
||||
t.Errorf("created organization = %v, want the field to be omitted entirely", got)
|
||||
}
|
||||
user, _ := body["user"].(map[string]any)
|
||||
if user["organization"] != "" {
|
||||
t.Errorf("returned organization = %v, want empty", user["organization"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+12
-11
@@ -21,7 +21,7 @@ server/ Go BFF: embeds web/dist, proxies /api -> API_BASE
|
||||
web/ Vue 3 + Vite + Tailwind v4 source
|
||||
src/
|
||||
main.js app bootstrap
|
||||
router.js /login, / (dashboard), /charging, /cars/:id, /settings, /admin
|
||||
router.js /login, / (dashboard), /charging, /cars/:id, /settings
|
||||
api.js the only place that calls the API Server (base URL resolution)
|
||||
auth.js token/profile state, isAdmin
|
||||
prefs.js theme/locale/date/font preferences -> <html>
|
||||
@@ -29,12 +29,12 @@ web/ Vue 3 + Vite + Tailwind v4 source
|
||||
lib/format.js date/km formatting + next-service status badges
|
||||
lib/attachment.js upload / fetch / open a record's attached file
|
||||
style.css Tailwind v4 entry (+ dark custom-variant)
|
||||
App.vue layout shell + nav (Charging + Admin links when relevant)
|
||||
App.vue layout shell + nav (Garage, Charging, Settings)
|
||||
components/ Modal, AttachmentField, CarFormModal, ServiceFormModal,
|
||||
TechnicalCheckFormModal, MaintenanceFormModal, FuelFormModal,
|
||||
DocumentFormModal, ReminderFormModal, PartFormModal, ShareModal,
|
||||
OrgManager, Logo
|
||||
views/ Login, Dashboard, CarDetail, Charging, Settings, AdminUsers
|
||||
OrgManager, AdminUsers, Logo
|
||||
views/ Login, Dashboard, CarDetail, Charging, Settings
|
||||
```
|
||||
|
||||
## Requirements
|
||||
@@ -116,13 +116,14 @@ Config (`server/.env`, copy from `.env.example`):
|
||||
- **Charging** — the EV charging screen for connected Anker Solix chargers:
|
||||
live status and, in own/proxy control mode, start/stop and charge-limit
|
||||
controls driven by the API Server's OCPP Central System.
|
||||
- **Settings** — split into tabs: account (name / email verification / password),
|
||||
appearance (theme light/dark/system, locale, date format, currency, font size),
|
||||
profile (avatar, bio), **integrations** (Toyota, Anker Solix), **organization**
|
||||
(create your own — which makes you its admin — or rename/delete the one you
|
||||
administer), data **export/import**, and the account-deletion state machine.
|
||||
- **Admin** — `/admin` user management (list / create / role / reset password /
|
||||
delete), gated by the admin role via a router guard + nav link.
|
||||
- **Settings** — split into tabs: *Personal settings* — account (name / email
|
||||
verification / password), appearance (theme light/dark/system, locale, date
|
||||
format, currency, font size), profile (avatar, bio), data **export/import**,
|
||||
and the account-deletion state machine; *Integrations* (Toyota, Anker Solix);
|
||||
*Users* for admins; and *Organization* (create your own — which makes you its
|
||||
admin — or rename/delete the one you administer).
|
||||
- **Users** — user management (list / create / role / reset password / delete)
|
||||
as the admin-only Settings tab; `/admin` redirects there for old links.
|
||||
- **Theming** — light/dark/system app-wide (Tailwind v4 class strategy); `prefs.js`
|
||||
toggles `.dark` on `<html>` and applies the saved theme/locale/date/font.
|
||||
|
||||
|
||||
+8
-13
@@ -1,7 +1,7 @@
|
||||
<script setup>
|
||||
import { onMounted, onBeforeUnmount, computed, ref } from "vue";
|
||||
import { RouterView, RouterLink, useRouter, useRoute } from "vue-router";
|
||||
import { state, isAuthenticated, isAdmin, logout, refreshProfile } from "./auth";
|
||||
import { state, isAuthenticated, logout, refreshProfile } from "./auth";
|
||||
import { prefs, applyProfilePrefs } from "./prefs";
|
||||
import { api } from "./api";
|
||||
import { t } from "./i18n";
|
||||
@@ -32,15 +32,12 @@ const userInitial = computed(() =>
|
||||
(state.user?.name || state.user?.email || "?").charAt(0).toUpperCase()
|
||||
);
|
||||
|
||||
// Sidebar nav. Admin item is filtered out for non-admins.
|
||||
const nav = computed(() =>
|
||||
[
|
||||
{ to: "/", label: t("nav.garage"), icon: "grid", exact: true },
|
||||
{ to: "/charging", label: t("nav.charging"), icon: "bolt" },
|
||||
{ to: "/settings", label: t("nav.settings"), icon: "gear" },
|
||||
isAdmin.value ? { to: "/admin", label: t("nav.users"), icon: "users" } : null,
|
||||
].filter(Boolean)
|
||||
);
|
||||
// Sidebar nav. User management is a tab inside Settings, not a rail item.
|
||||
const nav = computed(() => [
|
||||
{ to: "/", label: t("nav.garage"), icon: "grid", exact: true },
|
||||
{ to: "/charging", label: t("nav.charging"), icon: "bolt" },
|
||||
{ to: "/settings", label: t("nav.settings"), icon: "gear" },
|
||||
]);
|
||||
|
||||
function active(item) {
|
||||
return item.exact ? route.path === item.to : route.path.startsWith(item.to);
|
||||
@@ -88,9 +85,7 @@ onBeforeUnmount(() => themeObserver?.disconnect());
|
||||
<!-- charging -->
|
||||
<svg v-else-if="item.icon === 'bolt'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" class="h-5 w-5 shrink-0" :style="active(item) ? 'color:#60A5FA' : ''"><path stroke-linecap="round" stroke-linejoin="round" d="M13 2 4.5 13.5H11l-1 8.5 8.5-11.5H12z"/></svg>
|
||||
<!-- settings -->
|
||||
<svg v-else-if="item.icon === 'gear'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" class="h-5 w-5 shrink-0" :style="active(item) ? 'color:#60A5FA' : ''"><path stroke-linecap="round" stroke-linejoin="round" d="M9.6 3.6 9 6a7.5 7.5 0 0 0-1.7 1L5 6.3l-2 3.4 2 1.5a7.6 7.6 0 0 0 0 2l-2 1.5 2 3.4 2.3-.7c.5.4 1.1.8 1.7 1l.6 2.4h4l.6-2.4c.6-.2 1.2-.6 1.7-1l2.3.7 2-3.4-2-1.5a7.6 7.6 0 0 0 0-2l2-1.5-2-3.4-2.3.7A7.5 7.5 0 0 0 15 6l-.6-2.4z"/><circle cx="12" cy="12" r="2.6"/></svg>
|
||||
<!-- users -->
|
||||
<svg v-else viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" class="h-5 w-5 shrink-0" :style="active(item) ? 'color:#60A5FA' : ''"><path stroke-linecap="round" stroke-linejoin="round" d="M16 19v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2M9 9a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Zm13 10v-2a4 4 0 0 0-3-3.9M16 2.1A4 4 0 0 1 16 9.9"/></svg>
|
||||
<svg v-else viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" class="h-5 w-5 shrink-0" :style="active(item) ? 'color:#60A5FA' : ''"><path stroke-linecap="round" stroke-linejoin="round" d="M9.6 3.6 9 6a7.5 7.5 0 0 0-1.7 1L5 6.3l-2 3.4 2 1.5a7.6 7.6 0 0 0 0 2l-2 1.5 2 3.4 2.3-.7c.5.4 1.1.8 1.7 1l.6 2.4h4l.6-2.4c.6-.2 1.2-.6 1.7-1l2.3.7 2-3.4-2-1.5a7.6 7.6 0 0 0 0-2l2-1.5-2-3.4-2.3.7A7.5 7.5 0 0 0 15 6l-.6-2.4z"/><circle cx="12" cy="12" r="2.6"/></svg>
|
||||
<span class="hidden md:inline">{{ item.label }}</span>
|
||||
</RouterLink>
|
||||
</nav>
|
||||
|
||||
@@ -17,7 +17,7 @@ export const isAuthenticated = computed(() => !!state.token);
|
||||
|
||||
// Roles that may manage users. A superadmin is an admin that also spans every
|
||||
// organization; the API Server enforces the difference, the UI just needs to
|
||||
// know whether to offer the Users screen at all.
|
||||
// know whether to offer the Settings › Users tab at all.
|
||||
const MANAGER_ROLES = ["admin", "superadmin"];
|
||||
|
||||
// Admin gate for the UI. Driven by the full profile (fetched from /api/me),
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<script setup>
|
||||
// User management, shown as the Users tab of Settings (admins + superadmins).
|
||||
// Admins see their own organization; superadmins see every account.
|
||||
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";
|
||||
import Modal from "./Modal.vue";
|
||||
|
||||
const users = ref([]);
|
||||
const loading = ref(true);
|
||||
@@ -12,10 +14,14 @@ const error = ref("");
|
||||
|
||||
// Create-user modal.
|
||||
const showCreate = ref(false);
|
||||
const createForm = ref({ email: "", name: "", password: "", role: "user" });
|
||||
const createForm = ref({ email: "", name: "", password: "", role: "user", organization: "" });
|
||||
const creating = ref(false);
|
||||
const createError = ref("");
|
||||
|
||||
// Organizations a superadmin can drop the new account into. Admins get no
|
||||
// picker: the server puts their members in their own org regardless.
|
||||
const orgs = ref([]);
|
||||
|
||||
// Reset-password modal.
|
||||
const pwUser = ref(null);
|
||||
const newPassword = ref("");
|
||||
@@ -74,14 +80,19 @@ async function submitCreate() {
|
||||
creating.value = true;
|
||||
createError.value = "";
|
||||
try {
|
||||
await api.createUser({
|
||||
const body = {
|
||||
email: createForm.value.email.trim(),
|
||||
name: createForm.value.name.trim(),
|
||||
password: createForm.value.password,
|
||||
role: createForm.value.role,
|
||||
});
|
||||
};
|
||||
// Only a superadmin picks the organization — an empty pick deliberately
|
||||
// creates an org-less account. For an admin the server forces its own org,
|
||||
// so sending anything here would be noise.
|
||||
if (isSuperadmin.value) body.organization = createForm.value.organization;
|
||||
await api.createUser(body);
|
||||
showCreate.value = false;
|
||||
createForm.value = { email: "", name: "", password: "", role: "user" };
|
||||
createForm.value = { email: "", name: "", password: "", role: "user", organization: "" };
|
||||
await load();
|
||||
} catch (e) {
|
||||
createError.value = e.message;
|
||||
@@ -120,21 +131,31 @@ async function removeUser(u) {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
onMounted(async () => {
|
||||
await load();
|
||||
// Listing orgs is manager-only and an admin only ever gets their own back, so
|
||||
// fetch it just for the superadmin picker. A failure leaves the picker empty
|
||||
// rather than breaking the table.
|
||||
if (isSuperadmin.value) {
|
||||
try {
|
||||
orgs.value = (await api.listOrgs()) || [];
|
||||
} catch {
|
||||
orgs.value = [];
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="mb-6 flex items-end justify-between">
|
||||
<div class="mb-4 flex items-center justify-between gap-3">
|
||||
<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">
|
||||
<h2 class="text-lg font-bold tracking-[-0.02em] text-strong">{{ t("admin.title") }}</h2>
|
||||
<p class="mt-0.5 text-xs text-muted">
|
||||
{{ isSuperadmin ? t("admin.subtitleAll") : t("admin.subtitleOrg") }}
|
||||
{{ t("admin.subtitleOrgsNote") }}
|
||||
</p>
|
||||
</div>
|
||||
<button class="dh-btn dh-btn-primary" @click="showCreate = true">
|
||||
<button class="dh-btn dh-btn-primary shrink-0" @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>
|
||||
@@ -217,6 +238,16 @@ onMounted(load);
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Superadmins choose the organization (or leave the account org-less);
|
||||
an admin's members always land in the admin's own organization. -->
|
||||
<div v-if="isSuperadmin">
|
||||
<label class="dh-label">{{ t("admin.colOrganization") }}</label>
|
||||
<select v-model="createForm.organization" class="dh-input">
|
||||
<option value="">{{ t("admin.noOrganization") }}</option>
|
||||
<option v-for="o in orgs" :key="o.id" :value="o.id">{{ o.name }}</option>
|
||||
</select>
|
||||
<p class="mt-1 text-xs text-muted">{{ t("admin.organizationHint") }}</p>
|
||||
</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">
|
||||
@@ -26,7 +26,6 @@
|
||||
"garage": "Garage",
|
||||
"charging": "Opladning",
|
||||
"settings": "Indstillinger",
|
||||
"users": "Brugere",
|
||||
"lightMode": "Lys tilstand",
|
||||
"darkMode": "Mørk tilstand",
|
||||
"signedIn": "Logget ind",
|
||||
@@ -104,15 +103,15 @@
|
||||
},
|
||||
|
||||
"admin": {
|
||||
"eyebrow": "Administration",
|
||||
"title": "Brugere",
|
||||
"subtitleAll": "Konti på tværs af alle organisationer.",
|
||||
"subtitleOrg": "Konti i din organisation.",
|
||||
"subtitleOrgsNote": "Organisationer administreres under Indstillinger.",
|
||||
"addUser": "Tilføj bruger",
|
||||
"colEmail": "E-mail",
|
||||
"colName": "Navn",
|
||||
"colOrganization": "Organisation",
|
||||
"noOrganization": "— Ingen organisation —",
|
||||
"organizationHint": "Lad feltet stå tomt for at oprette en konto uden organisation.",
|
||||
"colRole": "Rolle",
|
||||
"colCreated": "Oprettet",
|
||||
"you": "(dig)",
|
||||
@@ -144,7 +143,9 @@
|
||||
"subtitle": "Administrer din konto, udseende og dine data.",
|
||||
"tabs": {
|
||||
"personal": "Personlige indstillinger",
|
||||
"integrations": "Integrationer"
|
||||
"integrations": "Integrationer",
|
||||
"users": "Brugere",
|
||||
"organization": "Organisation"
|
||||
},
|
||||
|
||||
"account": {
|
||||
|
||||
@@ -26,7 +26,6 @@
|
||||
"garage": "Garage",
|
||||
"charging": "Charging",
|
||||
"settings": "Settings",
|
||||
"users": "Users",
|
||||
"lightMode": "Light mode",
|
||||
"darkMode": "Dark mode",
|
||||
"signedIn": "Signed in",
|
||||
@@ -122,15 +121,15 @@
|
||||
},
|
||||
|
||||
"admin": {
|
||||
"eyebrow": "Admin",
|
||||
"title": "Users",
|
||||
"subtitleAll": "Accounts across every organization.",
|
||||
"subtitleOrg": "Accounts in your organization.",
|
||||
"subtitleOrgsNote": "Organizations are managed in Settings.",
|
||||
"addUser": "Add user",
|
||||
"colEmail": "Email",
|
||||
"colName": "Name",
|
||||
"colOrganization": "Organization",
|
||||
"noOrganization": "— No organization —",
|
||||
"organizationHint": "Leave unset to create an account that belongs to no organization.",
|
||||
"colRole": "Role",
|
||||
"colCreated": "Created",
|
||||
"you": "(you)",
|
||||
@@ -162,7 +161,9 @@
|
||||
"subtitle": "Manage your account, appearance, and data.",
|
||||
"tabs": {
|
||||
"personal": "Personal settings",
|
||||
"integrations": "Integrations"
|
||||
"integrations": "Integrations",
|
||||
"users": "Users",
|
||||
"organization": "Organization"
|
||||
},
|
||||
|
||||
"account": {
|
||||
|
||||
@@ -26,7 +26,6 @@
|
||||
"garage": "Garaż",
|
||||
"charging": "Ładowanie",
|
||||
"settings": "Ustawienia",
|
||||
"users": "Użytkownicy",
|
||||
"lightMode": "Tryb jasny",
|
||||
"darkMode": "Tryb ciemny",
|
||||
"signedIn": "Zalogowano",
|
||||
@@ -108,15 +107,15 @@
|
||||
},
|
||||
|
||||
"admin": {
|
||||
"eyebrow": "Administracja",
|
||||
"title": "Użytkownicy",
|
||||
"subtitleAll": "Konta ze wszystkich organizacji.",
|
||||
"subtitleOrg": "Konta w Twojej organizacji.",
|
||||
"subtitleOrgsNote": "Organizacjami zarządza się w Ustawieniach.",
|
||||
"addUser": "Dodaj użytkownika",
|
||||
"colEmail": "E-mail",
|
||||
"colName": "Imię i nazwisko",
|
||||
"colOrganization": "Organizacja",
|
||||
"noOrganization": "— Bez organizacji —",
|
||||
"organizationHint": "Zostaw puste, aby utworzyć konto bez organizacji.",
|
||||
"colRole": "Rola",
|
||||
"colCreated": "Utworzono",
|
||||
"you": "(Ty)",
|
||||
@@ -148,7 +147,9 @@
|
||||
"subtitle": "Zarządzaj kontem, wyglądem i danymi.",
|
||||
"tabs": {
|
||||
"personal": "Ustawienia osobiste",
|
||||
"integrations": "Integracje"
|
||||
"integrations": "Integracje",
|
||||
"users": "Użytkownicy",
|
||||
"organization": "Organizacja"
|
||||
},
|
||||
|
||||
"account": {
|
||||
|
||||
@@ -4,8 +4,7 @@ import CarDetail from "./views/CarDetail.vue";
|
||||
import Charging from "./views/Charging.vue";
|
||||
import Login from "./views/Login.vue";
|
||||
import Settings from "./views/Settings.vue";
|
||||
import AdminUsers from "./views/AdminUsers.vue";
|
||||
import { isAuthenticated, isAdmin } from "./auth";
|
||||
import { isAuthenticated } from "./auth";
|
||||
|
||||
const routes = [
|
||||
{ path: "/login", name: "login", component: Login, meta: { public: true } },
|
||||
@@ -13,7 +12,8 @@ const routes = [
|
||||
{ path: "/charging", name: "charging", component: Charging },
|
||||
{ path: "/cars/:id", name: "car", component: CarDetail, props: true },
|
||||
{ path: "/settings", name: "settings", component: Settings },
|
||||
{ path: "/admin", name: "admin", component: AdminUsers, meta: { admin: true } },
|
||||
// User management moved into Settings; keep old links working.
|
||||
{ path: "/admin", redirect: { name: "settings", query: { tab: "users" } } },
|
||||
];
|
||||
|
||||
const router = createRouter({
|
||||
@@ -29,11 +29,6 @@ router.beforeEach((to) => {
|
||||
if (to.name === "login" && isAuthenticated.value) {
|
||||
return { name: "dashboard" };
|
||||
}
|
||||
// Admin-only routes: bounce non-admins to the dashboard. (Server enforces the
|
||||
// real gate; this just avoids showing a page that would 403 on every call.)
|
||||
if (to.meta.admin && !isAdmin.value) {
|
||||
return { name: "dashboard" };
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -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