feat: convert secret-sources into a plugin (B20)

This commit is contained in:
LukeGus committed 2026-09-24 16:20:20 -05:00
1 parent 2ac7779eda
commit f4f26a9b8b
72 files changed
+18843 -933

No files matched your search

@@ -0,0 +1,28 @@
/**
* Where a secret source (a self-hosted 1Password Connect server) may be
* reached. Every request goes through ctx.fetch, which refuses private and
* loopback addresses unless the exact host is listed here, admin-only and
* install-wide.
*/
export const DEFAULT_PRIVATE_ALLOWLIST = [
"localhost",
"127.0.0.1",
"::1",
"host.docker.internal",
];
/** The admin setting is one host per line. A line that is not a bare host is ignored. */
const HOST_PATTERN = /^[a-z0-9._:-]+$/;
export function parseAllowlist(raw: unknown): string[] {
if (typeof raw !== "string") return [...DEFAULT_PRIVATE_ALLOWLIST];
const hosts: string[] = [];
for (const line of raw.split(/[\r\n,]+/)) {
const host = line.trim().toLowerCase();
if (host && HOST_PATTERN.test(host) && !hosts.includes(host)) {
hosts.push(host);
}
}
return hosts;
}
@@ -0,0 +1,27 @@
import type { Router } from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { sources } from "./tables.js";
import { createSecretSourceRepository } from "./repository.js";
import { createTokenStore } from "./token-store.js";
import { registerSecretSourceRoutes } from "./routes.js";
import { registerOnePasswordResolver } from "./resolver.js";
export async function activate(ctx: PluginContext) {
const table = await ctx.db.define(sources);
const repository = createSecretSourceRepository(ctx.db, table);
const tokenStore = createTokenStore(ctx);
registerSecretSourceRoutes(
ctx.http.router<Router>(),
ctx,
repository,
tokenStore,
);
registerOnePasswordResolver(ctx, repository, tokenStore);
ctx.log.info("Secret sources routes mounted at /plugin-api/secret-sources");
}
export async function deactivate() {
// Everything above was registered through ctx and is disposed by core.
}
@@ -0,0 +1,136 @@
import type { PluginFetch } from "@termix/plugin-sdk/backend";
/**
* The slice of the 1Password Connect REST API needed to resolve a secret
* reference: find the vault, find the item, read one field.
* https://developer.1password.com/docs/connect/api-reference
*/
export interface SecretReference {
vault: string;
item: string;
field: string;
}
const FETCH_TIMEOUT_MS = 10_000;
/** op://<vault>/<item>/<field> (a trailing ?query, as in ?ssh-format=openssh, is ignored) */
export function parseSecretReference(raw: string): SecretReference | null {
const match = /^op:\/\/([^/]+)\/([^/]+)\/([^/?]+)(?:\?.*)?$/.exec(raw.trim());
if (!match) return null;
const [, vault, item, field] = match.map((part) => decodeURIComponent(part));
return { vault, item, field };
}
export interface ConnectSource {
baseUrl: string;
token: string;
allowedPrivateHosts: readonly string[];
}
async function connectGet<T>(
fetch: PluginFetch,
source: ConnectSource,
path: string,
): Promise<T> {
const base = source.baseUrl.replace(/\/+$/, "");
const response = await fetch(`${base}${path}`, {
method: "GET",
headers: { Authorization: `Bearer ${source.token}` },
timeoutMs: FETCH_TIMEOUT_MS,
allowPrivateHosts: source.allowedPrivateHosts,
});
if (!response.ok) {
throw new Error(
`1Password Connect ${path} failed: HTTP ${response.status}`,
);
}
return (await response.json()) as T;
}
function eqFilter(value: string): string {
return encodeURIComponent(`title eq "${value.replace(/"/g, '\\"')}"`);
}
interface ConnectVault {
id: string;
name: string;
}
interface ConnectItemSummary {
id: string;
title: string;
}
interface ConnectField {
id: string;
label?: string;
purpose?: string;
type?: string;
value?: string;
}
/** Reachability + token validity: the vault list needs a valid token. */
export async function testConnectSource(
fetch: PluginFetch,
source: ConnectSource,
): Promise<number> {
const vaults = await connectGet<ConnectVault[]>(fetch, source, "/v1/vaults");
return vaults.length;
}
const byIdOrName =
(name: string) =>
(candidate: { id: string; name?: string; title?: string }) =>
candidate.id === name ||
(candidate.name ?? candidate.title ?? "").toLowerCase() ===
name.toLowerCase();
export async function resolveConnectReference(
fetch: PluginFetch,
source: ConnectSource,
ref: SecretReference,
): Promise<string> {
const vaults = await connectGet<ConnectVault[]>(
fetch,
source,
`/v1/vaults?filter=${eqFilter(ref.vault)}`,
);
const vault =
vaults.find(byIdOrName(ref.vault)) ??
(await connectGet<ConnectVault[]>(fetch, source, "/v1/vaults")).find(
byIdOrName(ref.vault),
);
if (!vault) throw new Error(`1Password vault "${ref.vault}" not found`);
const items = await connectGet<ConnectItemSummary[]>(
fetch,
source,
`/v1/vaults/${vault.id}/items?filter=${eqFilter(ref.item)}`,
);
const summary =
items.find(byIdOrName(ref.item)) ??
(ref.item.length >= 26 ? { id: ref.item, title: ref.item } : undefined);
if (!summary) {
throw new Error(`1Password item "${ref.item}" not found in "${ref.vault}"`);
}
const item = await connectGet<{ fields?: ConnectField[] }>(
fetch,
source,
`/v1/vaults/${vault.id}/items/${summary.id}`,
);
const wanted = ref.field.toLowerCase();
const field = (item.fields ?? []).find(
(f) =>
f.id === ref.field ||
f.label?.toLowerCase() === wanted ||
f.purpose?.toLowerCase() === wanted ||
(wanted === "private key" && f.id === "private_key") ||
(wanted === "private_key" && f.label?.toLowerCase() === "private key"),
);
if (!field || field.value === undefined) {
throw new Error(
`1Password field "${ref.field}" not found on "${ref.item}"`,
);
}
return field.value;
}
@@ -0,0 +1,116 @@
import { eq, or } from "drizzle-orm";
import type { PluginDatabase } from "@termix/plugin-sdk/backend";
export type SecretSourceKind = "onepassword-connect";
export interface SecretSourceRecord {
id: string;
userId: string;
name: string;
kind: SecretSourceKind;
baseUrl: string;
shared: boolean;
createdAt: string;
updatedAt: string;
}
export interface SecretSourceCreateInput {
id: string;
userId: string;
name: string;
kind: SecretSourceKind;
baseUrl: string;
shared: boolean;
}
export interface SecretSourceUpdateInput {
name?: string;
baseUrl?: string;
shared?: boolean;
}
/* eslint-disable @typescript-eslint/no-explicit-any */
// The table comes from ctx.db.define, which the SDK hands back untyped, and
// the drizzle handle is the server's own. Typed at this module's edge instead.
type Table = any;
type Drizzle = any;
/* eslint-enable @typescript-eslint/no-explicit-any */
export type SecretSourceRepository = ReturnType<
typeof createSecretSourceRepository
>;
/**
* Per-user secret source storage. The token is never in this table - it
* lives in ctx.secrets under "source:<id>". A row with an id the caller
* chose is written without RETURNING so it runs the same on all three
* engines.
*/
export function createSecretSourceRepository(db: PluginDatabase, table: Table) {
const client = () => db.client<Drizzle>();
async function findById(id: string): Promise<SecretSourceRecord | null> {
const drizzle = await client();
const rows = await drizzle
.select()
.from(table)
.where(eq(table.id, id))
.limit(1);
return (rows[0] as SecretSourceRecord) ?? null;
}
return {
findById,
async listVisibleToUser(userId: string): Promise<SecretSourceRecord[]> {
const drizzle = await client();
return drizzle
.select()
.from(table)
.where(or(eq(table.userId, userId), eq(table.shared, true)));
},
async create(
input: SecretSourceCreateInput,
now = new Date().toISOString(),
): Promise<SecretSourceRecord> {
const drizzle = await client();
await drizzle.insert(table).values({
id: input.id,
userId: input.userId,
name: input.name,
kind: input.kind,
baseUrl: input.baseUrl,
shared: input.shared,
createdAt: now,
updatedAt: now,
});
await db.persist();
return (await findById(input.id)) as SecretSourceRecord;
},
async update(
row: SecretSourceRecord,
input: SecretSourceUpdateInput,
now = new Date().toISOString(),
): Promise<void> {
const drizzle = await client();
await drizzle
.update(table)
.set({
...(input.name !== undefined ? { name: input.name } : {}),
...(input.baseUrl !== undefined ? { baseUrl: input.baseUrl } : {}),
...(input.shared !== undefined ? { shared: input.shared } : {}),
updatedAt: now,
})
.where(eq(table.id, row.id));
await db.persist();
},
async deleteById(id: string): Promise<void> {
const drizzle = await client();
await drizzle.delete(table).where(eq(table.id, id));
await db.persist();
},
};
}
@@ -0,0 +1,61 @@
import type { PluginContext } from "@termix/plugin-sdk/backend";
import type { SecretSourceRepository } from "./repository.js";
import type { TokenStore } from "./token-store.js";
import { parseAllowlist } from "./egress.js";
import {
parseSecretReference,
resolveConnectReference,
} from "./onepassword-connect.js";
/** The user's own source first, else a shared one. */
async function pickSecretSource(
repository: SecretSourceRepository,
userId: string,
) {
const sources = await repository.listVisibleToUser(userId);
return (
sources.find((source) => source.userId === userId) ?? sources[0] ?? null
);
}
/**
* The "op" scheme resolver: turns "op://vault/item/field" into the actual
* secret, using whichever source the acting user can see (their own first,
* else a shared one).
*/
export function registerOnePasswordResolver(
ctx: PluginContext,
repository: SecretSourceRepository,
tokenStore: TokenStore,
): void {
ctx.credentials.registerSecretResolver("op", async (userId, reference) => {
const ref = parseSecretReference(reference);
if (!ref) throw new Error(`Invalid secret reference: ${reference}`);
const source = await pickSecretSource(repository, userId);
if (!source) {
throw new Error(
"This host uses a secret reference but no secret source is configured",
);
}
const token = await tokenStore.getForOwner(source.id, source.userId);
if (!token) {
throw new Error(
"The secret source owner's data is locked; they need to sign in first",
);
}
return resolveConnectReference(
ctx.fetch,
{
baseUrl: source.baseUrl,
token,
allowedPrivateHosts: parseAllowlist(
await ctx.settings.get<string>("privateEndpoints"),
),
},
ref,
);
});
}
@@ -0,0 +1,255 @@
import { randomUUID } from "node:crypto";
import type { Request, Response, Router } from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import type { SecretSourceRepository } from "./repository.js";
import type { TokenStore } from "./token-store.js";
import { testConnectSource } from "./onepassword-connect.js";
import { parseAllowlist } from "./egress.js";
const KINDS = ["onepassword-connect"] as const;
function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
function validBaseUrl(raw: unknown): string | null {
if (!isNonEmptyString(raw)) return null;
try {
const url = new URL(raw.trim());
if (!["http:", "https:"].includes(url.protocol)) return null;
return url.toString().replace(/\/+$/, "");
} catch {
return null;
}
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
export function registerSecretSourceRoutes(
router: Router,
ctx: PluginContext,
repository: SecretSourceRepository,
tokenStore: TokenStore,
): void {
const isAdmin = () => ctx.rbac.has("admin.plugins.manage");
const allowedPrivateHosts = async () =>
parseAllowlist(await ctx.settings.get<string>("privateEndpoints"));
/**
* @openapi
* /plugin-api/secret-sources:
* get:
* summary: List secret sources visible to the caller (own + shared)
* tags:
* - Secret Sources
*/
router.get(
"/",
ctx.rbac.require("credentials.view") as never,
async (req: Request, res: Response) => {
const userId = ctx.currentActor()!;
try {
const rows = await repository.listVisibleToUser(userId);
res.json({
sources: rows.map((row) => ({
...row,
owned: row.userId === userId,
})),
});
} catch (error) {
ctx.log.error("Failed to list secret sources", error);
res.status(500).json({ error: "Failed to list secret sources" });
}
},
);
/**
* @openapi
* /plugin-api/secret-sources:
* post:
* summary: Create a secret source (1Password Connect)
* description: Sharing a source with every user requires admin. The token is encrypted with the owner's data key.
* tags:
* - Secret Sources
*/
router.post(
"/",
ctx.rbac.require("credentials.create") as never,
async (req: Request, res: Response) => {
const userId = ctx.currentActor()!;
const {
name,
kind = "onepassword-connect",
baseUrl,
token,
shared,
} = req.body ?? {};
const url = validBaseUrl(baseUrl);
if (!isNonEmptyString(name) || !url || !isNonEmptyString(token)) {
return res.status(400).json({
error: "name, a valid http(s) baseUrl and token are required",
});
}
if (!(KINDS as readonly string[]).includes(kind)) {
return res
.status(400)
.json({ error: "Unsupported secret source kind" });
}
if (shared === true && !(await isAdmin())) {
return res
.status(403)
.json({ error: "Only admins can share a secret source" });
}
try {
const id = randomUUID();
const created = await repository.create({
id,
userId,
name: name.trim(),
kind,
baseUrl: url,
shared: shared === true,
});
await tokenStore.set(id, token.trim());
await ctx.audit.record({
action: "secret_source_create",
resourceType: "secret_source",
resourceId: created.id,
resourceName: created.name,
success: true,
});
res.json({ source: { ...created, owned: true } });
} catch (error) {
ctx.log.error("Failed to create secret source", error);
res.status(500).json({ error: "Failed to create secret source" });
}
},
);
/**
* @openapi
* /plugin-api/secret-sources/{id}:
* put:
* summary: Update a secret source (owner only; omit token to keep it)
* tags:
* - Secret Sources
*/
router.put(
"/:id",
ctx.rbac.require("credentials.edit") as never,
async (req: Request, res: Response) => {
const userId = ctx.currentActor()!;
const { name, baseUrl, token, shared } = req.body ?? {};
try {
const source = await repository.findById(String(req.params.id));
if (!source) {
return res.status(404).json({ error: "Secret source not found" });
}
if (source.userId !== userId) {
return res
.status(403)
.json({ error: "Only the owner can change this source" });
}
const url = baseUrl === undefined ? undefined : validBaseUrl(baseUrl);
if (baseUrl !== undefined && !url) {
return res.status(400).json({ error: "baseUrl must be http(s)" });
}
if (shared === true && !source.shared && !(await isAdmin())) {
return res
.status(403)
.json({ error: "Only admins can share a secret source" });
}
await repository.update(source, {
...(isNonEmptyString(name) ? { name: name.trim() } : {}),
...(url ? { baseUrl: url } : {}),
...(typeof shared === "boolean" ? { shared } : {}),
});
if (isNonEmptyString(token)) {
await tokenStore.set(source.id, token.trim());
}
res.json({ success: true });
} catch (error) {
ctx.log.error("Failed to update secret source", error);
res.status(500).json({ error: "Failed to update secret source" });
}
},
);
/**
* @openapi
* /plugin-api/secret-sources/{id}:
* delete:
* summary: Delete a secret source (owner only)
* tags:
* - Secret Sources
*/
router.delete(
"/:id",
ctx.rbac.require("credentials.delete") as never,
async (req: Request, res: Response) => {
const userId = ctx.currentActor()!;
try {
const source = await repository.findById(String(req.params.id));
if (!source) {
return res.status(404).json({ error: "Secret source not found" });
}
if (source.userId !== userId) {
return res
.status(403)
.json({ error: "Only the owner can change this source" });
}
await repository.deleteById(source.id);
await tokenStore.clear(source.id);
await ctx.audit.record({
action: "secret_source_delete",
resourceType: "secret_source",
resourceId: source.id,
resourceName: source.name,
success: true,
});
res.json({ success: true });
} catch (error) {
ctx.log.error("Failed to delete secret source", error);
res.status(500).json({ error: "Failed to delete secret source" });
}
},
);
/**
* @openapi
* /plugin-api/secret-sources/{id}/test:
* post:
* summary: Check that the source is reachable and the token is accepted
* tags:
* - Secret Sources
*/
router.post(
"/:id/test",
ctx.rbac.require("credentials.view") as never,
async (req: Request, res: Response) => {
const userId = ctx.currentActor()!;
try {
const source = await repository.findById(String(req.params.id));
if (!source || (source.userId !== userId && !source.shared)) {
return res.status(404).json({ error: "Secret source not found" });
}
const token = await tokenStore.getForOwner(source.id, source.userId);
if (!token) {
return res
.status(200)
.json({ ok: false, error: "No token stored for this source" });
}
const vaults = await testConnectSource(ctx.fetch, {
baseUrl: source.baseUrl,
token,
allowedPrivateHosts: await allowedPrivateHosts(),
});
res.json({ ok: true, vaults });
} catch (error) {
res.status(200).json({ ok: false, error: errorMessage(error) });
}
},
);
}
@@ -0,0 +1,42 @@
import {
adoptLegacyTable,
boolean,
defineTable,
refUser,
text,
timestamp,
varchar,
} from "@termix/plugin-sdk/db";
/**
* Adopted from core's secret_sources, so column and index names are the
* legacy ones and the rename carries every row across.
*
* The access token is not a column here: it lives in ctx.secrets under
* "source:<id>", the same move the ai plugin made for provider API keys. A
* 2.8 database still has a token column, which the boot migration empties
* after moving the value.
*/
export const sources = adoptLegacyTable(
"secret_sources",
defineTable(
"sources",
{
id: varchar().primaryKey(),
userId: refUser(),
name: text().notNull(),
// "onepassword-connect" for now; the reference syntax is per kind.
kind: text().notNull().default("onepassword-connect"),
baseUrl: text().notNull(),
// Visible to every user; the token still decrypts with the owner's key.
shared: boolean().notNull().default(false),
createdAt: timestamp().notNull().defaultNow(),
updatedAt: timestamp().notNull().defaultNow(),
},
{
indexes: [{ name: "idx_secret_sources_user", columns: ["userId"] }],
},
),
);
export const tables = [sources];
@@ -0,0 +1,37 @@
import type { PluginContext } from "@termix/plugin-sdk/backend";
/**
* The access token for one source, kept out of the sources table and in
* ctx.secrets instead, the same move the ai plugin made for provider API
* keys: encrypted with the installation key rather than the owner's data
* key, so it is readable whenever the plugin runs rather than only while the
* owner is signed in. A shared source's token is still only ever handed to
* its owner's part of the resolve, so a read for any other acting user runs
* as the owner through ctx.asUser.
*/
function tokenKey(sourceId: string): string {
return `source:${sourceId}`;
}
export function createTokenStore(ctx: PluginContext) {
return {
async set(sourceId: string, token: string): Promise<void> {
await ctx.secrets.set(tokenKey(sourceId), token);
},
async clear(sourceId: string): Promise<void> {
await ctx.secrets.delete(tokenKey(sourceId));
},
/** Reads the token as the source's owner, whoever is currently acting. */
async getForOwner(
sourceId: string,
ownerId: string,
): Promise<string | null> {
return ctx.asUser(ownerId, () => ctx.secrets.get(tokenKey(sourceId)));
},
};
}
export type TokenStore = ReturnType<typeof createTokenStore>;
@@ -0,0 +1,18 @@
import { useTranslation } from "@termix/plugin-sdk/frontend";
/** One line under a secret field: references are allowed, here is where to set them up. */
export function SecretReferenceHint({ onManage }: { onManage: () => void }) {
const { t } = useTranslation();
return (
<p className="text-[10px] text-muted-foreground">
{t("hint")}{" "}
<button
type="button"
className="text-accent-brand hover:underline"
onClick={onManage}
>
{t("manage")}
</button>
</p>
);
}
@@ -0,0 +1,277 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { KeyRound, Pencil, Plus, Trash2, X } from "lucide-react";
import { toast } from "sonner";
import { Button, Input, PasswordInput } from "@termix/plugin-sdk/ui";
import { usePluginApi, useTranslation } from "@termix/plugin-sdk/frontend";
import {
createSecretSourcesApi,
type SecretSource,
} from "./secret-sources-api";
function errorMessage(error: unknown): string {
if (error && typeof error === "object" && "message" in error) {
return String((error as { message?: unknown }).message ?? "");
}
return String(error);
}
type FormState = {
id?: string;
name: string;
baseUrl: string;
token: string;
shared: boolean;
};
const emptyForm: FormState = {
name: "",
baseUrl: "",
token: "",
shared: false,
};
export function SecretSourceManager({ onClose }: { onClose: () => void }) {
const { t } = useTranslation();
const pluginApi = usePluginApi();
const api = useMemo(() => createSecretSourcesApi(pluginApi), [pluginApi]);
const [sources, setSources] = useState<SecretSource[]>([]);
const [form, setForm] = useState<FormState | null>(null);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState<string | null>(null);
const reload = useCallback(async () => {
try {
setSources(await api.list());
} catch (e) {
toast.error(errorMessage(e));
}
}, [api]);
useEffect(() => {
void reload();
}, [reload]);
const setField = <K extends keyof FormState>(key: K, value: FormState[K]) =>
setForm((prev) => (prev ? { ...prev, [key]: value } : prev));
const handleSave = async () => {
if (!form) return;
if (
!form.name.trim() ||
!form.baseUrl.trim() ||
(!form.id && !form.token)
) {
toast.error(t("required"));
return;
}
setSaving(true);
try {
if (form.id) {
await api.update(form.id, {
name: form.name,
baseUrl: form.baseUrl,
shared: form.shared,
...(form.token ? { token: form.token } : {}),
});
} else {
await api.create({
name: form.name,
baseUrl: form.baseUrl,
token: form.token,
shared: form.shared,
});
}
toast.success(t("saved"));
setForm(null);
await reload();
} catch (e) {
toast.error(errorMessage(e));
} finally {
setSaving(false);
}
};
const handleDelete = async (source: SecretSource) => {
try {
await api.remove(source.id);
toast.success(t("deleted"));
await reload();
} catch (e) {
toast.error(errorMessage(e));
}
};
const handleTest = async (source: SecretSource) => {
setTesting(source.id);
try {
const result = await api.test(source.id);
if (result.ok) {
toast.success(t("testOk", { count: result.vaults ?? 0 }));
} else {
toast.error(result.error ?? t("testFailed"));
}
} catch (e) {
toast.error(errorMessage(e));
} finally {
setTesting(null);
}
};
return (
<div className="flex flex-col gap-3 col-span-2 border border-border bg-muted/20 p-3">
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("title")}
</span>
<button
type="button"
onClick={onClose}
className="text-muted-foreground hover:text-foreground"
>
<X className="size-3.5" />
</button>
</div>
<p className="text-[10px] text-muted-foreground">{t("description")}</p>
{!form && (
<>
{sources.map((source) => (
<div
key={source.id}
className="flex items-center gap-2 border border-border bg-background px-2 py-1.5 text-xs"
>
<KeyRound className="size-3.5 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0">
<div className="truncate">
{source.name}
{source.shared && (
<span className="ml-1 text-[9px] uppercase text-muted-foreground">
{t("shared")}
</span>
)}
</div>
<div className="truncate text-[10px] text-muted-foreground">
{source.baseUrl}
</div>
</div>
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 text-[10px]"
disabled={testing === source.id}
onClick={() => void handleTest(source)}
>
{t("test")}
</Button>
{source.owned && (
<>
<button
type="button"
className="text-muted-foreground hover:text-foreground"
onClick={() =>
setForm({
id: source.id,
name: source.name,
baseUrl: source.baseUrl,
token: "",
shared: source.shared,
})
}
>
<Pencil className="size-3.5" />
</button>
<button
type="button"
className="text-muted-foreground hover:text-destructive"
onClick={() => void handleDelete(source)}
>
<Trash2 className="size-3.5" />
</button>
</>
)}
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
className="self-start border-accent-brand/40 text-accent-brand"
onClick={() => setForm(emptyForm)}
>
<Plus className="size-3 mr-1" /> {t("new")}
</Button>
</>
)}
{form && (
<div className="flex flex-col gap-2">
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
<div className="flex flex-col gap-1">
<label className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground">
{t("nameLabel")}
</label>
<Input
className="h-8 text-xs"
placeholder="Team 1Password"
value={form.name}
onChange={(e) => setField("name", e.target.value)}
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground">
{t("urlLabel")}
</label>
<Input
className="h-8 text-xs"
placeholder="https://connect.internal:8080"
value={form.baseUrl}
onChange={(e) => setField("baseUrl", e.target.value)}
/>
</div>
</div>
<div className="flex flex-col gap-1">
<label className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground">
{t("tokenLabel")}
</label>
<PasswordInput
className="h-8 text-xs pr-8"
placeholder={form.id ? t("tokenKeep") : "eyJhbGciOi..."}
value={form.token}
onChange={(e) => setField("token", e.target.value)}
/>
</div>
<label className="flex items-center gap-2 text-xs text-foreground">
<input
type="checkbox"
checked={form.shared}
onChange={(e) => setField("shared", e.target.checked)}
/>
{t("sharedLabel")}
</label>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setForm(null)}
disabled={saving}
>
{t("common.cancel")}
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="border-accent-brand/40 text-accent-brand"
onClick={() => void handleSave()}
disabled={saving}
>
{form.id ? t("common.save") : t("common.create")}
</Button>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,8 @@
import type { TermixApp } from "@termix/plugin-sdk/frontend";
import { SecretSourceManager } from "./SecretSourceManager";
import { SecretReferenceHint } from "./SecretReferenceHint";
export function activate(app: TermixApp): void {
app.registerComponent("secret-sources.hint", SecretReferenceHint);
app.registerComponent("secret-sources.manager", SecretSourceManager);
}
@@ -0,0 +1,51 @@
import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
export type SecretSourceKind = "onepassword-connect";
export interface SecretSource {
id: string;
name: string;
kind: SecretSourceKind;
baseUrl: string;
shared: boolean;
owned: boolean;
createdAt: string;
updatedAt: string;
}
export interface SecretSourceInput {
name: string;
baseUrl: string;
token: string;
shared?: boolean;
}
export interface SecretSourceUpdateInput {
name?: string;
baseUrl?: string;
token?: string;
shared?: boolean;
}
/** The secret-sources routes, through the plugin's own client. */
export function createSecretSourcesApi(api: PluginApiClient) {
const data = async <T>(request: Promise<{ data: T }>) => (await request).data;
return {
list: () =>
data(api.get<{ sources: SecretSource[] }>("/")).then((r) => r.sources),
create: (body: SecretSourceInput) =>
data(api.post<{ source: SecretSource }>("/", body)).then((r) => r.source),
update: (id: string, body: SecretSourceUpdateInput) =>
data(api.put<{ success: boolean }>(`/${id}`, body)),
remove: (id: string) => data(api.delete<{ success: boolean }>(`/${id}`)),
test: (id: string) =>
data(
api.post<{ ok: boolean; vaults?: number; error?: string }>(
`/${id}/test`,
),
),
};
}
export type SecretSourcesApi = ReturnType<typeof createSecretSourcesApi>;