Files
rhema-claw/web/claw/app/components/settings/AgentAccountsPage.vue
T
vhaudiquet 81da26b955 feat(rhema-code): add the colors setting with four palettes
The stored `colors` key names a palette — rhema (the default,
Jadeite), aquamarine, black-and-white, or dracula — and the
Appearance page of /settings cycles it live, like the theme and
icon toggles beside it. Each palette carries a dark and a light
appearance under the existing `theme` key; dracula's light side is
derived, the official palette ships only dark.

The appearance decision moves off token comparison: Theme now
answers is_dark() from the ground/text luminance, so palettes
beyond the original two get the right per-profile fallback tones.
2026-10-17 04:28:31 +08:00

251 lines
7.3 KiB
Vue

<script setup lang="ts">
// The Agent · Accounts page: logins the agent itself holds on external
// services. Presentational like its siblings — data flows in as props,
// intent flows up as events; the secret field is write-only by the
// wire's shape, and the roster can only ever render what `GET /v1/
// accounts` serves, which is the redacted record.
import type { ClawAccount, NewAccount } from '~/composables/useClaw'
const props = defineProps<{
accounts: ClawAccount[] | null
loading: boolean
/** A save or forget is in flight. */
mutating: boolean
}>()
const emit = defineEmits<{
add: [account: NewAccount]
remove: [account: ClawAccount]
}>()
const adding = ref(false)
/// The form's own shape: every field a string (empty = omit on the
/// wire), so the template never fights `NewAccount`'s optional fields.
const draft = ref({
label: '',
service: '',
url: '',
username: '',
secret: '',
secret_kind: 'token' as 'token' | 'password',
watch: false,
})
const formValid = computed(
() =>
draft.value.label.trim() !== '' &&
draft.value.service.trim() !== '' &&
draft.value.secret.trim() !== '',
)
function submit(): void {
if (!formValid.value || props.mutating) return
// Empty optionals are omitted, never sent as "" strings.
const payload: NewAccount = {
label: draft.value.label.trim(),
service: draft.value.service.trim().toLowerCase(),
secret: draft.value.secret,
}
if (draft.value.url.trim() !== '') payload.url = draft.value.url.trim()
if (draft.value.username.trim() !== '') payload.username = draft.value.username.trim()
if (draft.value.secret_kind === 'password') payload.secret_kind = 'password'
if (draft.value.watch) payload.watch = true
emit('add', payload)
draft.value = {
label: '',
service: '',
url: '',
username: '',
secret: '',
secret_kind: 'token',
watch: false,
}
adding.value = false
}
// A forget cascades the account's derived watches, so it asks twice.
const confirming = ref<string | null>(null)
function forget(account: ClawAccount): void {
if (props.mutating) return
if (confirming.value !== account.id) {
confirming.value = account.id
return
}
confirming.value = null
emit('remove', account)
}
function meta(account: ClawAccount): string {
const parts = [account.service]
if (account.username) parts.push(account.username)
if (account.url) parts.push(account.url)
return parts.join(' · ')
}
</script>
<template>
<div class="page">
<PageHeader title="Accounts" subtitle="Who the agent is on the outside">
<template #actions>
<button class="primary" :disabled="mutating" @click="adding = !adding">
{{ adding ? 'Close' : 'Add account…' }}
</button>
</template>
</PageHeader>
<div class="scroll">
<SettingCard v-if="adding" title="New account" meta="the secret is stored, never shown again">
<SettingRow title="Label" description="A name for the roster, e.g. “acme releases”.">
<input
v-model="draft.label"
type="text"
maxlength="128"
placeholder="acme releases"
autocomplete="off"
:disabled="mutating"
/>
</SettingRow>
<SettingRow
title="Service"
description="Lowercase: github, gitlab, or gitea — or any name for a service the agent only stores."
>
<input
v-model="draft.service"
type="text"
placeholder="github"
autocomplete="off"
:disabled="mutating"
/>
</SettingRow>
<SettingRow title="URL" description="Base URL, for self-hosted instances.">
<input
v-model="draft.url"
type="text"
placeholder="https://git.example.net"
autocomplete="off"
:disabled="mutating"
/>
</SettingRow>
<SettingRow title="Username" description="The login name on the service.">
<input
v-model="draft.username"
type="text"
placeholder="optional"
autocomplete="off"
:disabled="mutating"
/>
</SettingRow>
<SettingRow title="Secret" description="An API token — or a password, which the agent stores but can never use.">
<input
v-model="draft.secret"
type="password"
placeholder="paste the credential"
autocomplete="off"
:disabled="mutating"
/>
</SettingRow>
<SettingRow title="Kind" description="What the secret authenticates as.">
<select v-model="draft.secret_kind" :disabled="mutating">
<option value="token">token</option>
<option value="password">password</option>
</select>
</SettingRow>
<SettingRow
title="Follow notifications"
description="For a forge service: watch everything addressed to this account, triaged into agent work."
>
<input v-model="draft.watch" type="checkbox" :disabled="mutating" />
</SettingRow>
<div class="actions">
<button :disabled="mutating" @click="adding = false">Cancel</button>
<button class="primary" :disabled="!formValid || mutating" @click="submit">
{{ mutating ? 'Saving…' : 'Save account' }}
</button>
</div>
</SettingCard>
<SettingCard
title="Agent accounts"
:meta="accounts?.length ? `${accounts.length}` : 'none yet'"
>
<p v-if="loading" class="dim">loading…</p>
<p v-else-if="!accounts?.length" class="dim">
No agent accounts yet. An account is a login the agent holds on
an external service — a GitHub identity it answers issues as,
an email it sends from — distinct from the model providers
(what it thinks with) and the forge watches (what it looks at).
For a forge identity, “follow notifications” turns the account
into watches automatically: review requests, assignments, and
feedback become agent work, mentions surface to you.
</p>
<SettingRow
v-for="account in accounts"
:key="account.id"
:title="account.label"
:description="meta(account)"
>
<div class="roster-control">
<span class="kind">{{ account.secret_kind }}</span>
<button
class="ghost"
:disabled="mutating"
@click="forget(account)"
>
{{
confirming === account.id
? 'Forget — also drops its watches?'
: 'Forget…'
}}
</button>
</div>
</SettingRow>
</SettingCard>
</div>
</div>
</template>
<style scoped>
.page {
display: flex;
flex-direction: column;
min-height: 0;
min-width: 0;
flex: 1;
}
.scroll {
flex: 1;
overflow-y: auto;
padding: 0 24px 24px;
display: flex;
flex-direction: column;
gap: 16px;
max-width: 860px;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 8px 16px 14px;
}
.dim {
margin: 0;
padding: 14px 16px;
color: var(--text2);
}
.roster-control {
display: flex;
align-items: center;
gap: 8px;
}
.kind {
font-size: 12px;
color: var(--text3);
}
</style>