Restructure Web App into server/ + web/ (GsmNode parity)
Reorganize the Web App to match the GsmNode project layout: a Go backend-for-frontend in server/ that embeds the built SPA and reverse-proxies /api/* to the API Server, with the Vue 3 + Vite frontend moved into web/. - Move all frontend files into web/ (history preserved via renames) - Point vite build output at ../server/dist for Go embedding - Add server/ Go BFF (main.go, go.mod, .env.example, Run-WebApp.ps1) - Drop Docker/nginx deploy (Dockerfile, docker-compose.yml, nginx.conf.template, .dockerignore) in favor of the BFF, matching GsmNode - Update .claude/launch.json to run the dev server from web/ - Rewrite README.md for the new layout Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
ba3f227361
commit
75a2ccc226
@@ -0,0 +1,231 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from "vue";
|
||||
import { api } from "../api";
|
||||
import { state } from "../auth";
|
||||
import { formatDate } from "../lib/format.js";
|
||||
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 adminCount = () => users.value.filter((u) => u.role === "admin").length;
|
||||
|
||||
// Whether the destructive/demote controls should be disabled for a row, with a
|
||||
// reason (mirrors the server guards so the UI doesn't offer a doomed action).
|
||||
function deleteBlockedReason(u) {
|
||||
if (u.id === myId) return "You can't delete your own account.";
|
||||
if (u.role === "admin" && adminCount() <= 1) return "Can't delete the last admin.";
|
||||
return "";
|
||||
}
|
||||
function roleLockReason(u) {
|
||||
if (u.role === "admin" && adminCount() <= 1) return "Can't demote the last admin.";
|
||||
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(`Delete ${u.name || u.email}? This cannot be undone.`)) 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">Admin</p>
|
||||
<h1 class="text-3xl font-bold tracking-[-0.03em] text-strong">Users</h1>
|
||||
<p class="mt-1 text-sm text-muted">Manage accounts and roles.</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>
|
||||
Add user
|
||||
</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">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>Email</th>
|
||||
<th>Name</th>
|
||||
<th>Role</th>
|
||||
<th>Created</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">(you)</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-body">{{ u.name || '—' }}</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 value="user">user</option>
|
||||
<option value="admin">admin</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)">Reset password</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)"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Create user -->
|
||||
<Modal v-if="showCreate" title="Add a user" @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">Email *</label>
|
||||
<input v-model="createForm.email" type="email" required autocomplete="off" class="dh-input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="dh-label">Name</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">Password * <span class="text-muted">(min 8)</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">Role</label>
|
||||
<select v-model="createForm.role" class="dh-input">
|
||||
<option value="user">user</option>
|
||||
<option value="admin">admin</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">Cancel</button>
|
||||
<button type="submit" :disabled="creating" class="dh-btn dh-btn-primary">
|
||||
{{ creating ? "Creating…" : "Create user" }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<!-- Reset password -->
|
||||
<Modal v-if="pwUser" :title="`Reset password — ${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">New password <span class="text-muted">(min 8)</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">Cancel</button>
|
||||
<button type="submit" :disabled="savingPw" class="dh-btn dh-btn-primary">
|
||||
{{ savingPw ? "Saving…" : "Set password" }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,364 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { api } from "../api";
|
||||
import { formatDate, formatKm, serviceStatus } from "../lib/format.js";
|
||||
import CarFormModal from "../components/CarFormModal.vue";
|
||||
import ServiceFormModal from "../components/ServiceFormModal.vue";
|
||||
import PartFormModal from "../components/PartFormModal.vue";
|
||||
import ShareModal from "../components/ShareModal.vue";
|
||||
|
||||
const props = defineProps({ id: { type: String, required: true } });
|
||||
const router = useRouter();
|
||||
|
||||
const car = ref(null);
|
||||
const services = ref([]);
|
||||
const parts = ref([]);
|
||||
const loading = ref(true);
|
||||
const error = ref("");
|
||||
|
||||
// Modal state. `editing*` null => create mode; an object => edit that record.
|
||||
const showCarEdit = ref(false);
|
||||
const showService = ref(false);
|
||||
const editingService = ref(null);
|
||||
const showPart = ref(false);
|
||||
const editingPart = ref(null);
|
||||
|
||||
// Delete-car confirmation (guarded: user must type the car name).
|
||||
const showDeleteCar = ref(false);
|
||||
const deleteConfirmText = ref("");
|
||||
const deletingCar = ref(false);
|
||||
|
||||
const showShare = ref(false);
|
||||
|
||||
const latest = computed(() => services.value[0] || null);
|
||||
const status = computed(() => serviceStatus(latest.value, car.value));
|
||||
|
||||
// Access gating. Owner can do everything (incl. delete + sharing); a "write"
|
||||
// sharee can edit the car and its records/parts but not delete/share; "read"
|
||||
// is view-only. Default to owner while loading so nothing flickers as editable
|
||||
// for a read-only user (car is null until loaded, so buttons are hidden anyway).
|
||||
const isOwner = computed(() => car.value?.access === "owner");
|
||||
const canWrite = computed(() => car.value?.access === "owner" || car.value?.access === "write");
|
||||
const isReadOnly = computed(() => car.value?.access === "read");
|
||||
|
||||
const activeTab = ref("info");
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
[car.value, services.value, parts.value] = await Promise.all([
|
||||
api.getCar(props.id),
|
||||
api.listCarServices(props.id),
|
||||
api.listCarParts(props.id),
|
||||
]);
|
||||
} catch (e) {
|
||||
error.value = e.message;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openAddService() {
|
||||
editingService.value = null;
|
||||
showService.value = true;
|
||||
}
|
||||
function openEditService(s) {
|
||||
editingService.value = s;
|
||||
showService.value = true;
|
||||
}
|
||||
async function onServiceSaved() {
|
||||
showService.value = false;
|
||||
editingService.value = null;
|
||||
await load();
|
||||
}
|
||||
async function deleteService(id) {
|
||||
if (!confirm("Delete this service record?")) return;
|
||||
try {
|
||||
await api.deleteService(id);
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
function openAddPart() {
|
||||
editingPart.value = null;
|
||||
showPart.value = true;
|
||||
}
|
||||
function openEditPart(p) {
|
||||
editingPart.value = p;
|
||||
showPart.value = true;
|
||||
}
|
||||
async function onPartSaved() {
|
||||
showPart.value = false;
|
||||
editingPart.value = null;
|
||||
parts.value = await api.listCarParts(props.id);
|
||||
}
|
||||
async function deletePart(id) {
|
||||
if (!confirm("Delete this part?")) return;
|
||||
try {
|
||||
await api.deletePart(id);
|
||||
parts.value = await api.listCarParts(props.id);
|
||||
} catch (e) {
|
||||
error.value = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function onCarSaved(updated) {
|
||||
showCarEdit.value = false;
|
||||
car.value = updated;
|
||||
}
|
||||
|
||||
function openDeleteCar() {
|
||||
deleteConfirmText.value = "";
|
||||
showDeleteCar.value = true;
|
||||
}
|
||||
|
||||
// Enabled only once the typed name matches — guards this cascade delete.
|
||||
const canDeleteCar = computed(
|
||||
() => car.value && deleteConfirmText.value.trim() === car.value.name
|
||||
);
|
||||
|
||||
async function confirmDeleteCar() {
|
||||
if (!canDeleteCar.value) return;
|
||||
deletingCar.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
await api.deleteCar(props.id);
|
||||
router.push({ name: "dashboard" });
|
||||
} catch (e) {
|
||||
error.value = e.message;
|
||||
deletingCar.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function yn(v) {
|
||||
return v ? "Yes" : "No";
|
||||
}
|
||||
|
||||
const FUEL_LABELS = {
|
||||
petrol: "Petrol (gasoline)",
|
||||
diesel: "Diesel",
|
||||
hybrid: "Hybrid",
|
||||
electric: "Electric",
|
||||
};
|
||||
function fuelLabel(v) {
|
||||
return FUEL_LABELS[v] || "—";
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<RouterLink to="/" class="mb-4 inline-flex items-center gap-1 text-sm font-medium text-brandtext hover:underline">
|
||||
← All cars
|
||||
</RouterLink>
|
||||
|
||||
<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">Loading…</p>
|
||||
|
||||
<template v-else-if="car">
|
||||
<!-- Header -->
|
||||
<div class="dh-card mb-6 p-6">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold tracking-[-0.03em] text-strong">{{ car.name }}</h1>
|
||||
<p class="text-sm text-muted">
|
||||
{{ [car.make, car.model, car.year || ''].filter(Boolean).join(' ') }}
|
||||
<span v-if="car.registration"> · {{ car.registration }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span v-if="!isOwner" class="dh-badge dh-badge-neutral">
|
||||
Shared{{ isReadOnly ? ' · read-only' : '' }}
|
||||
</span>
|
||||
<span :class="status.classes">{{ status.label }}</span>
|
||||
<button v-if="isOwner" class="dh-btn dh-btn-ghost !px-3 !py-1.5" @click="showShare = true">Share</button>
|
||||
<button v-if="canWrite" class="dh-btn dh-btn-ghost !px-3 !py-1.5" @click="showCarEdit = true">Edit</button>
|
||||
<button v-if="isOwner" class="dh-btn !px-3 !py-1.5 border border-danger/30 text-danger hover:bg-danger-soft" @click="openDeleteCar">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="mb-6 flex gap-1 border-b border-subtle">
|
||||
<button
|
||||
class="-mb-px border-b-2 px-4 py-2.5 text-sm font-semibold transition-colors"
|
||||
:class="activeTab === 'info'
|
||||
? 'border-accent text-brandtext'
|
||||
: 'border-transparent text-muted hover:text-strong'"
|
||||
@click="activeTab = 'info'">
|
||||
Information
|
||||
</button>
|
||||
<button
|
||||
class="-mb-px border-b-2 px-4 py-2.5 text-sm font-semibold transition-colors"
|
||||
:class="activeTab === 'services'
|
||||
? 'border-accent text-brandtext'
|
||||
: 'border-transparent text-muted hover:text-strong'"
|
||||
@click="activeTab = 'services'">
|
||||
Service history
|
||||
</button>
|
||||
<button
|
||||
class="-mb-px border-b-2 px-4 py-2.5 text-sm font-semibold transition-colors"
|
||||
:class="activeTab === 'parts'
|
||||
? 'border-accent text-brandtext'
|
||||
: 'border-transparent text-muted hover:text-strong'"
|
||||
@click="activeTab = 'parts'">
|
||||
Parts catalog
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Information -->
|
||||
<section v-if="activeTab === 'info'">
|
||||
<div class="dh-card p-6">
|
||||
<dl class="grid grid-cols-2 gap-4 text-sm sm:grid-cols-4">
|
||||
<div><dt class="eyebrow">Engine oil spec</dt><dd class="mt-0.5 font-medium text-strong">{{ car.oilSpec || '—' }}</dd></div>
|
||||
<div><dt class="eyebrow">Transmission oil</dt><dd class="mt-0.5 font-medium text-strong">{{ car.transmissionOilSpec || '—' }}</dd></div>
|
||||
<div><dt class="eyebrow">Differential oil</dt><dd class="mt-0.5 font-medium text-strong">{{ car.differentialOilSpec || '—' }}</dd></div>
|
||||
<div><dt class="eyebrow">Brake fluid</dt><dd class="mt-0.5 font-medium text-strong">{{ car.brakeFluidSpec || '—' }}</dd></div>
|
||||
<div><dt class="eyebrow">Coolant</dt><dd class="mt-0.5 font-medium text-strong">{{ car.coolantSpec || '—' }}</dd></div>
|
||||
<div><dt class="eyebrow">Odometer</dt><dd class="mt-0.5 data font-medium text-strong">{{ formatKm(car.currentKm) }}</dd></div>
|
||||
<div><dt class="eyebrow">Service interval</dt><dd class="mt-0.5 data font-medium text-strong">{{ car.serviceIntervalDays }}d · {{ formatKm(car.serviceIntervalKm) }}</dd></div>
|
||||
<div><dt class="eyebrow">Next due</dt><dd class="mt-0.5 data font-medium text-strong">{{ formatDate(latest?.nextServiceDate) }} · {{ formatKm(latest?.nextServiceKm) }}</dd></div>
|
||||
<div><dt class="eyebrow">Registration plate</dt><dd class="mt-0.5 data font-medium text-strong">{{ car.registration || '—' }}</dd></div>
|
||||
<div><dt class="eyebrow">Registration country</dt><dd class="mt-0.5 font-medium text-strong">{{ car.registrationCountry || '—' }}</dd></div>
|
||||
<div><dt class="eyebrow">VIN</dt><dd class="mt-0.5 data font-medium text-strong">{{ car.vin || '—' }}</dd></div>
|
||||
<div><dt class="eyebrow">Fuel type</dt><dd class="mt-0.5 font-medium text-strong">{{ fuelLabel(car.fuelType) }}</dd></div>
|
||||
<div><dt class="eyebrow">Build date</dt><dd class="mt-0.5 data font-medium text-strong">{{ car.buildDate ? formatDate(car.buildDate) : '—' }}</dd></div>
|
||||
<div><dt class="eyebrow">First registration</dt><dd class="mt-0.5 data font-medium text-strong">{{ car.firstRegistrationDate ? formatDate(car.firstRegistrationDate) : '—' }}</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Service history -->
|
||||
<section v-else-if="activeTab === 'services'">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h2 class="text-lg font-bold tracking-[-0.02em] text-strong">Service history</h2>
|
||||
<button v-if="canWrite" class="dh-btn dh-btn-primary !px-3 !py-1.5" @click="openAddService">
|
||||
<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>
|
||||
Add service
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="services.length === 0" class="rounded-card border border-dashed border-default p-8 text-center text-muted">
|
||||
No service records yet.
|
||||
</div>
|
||||
|
||||
<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>Date</th>
|
||||
<th>Km</th>
|
||||
<th>Next date</th>
|
||||
<th>Next km</th>
|
||||
<th class="!text-center">Oil & filter</th>
|
||||
<th class="!text-center">Engine air</th>
|
||||
<th class="!text-center">Cabin air</th>
|
||||
<th>Notes</th>
|
||||
<th v-if="canWrite"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-subtle">
|
||||
<tr v-for="s in services" :key="s.id" class="transition-colors hover:bg-sunken">
|
||||
<td class="whitespace-nowrap px-4 py-3 data font-medium text-strong">{{ formatDate(s.date) }}</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 data text-body">{{ formatKm(s.km) }}</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 data text-muted">{{ formatDate(s.nextServiceDate) }}</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 data text-muted">{{ formatKm(s.nextServiceKm) }}</td>
|
||||
<td class="px-4 py-3 text-center text-xs font-semibold" :class="s.changedOil ? 'text-success' : 'text-muted'">{{ yn(s.changedOil) }}</td>
|
||||
<td class="px-4 py-3 text-center text-xs font-semibold" :class="s.changedEngineAirFilter ? 'text-success' : 'text-muted'">{{ yn(s.changedEngineAirFilter) }}</td>
|
||||
<td class="px-4 py-3 text-center text-xs font-semibold" :class="s.changedCabinAirFilter ? 'text-success' : 'text-muted'">{{ yn(s.changedCabinAirFilter) }}</td>
|
||||
<td class="px-4 py-3 text-body">{{ s.notes || '—' }}</td>
|
||||
<td v-if="canWrite" class="whitespace-nowrap px-4 py-3 text-right">
|
||||
<button class="text-xs font-medium text-brandtext hover:underline" @click="openEditService(s)">Edit</button>
|
||||
<button class="ml-3 text-xs font-medium text-danger hover:underline" @click="deleteService(s.id)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Parts catalog -->
|
||||
<section v-else>
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h2 class="text-lg font-bold tracking-[-0.02em] text-strong">Parts catalog</h2>
|
||||
<button v-if="canWrite" class="dh-btn dh-btn-primary !px-3 !py-1.5" @click="openAddPart">
|
||||
<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>
|
||||
Add part
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="parts.length === 0" class="rounded-card border border-dashed border-default p-8 text-center text-muted">
|
||||
No parts yet.
|
||||
</div>
|
||||
|
||||
<div v-else class="dh-card overflow-hidden 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>Part</th>
|
||||
<th>Part number</th>
|
||||
<th v-if="canWrite"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-subtle">
|
||||
<tr v-for="p in parts" :key="p.id" class="transition-colors hover:bg-sunken">
|
||||
<td class="px-4 py-3 font-medium text-strong">{{ p.name }}</td>
|
||||
<td class="px-4 py-3 data text-body">{{ p.partNumber || '—' }}</td>
|
||||
<td v-if="canWrite" class="whitespace-nowrap px-4 py-3 text-right">
|
||||
<button class="text-xs font-medium text-brandtext hover:underline" @click="openEditPart(p)">Edit</button>
|
||||
<button class="ml-3 text-xs font-medium text-danger hover:underline" @click="deletePart(p.id)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<!-- Modals -->
|
||||
<CarFormModal v-if="showCarEdit" :car="car" @saved="onCarSaved" @close="showCarEdit = false" />
|
||||
<ServiceFormModal
|
||||
v-if="showService"
|
||||
:car-id="id"
|
||||
:car="car"
|
||||
:service="editingService"
|
||||
@saved="onServiceSaved"
|
||||
@close="showService = false"
|
||||
/>
|
||||
<PartFormModal
|
||||
v-if="showPart"
|
||||
:car-id="id"
|
||||
:part="editingPart"
|
||||
@saved="onPartSaved"
|
||||
@close="showPart = false"
|
||||
/>
|
||||
<ShareModal v-if="showShare && car" :car="car" @close="showShare = false" />
|
||||
|
||||
<!-- Delete-car confirmation (type-to-confirm; cascade removes all data) -->
|
||||
<div v-if="showDeleteCar && car" class="fixed inset-0 z-30 grid place-items-center bg-brand-900/40 p-4 backdrop-blur-sm" @click.self="showDeleteCar = false">
|
||||
<div class="dh-card w-full max-w-md p-6 shadow-pop">
|
||||
<h2 class="mb-2 text-lg font-bold tracking-[-0.02em] text-danger">Delete this car?</h2>
|
||||
<p class="mb-4 text-sm text-body">
|
||||
This permanently deletes <strong class="text-strong">{{ car.name }}</strong> and all of its
|
||||
<strong class="text-strong">{{ services.length }}</strong> service record{{ services.length === 1 ? '' : 's' }}
|
||||
and <strong class="text-strong">{{ parts.length }}</strong> part{{ parts.length === 1 ? '' : 's' }}. This cannot be undone.
|
||||
</p>
|
||||
<label class="dh-label">
|
||||
Type <span class="data text-strong">{{ car.name }}</span> to confirm
|
||||
</label>
|
||||
<input v-model="deleteConfirmText" :placeholder="car.name" class="dh-input mb-4" />
|
||||
<div class="flex justify-end gap-2">
|
||||
<button type="button" class="dh-btn dh-btn-ghost" @click="showDeleteCar = false">Cancel</button>
|
||||
<button type="button" :disabled="!canDeleteCar || deletingCar" class="dh-btn dh-btn-danger" @click="confirmDeleteCar">
|
||||
{{ deletingCar ? 'Deleting…' : 'Delete permanently' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,143 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { api } from "../api";
|
||||
import { formatDate, formatKm, serviceStatus } from "../lib/format.js";
|
||||
import CarFormModal from "../components/CarFormModal.vue";
|
||||
|
||||
const router = useRouter();
|
||||
const cars = ref([]);
|
||||
const loading = ref(true);
|
||||
const error = ref("");
|
||||
const showAdd = ref(false);
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const list = await api.listCars();
|
||||
// For each car, fetch its latest service record to derive next-due status.
|
||||
cars.value = await Promise.all(
|
||||
list.map(async (car) => {
|
||||
const services = await api.listCarServices(car.id);
|
||||
const latest = services[0] || null; // API sorts newest-first
|
||||
return { ...car, latest, count: services.length };
|
||||
})
|
||||
);
|
||||
} catch (e) {
|
||||
error.value = e.message;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onSaved(car) {
|
||||
showAdd.value = false;
|
||||
router.push({ name: "car", params: { id: car.id } });
|
||||
}
|
||||
|
||||
// Service-life progress: how far the car is through its km service interval.
|
||||
// Returns a { pct, tone } or null when there isn't enough data to compute it.
|
||||
const TONE_COLOR = {
|
||||
ok: "var(--success-600)",
|
||||
soon: "var(--warning-600)",
|
||||
overdue: "var(--danger-600)",
|
||||
unknown: "var(--ink-300)",
|
||||
};
|
||||
function serviceLife(car) {
|
||||
const interval = Number(car.serviceIntervalKm);
|
||||
const nextKm = Number(car.latest?.nextServiceKm);
|
||||
const currentKm = Number(car.currentKm);
|
||||
if (!interval || !nextKm || !currentKm) return null;
|
||||
const remaining = nextKm - currentKm;
|
||||
const pct = Math.max(0, Math.min(100, Math.round((1 - remaining / interval) * 100)));
|
||||
return { pct, tone: TONE_COLOR[serviceStatus(car.latest, car).key] || TONE_COLOR.unknown };
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="mb-6 flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<p class="eyebrow">Garage</p>
|
||||
<h1 class="text-3xl font-bold tracking-[-0.03em] text-strong">Your cars</h1>
|
||||
<p class="mt-1 text-sm text-muted">Maintenance overview and service history.</p>
|
||||
</div>
|
||||
<button class="dh-btn dh-btn-primary" @click="showAdd = 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>
|
||||
Add car
|
||||
</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">Loading…</p>
|
||||
|
||||
<div v-else-if="cars.length === 0" class="rounded-card border border-dashed border-default p-12 text-center text-muted">
|
||||
No cars yet. Click <strong class="text-strong">Add car</strong> to get started.
|
||||
</div>
|
||||
|
||||
<div v-else class="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<RouterLink
|
||||
v-for="car in cars"
|
||||
:key="car.id"
|
||||
:to="{ name: 'car', params: { id: car.id } }"
|
||||
class="dh-card group block p-5 transition-shadow duration-150 hover:shadow-pop"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<h2 class="truncate text-lg font-bold tracking-[-0.02em] text-strong">{{ car.name }}</h2>
|
||||
<p class="truncate text-xs text-muted">{{ [car.make, car.model, car.year || ''].filter(Boolean).join(' ') }}</p>
|
||||
</div>
|
||||
<span :class="serviceStatus(car.latest, car).classes">
|
||||
{{ serviceStatus(car.latest, car).label }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span
|
||||
v-if="car.access && car.access !== 'owner'"
|
||||
class="dh-badge dh-badge-neutral mt-2"
|
||||
>
|
||||
Shared{{ car.access === 'read' ? ' · read-only' : '' }}
|
||||
</span>
|
||||
|
||||
<!-- Service-life bar: fraction of the km interval used up. -->
|
||||
<div v-if="serviceLife(car)" class="mt-4">
|
||||
<div class="mb-1.5 flex items-center justify-between">
|
||||
<span class="eyebrow">Service life</span>
|
||||
<span class="data text-xs font-medium text-strong">{{ serviceLife(car).pct }}%</span>
|
||||
</div>
|
||||
<div class="h-2 overflow-hidden rounded-pill bg-sunken">
|
||||
<div class="h-full rounded-pill" :style="{ width: serviceLife(car).pct + '%', background: serviceLife(car).tone }" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl class="mt-4 space-y-2 text-sm">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<dt class="eyebrow">Last service</dt>
|
||||
<dd class="data font-medium text-strong">{{ formatDate(car.latest?.date) }}</dd>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<dt class="eyebrow">Odometer</dt>
|
||||
<dd class="data font-medium text-strong">{{ formatKm(car.currentKm) }}</dd>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<dt class="eyebrow">Next due</dt>
|
||||
<dd class="data font-medium text-strong">{{ formatDate(car.latest?.nextServiceDate) }}</dd>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<dt class="eyebrow">Next due km</dt>
|
||||
<dd class="data font-medium text-strong">{{ formatKm(car.latest?.nextServiceKm) }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<p class="mt-4 border-t border-subtle pt-3 text-xs text-muted">
|
||||
{{ car.count }} service record{{ car.count === 1 ? '' : 's' }}
|
||||
</p>
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<CarFormModal v-if="showAdd" @saved="onSaved" @close="showAdd = false" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,114 @@
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { useRouter, useRoute } from "vue-router";
|
||||
import { login } from "../auth";
|
||||
import { getServerUrl, setServerUrl, DEFAULT_API_BASE } from "../api";
|
||||
import Logo from "../components/Logo.vue";
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const email = ref("");
|
||||
const password = ref("");
|
||||
const error = ref("");
|
||||
const loading = ref(false);
|
||||
const showPassword = ref(false);
|
||||
|
||||
// Server settings: an optional override of the API Server base URL, persisted
|
||||
// locally. Empty means "use the default" (DEFAULT_API_BASE).
|
||||
const showServer = ref(false);
|
||||
const serverUrl = ref(getServerUrl());
|
||||
const serverSaved = ref(false);
|
||||
|
||||
function saveServer() {
|
||||
setServerUrl(serverUrl.value);
|
||||
serverUrl.value = getServerUrl();
|
||||
serverSaved.value = true;
|
||||
setTimeout(() => (serverSaved.value = false), 2000);
|
||||
}
|
||||
|
||||
function resetServer() {
|
||||
setServerUrl("");
|
||||
serverUrl.value = "";
|
||||
serverSaved.value = true;
|
||||
setTimeout(() => (serverSaved.value = false), 2000);
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
await login(email.value.trim(), password.value);
|
||||
const redirect = typeof route.query.redirect === "string" ? route.query.redirect : "/";
|
||||
router.replace(redirect);
|
||||
} catch (e) {
|
||||
error.value = e.message || "Login failed";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid min-h-screen place-items-center bg-page px-4">
|
||||
<div class="w-full max-w-sm">
|
||||
<div class="mb-7 text-center">
|
||||
<Logo class="mx-auto mb-4 h-10 w-auto" />
|
||||
<h1 class="text-2xl font-bold tracking-[-0.02em] text-strong">Sign in</h1>
|
||||
<p class="mt-1 text-sm text-muted">Your car, on track.</p>
|
||||
</div>
|
||||
|
||||
<form class="dh-card space-y-4 p-6" @submit.prevent="submit">
|
||||
<p v-if="error" class="rounded-control bg-danger-soft px-3 py-2 text-sm font-medium text-danger">{{ error }}</p>
|
||||
<div>
|
||||
<label class="dh-label">Email</label>
|
||||
<input v-model="email" type="email" required autocomplete="username" class="dh-input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="dh-label">Password</label>
|
||||
<div class="relative">
|
||||
<input v-model="password" :type="showPassword ? 'text' : 'password'" required autocomplete="current-password"
|
||||
class="dh-input pr-10" />
|
||||
<button type="button" @click="showPassword = !showPassword"
|
||||
:aria-label="showPassword ? 'Hide password' : 'Show password'"
|
||||
class="absolute inset-y-0 right-0 flex items-center px-3 text-muted transition-colors hover:text-strong">
|
||||
<svg v-if="showPassword" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" class="h-5 w-5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.98 8.223A10.477 10.477 0 0 0 1.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.451 10.451 0 0 1 12 4.5c4.756 0 8.773 3.162 10.065 7.498a10.522 10.522 0 0 1-4.293 5.774M6.228 6.228 3 3m3.228 3.228 3.65 3.65m7.894 7.894L21 21m-3.228-3.228-3.65-3.65m0 0a3 3 0 1 0-4.243-4.243m4.242 4.242L9.88 9.88" />
|
||||
</svg>
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" class="h-5 w-5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" :disabled="loading" class="dh-btn dh-btn-primary w-full">
|
||||
{{ loading ? "Signing in…" : "Sign in" }}
|
||||
</button>
|
||||
|
||||
<!-- Server settings: optional override of the API server address -->
|
||||
<div class="border-t border-subtle pt-3">
|
||||
<button type="button" @click="showServer = !showServer"
|
||||
class="flex w-full items-center justify-between text-xs font-semibold text-muted transition-colors hover:text-strong">
|
||||
<span>Server settings</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"
|
||||
class="h-4 w-4 transition-transform" :class="showServer ? 'rotate-180' : ''">
|
||||
<path fill-rule="evenodd" d="M5.23 7.21a.75.75 0 0 1 1.06.02L10 11.168l3.71-3.938a.75.75 0 1 1 1.08 1.04l-4.25 4.5a.75.75 0 0 1-1.08 0l-4.25-4.5a.75.75 0 0 1 .02-1.06Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
<div v-if="showServer" class="mt-3 space-y-2">
|
||||
<label class="eyebrow block">API server URL</label>
|
||||
<input v-model="serverUrl" type="text" :placeholder="DEFAULT_API_BASE" autocomplete="off" class="dh-input data" />
|
||||
<p class="text-xs text-muted">
|
||||
Leave blank to use the default (<span class="data">{{ DEFAULT_API_BASE }}</span>).
|
||||
</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<button type="button" @click="saveServer" class="dh-btn dh-btn-ghost !px-3 !py-1.5 !text-xs">Save</button>
|
||||
<button type="button" @click="resetServer" class="dh-btn !px-3 !py-1.5 !text-xs text-muted hover:bg-sunken">Reset to default</button>
|
||||
<span v-if="serverSaved" class="text-xs font-medium text-success">Saved ✓</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,682 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { api } from "../api";
|
||||
import { state, logout, refreshProfile } from "../auth";
|
||||
import { prefs, applyProfilePrefs } from "../prefs";
|
||||
import { formatDate } from "../lib/format.js";
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const loading = ref(true);
|
||||
const loadError = ref("");
|
||||
const profile = ref(null);
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
loadError.value = "";
|
||||
try {
|
||||
profile.value = await refreshProfile();
|
||||
await loadSessions();
|
||||
} catch (e) {
|
||||
loadError.value = e.message;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Account: name ---
|
||||
|
||||
const nameDraft = ref("");
|
||||
const nameSaving = ref(false);
|
||||
const nameSaved = ref(false);
|
||||
const nameError = ref("");
|
||||
|
||||
function initDrafts() {
|
||||
nameDraft.value = profile.value.name || "";
|
||||
bioDraft.value = profile.value.bio || "";
|
||||
}
|
||||
|
||||
async function saveName() {
|
||||
nameSaving.value = true;
|
||||
nameError.value = "";
|
||||
nameSaved.value = false;
|
||||
try {
|
||||
profile.value = await api.updateMe({ name: nameDraft.value.trim() });
|
||||
// Keep the header's displayed name in sync.
|
||||
state.user = { ...state.user, name: profile.value.name };
|
||||
localStorage.setItem("cc_user", JSON.stringify(state.user));
|
||||
nameSaved.value = true;
|
||||
setTimeout(() => (nameSaved.value = false), 2000);
|
||||
} catch (e) {
|
||||
nameError.value = e.message;
|
||||
} finally {
|
||||
nameSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Account: email verification ---
|
||||
|
||||
const verifySending = ref(false);
|
||||
const verifySent = ref(false);
|
||||
const verifyError = ref("");
|
||||
|
||||
async function sendVerification() {
|
||||
verifySending.value = true;
|
||||
verifyError.value = "";
|
||||
try {
|
||||
await api.requestVerification();
|
||||
verifySent.value = true;
|
||||
} catch (e) {
|
||||
verifyError.value = e.message;
|
||||
} finally {
|
||||
verifySending.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Account: password change ---
|
||||
|
||||
const oldPassword = ref("");
|
||||
const newPassword = ref("");
|
||||
const confirmPassword = ref("");
|
||||
const passwordSaving = ref(false);
|
||||
const passwordSaved = ref(false);
|
||||
const passwordError = ref("");
|
||||
|
||||
const passwordMismatch = computed(
|
||||
() => confirmPassword.value.length > 0 && newPassword.value !== confirmPassword.value
|
||||
);
|
||||
|
||||
async function savePassword() {
|
||||
passwordError.value = "";
|
||||
if (newPassword.value.length < 8) {
|
||||
passwordError.value = "New password must be at least 8 characters.";
|
||||
return;
|
||||
}
|
||||
if (passwordMismatch.value) {
|
||||
passwordError.value = "New password and confirmation don't match.";
|
||||
return;
|
||||
}
|
||||
passwordSaving.value = true;
|
||||
try {
|
||||
await api.changePassword(oldPassword.value, newPassword.value);
|
||||
oldPassword.value = "";
|
||||
newPassword.value = "";
|
||||
confirmPassword.value = "";
|
||||
passwordSaved.value = true;
|
||||
setTimeout(() => (passwordSaved.value = false), 2500);
|
||||
} catch (e) {
|
||||
passwordError.value = e.message;
|
||||
} finally {
|
||||
passwordSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Appearance (auto-saves on change) ---
|
||||
|
||||
const appearanceError = ref("");
|
||||
const savingAppearance = ref(false);
|
||||
|
||||
async function saveAppearance(patch) {
|
||||
appearanceError.value = "";
|
||||
savingAppearance.value = true;
|
||||
// Apply immediately for a responsive feel; roll back on failure.
|
||||
const previous = { ...prefs };
|
||||
applyProfilePrefs({ ...prefs, ...patch });
|
||||
try {
|
||||
profile.value = await api.updateMe(patch);
|
||||
} catch (e) {
|
||||
applyProfilePrefs(previous);
|
||||
appearanceError.value = e.message;
|
||||
} finally {
|
||||
savingAppearance.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const dateFormatExample = computed(() => formatDate(new Date().toISOString()));
|
||||
|
||||
// --- Profile: avatar + bio ---
|
||||
|
||||
const avatarUrl = ref("");
|
||||
const avatarUploading = ref(false);
|
||||
const avatarError = ref("");
|
||||
const fileInput = ref(null);
|
||||
|
||||
async function loadAvatar() {
|
||||
if (!profile.value?.hasAvatar) {
|
||||
avatarUrl.value = "";
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { blob } = await api.getAvatarBlob();
|
||||
avatarUrl.value = URL.createObjectURL(blob);
|
||||
} catch {
|
||||
avatarUrl.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
function pickAvatar() {
|
||||
fileInput.value?.click();
|
||||
}
|
||||
|
||||
async function onAvatarChosen(e) {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
if (!file) return;
|
||||
avatarUploading.value = true;
|
||||
avatarError.value = "";
|
||||
try {
|
||||
profile.value = await api.uploadAvatar(file);
|
||||
await loadAvatar();
|
||||
} catch (err) {
|
||||
avatarError.value = err.message;
|
||||
} finally {
|
||||
avatarUploading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeAvatar() {
|
||||
avatarUploading.value = true;
|
||||
avatarError.value = "";
|
||||
try {
|
||||
await api.deleteAvatar();
|
||||
profile.value = { ...profile.value, hasAvatar: false };
|
||||
avatarUrl.value = "";
|
||||
} catch (err) {
|
||||
avatarError.value = err.message;
|
||||
} finally {
|
||||
avatarUploading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const bioDraft = ref("");
|
||||
const bioSaving = ref(false);
|
||||
const bioSaved = ref(false);
|
||||
const bioError = ref("");
|
||||
|
||||
async function saveBio() {
|
||||
bioSaving.value = true;
|
||||
bioError.value = "";
|
||||
try {
|
||||
profile.value = await api.updateMe({ bio: bioDraft.value });
|
||||
bioSaved.value = true;
|
||||
setTimeout(() => (bioSaved.value = false), 2000);
|
||||
} catch (e) {
|
||||
bioError.value = e.message;
|
||||
} finally {
|
||||
bioSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Privacy & security: sessions ---
|
||||
|
||||
const sessions = ref([]);
|
||||
const sessionsError = ref("");
|
||||
const revokingId = ref("");
|
||||
const revokingOthers = ref(false);
|
||||
|
||||
async function loadSessions() {
|
||||
sessions.value = await api.listSessions();
|
||||
}
|
||||
|
||||
async function revokeSession(session) {
|
||||
if (!confirm(`Log out "${session.deviceLabel}"?`)) return;
|
||||
revokingId.value = session.id;
|
||||
sessionsError.value = "";
|
||||
try {
|
||||
await api.revokeSession(session.id);
|
||||
if (session.current) {
|
||||
onLogout();
|
||||
return;
|
||||
}
|
||||
await loadSessions();
|
||||
} catch (e) {
|
||||
sessionsError.value = e.message;
|
||||
} finally {
|
||||
revokingId.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeOthers() {
|
||||
if (!confirm("Log out every other device? This device stays signed in.")) return;
|
||||
revokingOthers.value = true;
|
||||
sessionsError.value = "";
|
||||
try {
|
||||
await api.revokeOtherSessions();
|
||||
await loadSessions();
|
||||
} catch (e) {
|
||||
sessionsError.value = e.message;
|
||||
} finally {
|
||||
revokingOthers.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onLogout() {
|
||||
logout();
|
||||
router.replace({ name: "login" });
|
||||
}
|
||||
|
||||
// --- Advanced: export ---
|
||||
|
||||
const exporting = ref(false);
|
||||
const exportError = ref("");
|
||||
|
||||
async function exportData() {
|
||||
exporting.value = true;
|
||||
exportError.value = "";
|
||||
try {
|
||||
const { blob, filename } = await api.exportData();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename || "drivervault-export.json";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (e) {
|
||||
exportError.value = e.message;
|
||||
} finally {
|
||||
exporting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Advanced: import ---
|
||||
|
||||
const importing = ref(false);
|
||||
const importError = ref("");
|
||||
const importResult = ref(null);
|
||||
const importFileInput = ref(null);
|
||||
|
||||
function pickImportFile() {
|
||||
importFileInput.value?.click();
|
||||
}
|
||||
|
||||
async function onImportFileChosen(e) {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
if (!file) return;
|
||||
|
||||
importError.value = "";
|
||||
importResult.value = null;
|
||||
|
||||
let payload;
|
||||
try {
|
||||
payload = JSON.parse(await file.text());
|
||||
} catch {
|
||||
importError.value = "That file isn't valid JSON.";
|
||||
return;
|
||||
}
|
||||
if (!Array.isArray(payload?.cars) || payload.cars.length === 0) {
|
||||
importError.value = "That file doesn't look like a DriverVault export (missing a \"cars\" list).";
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!confirm(
|
||||
`Import ${payload.cars.length} car(s) from this file? This adds new records — it does not merge with or overwrite existing cars.`
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
importing.value = true;
|
||||
try {
|
||||
importResult.value = await api.importData(payload);
|
||||
} catch (err) {
|
||||
importError.value = err.message;
|
||||
} finally {
|
||||
importing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Danger zone: delete account (typed confirmation + cooldown) ---
|
||||
|
||||
const showDeleteConfirm = ref(false);
|
||||
const deleteConfirmEmail = ref("");
|
||||
const deleteRequesting = ref(false);
|
||||
const deleteError = ref("");
|
||||
const eligibleAt = ref(null); // set once a deletion request succeeds this session
|
||||
|
||||
const deletionPending = computed(() => !!profile.value?.deletionRequestedAt);
|
||||
const cooldownElapsed = computed(() => {
|
||||
if (!deletionPending.value) return false;
|
||||
const eligible = eligibleAt.value || new Date(new Date(profile.value.deletionRequestedAt).getTime() + 3 * 24 * 60 * 60 * 1000);
|
||||
return new Date() >= eligible;
|
||||
});
|
||||
const canRequestDelete = computed(
|
||||
() => deleteConfirmEmail.value.trim().toLowerCase() === (profile.value?.email || "").toLowerCase()
|
||||
);
|
||||
|
||||
async function requestDeletion() {
|
||||
if (!canRequestDelete.value) return;
|
||||
deleteRequesting.value = true;
|
||||
deleteError.value = "";
|
||||
try {
|
||||
const res = await api.requestAccountDeletion(deleteConfirmEmail.value.trim());
|
||||
eligibleAt.value = new Date(res.eligibleAt);
|
||||
profile.value = { ...profile.value, deletionRequestedAt: new Date().toISOString() };
|
||||
showDeleteConfirm.value = false;
|
||||
deleteConfirmEmail.value = "";
|
||||
} catch (e) {
|
||||
deleteError.value = e.message;
|
||||
} finally {
|
||||
deleteRequesting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelDeletion() {
|
||||
deleteError.value = "";
|
||||
try {
|
||||
await api.cancelAccountDeletion();
|
||||
profile.value = { ...profile.value, deletionRequestedAt: null };
|
||||
eligibleAt.value = null;
|
||||
} catch (e) {
|
||||
deleteError.value = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function finalizeDeletion() {
|
||||
if (!confirm("This permanently deletes your account. This cannot be undone. Continue?")) return;
|
||||
deleteError.value = "";
|
||||
try {
|
||||
await api.finalizeAccountDeletion();
|
||||
onLogout();
|
||||
} catch (e) {
|
||||
deleteError.value = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await load();
|
||||
initDrafts();
|
||||
await loadAvatar();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (avatarUrl.value) URL.revokeObjectURL(avatarUrl.value);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-auto max-w-3xl">
|
||||
<div class="mb-6">
|
||||
<p class="eyebrow">Account</p>
|
||||
<h1 class="text-3xl font-bold tracking-[-0.03em] text-strong">Settings</h1>
|
||||
<p class="mt-1 text-sm text-muted">Manage your account, appearance, and data.</p>
|
||||
</div>
|
||||
|
||||
<p v-if="loadError" class="mb-4 rounded-control bg-danger-soft px-4 py-3 text-sm font-medium text-danger">{{ loadError }}</p>
|
||||
<p v-if="loading" class="text-muted">Loading…</p>
|
||||
|
||||
<div v-else-if="profile" class="space-y-6">
|
||||
<!-- Account -->
|
||||
<section class="dh-card p-6">
|
||||
<h2 class="mb-4 text-lg font-bold tracking-[-0.02em] text-strong">Account</h2>
|
||||
|
||||
<div class="mb-5">
|
||||
<label class="dh-label">Name</label>
|
||||
<div class="flex gap-2">
|
||||
<input v-model="nameDraft" class="dh-input max-w-sm" />
|
||||
<button class="dh-btn dh-btn-primary" :disabled="nameSaving || !nameDraft.trim()" @click="saveName">
|
||||
{{ nameSaving ? "Saving…" : nameSaved ? "Saved ✓" : "Save" }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="nameError" class="mt-1 text-sm text-danger">{{ nameError }}</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-5">
|
||||
<label class="dh-label">Email</label>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="data rounded-control border border-subtle bg-sunken px-3 py-2 text-sm text-body">
|
||||
{{ profile.email }}
|
||||
</span>
|
||||
<span class="dh-badge" :class="profile.verified ? 'dh-badge-success' : 'dh-badge-warning'">
|
||||
{{ profile.verified ? "Verified" : "Not verified" }}
|
||||
</span>
|
||||
<button
|
||||
v-if="!profile.verified && !verifySent"
|
||||
class="text-sm font-medium text-brandtext hover:underline disabled:opacity-50"
|
||||
:disabled="verifySending"
|
||||
@click="sendVerification"
|
||||
>
|
||||
{{ verifySending ? "Sending…" : "Resend verification email" }}
|
||||
</button>
|
||||
<span v-if="verifySent" class="text-sm text-muted">Verification email requested.</span>
|
||||
</div>
|
||||
<p v-if="verifyError" class="mt-1 text-sm text-danger">{{ verifyError }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="mb-2 text-sm font-semibold text-strong">Change password</h3>
|
||||
<div class="grid max-w-sm gap-2">
|
||||
<input v-model="oldPassword" type="password" placeholder="Current password" autocomplete="current-password" class="dh-input" />
|
||||
<input v-model="newPassword" type="password" placeholder="New password" autocomplete="new-password" class="dh-input" />
|
||||
<input v-model="confirmPassword" type="password" placeholder="Confirm new password" autocomplete="new-password" class="dh-input" />
|
||||
</div>
|
||||
<p v-if="passwordMismatch" class="mt-1 text-sm text-warning">Passwords don't match yet.</p>
|
||||
<p v-if="passwordError" class="mt-1 text-sm text-danger">{{ passwordError }}</p>
|
||||
<button class="dh-btn dh-btn-ghost mt-2" :disabled="passwordSaving || !oldPassword || !newPassword" @click="savePassword">
|
||||
{{ passwordSaving ? "Updating…" : passwordSaved ? "Password updated ✓" : "Update password" }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Appearance -->
|
||||
<section class="dh-card p-6">
|
||||
<h2 class="mb-4 text-lg font-bold tracking-[-0.02em] text-strong">Appearance</h2>
|
||||
|
||||
<div class="mb-5">
|
||||
<label class="dh-label">Theme</label>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
v-for="t in ['light', 'dark', 'system']"
|
||||
:key="t"
|
||||
class="rounded-control border px-3 py-1.5 text-sm font-medium capitalize transition-colors"
|
||||
:class="prefs.theme === t
|
||||
? 'border-accent bg-accent text-white'
|
||||
: 'border-subtle text-body hover:bg-sunken hover:text-strong'"
|
||||
@click="saveAppearance({ theme: t })"
|
||||
>
|
||||
{{ t }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-5 grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="dh-label">Language & region</label>
|
||||
<select :value="prefs.locale" class="dh-input" @change="saveAppearance({ locale: $event.target.value })">
|
||||
<option value="en-US">English (US)</option>
|
||||
<option value="en-GB">English (UK)</option>
|
||||
<option value="pl-PL">Polski</option>
|
||||
<option value="de-DE">Deutsch</option>
|
||||
<option value="fr-FR">Français</option>
|
||||
<option value="es-ES">Español</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="dh-label">Date format</label>
|
||||
<select :value="prefs.dateFormat" class="dh-input" @change="saveAppearance({ dateFormat: $event.target.value })">
|
||||
<option value="YMD">YYYY-MM-DD</option>
|
||||
<option value="DMY_NUM">DD-MM-YYYY</option>
|
||||
<option value="DMY">DD Mon YYYY</option>
|
||||
<option value="MDY">Mon DD, YYYY</option>
|
||||
</select>
|
||||
<p class="mt-1 text-xs text-muted">Example: <span class="data">{{ dateFormatExample }}</span></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="dh-label">Font size</label>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
v-for="f in ['small', 'medium', 'large']"
|
||||
:key="f"
|
||||
class="rounded-control border px-3 py-1.5 text-sm font-medium capitalize transition-colors"
|
||||
:class="prefs.fontSize === f
|
||||
? 'border-accent bg-accent text-white'
|
||||
: 'border-subtle text-body hover:bg-sunken hover:text-strong'"
|
||||
@click="saveAppearance({ fontSize: f })"
|
||||
>
|
||||
{{ f }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="appearanceError" class="mt-3 text-sm text-danger">{{ appearanceError }}</p>
|
||||
</section>
|
||||
|
||||
<!-- Profile -->
|
||||
<section class="dh-card p-6">
|
||||
<h2 class="mb-4 text-lg font-bold tracking-[-0.02em] text-strong">Profile</h2>
|
||||
|
||||
<div class="mb-5 flex items-center gap-4">
|
||||
<img v-if="avatarUrl" :src="avatarUrl" alt="Avatar" class="h-16 w-16 rounded-full object-cover ring-1 ring-subtle" />
|
||||
<div v-else class="grid h-16 w-16 place-items-center rounded-full bg-brand-100 text-xl font-bold text-brandtext">
|
||||
{{ (profile.name || profile.email || "?").charAt(0).toUpperCase() }}
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button class="dh-btn dh-btn-ghost !px-3 !py-1.5" :disabled="avatarUploading" @click="pickAvatar">
|
||||
{{ avatarUploading ? "Uploading…" : "Upload photo" }}
|
||||
</button>
|
||||
<button v-if="profile.hasAvatar" class="dh-btn dh-btn-ghost !px-3 !py-1.5" :disabled="avatarUploading" @click="removeAvatar">
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
<input ref="fileInput" type="file" accept="image/png,image/jpeg,image/gif,image/webp,image/svg+xml" class="hidden" @change="onAvatarChosen" />
|
||||
</div>
|
||||
<p v-if="avatarError" class="mb-4 text-sm text-danger">{{ avatarError }}</p>
|
||||
|
||||
<div>
|
||||
<label class="dh-label">Bio</label>
|
||||
<textarea
|
||||
v-model="bioDraft"
|
||||
rows="3"
|
||||
placeholder="A short note visible to other people in your household."
|
||||
class="dh-input"
|
||||
/>
|
||||
<p v-if="bioError" class="mt-1 text-sm text-danger">{{ bioError }}</p>
|
||||
<button class="dh-btn dh-btn-ghost mt-2" :disabled="bioSaving" @click="saveBio">
|
||||
{{ bioSaving ? "Saving…" : bioSaved ? "Saved ✓" : "Save bio" }}
|
||||
</button>
|
||||
</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">Privacy & security</h2>
|
||||
<button
|
||||
v-if="sessions.length > 1"
|
||||
class="text-sm font-medium text-brandtext hover:underline disabled:opacity-50"
|
||||
:disabled="revokingOthers"
|
||||
@click="revokeOthers"
|
||||
>
|
||||
{{ revokingOthers ? "Logging out…" : "Log out all other devices" }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="mb-3 text-sm text-muted">
|
||||
Two-factor authentication isn't available yet. Active sessions below reflect every device currently signed in.
|
||||
</p>
|
||||
|
||||
<p v-if="sessionsError" class="mb-3 text-sm text-danger">{{ sessionsError }}</p>
|
||||
|
||||
<ul class="divide-y divide-subtle">
|
||||
<li v-for="sess in sessions" :key="sess.id" class="flex items-center justify-between gap-3 py-3">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-strong">
|
||||
{{ sess.deviceLabel }}
|
||||
<span v-if="sess.current" class="dh-badge dh-badge-neutral ml-2">This device</span>
|
||||
</p>
|
||||
<p class="text-xs text-muted">
|
||||
<span class="data">{{ sess.ip }}</span> · signed in {{ formatDate(sess.created) }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
class="shrink-0 text-sm font-medium text-danger hover:underline disabled:opacity-50"
|
||||
:disabled="revokingId === sess.id"
|
||||
@click="revokeSession(sess)"
|
||||
>
|
||||
{{ revokingId === sess.id ? "Logging out…" : "Log out" }}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- Advanced / Danger Zone -->
|
||||
<section class="dh-card p-6">
|
||||
<h2 class="mb-4 text-lg font-bold tracking-[-0.02em] text-strong">Advanced</h2>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-strong">Export your data</p>
|
||||
<p class="text-xs text-muted">Download your profile and all cars, service records, and parts as JSON.</p>
|
||||
</div>
|
||||
<button class="dh-btn dh-btn-ghost !px-3 !py-1.5 shrink-0" :disabled="exporting" @click="exportData">
|
||||
{{ exporting ? "Preparing…" : "Export data" }}
|
||||
</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">Import your data</p>
|
||||
<p class="text-xs text-muted">
|
||||
Add cars from a previously exported JSON file. This creates new records — it doesn't merge with or overwrite anything existing.
|
||||
</p>
|
||||
</div>
|
||||
<button class="dh-btn dh-btn-ghost !px-3 !py-1.5 shrink-0" :disabled="importing" @click="pickImportFile">
|
||||
{{ importing ? "Importing…" : "Import data" }}
|
||||
</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">
|
||||
Imported {{ importResult.carsImported }} car(s), {{ importResult.servicesImported }} service record(s), {{ importResult.partsImported }} part(s).
|
||||
</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">Danger zone</h2>
|
||||
|
||||
<template v-if="!deletionPending">
|
||||
<p class="mb-3 text-sm text-danger/90">
|
||||
Deleting your account removes your login and profile. It does not delete your household's shared cars or
|
||||
service history. There's a 3-day cooldown before the deletion is final, and you can cancel any time before then.
|
||||
</p>
|
||||
<button class="dh-btn !border !border-danger/40 !bg-transparent !text-danger hover:!bg-danger/10" @click="showDeleteConfirm = true">
|
||||
Delete my account
|
||||
</button>
|
||||
|
||||
<div v-if="showDeleteConfirm" class="mt-4 rounded-control border border-danger/30 bg-card p-4">
|
||||
<label class="dh-label">
|
||||
Type <span class="data text-strong">{{ profile.email }}</span> to confirm
|
||||
</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 = ''">Cancel</button>
|
||||
<button :disabled="!canRequestDelete || deleteRequesting" class="dh-btn dh-btn-danger" @click="requestDeletion">
|
||||
{{ deleteRequesting ? "Requesting…" : "Request deletion" }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<p class="mb-3 text-sm text-danger/90">
|
||||
Account deletion requested on {{ formatDate(profile.deletionRequestedAt) }}.
|
||||
<template v-if="!cooldownElapsed">You can still cancel — it becomes permanent after the 3-day cooldown.</template>
|
||||
<template v-else>The cooldown has passed. You can now finalize the deletion.</template>
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
<button class="dh-btn dh-btn-ghost !bg-card" @click="cancelDeletion">Cancel deletion request</button>
|
||||
<button v-if="cooldownElapsed" class="dh-btn dh-btn-danger" @click="finalizeDeletion">
|
||||
Permanently delete my account
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<p v-if="deleteError" class="mt-3 text-sm font-medium text-danger">{{ deleteError }}</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user