Add per-user Toyota integration with a settings cascade
Let each user run the Toyota Connected plugin under their own MyToyota
credentials and enable/disable it for themselves in the Web App, while a
superadmin (and, in an organization, an org admin) can impose settings
from above. Resolution is a cascade — top wins, and a lower level only
fills fields the levels above left blank:
- org user: API Server (superadmin) -> org admin -> user
- org-less user: API Server (superadmin) -> user
The MyToyota email + password resolve together as a pair from the highest
layer that supplies an email; brand resolves on its own; enablement is
strictly per-user, gated by the global master switch and the org gate.
API Server:
- plugins.Manager gains RawConfig / HealthCheckWith / InvokeWith so the
cascade can read global config and probe/invoke under a per-caller
resolved config.
- internal/api/integrations.go resolves the cascade and serves
GET/PUT /api/integrations/toyota, POST .../health, GET .../vehicles.
Secrets and inherited usernames are masked before leaving the server.
- The toyota builtin's credentials are no longer required at the global
layer, so the master switch can be enabled without global credentials.
- setup-pocketbase.mjs adds a pluginSettings JSON field to the users and
organizations collections (the user and org layers of the cascade).
Web App:
- api.js gains getToyota/saveToyota/testToyota.
- Settings grows an Integrations section: an enable toggle, credential
fields with locked / "inherited from" states, a brand select, an
org-scope switch for admins, and a live test-connection button.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
5a729abd71
commit
5e435c5f77
9 files changed
+910
-11
No files matched your search
@@ -222,6 +222,15 @@ export const api = {
|
||||
deleteAvatar: () => request("/me/avatar", { method: "DELETE" }),
|
||||
getAvatarBlob: () => requestBlob("/me/avatar"),
|
||||
|
||||
// Integrations — per-user plugin settings under the superadmin → org admin →
|
||||
// user cascade. getToyota returns the resolved view (effective/own/locked per
|
||||
// field, with secrets and inherited usernames masked); saveToyota writes the
|
||||
// caller's editable layer (scope "user" by default, "org" for org admins);
|
||||
// testToyota runs a live login probe under the resolved credentials.
|
||||
getToyota: () => request("/integrations/toyota"),
|
||||
saveToyota: (body) => request("/integrations/toyota", { method: "PUT", body: JSON.stringify(body) }),
|
||||
testToyota: () => request("/integrations/toyota/health", { method: "POST" }),
|
||||
|
||||
// Settings — advanced / danger zone
|
||||
exportData: () => requestBlob("/me/export"),
|
||||
importData: (payload) => request("/me/import", { method: "POST", body: JSON.stringify(payload) }),
|
||||
|
||||
@@ -158,6 +158,38 @@
|
||||
"saveBio": "Save bio"
|
||||
},
|
||||
|
||||
"integrations": {
|
||||
"title": "Integrations",
|
||||
"subtitle": "Connect external services to your account.",
|
||||
"toyota": "Toyota Connected (MyToyota)",
|
||||
"toyotaDesc": "Sign in with your MyToyota account to pull vehicle data from Toyota Connected Europe. Europe only.",
|
||||
"enable": "Enable for my account",
|
||||
"enableOrg": "Enable for the organization",
|
||||
"unavailable": "This integration is turned off by the administrator.",
|
||||
"orgDisabled": "This integration is turned off for your organization.",
|
||||
"scopeMy": "My settings",
|
||||
"scopeOrg": "Organization",
|
||||
"scopeHint": "Organization settings apply to everyone in your organization who leaves a field blank.",
|
||||
"readOnly": "You manage this integration's shared settings in the API Server panel; here it is read-only.",
|
||||
"email": "MyToyota email",
|
||||
"password": "MyToyota password",
|
||||
"brand": "Brand",
|
||||
"brandToyota": "Toyota",
|
||||
"brandLexus": "Lexus",
|
||||
"inherited": "Set above your level",
|
||||
"inheritedFrom": "Inherited from {source}",
|
||||
"sourceGlobal": "the administrator",
|
||||
"sourceOrg": "your organization",
|
||||
"sourceUser": "you",
|
||||
"passwordKeep": "Leave blank to keep the saved password.",
|
||||
"test": "Test connection",
|
||||
"testing": "Testing…",
|
||||
"save": "Save",
|
||||
"saved": "Saved ✓",
|
||||
"connected": "Connected",
|
||||
"notConnected": "Not connected"
|
||||
},
|
||||
|
||||
"privacy": {
|
||||
"title": "Privacy & security",
|
||||
"signOut": "Sign out",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from "vue";
|
||||
import { ref, computed, watch, onMounted, onBeforeUnmount } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { api } from "../api";
|
||||
import { state, logout, refreshProfile } from "../auth";
|
||||
@@ -274,6 +274,130 @@ async function saveBio() {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Integrations: Toyota Connected (per-user, cascading settings) ---
|
||||
//
|
||||
// The server resolves a superadmin → org admin → user cascade and returns, per
|
||||
// field, the effective value (secrets/inherited usernames masked), the caller's
|
||||
// own-layer value, its source layer, and whether it's locked (set above us).
|
||||
// An org admin gets a second "org" scope to edit organization-wide defaults.
|
||||
|
||||
const toyota = ref(null); // resolved view from the server
|
||||
const toyotaScope = ref("user"); // "user" | "org" (org admins only)
|
||||
const toyotaForm = ref({ username: "", password: "", brand: "" });
|
||||
const toyotaSaving = ref(false);
|
||||
const toyotaSaved = ref(false);
|
||||
const toyotaError = ref("");
|
||||
const toyotaTesting = ref(false);
|
||||
const toyotaHealth = ref(null); // { status, detail } from the last test
|
||||
|
||||
// Superadmins manage the shared (global) layer in the API Server panel; here the
|
||||
// credential fields are read-only (they may still toggle their own opt-in).
|
||||
const toyotaReadOnly = computed(() => !!toyota.value?.isSuperadmin);
|
||||
const toyotaScopeKey = computed(() => (toyota.value?.isSuperadmin ? "user" : toyotaScope.value));
|
||||
const toyotaScopeData = computed(
|
||||
() => toyota.value?.scopes?.[toyotaScopeKey.value] || { editableLayer: "user", fields: {} }
|
||||
);
|
||||
const toyotaEditingOrg = computed(() => toyotaScopeKey.value === "org");
|
||||
|
||||
function toyotaField(k) {
|
||||
return toyotaScopeData.value.fields?.[k] || { effective: "", own: "", source: "unset", locked: false };
|
||||
}
|
||||
// A field is locked when it's set above the caller's editable layer, or the
|
||||
// whole scope is read-only (superadmin).
|
||||
function toyotaLocked(k) {
|
||||
return toyotaReadOnly.value || toyotaField(k).locked;
|
||||
}
|
||||
// The toggle reflects the personal opt-in normally, or the org gate in org scope.
|
||||
const toyotaEnabled = computed(() =>
|
||||
toyotaEditingOrg.value ? toyota.value?.orgEnabled : toyota.value?.enabled
|
||||
);
|
||||
// A friendly "inherited from …" note for a locked field.
|
||||
function toyotaSourceLabel(k) {
|
||||
const map = { global: "sourceGlobal", org: "sourceOrg", user: "sourceUser" };
|
||||
const key = map[toyotaField(k).source] || "sourceGlobal";
|
||||
return t("settings.integrations.inheritedFrom", { source: t("settings.integrations." + key) });
|
||||
}
|
||||
|
||||
// Load the editable inputs from the current scope's own-layer values. Locked
|
||||
// fields and the secret password are never prefilled.
|
||||
function fillToyotaForm() {
|
||||
const f = toyotaScopeData.value.fields || {};
|
||||
toyotaForm.value = {
|
||||
username: f.username?.locked ? "" : f.username?.own || "",
|
||||
password: "",
|
||||
brand: f.brand?.locked ? "" : f.brand?.own || "",
|
||||
};
|
||||
}
|
||||
|
||||
function applyToyotaView(body) {
|
||||
toyota.value = body;
|
||||
// Keep the selected scope valid (fall back to personal when org isn't offered).
|
||||
if (toyotaScope.value === "org" && !body.canEditOrg) toyotaScope.value = "user";
|
||||
fillToyotaForm();
|
||||
}
|
||||
|
||||
async function loadToyota() {
|
||||
try {
|
||||
applyToyotaView(await api.getToyota());
|
||||
} catch (e) {
|
||||
toyotaError.value = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
// Refill the form when an admin flips between personal and organization scope.
|
||||
watch(toyotaScope, () => {
|
||||
toyotaError.value = "";
|
||||
toyotaSaved.value = false;
|
||||
toyotaHealth.value = null;
|
||||
fillToyotaForm();
|
||||
});
|
||||
|
||||
async function toggleToyota(v) {
|
||||
toyotaError.value = "";
|
||||
const org = toyotaEditingOrg.value;
|
||||
try {
|
||||
applyToyotaView(await api.saveToyota(org ? { scope: "org", enabled: v } : { scope: "user", enabled: v }));
|
||||
} catch (e) {
|
||||
toyotaError.value = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveToyotaSettings() {
|
||||
toyotaError.value = "";
|
||||
toyotaSaving.value = true;
|
||||
toyotaSaved.value = false;
|
||||
const config = {};
|
||||
for (const k of ["username", "password", "brand"]) {
|
||||
if (toyotaLocked(k)) continue;
|
||||
// Only send the password when the user actually typed a new one.
|
||||
if (k === "password" && !toyotaForm.value.password) continue;
|
||||
config[k] = toyotaForm.value[k];
|
||||
}
|
||||
try {
|
||||
applyToyotaView(await api.saveToyota({ scope: toyotaScopeKey.value, config }));
|
||||
toyotaSaved.value = true;
|
||||
setTimeout(() => (toyotaSaved.value = false), 2000);
|
||||
} catch (e) {
|
||||
toyotaError.value = e.message;
|
||||
} finally {
|
||||
toyotaSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function testToyotaConnection() {
|
||||
toyotaError.value = "";
|
||||
toyotaHealth.value = null;
|
||||
toyotaTesting.value = true;
|
||||
try {
|
||||
const { health } = await api.testToyota();
|
||||
toyotaHealth.value = health;
|
||||
} catch (e) {
|
||||
toyotaError.value = e.message;
|
||||
} finally {
|
||||
toyotaTesting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onLogout() {
|
||||
logout();
|
||||
router.replace({ name: "login" });
|
||||
@@ -407,6 +531,7 @@ onMounted(async () => {
|
||||
await load();
|
||||
initDrafts();
|
||||
await loadAvatar();
|
||||
await loadToyota();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
@@ -594,6 +719,128 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Integrations -->
|
||||
<section v-if="toyota" class="dh-card p-6">
|
||||
<h2 class="text-lg font-bold tracking-[-0.02em] text-strong">{{ t("settings.integrations.title") }}</h2>
|
||||
<p class="mb-4 mt-1 text-sm text-muted">{{ t("settings.integrations.subtitle") }}</p>
|
||||
|
||||
<div class="rounded-control border border-subtle p-4">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-strong">{{ t("settings.integrations.toyota") }}</p>
|
||||
<p class="mt-0.5 text-xs text-muted">{{ t("settings.integrations.toyotaDesc") }}</p>
|
||||
</div>
|
||||
<span
|
||||
v-if="!toyotaEditingOrg"
|
||||
class="dh-badge shrink-0"
|
||||
:class="toyota.enabled ? 'dh-badge-success' : 'dh-badge-warning'"
|
||||
>
|
||||
{{ toyota.enabled ? t("settings.integrations.connected") : t("settings.integrations.notConnected") }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Master / org gates -->
|
||||
<p v-if="!toyota.available" class="mt-3 text-sm text-warning">{{ t("settings.integrations.unavailable") }}</p>
|
||||
<p
|
||||
v-else-if="toyota.orgId && !toyota.orgEnabled && !toyotaEditingOrg"
|
||||
class="mt-3 text-sm text-warning"
|
||||
>
|
||||
{{ t("settings.integrations.orgDisabled") }}
|
||||
</p>
|
||||
|
||||
<template v-else>
|
||||
<!-- Scope switch (org admins) -->
|
||||
<div v-if="toyota.canEditOrg" class="mt-4 flex gap-2">
|
||||
<button
|
||||
v-for="sc in ['user', 'org']"
|
||||
:key="sc"
|
||||
class="rounded-control border px-3 py-1.5 text-sm font-medium transition-colors"
|
||||
:class="toyotaScope === sc
|
||||
? 'border-accent bg-accent text-white'
|
||||
: 'border-subtle text-body hover:bg-sunken hover:text-strong'"
|
||||
@click="toyotaScope = sc"
|
||||
>
|
||||
{{ sc === 'org' ? t("settings.integrations.scopeOrg") : t("settings.integrations.scopeMy") }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="toyotaEditingOrg" class="mt-2 text-xs text-muted">{{ t("settings.integrations.scopeHint") }}</p>
|
||||
<p v-if="toyotaReadOnly" class="mt-3 text-xs text-muted">{{ t("settings.integrations.readOnly") }}</p>
|
||||
|
||||
<!-- Enable toggle -->
|
||||
<label class="mt-4 flex items-center gap-2 text-sm font-medium text-body">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="h-4 w-4 rounded border-subtle text-accent focus:ring-accent"
|
||||
:checked="toyotaEnabled"
|
||||
@change="toggleToyota($event.target.checked)"
|
||||
/>
|
||||
<span>{{ toyotaEditingOrg ? t("settings.integrations.enableOrg") : t("settings.integrations.enable") }}</span>
|
||||
</label>
|
||||
|
||||
<!-- Credential fields -->
|
||||
<div class="mt-4 grid max-w-sm gap-3">
|
||||
<div>
|
||||
<label class="dh-label">{{ t("settings.integrations.email") }}</label>
|
||||
<input
|
||||
v-model="toyotaForm.username"
|
||||
class="dh-input"
|
||||
:disabled="toyotaLocked('username')"
|
||||
:placeholder="toyotaLocked('username') ? '••••••••' : ''"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<p v-if="toyotaField('username').locked" class="mt-1 text-xs text-muted">{{ toyotaSourceLabel('username') }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="dh-label">{{ t("settings.integrations.password") }}</label>
|
||||
<input
|
||||
v-model="toyotaForm.password"
|
||||
type="password"
|
||||
class="dh-input"
|
||||
:disabled="toyotaLocked('password')"
|
||||
:placeholder="toyotaField('password').effective ? '••••••••' : ''"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<p v-if="toyotaField('password').locked" class="mt-1 text-xs text-muted">{{ toyotaSourceLabel('password') }}</p>
|
||||
<p v-else class="mt-1 text-xs text-muted">{{ t("settings.integrations.passwordKeep") }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="dh-label">{{ t("settings.integrations.brand") }}</label>
|
||||
<select v-model="toyotaForm.brand" class="dh-input" :disabled="toyotaLocked('brand')">
|
||||
<option value="">—</option>
|
||||
<option value="T">{{ t("settings.integrations.brandToyota") }}</option>
|
||||
<option value="L">{{ t("settings.integrations.brandLexus") }}</option>
|
||||
</select>
|
||||
<p v-if="toyotaField('brand').locked" class="mt-1 text-xs text-muted">{{ toyotaSourceLabel('brand') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex items-center gap-2">
|
||||
<button
|
||||
v-if="!toyotaReadOnly"
|
||||
class="dh-btn dh-btn-primary"
|
||||
:disabled="toyotaSaving"
|
||||
@click="saveToyotaSettings"
|
||||
>
|
||||
{{ toyotaSaving ? t("common.saving") : toyotaSaved ? t("settings.integrations.saved") : t("settings.integrations.save") }}
|
||||
</button>
|
||||
<button class="dh-btn dh-btn-ghost" :disabled="toyotaTesting" @click="testToyotaConnection">
|
||||
{{ toyotaTesting ? t("settings.integrations.testing") : t("settings.integrations.test") }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="toyotaHealth"
|
||||
class="mt-2 text-sm"
|
||||
:class="toyotaHealth.status === 'ok' ? 'text-success' : 'text-danger'"
|
||||
>
|
||||
{{ toyotaHealth.detail || toyotaHealth.status }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<p v-if="toyotaError" class="mt-2 text-sm text-danger">{{ toyotaError }}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Privacy & Security -->
|
||||
<section class="dh-card p-6">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
|
||||
Reference in new issue
Block a user