Newer
Older
gnexus-creds / frontend / src / App.vue
@Eugene Sukhodolskiy Eugene Sukhodolskiy 4 days ago 9 KB Implement initial gnexus-creds MVP scaffold
<script setup>
import { computed, onMounted, reactive, ref } from "vue";
import {
  GnBadge,
  GnButton,
  GnCard,
  GnCheckbox,
  GnInput,
  GnModal,
  GnPageHeader,
  GnTabs,
  GnTextarea
} from "gnexus-ui-kit/vue";

import { api } from "./api";

const tabs = [
  { id: "secrets", label: "Secrets" },
  { id: "history", label: "History" },
  { id: "audit", label: "Audit" },
  { id: "tokens", label: "Tokens" },
  { id: "settings", label: "Settings" }
];

const activeTab = ref("secrets");
const me = ref(null);
const loading = ref(false);
const error = ref("");
const secrets = ref([]);
const total = ref(0);
const query = ref("");
const selected = ref(null);
const revealed = ref(null);
const versions = ref([]);
const auditEvents = ref([]);
const tokens = ref([]);
const newToken = ref(null);
const showDeleteConfirm = ref(false);

const form = reactive({
  title: "",
  purpose: "",
  category: "",
  source: "",
  notes: "",
  tags: "",
  allow_ui: true,
  allow_rest_api: true,
  allow_mcp: false,
  fieldsText: "username=\npassword=*"
});

const visibleFields = computed(() => selected.value?.fields || []);

function parseFields() {
  return form.fieldsText
    .split("\n")
    .map((line, index) => {
      const [rawName, ...rest] = line.split("=");
      const name = rawName.trim();
      if (!name) return null;
      const rawValue = rest.join("=");
      const encrypted = rawValue.startsWith("*");
      return {
        name,
        value: encrypted ? rawValue.slice(1) : rawValue,
        encrypted,
        masked: encrypted,
        position: index
      };
    })
    .filter(Boolean);
}

async function loadSecrets() {
  loading.value = true;
  error.value = "";
  try {
    const payload = await api.listSecrets({ q: query.value, limit: 50 });
    secrets.value = payload.items;
    total.value = payload.total;
    selected.value = secrets.value[0] || null;
    revealed.value = null;
  } catch (err) {
    error.value = err.message;
  } finally {
    loading.value = false;
  }
}

async function createSecret() {
  await api.createSecret({
    title: form.title,
    purpose: form.purpose || null,
    category: form.category || null,
    source: form.source || null,
    notes: form.notes || null,
    tags: form.tags.split(",").map((tag) => tag.trim()).filter(Boolean),
    allow_ui: form.allow_ui,
    allow_rest_api: form.allow_rest_api,
    allow_mcp: form.allow_mcp,
    fields: parseFields()
  });
  form.title = "";
  form.purpose = "";
  form.category = "";
  form.source = "";
  form.notes = "";
  form.tags = "";
  await loadSecrets();
}

async function reveal(secret) {
  revealed.value = await api.revealSecret(secret.id);
}

async function copyField(secret, field) {
  const payload = revealed.value?.id === secret.id ? revealed.value : await api.revealSecret(secret.id);
  const full = payload.fields.find((item) => item.name === field.name);
  if (full?.value) {
    await navigator.clipboard.writeText(full.value);
  }
}

async function loadVersions(secret) {
  selected.value = secret;
  activeTab.value = "history";
  versions.value = await api.versions(secret.id);
}

async function loadAudit() {
  activeTab.value = "audit";
  auditEvents.value = (await api.audit()).items;
}

async function loadTokens() {
  activeTab.value = "tokens";
  tokens.value = await api.tokens();
}

async function createApiToken() {
  newToken.value = await api.createToken({
    name: "MCP client",
    scopes: ["read", "reveal", "write", "mcp"]
  });
  await loadTokens();
}

async function exportData() {
  const payload = await api.exportData();
  const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
  const url = URL.createObjectURL(blob);
  const link = document.createElement("a");
  link.href = url;
  link.download = "gnexus-creds-export.json";
  link.click();
  URL.revokeObjectURL(url);
}

async function deleteAllData() {
  await api.deleteAccountData();
  showDeleteConfirm.value = false;
  await loadSecrets();
}

onMounted(async () => {
  try {
    me.value = await api.me();
    await loadSecrets();
  } catch {
    window.location.href = "/auth/login";
  }
});
</script>

<template>
  <main class="creds-app">
    <GnPageHeader
      title="gnexus-creds"
      eyebrow="Personal secret storage"
      :description="me ? `${me.email} · ${me.role}` : 'Loading session'"
    />

    <GnTabs v-model="activeTab" :items="tabs" />

    <section v-if="activeTab === 'secrets'" class="creds-grid">
      <GnCard class="panel">
        <div class="toolbar">
          <GnInput v-model="query" label="Search" placeholder="service, login, tag, source" />
          <GnButton variant="primary" @click="loadSecrets">Search</GnButton>
        </div>
        <p v-if="error" class="error">{{ error }}</p>
        <p class="muted">{{ total }} records</p>
        <button
          v-for="secret in secrets"
          :key="secret.id"
          class="secret-row"
          type="button"
          @click="selected = secret; revealed = null"
        >
          <span>
            <strong>{{ secret.title }}</strong>
            <small>{{ secret.purpose || secret.category || "No purpose" }}</small>
          </span>
          <GnBadge :tone="secret.status === 'actual' ? 'success' : 'warning'">
            {{ secret.status }}
          </GnBadge>
        </button>
      </GnCard>

      <GnCard class="panel">
        <h2>{{ selected?.title || "No secret selected" }}</h2>
        <template v-if="selected">
          <p class="muted">{{ selected.purpose }} · {{ selected.category }}</p>
          <div class="field-list">
            <div v-for="field in visibleFields" :key="field.name" class="field-row">
              <span>{{ field.name }}</span>
              <code>{{ field.value ?? "••••••" }}</code>
              <GnButton size="sm" @click="copyField(selected, field)">Copy</GnButton>
            </div>
          </div>
          <div class="toolbar">
            <GnButton variant="primary" @click="reveal(selected)">Reveal</GnButton>
            <GnButton @click="loadVersions(selected)">Versions</GnButton>
          </div>
          <div v-if="revealed" class="field-list revealed">
            <div v-for="field in revealed.fields" :key="field.name" class="field-row">
              <span>{{ field.name }}</span>
              <code>{{ field.value }}</code>
            </div>
          </div>
        </template>
      </GnCard>

      <GnCard class="panel">
        <h2>Create secret</h2>
        <GnInput v-model="form.title" label="Title" />
        <GnInput v-model="form.purpose" label="Purpose" />
        <GnInput v-model="form.category" label="Category" />
        <GnInput v-model="form.tags" label="Tags" placeholder="comma,separated" />
        <GnTextarea v-model="form.fieldsText" label="Fields" rows="6" />
        <GnInput v-model="form.source" label="Source" />
        <GnInput v-model="form.notes" label="Notes" maxlength="140" />
        <div class="checks">
          <GnCheckbox v-model="form.allow_ui" label="UI" />
          <GnCheckbox v-model="form.allow_rest_api" label="REST API" />
          <GnCheckbox v-model="form.allow_mcp" label="MCP" />
        </div>
        <GnButton variant="primary" @click="createSecret">Create</GnButton>
      </GnCard>
    </section>

    <section v-if="activeTab === 'history'" class="panel-stack">
      <GnCard class="panel">
        <h2>Version history</h2>
        <p class="muted">{{ selected?.title || "Select a secret first" }}</p>
        <div v-for="version in versions" :key="version.id" class="secret-row">
          <span>Version {{ version.version_number }}</span>
          <small>{{ version.created_at }}</small>
        </div>
      </GnCard>
    </section>

    <section v-if="activeTab === 'audit'" class="panel-stack">
      <GnCard class="panel">
        <div class="toolbar">
          <h2>Audit</h2>
          <GnButton @click="loadAudit">Refresh</GnButton>
        </div>
        <div v-for="event in auditEvents" :key="event.id" class="secret-row">
          <span>{{ event.action }}</span>
          <small>{{ event.channel }} · {{ event.created_at }}</small>
        </div>
      </GnCard>
    </section>

    <section v-if="activeTab === 'tokens'" class="panel-stack">
      <GnCard class="panel">
        <div class="toolbar">
          <h2>API tokens</h2>
          <GnButton @click="loadTokens">Refresh</GnButton>
          <GnButton variant="primary" @click="createApiToken">Create MCP token</GnButton>
        </div>
        <GnTextarea v-if="newToken" :model-value="newToken.token" label="New token" readonly />
        <div v-for="token in tokens" :key="token.id" class="secret-row">
          <span>{{ token.name }}</span>
          <small>{{ token.scopes.join(", ") }}</small>
        </div>
      </GnCard>
    </section>

    <section v-if="activeTab === 'settings'" class="panel-stack">
      <GnCard class="panel">
        <h2>Settings</h2>
        <div class="toolbar">
          <GnButton @click="exportData">Export decrypted JSON</GnButton>
          <GnButton variant="danger" @click="showDeleteConfirm = true">Delete all data</GnButton>
        </div>
      </GnCard>
    </section>

    <GnModal v-model="showDeleteConfirm" title="Delete all data">
      <p>This permanently removes all secrets and versions. Audit records remain.</p>
      <template #footer>
        <GnButton @click="showDeleteConfirm = false">Cancel</GnButton>
        <GnButton variant="danger" @click="deleteAllData">Delete</GnButton>
      </template>
    </GnModal>
  </main>
</template>