Initial commit

This commit is contained in:
tajniak81
2026-07-06 08:50:52 +02:00
commit ba3f227361
153 changed files with 18116 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#2563eb" />
<title>DriverVault · API Server</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+1964
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
{
"name": "drivervault-api-panel",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.5.13"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"@vitejs/plugin-vue": "^5.2.1",
"tailwindcss": "^4.0.0",
"vite": "^6.0.7"
}
}
+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256">
<rect width="256" height="256" rx="56" fill="#1E40AF"></rect>
<svg x="53" y="53" width="150" height="150" viewBox="0 0 48 48">
<g transform="translate(7 0) skewX(-13)">
<rect x="9" y="16" width="6" height="16" rx="3" fill="#ffffff" opacity="0.55"></rect>
<rect x="19" y="12" width="6" height="24" rx="3" fill="#ffffff" opacity="0.8"></rect>
<rect x="29" y="8" width="6" height="32" rx="3" fill="#ffffff"></rect>
</g>
</svg>
</svg>

After

Width:  |  Height:  |  Size: 550 B

+196
View File
@@ -0,0 +1,196 @@
<script setup>
import { ref, onMounted, onUnmounted, computed } from "vue";
import { theme, toggleTheme } from "./theme";
import EndpointTable from "./components/EndpointTable.vue";
// Live health poll against this server.
const status = ref("checking"); // checking | ok | error | unreachable
const httpStatus = ref(null);
const latency = ref(null);
const checkedAt = ref(null);
let timer = null;
async function check() {
const started = performance.now();
try {
const r = await fetch("/api/health");
latency.value = Math.round(performance.now() - started);
httpStatus.value = r.status;
status.value = r.ok ? "ok" : "error";
} catch {
latency.value = null;
httpStatus.value = null;
status.value = "unreachable";
}
checkedAt.value = new Date();
}
onMounted(() => {
check();
timer = setInterval(check, 10000);
});
onUnmounted(() => clearInterval(timer));
const badge = {
checking: { label: "checking", cls: "bg-sunken text-muted" },
ok: { label: "operational", cls: "bg-success-soft text-success" },
error: { label: "error", cls: "bg-danger-soft text-danger" },
unreachable: { label: "unreachable", cls: "bg-danger-soft text-danger" },
};
// Logo bar fills — flip for legibility on the dark shell.
const barFills = computed(() =>
theme.value === "dark"
? ["#60a5fa", "#93c5fd", "#ffffff"]
: ["var(--brand-700)", "var(--brand-500)", "var(--brand-400)"],
);
// The DriverVault REST surface, grouped by resource. Mirrors the routes registered
// in internal/api/server.go.
const publicApi = [
{ method: "GET", path: "/api/health", desc: "Liveness probe (no auth)" },
{ method: "POST", path: "/api/auth/login", desc: "Exchange email + password for a JWT" },
{ method: "GET", path: "/api/auth/me", desc: "Identity of the bearer token" },
];
const carsApi = [
{ method: "GET", path: "/api/cars", desc: "List owned + shared cars" },
{ method: "POST", path: "/api/cars", desc: "Create a car" },
{ method: "GET", path: "/api/cars/{id}", desc: "Fetch one car" },
{ method: "PATCH", path: "/api/cars/{id}", desc: "Update a car" },
{ method: "DELETE", path: "/api/cars/{id}", desc: "Delete a car (owner only)" },
{ method: "GET", path: "/api/cars/{id}/service-records", desc: "A car's service history" },
{ method: "GET", path: "/api/cars/{id}/parts", desc: "A car's parts catalog" },
{ method: "GET", path: "/api/cars/{id}/shares", desc: "Who a car is shared with (owner)" },
{ method: "POST", path: "/api/cars/{id}/shares", desc: "Share a car by email (owner)" },
{ method: "DELETE", path: "/api/cars/{id}/shares/{userId}", desc: "Revoke a share (owner)" },
];
const serviceApi = [
{ method: "GET", path: "/api/service-records", desc: "List service records" },
{ method: "POST", path: "/api/service-records", desc: "Log a service record" },
{ method: "GET", path: "/api/service-records/{id}", desc: "Fetch one record" },
{ method: "PATCH", path: "/api/service-records/{id}", desc: "Update a record" },
{ method: "DELETE", path: "/api/service-records/{id}", desc: "Delete a record" },
];
const partsApi = [
{ method: "GET", path: "/api/parts", desc: "List parts" },
{ method: "POST", path: "/api/parts", desc: "Add a part" },
{ method: "GET", path: "/api/parts/{id}", desc: "Fetch one part" },
{ method: "PATCH", path: "/api/parts/{id}", desc: "Update a part" },
{ method: "DELETE", path: "/api/parts/{id}", desc: "Delete a part" },
];
const accountApi = [
{ method: "GET", path: "/api/me", desc: "Current user profile" },
{ method: "PATCH", path: "/api/me", desc: "Update profile" },
{ method: "POST", path: "/api/me/password", desc: "Change password" },
{ method: "POST", path: "/api/me/avatar", desc: "Upload avatar" },
{ method: "GET", path: "/api/me/avatar", desc: "Fetch avatar" },
{ method: "DELETE", path: "/api/me/avatar", desc: "Remove avatar" },
{ method: "POST", path: "/api/me/verify/request", desc: "Request email verification" },
{ method: "GET", path: "/api/me/export", desc: "Export your data" },
{ method: "POST", path: "/api/me/import", desc: "Import data" },
{ method: "POST", path: "/api/me/delete", desc: "Request account deletion" },
{ method: "POST", path: "/api/me/delete/cancel", desc: "Cancel deletion request" },
{ method: "DELETE", path: "/api/me", desc: "Finalize account deletion" },
];
const sessionsApi = [
{ method: "GET", path: "/api/sessions", desc: "List active sessions (devices)" },
{ method: "DELETE", path: "/api/sessions/{id}", desc: "Revoke one session" },
{ method: "DELETE", path: "/api/sessions", desc: "Revoke all other sessions" },
];
const adminApi = [
{ method: "GET", path: "/api/admin/users", desc: "List users" },
{ method: "POST", path: "/api/admin/users", desc: "Create a user" },
{ method: "PATCH", path: "/api/admin/users/{id}", desc: "Update name / role" },
{ method: "POST", path: "/api/admin/users/{id}/password", desc: "Reset a user's password" },
{ method: "DELETE", path: "/api/admin/users/{id}", desc: "Delete a user" },
];
</script>
<template>
<div class="mx-auto flex max-w-3xl flex-col gap-6 px-6 pt-12 pb-16">
<!-- Header -->
<div class="flex items-center gap-3">
<span class="inline-flex items-center gap-2.5 select-none">
<svg class="h-8 w-8 shrink-0" viewBox="0 0 48 48" fill="none" aria-hidden="true">
<g transform="translate(7 0) skewX(-13)">
<rect x="9" y="16" width="6" height="16" rx="3" :fill="barFills[0]" />
<rect x="19" y="12" width="6" height="24" rx="3" :fill="barFills[1]" />
<rect x="29" y="8" width="6" height="32" rx="3" :fill="barFills[2]" />
</g>
</svg>
<span class="text-2xl leading-none font-extrabold tracking-[-0.03em] italic">
<span class="text-strong">Driver</span><span class="text-brandtext">Vault</span>
</span>
</span>
<span class="eyebrow mt-1.5">API server</span>
<div class="flex-1"></div>
<button
class="dh-btn-ghost"
:title="theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme'"
@click="toggleTheme"
>
<svg
v-if="theme === 'dark'"
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"
>
<circle cx="12" cy="12" r="4" />
<path stroke-linecap="round" d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" />
</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-4 w-4"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8Z" />
</svg>
Theme
</button>
</div>
<!-- Status -->
<div class="dh-card">
<div class="flex items-center justify-between border-b border-subtle px-5 py-4">
<div class="text-base font-bold tracking-[-0.02em] text-strong">Status</div>
<span
class="data inline-flex items-center gap-1.5 rounded-pill px-2.5 py-1 text-xs font-medium"
:class="badge[status].cls"
>
<span class="h-1.5 w-1.5 rounded-full bg-current"></span>
{{ badge[status].label }}<template v-if="status === 'error'"> {{ httpStatus }}</template>
</span>
</div>
<div class="data flex flex-wrap items-center gap-x-6 gap-y-2 px-5 py-4 text-xs text-muted">
<span>GET /api/health</span>
<span>{{ latency !== null ? latency + "ms" : "—" }}</span>
<span>{{ checkedAt ? "checked " + checkedAt.toLocaleTimeString() : "—" }}</span>
</div>
</div>
<EndpointTable title="Public" auth="No auth" :endpoints="publicApi" />
<EndpointTable title="Cars" auth="Bearer JWT" :endpoints="carsApi" />
<EndpointTable title="Service records" auth="Bearer JWT" :endpoints="serviceApi" />
<EndpointTable title="Parts" auth="Bearer JWT" :endpoints="partsApi" />
<EndpointTable title="Account" auth="Bearer JWT" :endpoints="accountApi" />
<EndpointTable title="Sessions" auth="Bearer JWT" :endpoints="sessionsApi" />
<EndpointTable title="Admin" auth="Admin JWT" :endpoints="adminApi" />
<p class="eyebrow text-center">
DriverVault car maintenance &amp; service tracker.
</p>
</div>
</template>
@@ -0,0 +1,44 @@
<script setup>
defineProps({
title: String,
auth: String,
endpoints: Array, // [{ method, path, desc }]
});
const methodClass = {
GET: "text-success",
POST: "text-brandtext",
PATCH: "text-warning",
DELETE: "text-danger",
};
</script>
<template>
<div class="dh-card overflow-hidden">
<div class="flex items-center justify-between border-b border-subtle px-5 py-4">
<div class="text-base font-bold tracking-[-0.02em] text-strong">{{ title }}</div>
<span class="eyebrow">{{ auth }}</span>
</div>
<table class="w-full text-left text-sm">
<thead>
<tr class="[&>th]:eyebrow [&>th]:px-5 [&>th]:py-2.5 [&>th]:font-medium">
<th>Endpoint</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr
v-for="e in endpoints"
:key="e.method + e.path"
class="transition-colors hover:bg-sunken"
>
<td class="data border-t border-subtle px-5 py-2.5 text-xs whitespace-nowrap">
<span class="font-semibold" :class="methodClass[e.method]">{{ e.method }}</span>
<span class="text-strong"> {{ e.path }}</span>
</td>
<td class="border-t border-subtle px-5 py-2.5 text-body">{{ e.desc }}</td>
</tr>
</tbody>
</table>
</div>
</template>
+6
View File
@@ -0,0 +1,6 @@
import { createApp } from "vue";
import App from "./App.vue";
import "./style.css";
import "./theme";
createApp(App).mount("#app");
+219
View File
@@ -0,0 +1,219 @@
@import "tailwindcss";
/* DriverVault webfonts — Archivo (UI + display, italic 800 is the brand voice) + DM Mono (data/labels). */
@import url("https://fonts.googleapis.com/css2?family=Archivo:ital,wght@0,400;0,500;0,600;0,700;0,800;1,700;1,800&family=DM+Mono:ital,wght@0,400;0,500&display=swap");
/* Tailwind v4 defaults dark: to prefers-color-scheme; switch to a class strategy
so theme.js can toggle `.dark` on <html>. */
@custom-variant dark (&:where(.dark, .dark *));
/* ============================================================================
DriverVault design tokens (subset used by the API panel). Raw ramps + semantic
aliases; html.dark re-points the aliases so utilities flip for free.
========================================================================== */
:root {
color-scheme: light;
--brand-900: #0b1730;
--brand-800: #0f1e3d;
--brand-700: #1e40af;
--brand-600: #2563eb;
--brand-500: #3b82f6;
--brand-400: #60a5fa;
--brand-300: #93c5fd;
--brand-200: #c7dbfb;
--brand-100: #e8f0fd;
--ink-900: #0f1e3d;
--ink-700: #28374f;
--ink-600: #3e4e68;
--ink-500: #5c6b85;
--ink-400: #7a8aa6;
--ink-300: #b4bece;
--ink-200: #d6deea;
--ink-100: #e4e9f2;
--ink-50: #eef2f8;
--ink-25: #f7f9fc;
--white: #ffffff;
--success-600: #1f8a5b;
--success-100: #e1f3ea;
--warning-600: #d9822b;
--warning-100: #fbeddd;
--danger-600: #dc2a45;
--danger-100: #fbe3e7;
--info-600: #2563eb;
--info-100: #e8f0fd;
--surface-page: var(--ink-25);
--surface-card: var(--white);
--surface-sunken: var(--ink-50);
--border-subtle: var(--ink-100);
--border-default: var(--ink-200);
--border-strong: var(--ink-300);
--text-strong: var(--ink-900);
--text-body: var(--ink-600);
--text-muted: var(--ink-400);
--text-brand: var(--brand-700);
--accent: var(--brand-600);
--accent-hover: var(--brand-700);
--focus-ring: var(--brand-400);
--shadow-xs: 0 1px 2px rgba(15, 30, 61, 0.06);
--shadow-sm: 0 1px 2px rgba(15, 30, 61, 0.04), 0 2px 6px rgba(15, 30, 61, 0.06);
--shadow-md: 0 1px 2px rgba(15, 30, 61, 0.04), 0 12px 30px -12px rgba(15, 30, 61, 0.14);
--font-display: "Archivo", system-ui, sans-serif;
--font-sans: "Archivo", system-ui, sans-serif;
--font-mono: "DM Mono", ui-monospace, "SF Mono", monospace;
}
html.dark {
color-scheme: dark;
--success-100: #12352a;
--warning-100: #3a2a16;
--danger-100: #3a1620;
--info-100: #122a4d;
--surface-page: #0b1730;
--surface-card: #13233f;
--surface-sunken: #0f1e38;
--border-subtle: #21324f;
--border-default: #2c3f5e;
--border-strong: #3c5173;
--text-strong: #f2f6fc;
--text-body: #b7c4d9;
--text-muted: #7c8ca8;
--text-brand: #93c5fd;
--accent: #3b82f6;
--accent-hover: #60a5fa;
--focus-ring: #60a5fa;
--success-600: #35b27a;
--warning-600: #e0a03a;
--danger-600: #e85c74;
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.4);
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4), 0 2px 6px rgba(0, 0, 0, 0.4);
--shadow-md: 0 1px 2px rgba(0, 0, 0, 0.35), 0 12px 30px -12px rgba(0, 0, 0, 0.55);
}
/* ============================================================================
Map tokens into Tailwind's theme so semantic utilities exist and flip in dark
mode without a dark: variant. Utility names mirror the DriverVault web app.
========================================================================== */
@theme inline {
--font-sans: var(--font-sans);
--font-mono: var(--font-mono);
--color-brand-300: var(--brand-300);
--color-brand-400: var(--brand-400);
--color-brand-500: var(--brand-500);
--color-brand-600: var(--brand-600);
--color-brand-700: var(--brand-700);
--color-brand-900: var(--brand-900);
--color-page: var(--surface-page);
--color-card: var(--surface-card);
--color-sunken: var(--surface-sunken);
--color-strong: var(--text-strong);
--color-body: var(--text-body);
--color-muted: var(--text-muted);
--color-brandtext: var(--text-brand);
--color-subtle: var(--border-subtle);
--color-default: var(--border-default);
--color-strongline: var(--border-strong);
--color-accent: var(--accent);
--color-accent-hover: var(--accent-hover);
--color-success: var(--success-600);
--color-success-soft: var(--success-100);
--color-warning: var(--warning-600);
--color-warning-soft: var(--warning-100);
--color-danger: var(--danger-600);
--color-danger-soft: var(--danger-100);
--color-info: var(--info-600);
--color-info-soft: var(--info-100);
--radius-control: 12px;
--radius-card: 18px;
--radius-pill: 999px;
--shadow-xs: var(--shadow-xs);
--shadow-sm: var(--shadow-sm);
--shadow-card: var(--shadow-md);
}
html,
body,
#app {
height: 100%;
}
body {
margin: 0;
font-family: var(--font-sans);
background: var(--surface-page);
color: var(--text-body);
-webkit-font-smoothing: antialiased;
}
/* Small mono eyebrow labels (uppercase, tracked out). */
.eyebrow {
font-family: var(--font-mono);
text-transform: uppercase;
letter-spacing: 0.16em;
font-size: 11px;
color: var(--text-muted);
}
/* Monospaced data (methods, paths, latencies) so columns align. */
.data {
font-family: var(--font-mono);
letter-spacing: 0.02em;
font-variant-numeric: tabular-nums;
}
/* Secondary button (theme toggle). */
.dh-btn-ghost {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
height: 34px;
padding: 0 12px;
border-radius: var(--radius-control);
border: 1px solid var(--border-default);
background: var(--surface-card);
color: var(--text-strong);
font-family: var(--font-sans);
font-weight: 600;
font-size: 0.8125rem;
cursor: pointer;
transition: background-color 140ms ease, border-color 140ms ease;
}
.dh-btn-ghost:hover {
background: var(--surface-sunken);
border-color: var(--border-strong);
}
.dh-btn-ghost:focus-visible {
outline: none;
box-shadow: 0 0 0 3px var(--focus-ring);
}
.dh-card {
border: 1px solid var(--border-subtle);
background: var(--surface-card);
border-radius: var(--radius-card);
box-shadow: var(--shadow-sm);
}
+35
View File
@@ -0,0 +1,35 @@
import { ref } from "vue";
// Persisted light/dark theme for the panel. Matches the DriverVault web app's
// class strategy (`.dark` on <html>), but under its own storage key so it
// doesn't collide with the main app's `theme` preference.
const KEY = "dh-panel-theme";
function initial() {
try {
const saved = localStorage.getItem(KEY);
if (saved === "dark" || saved === "light") return saved;
} catch {
/* private mode */
}
// Fall back to the OS preference.
return window.matchMedia?.("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
export const theme = ref(initial());
export function applyTheme(t) {
theme.value = t;
document.documentElement.classList.toggle("dark", t === "dark");
try {
localStorage.setItem(KEY, t);
} catch {
/* private mode — theme just won't persist */
}
}
export function toggleTheme() {
applyTheme(theme.value === "dark" ? "light" : "dark");
}
applyTheme(theme.value);
+20
View File
@@ -0,0 +1,20 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import tailwindcss from "@tailwindcss/vite";
// Builds into internal/api/dist, which the Go server embeds via go:embed
// and serves at the server root (the DriverVault API panel).
export default defineConfig({
plugins: [vue(), tailwindcss()],
build: {
outDir: "../internal/api/dist",
emptyOutDir: true,
},
server: {
port: 5174,
proxy: {
// Dev-mode proxy to a locally running API Server.
"/api": "http://localhost:8080",
},
},
});