Mirror PilotVault's API Server layout and add the superadmin console,
plugin system, runtime PocketBase settings, and user/organization
management. The car domain (cars, service records, parts, sharing) is
carried over unchanged apart from the auth switch.
Layout: main.go -> cmd/server/main.go; module carcontrol/api ->
drivervault/apiserver. internal/api is split by concern (auth, users,
orgs, settings, plugins, status, health, respond).
Auth: replace the server-minted HS256 JWT and the sessions collection
with a PocketBase token proxy. /api/auth/login relays PocketBase's
{token, record}, and every protected request re-resolves that token
against PocketBase, so a role change or deletion takes effect at once
instead of waiting out a token. AUTH_SECRET is obsolete and internal/auth
is gone. Per-device session listing/revocation goes with it: PocketBase
tokens are stateless. Changing a password rotates the user's token key,
which invalidates every token already issued.
Roles: add superadmin alongside user/admin, plus an organizations
collection and users.organization. Admins are scoped to their own
organization; superadmins span all of them. Guards prevent changing your
own role, deleting your own account, an admin touching a superadmin, and
deleting an organization that still has members.
Plugins: new internal/plugins package with one contract over two kinds --
builtin (compiled in) and external (any HTTP service, registered at
runtime with no rebuild). State persists to plugins.json; secrets are
masked on read and preserved when saved back at the mask.
PocketBase settings: /api/admin/pb-config applies a new connection at
runtime and persists it to .env. It deliberately does not require a
working service account, so a wrong or unreachable connection can still
be fixed from the panel.
Panel: rebuilt as the superadmin console -- login gate, status, users,
organizations, PocketBase, plugins, and the endpoint reference.
Clients: update the Web App and Phone App for the PocketBase token shape,
the move of user management to /api/users ({users}/{user} envelopes, with
password resets folded into PATCH), and the removal of sessions. Both now
mirror the server's real guards rather than the old last-admin rule, and
parse PocketBase's field-level error shape.
Config: modern POCKETBASE_*/API_ADDR names with legacy PB_*/PORT
fallbacks, so existing .env files keep working. Also fixes /api/status
probing the Web App on 8090 instead of DriverVault's 5173.
Run scripts/setup-pocketbase.mjs to add the organizations collection and
grow users.role; every client must log in once more.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
614 lines
21 KiB
Vue
614 lines
21 KiB
Vue
<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();
|
|
} 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;
|
|
}
|
|
}
|
|
|
|
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 class="text-sm font-medium text-danger hover:underline" @click="onLogout">
|
|
Sign out
|
|
</button>
|
|
</div>
|
|
|
|
<p class="text-sm text-muted">
|
|
Two-factor authentication isn't available yet. Sessions are held as
|
|
server-issued tokens that expire on their own, so signing out here ends
|
|
this device's session only — there's no per-device list to revoke from.
|
|
To lock out every device, change your password above.
|
|
</p>
|
|
</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>
|