mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-08-13 14:37:26 +00:00
feat(pwa): logicsrc credentials app — real auth + Turso, redesigned; retire commandboard-api credshare
Adds apps/pwa: an Express + libSQL/Turso app that is now the home of team credential sharing, with the moshcode-style auth stack ported and reskinned to match logicsrc.com (light theme, Inter, green accent). apps/pwa - auth: email/password (scrypt), passkeys (WebAuthn), CoinPay OAuth, cookie sessions, and lsk_ API keys for the CLI via a loopback OAuth-PKCE flow (/cli/authorize + /cli/token). Ported from the moshcode PWA. - credshare API (/api/credshare/*): teams, members, invites, vaults, sealed grants, ciphertext secrets, audit — authed by session OR Bearer lsk_ key. Zero-knowledge: only ciphertext + sealed vault keys + public keys stored. - teams dashboard, accept-invite, and settings (API keys) pages, server-rendered in the LogicSRC brand (lib/html.mjs). - migrations (libSQL) 001_auth + 002_credshare, migrate-on-boot; Turso via TURSO_DATABASE_URL / TURSO_AUTH_TOKEN, or a local file db for dev. - trimmed moshcode-specific approvals/credits/push/deliver. CLI - `logicsrc login` now does browser loopback OAuth-PKCE against the app and stores an lsk_ token (email-OTP removed); --token for CI. Client repointed. Distribution - install.sh (served at logicsrc.com/install.sh) installs the CLI from the GitHub repo: tarball -> npm install -> `npm run build:cli` -> logicsrc wrapper. - root build:cli builds only the CLI's workspace chain (skips web/api/next). Cleanup - removed the commandboard-api credshare backend (superseded by the PWA) and its Supabase/Turso stores + libsql dep; commandboard-api tests green (40). - removed the Next.js /teams page (the PWA is the web UI now). Verified end-to-end: two accounts register on the PWA, mint lsk_ keys, CLI login uploads identity keys, owner pushes an encrypted .env, teammate invited -> accepted -> granted -> pulls the exact file. Server stores ciphertext only. Full workspace build + tests green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
f057589d66
commit
9ba044577f
46 changed files with 2785 additions and 1730 deletions
98
apps/logicsrc-web/public/install.sh
Executable file
98
apps/logicsrc-web/public/install.sh
Executable file
|
|
@ -0,0 +1,98 @@
|
|||
#!/bin/sh
|
||||
# LogicSRC — one-line installer for the `logicsrc` CLI (from the GitHub repo).
|
||||
#
|
||||
# curl -fsSL https://logicsrc.com/install.sh | sh
|
||||
#
|
||||
# Subcommands:
|
||||
# curl -fsSL https://logicsrc.com/install.sh | sh -s -- install (default)
|
||||
# curl -fsSL https://logicsrc.com/install.sh | sh -s -- update
|
||||
# curl -fsSL https://logicsrc.com/install.sh | sh -s -- uninstall
|
||||
#
|
||||
# What it does:
|
||||
# 1. Detects OS (Linux/macOS — Windows: use WSL) and requires Node 18+.
|
||||
# 2. Fetches the repo tarball from GitHub into $LOGICSRC_HOME/src.
|
||||
# 3. `npm install` + `npm run build:cli` (builds only the CLI's workspaces).
|
||||
# 4. Drops a `logicsrc` wrapper on $HOME/.local/bin.
|
||||
#
|
||||
# Env overrides:
|
||||
# LOGICSRC_HOME=/path install dir (default: $HOME/.logicsrc-cli)
|
||||
# LOGICSRC_BIN=/path/dir wrapper bin dir (default: $HOME/.local/bin)
|
||||
# LOGICSRC_REF=branch|tag git ref (default: master)
|
||||
set -eu
|
||||
|
||||
GH_REPO="profullstack/logicsrc"
|
||||
LOGICSRC_REF="${LOGICSRC_REF:-master}"
|
||||
TARBALL_URL="https://codeload.github.com/$GH_REPO/tar.gz/$LOGICSRC_REF"
|
||||
|
||||
# --- operator identity (curl|sh may land with HOME/USER unset) ---
|
||||
_home() { if [ -n "${HOME:-}" ] && [ -d "$HOME" ]; then echo "$HOME"; else echo "${HOME:-/tmp}"; fi; }
|
||||
HOME="$(_home)"; export HOME
|
||||
LOGICSRC_HOME="${LOGICSRC_HOME:-$HOME/.logicsrc-cli}"
|
||||
LOGICSRC_BIN="${LOGICSRC_BIN:-$HOME/.local/bin}"
|
||||
SRC_DIR="$LOGICSRC_HOME/src"
|
||||
WRAPPER="$LOGICSRC_BIN/logicsrc"
|
||||
|
||||
if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then
|
||||
G=$(printf '\033[32m'); Y=$(printf '\033[33m'); B=$(printf '\033[34m'); R=$(printf '\033[31m'); X=$(printf '\033[0m')
|
||||
else G=''; Y=''; B=''; R=''; X=''; fi
|
||||
info() { printf '%s==>%s %s\n' "$B" "$X" "$*"; }
|
||||
ok() { printf '%s ✓%s %s\n' "$G" "$X" "$*"; }
|
||||
warn() { printf '%s !%s %s\n' "$Y" "$X" "$*" >&2; }
|
||||
fail() { printf '%s ✗%s %s\n' "$R" "$X" "$*" >&2; exit 1; }
|
||||
|
||||
need() { command -v "$1" >/dev/null 2>&1 || fail "missing '$1' — please install it and re-run."; }
|
||||
|
||||
detect_os() {
|
||||
case "$(uname -s)" in
|
||||
Linux) : ;; Darwin) : ;;
|
||||
*) fail "unsupported OS (Linux and macOS only — Windows: use WSL)";;
|
||||
esac
|
||||
}
|
||||
|
||||
check_node() {
|
||||
need node
|
||||
major="$(node -p 'process.versions.node.split(".")[0]' 2>/dev/null || echo 0)"
|
||||
[ "$major" -ge 18 ] 2>/dev/null || fail "Node 18+ required (found $(node -v 2>/dev/null || echo none)). Install from https://nodejs.org or via mise/nvm."
|
||||
need npm
|
||||
}
|
||||
|
||||
do_install() {
|
||||
detect_os; check_node
|
||||
need curl; need tar
|
||||
info "fetching logicsrc@$LOGICSRC_REF from GitHub…"
|
||||
mkdir -p "$SRC_DIR"
|
||||
tmp="$(mktemp -d)"
|
||||
curl -fsSL "$TARBALL_URL" | tar -xz -C "$tmp" --strip-components=1
|
||||
rm -rf "$SRC_DIR"; mkdir -p "$(dirname "$SRC_DIR")"; mv "$tmp" "$SRC_DIR"
|
||||
ok "downloaded to $SRC_DIR"
|
||||
|
||||
info "installing dependencies (this can take a minute)…"
|
||||
( cd "$SRC_DIR" && npm install --no-audit --no-fund --ignore-scripts >/dev/null 2>&1 ) || fail "npm install failed — run it by hand in $SRC_DIR"
|
||||
info "building the CLI…"
|
||||
( cd "$SRC_DIR" && npm run build:cli >/dev/null 2>&1 ) || fail "build failed — run 'npm run build:cli' in $SRC_DIR"
|
||||
|
||||
mkdir -p "$LOGICSRC_BIN"
|
||||
cat > "$WRAPPER" <<EOF
|
||||
#!/bin/sh
|
||||
exec node "$SRC_DIR/packages/cli/dist/index.js" "\$@"
|
||||
EOF
|
||||
chmod +x "$WRAPPER"
|
||||
ok "installed logicsrc → $WRAPPER"
|
||||
|
||||
case ":$PATH:" in
|
||||
*":$LOGICSRC_BIN:"*) : ;;
|
||||
*) warn "add $LOGICSRC_BIN to your PATH: export PATH=\"$LOGICSRC_BIN:\$PATH\"";;
|
||||
esac
|
||||
printf '\n%s🔐 logicsrc installed.%s Next:\n logicsrc login\n logicsrc teams push <team> prod --env .env\n\n' "$G" "$X"
|
||||
}
|
||||
|
||||
do_uninstall() {
|
||||
rm -f "$WRAPPER"; rm -rf "$LOGICSRC_HOME"
|
||||
ok "removed logicsrc ($WRAPPER, $LOGICSRC_HOME)"
|
||||
}
|
||||
|
||||
case "${1:-install}" in
|
||||
install|update|upgrade) do_install ;;
|
||||
remove|uninstall) do_uninstall ;;
|
||||
*) fail "unknown command '$1' (install | update | uninstall)";;
|
||||
esac
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
import type { ReactNode } from "react";
|
||||
import type { Metadata } from "next";
|
||||
import { SiteShell } from "@/components/site-shell";
|
||||
import { TeamsClient } from "@/components/teams-client";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Accept invite · LogicSRC Teams",
|
||||
description: "Accept a LogicSRC credential-sharing team invite.",
|
||||
robots: { index: false },
|
||||
};
|
||||
|
||||
// Invite emails link here as /teams/accept?token=… — log in, then the token is
|
||||
// auto-accepted by the client.
|
||||
export default async function AcceptInvitePage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ token?: string }>;
|
||||
}): Promise<ReactNode> {
|
||||
const { token } = await searchParams;
|
||||
return (
|
||||
<SiteShell active="Credentials">
|
||||
<TeamsClient initialToken={token} />
|
||||
</SiteShell>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
import type { ReactNode } from "react";
|
||||
import type { Metadata } from "next";
|
||||
import { SiteShell } from "@/components/site-shell";
|
||||
import { TeamsClient } from "@/components/teams-client";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Teams · Credential Sharing · LogicSRC",
|
||||
description:
|
||||
"Manage credential-sharing teams and invites. Share secrets with teammates by email — end-to-end encrypted, so the server never sees plaintext. Decryption happens only in the logicsrc CLI.",
|
||||
alternates: { canonical: "/teams" },
|
||||
};
|
||||
|
||||
export default function TeamsPage(): ReactNode {
|
||||
return (
|
||||
<SiteShell active="Credentials">
|
||||
<TeamsClient />
|
||||
</SiteShell>
|
||||
);
|
||||
}
|
||||
|
|
@ -8,7 +8,6 @@ const NAV: Array<{ href: string; label: string; external?: boolean }> = [
|
|||
{ href: "/agent-swarm", label: "Soon" },
|
||||
{ href: "/agentbyte", label: "AgentByte" },
|
||||
{ href: "/credential-sharing", label: "Credentials" },
|
||||
{ href: "/teams", label: "Teams" },
|
||||
{ href: "/#cli", label: "CLI" },
|
||||
{ href: "/docs", label: "Docs" },
|
||||
{ href: "/blog", label: "Blog" },
|
||||
|
|
|
|||
|
|
@ -1,283 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
/**
|
||||
* Team credential-sharing management UI. The browser holds NO private key, so it
|
||||
* never decrypts secrets — it manages membership, invites, and shows vault
|
||||
* metadata (names, secret counts, who has access). Actual secret values are only
|
||||
* ever decrypted in the `logicsrc` CLI on a device that holds the identity key.
|
||||
*/
|
||||
|
||||
const API_BASE = (process.env.NEXT_PUBLIC_COMMANDBOARD_API_URL ?? "https://commandboard.run").replace(/\/$/, "");
|
||||
const TOKEN_KEY = "logicsrc.credshare.token";
|
||||
const EMAIL_KEY = "logicsrc.credshare.email";
|
||||
|
||||
interface Team {
|
||||
slug: string;
|
||||
name: string;
|
||||
}
|
||||
interface Member {
|
||||
email: string;
|
||||
role: string;
|
||||
status: string;
|
||||
hasPublicKey: boolean;
|
||||
}
|
||||
interface Vault {
|
||||
id: string;
|
||||
name: string;
|
||||
hasAccess: boolean;
|
||||
secretCount: number;
|
||||
}
|
||||
|
||||
async function api<T>(path: string, init: RequestInit & { token?: string } = {}): Promise<T> {
|
||||
const headers: Record<string, string> = { accept: "application/json", ...(init.headers as Record<string, string>) };
|
||||
if (init.body) headers["content-type"] = "application/json";
|
||||
if (init.token) headers["authorization"] = `Bearer ${init.token}`;
|
||||
const res = await fetch(`${API_BASE}/api/credshare${path}`, { ...init, headers });
|
||||
const text = await res.text();
|
||||
const parsed = text ? JSON.parse(text) : undefined;
|
||||
if (!res.ok) throw new Error((parsed && parsed.error) || `${res.status} ${res.statusText}`);
|
||||
return parsed as T;
|
||||
}
|
||||
|
||||
export function TeamsClient({ initialToken }: { initialToken?: string }): React.ReactElement {
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [email, setEmail] = useState("");
|
||||
const [codeSent, setCodeSent] = useState(false);
|
||||
const [code, setCode] = useState("");
|
||||
const [me, setMe] = useState<{ email: string; publicKey: string | null } | null>(null);
|
||||
const [teams, setTeams] = useState<Team[]>([]);
|
||||
const [active, setActive] = useState<string | null>(null);
|
||||
const [members, setMembers] = useState<Member[]>([]);
|
||||
const [vaults, setVaults] = useState<Vault[]>([]);
|
||||
const [inviteEmail, setInviteEmail] = useState("");
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const saved = window.localStorage.getItem(TOKEN_KEY);
|
||||
if (saved) setToken(saved);
|
||||
setEmail(window.localStorage.getItem(EMAIL_KEY) ?? "");
|
||||
}, []);
|
||||
|
||||
const refreshTeams = useCallback(async (tok: string) => {
|
||||
const data = await api<{ user: { email: string; publicKey: string | null }; teams: Team[] }>("/me", { token: tok });
|
||||
setMe(data.user);
|
||||
setTeams(data.teams);
|
||||
setActive((cur) => cur ?? data.teams[0]?.slug ?? null);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
refreshTeams(token).catch((e) => setError(String(e.message ?? e)));
|
||||
}, [token, refreshTeams]);
|
||||
|
||||
const loadTeam = useCallback(
|
||||
async (slug: string, tok: string) => {
|
||||
const [m, v] = await Promise.all([
|
||||
api<{ members: Member[] }>(`/teams/${encodeURIComponent(slug)}/members`, { token: tok }),
|
||||
api<{ vaults: Vault[] }>(`/teams/${encodeURIComponent(slug)}/vaults`, { token: tok })
|
||||
]);
|
||||
setMembers(m.members);
|
||||
setVaults(v.vaults);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (token && active) loadTeam(active, token).catch((e) => setError(String(e.message ?? e)));
|
||||
}, [token, active, loadTeam]);
|
||||
|
||||
// Auto-accept an invite passed via ?token= once the user is logged in.
|
||||
useEffect(() => {
|
||||
if (!initialToken || !token) return;
|
||||
(async () => {
|
||||
try {
|
||||
setBusy(true);
|
||||
const res = await api<{ team?: Team }>("/invites/accept", { method: "POST", token, body: JSON.stringify({ token: initialToken }) });
|
||||
setStatus(`Joined ${res.team?.slug ?? "the team"}. Grant + pull secrets from the CLI.`);
|
||||
await refreshTeams(token);
|
||||
if (res.team) setActive(res.team.slug);
|
||||
} catch (e) {
|
||||
setError(String((e as Error).message ?? e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
})();
|
||||
}, [initialToken, token, refreshTeams]);
|
||||
|
||||
async function requestCode() {
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await api<{ emailSent: boolean; devCode?: string }>("/auth/request", { method: "POST", body: JSON.stringify({ email }) });
|
||||
setCodeSent(true);
|
||||
window.localStorage.setItem(EMAIL_KEY, email);
|
||||
setStatus(res.emailSent ? `Code sent to ${email}.` : `Dev mode — your code is ${res.devCode}.`);
|
||||
if (res.devCode) setCode(res.devCode);
|
||||
} catch (e) {
|
||||
setError(String((e as Error).message ?? e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyCode() {
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await api<{ token: string }>("/auth/verify", { method: "POST", body: JSON.stringify({ email, code }) });
|
||||
window.localStorage.setItem(TOKEN_KEY, res.token);
|
||||
setToken(res.token);
|
||||
setCodeSent(false);
|
||||
setCode("");
|
||||
setStatus("Logged in.");
|
||||
} catch (e) {
|
||||
setError(String((e as Error).message ?? e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function createTeam() {
|
||||
const slug = window.prompt("New team slug (lowercase, dashes):");
|
||||
if (!slug || !token) return;
|
||||
try {
|
||||
await api("/teams", { method: "POST", token, body: JSON.stringify({ slug }) });
|
||||
await refreshTeams(token);
|
||||
setActive(slug);
|
||||
} catch (e) {
|
||||
setError(String((e as Error).message ?? e));
|
||||
}
|
||||
}
|
||||
|
||||
async function invite() {
|
||||
if (!inviteEmail || !active || !token) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await api<{ emailSent: boolean; token?: string }>(`/teams/${encodeURIComponent(active)}/invites`, { method: "POST", token, body: JSON.stringify({ email: inviteEmail }) });
|
||||
setStatus(res.emailSent ? `Invited ${inviteEmail}.` : `Invited ${inviteEmail}. Share this accept link: ${window.location.origin}/teams/accept?token=${res.token}`);
|
||||
setInviteEmail("");
|
||||
await loadTeam(active, token);
|
||||
} catch (e) {
|
||||
setError(String((e as Error).message ?? e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function logout() {
|
||||
window.localStorage.removeItem(TOKEN_KEY);
|
||||
setToken(null);
|
||||
setMe(null);
|
||||
setTeams([]);
|
||||
setActive(null);
|
||||
setStatus("Logged out.");
|
||||
}
|
||||
|
||||
const notice = (
|
||||
<>
|
||||
{error && <p style={{ color: "#e5484d", margin: "0.5rem 0" }}>⚠ {error}</p>}
|
||||
{status && !error && <p style={{ color: "#30a46c", margin: "0.5rem 0" }}>{status}</p>}
|
||||
</>
|
||||
);
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<div className="band" style={{ maxWidth: "32rem" }}>
|
||||
<div className="section-head">
|
||||
<h2>Team credential sharing</h2>
|
||||
<p>Log in by email to manage teams and invites. Secrets stay end-to-end encrypted — decrypt them with the <code>logicsrc</code> CLI, never here.</p>
|
||||
</div>
|
||||
{notice}
|
||||
{!codeSent ? (
|
||||
<div style={{ display: "flex", gap: "0.5rem", marginTop: "1rem", flexWrap: "wrap" }}>
|
||||
<input type="email" placeholder="you@example.com" value={email} onChange={(e) => setEmail(e.target.value)} style={inputStyle} />
|
||||
<button onClick={requestCode} disabled={busy || !email} style={buttonStyle}>Send code</button>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: "flex", gap: "0.5rem", marginTop: "1rem", flexWrap: "wrap" }}>
|
||||
<input inputMode="numeric" placeholder="6-digit code" value={code} onChange={(e) => setCode(e.target.value)} style={inputStyle} />
|
||||
<button onClick={verifyCode} disabled={busy || !code} style={buttonStyle}>Verify</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="band" style={{ maxWidth: "48rem" }}>
|
||||
<div className="section-head" style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", gap: "1rem" }}>
|
||||
<h2>Your teams</h2>
|
||||
<span style={{ fontSize: "0.85rem", opacity: 0.7 }}>
|
||||
{me?.email} {me && !me.publicKey && "· ⚠ no CLI key yet (run logicsrc login)"} · <a onClick={logout} style={{ cursor: "pointer" }}>log out</a>
|
||||
</span>
|
||||
</div>
|
||||
{notice}
|
||||
|
||||
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap", margin: "0.75rem 0" }}>
|
||||
{teams.map((t) => (
|
||||
<button key={t.slug} onClick={() => setActive(t.slug)} style={{ ...chipStyle, ...(active === t.slug ? chipActive : {}) }}>
|
||||
{t.name || t.slug}
|
||||
</button>
|
||||
))}
|
||||
<button onClick={createTeam} style={chipStyle}>+ new team</button>
|
||||
</div>
|
||||
|
||||
{active && (
|
||||
<>
|
||||
<h3 style={{ marginTop: "1.5rem" }}>Members</h3>
|
||||
<table style={tableStyle}>
|
||||
<thead>
|
||||
<tr><th style={thStyle}>Email</th><th style={thStyle}>Role</th><th style={thStyle}>Status</th><th style={thStyle}>CLI key</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{members.map((m) => (
|
||||
<tr key={m.email}>
|
||||
<td style={tdStyle}>{m.email}</td><td style={tdStyle}>{m.role}</td>
|
||||
<td style={tdStyle}>{m.status}</td><td style={tdStyle}>{m.hasPublicKey ? "✓" : "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div style={{ display: "flex", gap: "0.5rem", marginTop: "0.75rem", flexWrap: "wrap" }}>
|
||||
<input type="email" placeholder="teammate@example.com" value={inviteEmail} onChange={(e) => setInviteEmail(e.target.value)} style={inputStyle} />
|
||||
<button onClick={invite} disabled={busy || !inviteEmail} style={buttonStyle}>Invite</button>
|
||||
</div>
|
||||
|
||||
<h3 style={{ marginTop: "1.5rem" }}>Vaults</h3>
|
||||
{vaults.length === 0 ? (
|
||||
<p style={{ opacity: 0.7 }}>No vaults yet. Create one from the CLI: <code>logicsrc teams push {active} prod</code></p>
|
||||
) : (
|
||||
<table style={tableStyle}>
|
||||
<thead><tr><th style={thStyle}>Vault</th><th style={thStyle}>Secrets</th><th style={thStyle}>Your access</th></tr></thead>
|
||||
<tbody>
|
||||
{vaults.map((v) => (
|
||||
<tr key={v.id}>
|
||||
<td style={tdStyle}><code>{v.name}</code></td><td style={tdStyle}>{v.secretCount}</td>
|
||||
<td style={tdStyle}>{v.hasAccess ? "✓ granted" : "— ask a member to grant you"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
<p style={{ opacity: 0.7, marginTop: "1rem", fontSize: "0.9rem" }}>
|
||||
Pull secrets on your machine: <code>logicsrc teams pull {active} <vault></code> — values are decrypted locally with your key. The server (and this page) only ever see ciphertext.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const inputStyle: React.CSSProperties = { padding: "0.6rem 0.8rem", borderRadius: "0.5rem", border: "1px solid var(--border, #333)", background: "transparent", color: "inherit", minWidth: "16rem", flex: 1 };
|
||||
const buttonStyle: React.CSSProperties = { padding: "0.6rem 1rem", borderRadius: "0.5rem", border: "1px solid var(--border, #333)", background: "var(--accent, #5b7cfa)", color: "#fff", cursor: "pointer" };
|
||||
const chipStyle: React.CSSProperties = { padding: "0.4rem 0.8rem", borderRadius: "999px", border: "1px solid var(--border, #333)", background: "transparent", color: "inherit", cursor: "pointer" };
|
||||
const chipActive: React.CSSProperties = { background: "var(--accent, #5b7cfa)", color: "#fff", borderColor: "transparent" };
|
||||
const tableStyle: React.CSSProperties = { width: "100%", borderCollapse: "collapse", marginTop: "0.5rem", fontSize: "0.92rem" };
|
||||
const thStyle: React.CSSProperties = { textAlign: "left", padding: "0.4rem 0.6rem", borderBottom: "1px solid var(--border, #333)", opacity: 0.7, fontWeight: 600 };
|
||||
const tdStyle: React.CSSProperties = { padding: "0.4rem 0.6rem", borderBottom: "1px solid var(--border, #222)" };
|
||||
Loading…
Add table
Add a link
Reference in a new issue