@cat-factory/app 0.200.4 → 0.201.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/components/foundational/FoundationalContractSummary.vue +14 -2
- package/app/components/foundational/FoundationalServiceCatalogList.vue +10 -6
- package/app/components/foundational/FoundationalServiceManager.vue +14 -4
- package/app/components/foundational/FoundationalSuppressions.vue +32 -8
- package/app/components/github/BranchProtectionPreflight.vue +202 -0
- package/app/components/github/GitHubPanel.vue +14 -0
- package/app/components/layout/AccountRunCredentialSettings.vue +97 -0
- package/app/components/layout/AccountTeamSettings.vue +9 -0
- package/app/components/settings/ConnectionWarnings.vue +12 -2
- package/app/components/settings/UserSecretsSection.vue +17 -2
- package/app/components/settings/WorkspaceSettingsPanel.vue +33 -0
- package/app/composables/api/foundationalServices.ts +20 -11
- package/app/composables/api/github.ts +6 -0
- package/app/stores/foundationalServices.spec.ts +29 -7
- package/app/stores/foundationalServices.ts +15 -12
- package/app/stores/workspaceSettings.ts +3 -0
- package/app/utils/agentOutput.ts +5 -2
- package/app/utils/connectionWarnings.ts +6 -0
- package/i18n/locales/de.json +56 -4
- package/i18n/locales/en.json +56 -4
- package/i18n/locales/es.json +56 -4
- package/i18n/locales/fr.json +56 -4
- package/i18n/locales/he.json +56 -4
- package/i18n/locales/it.json +56 -4
- package/i18n/locales/ja.json +56 -4
- package/i18n/locales/pl.json +56 -4
- package/i18n/locales/tr.json +56 -4
- package/i18n/locales/uk.json +56 -4
- package/package.json +6 -7
|
@@ -4,8 +4,14 @@
|
|
|
4
4
|
// catalog carries, so rendering the same fields here is what makes the surface answer "what will
|
|
5
5
|
// the design actually see?".
|
|
6
6
|
//
|
|
7
|
-
// `omittedOperations` is rendered rather than dropped: the operation list is CAPPED
|
|
8
|
-
//
|
|
7
|
+
// `omittedOperations` is rendered rather than dropped: the operation list is CAPPED (and, for a
|
|
8
|
+
// contract module, read by a best-effort static extractor), and a reader who assumed it complete
|
|
9
|
+
// would conclude the missing endpoints do not exist.
|
|
10
|
+
//
|
|
11
|
+
// An EMPTY list is likewise never left to speak for itself: `operationsAreIndexable` tells apart a
|
|
12
|
+
// format this platform does not read from an interface that genuinely declares nothing, and the
|
|
13
|
+
// two need opposite reactions from whoever is looking.
|
|
14
|
+
import { operationsAreIndexable } from '@cat-factory/contracts'
|
|
9
15
|
import type { ApiContractFormat, ApiContractSummary } from '~/types/domain'
|
|
10
16
|
|
|
11
17
|
defineProps<{
|
|
@@ -25,6 +31,12 @@ const { t, n } = useI18n()
|
|
|
25
31
|
<span v-if="c.operations.length" class="ms-1 font-mono text-slate-500">
|
|
26
32
|
{{ c.operations.join(' · ') }}
|
|
27
33
|
</span>
|
|
34
|
+
<span v-else-if="!operationsAreIndexable(c.format)" class="ms-1 text-slate-600">
|
|
35
|
+
{{ t('foundational.contracts.notIndexed') }}
|
|
36
|
+
</span>
|
|
37
|
+
<span v-else-if="!c.omittedOperations" class="ms-1 text-slate-600">
|
|
38
|
+
{{ t('foundational.contracts.noOperations') }}
|
|
39
|
+
</span>
|
|
28
40
|
<span v-if="c.omittedOperations > 0" class="ms-1 text-amber-500/80">
|
|
29
41
|
{{ t('foundational.contracts.omitted', { count: c.omittedOperations }) }}
|
|
30
42
|
</span>
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
// The MERGED catalog an Architect is actually handed for this board —
|
|
3
|
-
//
|
|
4
|
-
//
|
|
2
|
+
// The MERGED catalog an Architect is actually handed for this board — the deployment's
|
|
3
|
+
// code-registered `builtin` services ⊕ account ⊕ workspace, the later tier winning by id
|
|
4
|
+
// (backend/docs/adr/0031-foundational-services.md). Workspace scope only: a board is what runs
|
|
5
|
+
// agents, so this is the only place the whole merge is realised.
|
|
5
6
|
//
|
|
6
7
|
// This view is where the two board-level decisions live, and they are deliberately different
|
|
7
8
|
// actions rather than one "remove":
|
|
@@ -24,6 +25,7 @@ const { t } = useI18n()
|
|
|
24
25
|
|
|
25
26
|
// Exhaustive maps of literal `t(...)` keys, so a new tier/format fails the typed-key guard.
|
|
26
27
|
const tierLabel = computed<Record<FoundationalServiceTier, string>>(() => ({
|
|
28
|
+
builtin: t('foundational.tier.builtin'),
|
|
27
29
|
account: t('foundational.tier.account'),
|
|
28
30
|
workspace: t('foundational.tier.workspace'),
|
|
29
31
|
}))
|
|
@@ -34,6 +36,7 @@ const formatLabel = computed<Record<ApiContractFormat, string>>(() => ({
|
|
|
34
36
|
}))
|
|
35
37
|
// `as const` keeps the literal colour names assignable to UBadge's `color` union.
|
|
36
38
|
const tierColor = {
|
|
39
|
+
builtin: 'neutral',
|
|
37
40
|
account: 'info',
|
|
38
41
|
workspace: 'primary',
|
|
39
42
|
} as const satisfies Record<FoundationalServiceTier, string>
|
|
@@ -147,10 +150,11 @@ async function suppress(serviceId: string) {
|
|
|
147
150
|
<UBadge size="xs" :color="tierColor[s.tier]" variant="subtle">
|
|
148
151
|
{{ tierLabel[s.tier] }}
|
|
149
152
|
</UBadge>
|
|
150
|
-
<!-- Only an INHERITED entry can be suppressed
|
|
151
|
-
|
|
153
|
+
<!-- Only an INHERITED entry can be suppressed — an account service or one the
|
|
154
|
+
deployment registered in code; the board's own row is managed in the registry tab,
|
|
155
|
+
where deleting it is the honest action. -->
|
|
152
156
|
<UButton
|
|
153
|
-
v-if="s.tier
|
|
157
|
+
v-if="s.tier !== 'workspace'"
|
|
154
158
|
icon="i-lucide-eye-off"
|
|
155
159
|
size="xs"
|
|
156
160
|
variant="ghost"
|
|
@@ -7,8 +7,9 @@
|
|
|
7
7
|
//
|
|
8
8
|
// The tab split is the feature's own split, not a layout choice: "Catalog" is what an agent sees
|
|
9
9
|
// (identity + operation names, never a document body), while "This tier" is what this owner
|
|
10
|
-
// actually registers.
|
|
11
|
-
// suppression
|
|
10
|
+
// actually registers. Only a board gets the catalog tab — it is what runs agents — but BOTH
|
|
11
|
+
// scopes get the suppression list, because an account inherits the deployment's code-registered
|
|
12
|
+
// services exactly as a board inherits its account's.
|
|
12
13
|
import { computed, ref, watch } from 'vue'
|
|
13
14
|
import type { FoundationalServiceOwnerKind } from '~/types/domain'
|
|
14
15
|
import {
|
|
@@ -97,11 +98,20 @@ const activeTab = computed({
|
|
|
97
98
|
<template #catalog>
|
|
98
99
|
<div class="flex flex-col gap-4">
|
|
99
100
|
<FoundationalServiceCatalogList />
|
|
100
|
-
<FoundationalSuppressions />
|
|
101
|
+
<FoundationalSuppressions :kind="props.kind" :owner-id="props.ownerId" />
|
|
101
102
|
</div>
|
|
102
103
|
</template>
|
|
103
104
|
<template #registry>
|
|
104
|
-
<
|
|
105
|
+
<div class="flex flex-col gap-4">
|
|
106
|
+
<FoundationalServiceRegistry :kind="props.kind" :owner-id="props.ownerId" />
|
|
107
|
+
<!-- A scope with no catalog tab still has to be able to see (and lift) what it is
|
|
108
|
+
opting out of; with one, the list lives beside the catalog it explains. -->
|
|
109
|
+
<FoundationalSuppressions
|
|
110
|
+
v-if="!props.showCatalog"
|
|
111
|
+
:kind="props.kind"
|
|
112
|
+
:owner-id="props.ownerId"
|
|
113
|
+
/>
|
|
114
|
+
</div>
|
|
105
115
|
</template>
|
|
106
116
|
<template #sources>
|
|
107
117
|
<FoundationalServiceSources :kind="props.kind" :owner-id="props.ownerId" />
|
|
@@ -1,19 +1,43 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
// What this
|
|
2
|
+
// What this tier is opted OUT of (backend/docs/adr/0031-foundational-services.md). A suppressed id is
|
|
3
3
|
// by construction absent from the merged catalog, so without this list the suppress action would
|
|
4
4
|
// be a one-way door — which is why it is a section of its own rather than a badge on the catalog.
|
|
5
5
|
//
|
|
6
|
+
// Rendered at BOTH scopes, because both inherit: a board from its account, and either from the
|
|
7
|
+
// services the deployment registered in code (the `builtin` tier).
|
|
8
|
+
//
|
|
6
9
|
// `inherited: false` is rendered distinctly rather than hidden: the tombstone shadows nothing
|
|
7
|
-
// today (the
|
|
8
|
-
// own registration), and an operator reading it as "a capability is being withheld" would go
|
|
10
|
+
// today (the tier below withdrew the service, or this row is what remains of this tier deleting
|
|
11
|
+
// its own registration), and an operator reading it as "a capability is being withheld" would go
|
|
9
12
|
// looking for something that is not there.
|
|
10
|
-
import { reactive } from 'vue'
|
|
11
|
-
import {
|
|
13
|
+
import { computed, reactive } from 'vue'
|
|
14
|
+
import type { FoundationalServiceOwnerKind } from '~/types/domain'
|
|
15
|
+
import {
|
|
16
|
+
useFoundationalServices,
|
|
17
|
+
useFoundationalServicesStore,
|
|
18
|
+
} from '~/stores/foundationalServices'
|
|
19
|
+
|
|
20
|
+
const props = defineProps<{ kind: FoundationalServiceOwnerKind; ownerId: string }>()
|
|
12
21
|
|
|
13
|
-
const catalog =
|
|
22
|
+
const catalog =
|
|
23
|
+
props.kind === 'workspace'
|
|
24
|
+
? useFoundationalServicesStore()
|
|
25
|
+
: useFoundationalServices(props.kind, props.ownerId)
|
|
14
26
|
const toast = useToast()
|
|
15
27
|
const { t } = useI18n()
|
|
16
28
|
|
|
29
|
+
// Exhaustive scope→key maps of literal `t(...)` keys: what a board opts out of (its account's
|
|
30
|
+
// services) and what an account opts out of (the deployment's) are different enough sentences
|
|
31
|
+
// that one string with a placeholder would read as neither.
|
|
32
|
+
const title = computed<Record<FoundationalServiceOwnerKind, string>>(() => ({
|
|
33
|
+
workspace: t('foundational.suppressions.title.workspace'),
|
|
34
|
+
account: t('foundational.suppressions.title.account'),
|
|
35
|
+
}))
|
|
36
|
+
const intro = computed<Record<FoundationalServiceOwnerKind, string>>(() => ({
|
|
37
|
+
workspace: t('foundational.suppressions.intro.workspace'),
|
|
38
|
+
account: t('foundational.suppressions.intro.account'),
|
|
39
|
+
}))
|
|
40
|
+
|
|
17
41
|
const busyRows = reactive(new Set<string>())
|
|
18
42
|
const rowBusy = (id: string) => busyRows.has(id)
|
|
19
43
|
|
|
@@ -42,8 +66,8 @@ async function restore(serviceId: string) {
|
|
|
42
66
|
class="flex flex-col gap-2"
|
|
43
67
|
data-testid="foundational-suppressions"
|
|
44
68
|
>
|
|
45
|
-
<p class="text-sm font-medium">{{
|
|
46
|
-
<p class="text-xs text-slate-500">{{
|
|
69
|
+
<p class="text-sm font-medium">{{ title[props.kind] }}</p>
|
|
70
|
+
<p class="text-xs text-slate-500">{{ intro[props.kind] }}</p>
|
|
47
71
|
<div
|
|
48
72
|
v-for="s in catalog.suppressions"
|
|
49
73
|
:key="s.id"
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// The branch-protection preflight: for each linked repository, is its DEFAULT branch protected
|
|
3
|
+
// on the host?
|
|
4
|
+
//
|
|
5
|
+
// This is the one control the platform can neither provide nor enforce. An agent run pushes with
|
|
6
|
+
// a `Contents: write` credential, and nothing platform-side stops a stolen one from pushing
|
|
7
|
+
// straight to `main` — or merging an open pull request through the host's merge API, which needs
|
|
8
|
+
// no more permission than the push already had. Branch protection is what covers both, it lives
|
|
9
|
+
// on GitHub rather than in this product, and until now nothing in-product said when it was
|
|
10
|
+
// missing. See `backend/docs/security-model.md`.
|
|
11
|
+
//
|
|
12
|
+
// On demand, never on open: each repository costs one or two live GitHub reads, so a panel that
|
|
13
|
+
// probed automatically would spend an operator's rate limit every time they glanced at settings.
|
|
14
|
+
import { computed, ref } from 'vue'
|
|
15
|
+
import type { BranchProtectionReportView, BranchProtectionStateValue } from '@cat-factory/contracts'
|
|
16
|
+
import { describeGenericFailure } from '~/composables/usePipelineErrorToast'
|
|
17
|
+
|
|
18
|
+
const { t } = useI18n()
|
|
19
|
+
const api = useApi()
|
|
20
|
+
const workspace = useWorkspaceStore()
|
|
21
|
+
|
|
22
|
+
const report = ref<BranchProtectionReportView | null>(null)
|
|
23
|
+
const checking = ref(false)
|
|
24
|
+
// A failed probe is described from its STATUS CLASS in translated copy, with the backend's
|
|
25
|
+
// untranslated prose (plus the request id an operator greps for) behind a disclosure — the same
|
|
26
|
+
// split every other failure surface makes, through the same pure classifier the run-control
|
|
27
|
+
// toasts use. Rendering `e.message` as the headline would hand a non-English operator English
|
|
28
|
+
// on the one panel whose entire job is telling them their deployment is exposed.
|
|
29
|
+
const failure = ref<{ descriptionKey: string; detail: string } | null>(null)
|
|
30
|
+
const showFailureDetail = ref(false)
|
|
31
|
+
|
|
32
|
+
type Row = BranchProtectionReportView['repos'][number]
|
|
33
|
+
type UnknownReason = NonNullable<Row['protection']['reason']>
|
|
34
|
+
|
|
35
|
+
/** Unprotected first, then unknown, then protected — the order an operator has to act in. */
|
|
36
|
+
const RANK: Record<BranchProtectionStateValue, number> = {
|
|
37
|
+
unprotected: 0,
|
|
38
|
+
unknown: 1,
|
|
39
|
+
protected: 2,
|
|
40
|
+
}
|
|
41
|
+
const rows = computed(() =>
|
|
42
|
+
[...(report.value?.repos ?? [])].sort(
|
|
43
|
+
(a, b) => RANK[a.protection.state] - RANK[b.protection.state],
|
|
44
|
+
),
|
|
45
|
+
)
|
|
46
|
+
const exposedCount = computed(
|
|
47
|
+
() => rows.value.filter((r) => r.protection.state === 'unprotected').length,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
const STATE_STYLE: Record<BranchProtectionStateValue, string> = {
|
|
51
|
+
unprotected: 'text-rose-300',
|
|
52
|
+
unknown: 'text-amber-300',
|
|
53
|
+
protected: 'text-emerald-400',
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Exhaustive Records of LITERAL keys, not an assembled `\`…\${state}\`` lookup: the typed-key
|
|
57
|
+
// check cannot see a runtime-assembled key, so these are what fail the build when the backend
|
|
58
|
+
// adds a state or a reason the SPA has no copy for.
|
|
59
|
+
const STATE_LABEL: Record<BranchProtectionStateValue, () => string> = {
|
|
60
|
+
protected: () => t('vcs.branchProtection.state.protected'),
|
|
61
|
+
unprotected: () => t('vcs.branchProtection.state.unprotected'),
|
|
62
|
+
unknown: () => t('vcs.branchProtection.state.unknown'),
|
|
63
|
+
}
|
|
64
|
+
const UNKNOWN_REASON: Record<UnknownReason, () => string> = {
|
|
65
|
+
branch_not_found: () => t('vcs.branchProtection.unknownReason.branch_not_found'),
|
|
66
|
+
forbidden: () => t('vcs.branchProtection.unknownReason.forbidden'),
|
|
67
|
+
error: () => t('vcs.branchProtection.unknownReason.error'),
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The line under a repository. `unknown` names its CAUSE, and a protected branch whose rule we
|
|
72
|
+
* could not read says so rather than passing as fully verified — the two are different operator
|
|
73
|
+
* situations, and collapsing them is how a report starts lying.
|
|
74
|
+
*/
|
|
75
|
+
function detailLine(row: Row): string {
|
|
76
|
+
const p = row.protection
|
|
77
|
+
if (p.state === 'unknown') return UNKNOWN_REASON[p.reason ?? 'error']()
|
|
78
|
+
if (p.state === 'unprotected') return t('vcs.branchProtection.unprotectedHint')
|
|
79
|
+
if (p.detailUnavailable) return t('vcs.branchProtection.detailUnavailable')
|
|
80
|
+
if (!p.detail) return ''
|
|
81
|
+
return p.detail.requiresPullRequest
|
|
82
|
+
? t('vcs.branchProtection.requiresPr', {
|
|
83
|
+
reviews: p.detail.requiredApprovingReviewCount,
|
|
84
|
+
checks: p.detail.requiredStatusChecks.length,
|
|
85
|
+
})
|
|
86
|
+
: t('vcs.branchProtection.noPrRequired')
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function check() {
|
|
90
|
+
const workspaceId = workspace.workspaceId
|
|
91
|
+
if (!workspaceId) return
|
|
92
|
+
checking.value = true
|
|
93
|
+
failure.value = null
|
|
94
|
+
showFailureDetail.value = false
|
|
95
|
+
try {
|
|
96
|
+
report.value = await api.checkGitHubBranchProtection(workspaceId)
|
|
97
|
+
} catch (e) {
|
|
98
|
+
const described = describeGenericFailure(e)
|
|
99
|
+
// Joined exactly as the toast disclosure joins it, so the same failure reads the same way
|
|
100
|
+
// wherever it surfaces. Empty parts drop out: a disclosure that reveals nothing is worse
|
|
101
|
+
// than no disclosure, so the button is hidden when there is no detail to show.
|
|
102
|
+
const detail = [
|
|
103
|
+
described.message,
|
|
104
|
+
described.issues.join(', '),
|
|
105
|
+
described.requestId ? t('errors.generic.requestId', { id: described.requestId }) : '',
|
|
106
|
+
]
|
|
107
|
+
.filter((part) => part && part.trim().length > 0)
|
|
108
|
+
.join(' · ')
|
|
109
|
+
failure.value = { descriptionKey: described.descriptionKey, detail }
|
|
110
|
+
} finally {
|
|
111
|
+
checking.value = false
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
</script>
|
|
115
|
+
|
|
116
|
+
<template>
|
|
117
|
+
<section class="space-y-2" data-testid="branch-protection-preflight">
|
|
118
|
+
<h3 class="text-sm font-semibold text-slate-200">{{ t('vcs.branchProtection.heading') }}</h3>
|
|
119
|
+
<p class="text-[11px] text-slate-400">{{ t('vcs.branchProtection.body') }}</p>
|
|
120
|
+
|
|
121
|
+
<UButton
|
|
122
|
+
color="neutral"
|
|
123
|
+
variant="soft"
|
|
124
|
+
size="xs"
|
|
125
|
+
icon="i-lucide-shield-check"
|
|
126
|
+
:loading="checking"
|
|
127
|
+
data-testid="branch-protection-check"
|
|
128
|
+
@click="check()"
|
|
129
|
+
>
|
|
130
|
+
{{ t('vcs.branchProtection.check') }}
|
|
131
|
+
</UButton>
|
|
132
|
+
|
|
133
|
+
<div v-if="failure" class="space-y-1" data-testid="branch-protection-failure">
|
|
134
|
+
<p class="text-xs text-rose-400">{{ t(failure.descriptionKey) }}</p>
|
|
135
|
+
<!-- The raw prose stays reachable, never dropped: the backend's messages here name the
|
|
136
|
+
operator remedy, and the request id is the join to the one server log line that
|
|
137
|
+
explains it. One click away, so it is never what a user is shown FIRST. -->
|
|
138
|
+
<UButton
|
|
139
|
+
v-if="failure.detail && !showFailureDetail"
|
|
140
|
+
color="neutral"
|
|
141
|
+
variant="link"
|
|
142
|
+
size="xs"
|
|
143
|
+
class="p-0"
|
|
144
|
+
data-testid="branch-protection-failure-detail"
|
|
145
|
+
@click="showFailureDetail = true"
|
|
146
|
+
>
|
|
147
|
+
{{ t('errors.generic.showDetail') }}
|
|
148
|
+
</UButton>
|
|
149
|
+
<p v-if="showFailureDetail" class="font-mono text-[10px] break-all text-slate-500">
|
|
150
|
+
{{ failure.detail }}
|
|
151
|
+
</p>
|
|
152
|
+
</div>
|
|
153
|
+
|
|
154
|
+
<!-- The provider cannot answer this at all. Said explicitly, because an empty list here
|
|
155
|
+
would otherwise read exactly like a clean bill of health. -->
|
|
156
|
+
<p
|
|
157
|
+
v-else-if="report && report.capability === 'unavailable'"
|
|
158
|
+
class="text-xs text-amber-300"
|
|
159
|
+
data-testid="branch-protection-unavailable"
|
|
160
|
+
>
|
|
161
|
+
{{ t('vcs.branchProtection.unavailable') }}
|
|
162
|
+
</p>
|
|
163
|
+
|
|
164
|
+
<template v-else-if="report">
|
|
165
|
+
<p
|
|
166
|
+
class="text-xs"
|
|
167
|
+
:class="exposedCount ? 'text-rose-300' : 'text-emerald-400'"
|
|
168
|
+
data-testid="branch-protection-summary"
|
|
169
|
+
>
|
|
170
|
+
{{
|
|
171
|
+
exposedCount
|
|
172
|
+
? t('vcs.branchProtection.exposed', { count: exposedCount }, exposedCount)
|
|
173
|
+
: t('vcs.branchProtection.allProtected')
|
|
174
|
+
}}
|
|
175
|
+
</p>
|
|
176
|
+
|
|
177
|
+
<ul class="space-y-1">
|
|
178
|
+
<li
|
|
179
|
+
v-for="row in rows"
|
|
180
|
+
:key="row.repoGithubId"
|
|
181
|
+
class="rounded-md border border-slate-800 bg-slate-900/40 px-2 py-1.5"
|
|
182
|
+
>
|
|
183
|
+
<div class="flex items-baseline justify-between gap-2">
|
|
184
|
+
<span class="font-mono text-xs text-slate-300">{{ row.owner }}/{{ row.name }}</span>
|
|
185
|
+
<span class="text-[11px]" :class="STATE_STYLE[row.protection.state]">
|
|
186
|
+
{{ STATE_LABEL[row.protection.state]() }}
|
|
187
|
+
</span>
|
|
188
|
+
</div>
|
|
189
|
+
<p class="text-[10px] text-slate-500">
|
|
190
|
+
{{ row.defaultBranch }}<span v-if="detailLine(row)"> — {{ detailLine(row) }}</span>
|
|
191
|
+
</p>
|
|
192
|
+
</li>
|
|
193
|
+
</ul>
|
|
194
|
+
|
|
195
|
+
<!-- A cap that truncated silently would read as "these are all your repositories", which
|
|
196
|
+
on a security report is the same failure as calling an unprobed repo protected. -->
|
|
197
|
+
<p v-if="report.omittedRepos > 0" class="text-[11px] text-amber-300">
|
|
198
|
+
{{ t('vcs.branchProtection.omitted', { count: report.omittedRepos }, report.omittedRepos) }}
|
|
199
|
+
</p>
|
|
200
|
+
</template>
|
|
201
|
+
</section>
|
|
202
|
+
</template>
|
|
@@ -10,12 +10,14 @@ import type { GitHubPullRequest, GitHubRepo, VcsProvider } from '~/types/domain'
|
|
|
10
10
|
// tag, so it silently renders as an empty element. Importing it directly binds
|
|
11
11
|
// the tag unambiguously.
|
|
12
12
|
import GitHubConnect from './GitHubConnect.vue'
|
|
13
|
+
import BranchProtectionPreflight from './BranchProtectionPreflight.vue'
|
|
13
14
|
import GitLabConnect from '~/components/vcs/GitLabConnect.vue'
|
|
14
15
|
import IntegrationBackTitle from '~/components/layout/IntegrationBackTitle.vue'
|
|
15
16
|
import { VCS_PROVIDER_ICONS, VCS_PROVIDER_LABELS } from '~/utils/vcs'
|
|
16
17
|
|
|
17
18
|
const { t } = useI18n()
|
|
18
19
|
const ui = useUiStore()
|
|
20
|
+
const access = useWorkspaceAccess()
|
|
19
21
|
const github = useGitHubStore()
|
|
20
22
|
const toast = useToast()
|
|
21
23
|
const { confirm } = useConfirm()
|
|
@@ -459,6 +461,18 @@ async function merge(pr: GitHubPullRequest) {
|
|
|
459
461
|
<p v-if="!github.repos.length && !managing" class="py-4 text-sm text-slate-400">
|
|
460
462
|
{{ t('github.panel.noLinkedRepos') }}
|
|
461
463
|
</p>
|
|
464
|
+
|
|
465
|
+
<!-- Whether the host actually protects what an agent run pushes to. Placed above the
|
|
466
|
+
repo list because it is a posture check across ALL of them, not a per-repo
|
|
467
|
+
attribute. Admin-only to match the route: the probe spends the installation's
|
|
468
|
+
GitHub rate limit, which the CI gate and the merger share, so it is gated rather
|
|
469
|
+
than merely read-only. Hidden rather than disabled — a member has nothing to do
|
|
470
|
+
with a control whose result is the operator's to act on. -->
|
|
471
|
+
<BranchProtectionPreflight
|
|
472
|
+
v-if="github.repos.length && access.canManageIntegrations.value"
|
|
473
|
+
class="rounded-md border border-slate-800 bg-slate-900/40 p-3"
|
|
474
|
+
/>
|
|
475
|
+
|
|
462
476
|
<div
|
|
463
477
|
v-for="repo in github.repos"
|
|
464
478
|
:key="repo.githubId"
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { onMounted, ref, watch } from 'vue'
|
|
3
|
+
|
|
4
|
+
// The account-wide FLOOR under each board's "may a run act as its initiator's own personal
|
|
5
|
+
// access token?" switch (admin only).
|
|
6
|
+
//
|
|
7
|
+
// Why a second tier at all: the workspace switch is edited with `settings.manage`, which a
|
|
8
|
+
// member elevated on one board holds — and the control exists precisely for the case where
|
|
9
|
+
// someone re-widens what the operator scoped. A floor a board admin cannot lift is the only
|
|
10
|
+
// version of it that binds. Effective = this permits AND the board permits.
|
|
11
|
+
//
|
|
12
|
+
// Left UNSET by default, deliberately. A personal token is the right credential for someone
|
|
13
|
+
// adopting cat-factory alone inside an org that has not adopted it: there is no App
|
|
14
|
+
// installation to inherit and no account admin to ask. Setting this is for the opposite case.
|
|
15
|
+
// See `backend/docs/security-model.md`.
|
|
16
|
+
const props = defineProps<{ accountId: string }>()
|
|
17
|
+
|
|
18
|
+
const store = useAccountSettingsStore()
|
|
19
|
+
const toast = useToast()
|
|
20
|
+
const { t } = useI18n()
|
|
21
|
+
|
|
22
|
+
// Tri-state, and it must stay tri-state: "not set" is a real, distinct answer from "permitted".
|
|
23
|
+
// Collapsing it to a boolean would make every existing account look as though an admin had
|
|
24
|
+
// actively decided, and would write that decision on the next unrelated save.
|
|
25
|
+
const forbid = ref(false)
|
|
26
|
+
const saving = ref(false)
|
|
27
|
+
|
|
28
|
+
function hydrate() {
|
|
29
|
+
forbid.value = store.view?.config?.allowInitiatorPat === false
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
onMounted(async () => {
|
|
33
|
+
if (!store.view && store.available !== false) await store.load(props.accountId)
|
|
34
|
+
hydrate()
|
|
35
|
+
})
|
|
36
|
+
watch(() => store.view, hydrate)
|
|
37
|
+
|
|
38
|
+
async function save() {
|
|
39
|
+
saving.value = true
|
|
40
|
+
try {
|
|
41
|
+
// `config` fully REPLACES the stored non-secret config, so the rest is carried forward —
|
|
42
|
+
// the same contract the model-policy editor honours. Clearing the floor writes `undefined`
|
|
43
|
+
// rather than `true`, keeping "no opinion" distinguishable from "explicitly permitted".
|
|
44
|
+
await store.save(props.accountId, {
|
|
45
|
+
config: {
|
|
46
|
+
...store.view?.config,
|
|
47
|
+
...(forbid.value ? { allowInitiatorPat: false } : { allowInitiatorPat: undefined }),
|
|
48
|
+
},
|
|
49
|
+
})
|
|
50
|
+
toast.add({ title: t('settings.runCredentialPolicy.saved'), color: 'success' })
|
|
51
|
+
} catch (e) {
|
|
52
|
+
toast.add({
|
|
53
|
+
title: t('settings.runCredentialPolicy.saveFailed'),
|
|
54
|
+
description: e instanceof Error ? e.message : String(e),
|
|
55
|
+
color: 'error',
|
|
56
|
+
})
|
|
57
|
+
} finally {
|
|
58
|
+
saving.value = false
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
</script>
|
|
62
|
+
|
|
63
|
+
<template>
|
|
64
|
+
<div v-if="store.available !== false" class="space-y-2" data-testid="account-run-credential">
|
|
65
|
+
<h3 class="text-sm font-semibold text-slate-200">
|
|
66
|
+
{{ t('settings.runCredentialPolicy.heading') }}
|
|
67
|
+
</h3>
|
|
68
|
+
<p class="text-[11px] text-slate-400">{{ t('settings.runCredentialPolicy.body') }}</p>
|
|
69
|
+
|
|
70
|
+
<label class="flex items-center gap-2">
|
|
71
|
+
<USwitch v-model="forbid" size="sm" data-testid="account-forbid-initiator-pat" />
|
|
72
|
+
<span class="text-sm text-slate-200">{{ t('settings.runCredentialPolicy.toggle') }}</span>
|
|
73
|
+
</label>
|
|
74
|
+
|
|
75
|
+
<!-- What the choice actually costs, stated at the point of choosing rather than discovered
|
|
76
|
+
later: attribution is a real feature, and turning this on is what trades it away. -->
|
|
77
|
+
<p v-if="forbid" class="text-[11px] text-amber-300">
|
|
78
|
+
{{ t('settings.runCredentialPolicy.onHint') }}
|
|
79
|
+
</p>
|
|
80
|
+
<p v-else class="text-[11px] text-slate-500">
|
|
81
|
+
{{ t('settings.runCredentialPolicy.offHint') }}
|
|
82
|
+
</p>
|
|
83
|
+
|
|
84
|
+
<div class="flex justify-end">
|
|
85
|
+
<UButton
|
|
86
|
+
color="primary"
|
|
87
|
+
size="xs"
|
|
88
|
+
icon="i-lucide-save"
|
|
89
|
+
:loading="saving"
|
|
90
|
+
data-testid="account-run-credential-save"
|
|
91
|
+
@click="save"
|
|
92
|
+
>
|
|
93
|
+
{{ t('common.save') }}
|
|
94
|
+
</UButton>
|
|
95
|
+
</div>
|
|
96
|
+
</div>
|
|
97
|
+
</template>
|
|
@@ -5,6 +5,7 @@ import type { AccountRole } from '~/types/domain'
|
|
|
5
5
|
import type { InvitationStatus } from '@cat-factory/contracts'
|
|
6
6
|
import AccountDeploymentSettings from '~/components/layout/AccountDeploymentSettings.vue'
|
|
7
7
|
import AccountModelPolicySettings from '~/components/layout/AccountModelPolicySettings.vue'
|
|
8
|
+
import AccountRunCredentialSettings from '~/components/layout/AccountRunCredentialSettings.vue'
|
|
8
9
|
import SecretInput from '~/components/common/SecretInput.vue'
|
|
9
10
|
|
|
10
11
|
// Team settings for an org account: the member roster (with combinable admin /
|
|
@@ -337,5 +338,13 @@ async function disconnectEmail() {
|
|
|
337
338
|
<section v-if="isAdmin && modelPolicySupported">
|
|
338
339
|
<AccountModelPolicySettings :account-id="accountId" />
|
|
339
340
|
</section>
|
|
341
|
+
|
|
342
|
+
<!-- account-wide floor under each board's run-credential switch (admin-only). Not gated on
|
|
343
|
+
`modelPolicySupported`: unlike a model policy this binds wherever account settings
|
|
344
|
+
exist, and a deployment that could not enforce it would be the one case where saying
|
|
345
|
+
so matters most. -->
|
|
346
|
+
<section v-if="isAdmin">
|
|
347
|
+
<AccountRunCredentialSettings :account-id="accountId" />
|
|
348
|
+
</section>
|
|
340
349
|
</div>
|
|
341
350
|
</template>
|
|
@@ -14,7 +14,15 @@
|
|
|
14
14
|
import type { ConnectionWarning } from '@cat-factory/contracts'
|
|
15
15
|
import { CONNECTION_WARNING_KEYS } from '~/utils/connectionWarnings'
|
|
16
16
|
|
|
17
|
-
defineProps<{
|
|
17
|
+
const props = defineProps<{
|
|
18
|
+
warnings?: ConnectionWarning[]
|
|
19
|
+
/**
|
|
20
|
+
* Heading for the block. Defaults to the connection-test wording ("gaps in this
|
|
21
|
+
* configuration"), which is wrong for a surface where the finding is about a CREDENTIAL's
|
|
22
|
+
* reach rather than a config gap — those callers pass their own already-translated title.
|
|
23
|
+
*/
|
|
24
|
+
title?: string
|
|
25
|
+
}>()
|
|
18
26
|
|
|
19
27
|
const { t, te } = useI18n()
|
|
20
28
|
|
|
@@ -30,7 +38,9 @@ const copy = (warning: ConnectionWarning): string => {
|
|
|
30
38
|
class="rounded-md border border-amber-500/40 bg-amber-950/40 px-3 py-2 text-xs text-amber-200"
|
|
31
39
|
data-testid="connection-warnings"
|
|
32
40
|
>
|
|
33
|
-
<p class="font-semibold">
|
|
41
|
+
<p class="font-semibold">
|
|
42
|
+
{{ props.title ?? t('settings.providerConnection.test.warningsTitle') }}
|
|
43
|
+
</p>
|
|
34
44
|
<ul class="mt-1 list-disc space-y-1 pl-4">
|
|
35
45
|
<li v-for="warning in warnings" :key="warning.code">{{ copy(warning) }}</li>
|
|
36
46
|
</ul>
|
|
@@ -5,9 +5,11 @@
|
|
|
5
5
|
// renders them without hard-coding any kind. Stored PER USER (runs you initiate use YOUR
|
|
6
6
|
// access); the secret is write-only server-side and never shown again.
|
|
7
7
|
import { computed, ref, watch } from 'vue'
|
|
8
|
+
import type { ConnectionTestResult } from '@cat-factory/contracts'
|
|
8
9
|
import type { ProviderConfigField, UserSecretKind } from '~/types/userSecrets'
|
|
9
10
|
import IntegrationBackTitle from '~/components/layout/IntegrationBackTitle.vue'
|
|
10
11
|
import SecretInput from '~/components/common/SecretInput.vue'
|
|
12
|
+
import ConnectionWarnings from '~/components/settings/ConnectionWarnings.vue'
|
|
11
13
|
|
|
12
14
|
const { t } = useI18n()
|
|
13
15
|
const ui = useUiStore()
|
|
@@ -30,7 +32,7 @@ const status = computed(() => store.statusFor(kind.value))
|
|
|
30
32
|
// all other fields map into `metadata`.
|
|
31
33
|
const values = ref<Record<string, string>>({})
|
|
32
34
|
const labelDraft = ref('')
|
|
33
|
-
const testResult = ref<
|
|
35
|
+
const testResult = ref<ConnectionTestResult | null>(null)
|
|
34
36
|
const testing = ref(false)
|
|
35
37
|
const busy = ref(false)
|
|
36
38
|
|
|
@@ -103,7 +105,13 @@ async function save() {
|
|
|
103
105
|
try {
|
|
104
106
|
await store.store(kind.value, { ...payload, label: labelDraft.value.trim() || undefined })
|
|
105
107
|
values.value[secretField.value!.key] = ''
|
|
106
|
-
|
|
108
|
+
// Probe AFTER the save so what this credential can reach is stated at the moment it is
|
|
109
|
+
// stored, not only when someone happens to press Test first. A run you start authenticates
|
|
110
|
+
// with this token in preference to the deployment's own, and the platform cannot narrow it —
|
|
111
|
+
// so this is the one point at which an over-broad token is visible at all
|
|
112
|
+
// (backend/docs/security-model.md). Best-effort: the save already succeeded, so a probe
|
|
113
|
+
// failure must not read as one.
|
|
114
|
+
testResult.value = await store.test(kind.value, payload).catch(() => null)
|
|
107
115
|
toast.add({
|
|
108
116
|
title: t('settings.userSecrets.toast.saved', {
|
|
109
117
|
label: descriptor.value?.label ?? t('settings.userSecrets.secretFallback'),
|
|
@@ -236,6 +244,13 @@ async function remove() {
|
|
|
236
244
|
</span>
|
|
237
245
|
</div>
|
|
238
246
|
|
|
247
|
+
<!-- How far this token reaches. Independent of the pass/fail verdict: an over-broad
|
|
248
|
+
token is perfectly VALID, and that is exactly why it is otherwise invisible. -->
|
|
249
|
+
<ConnectionWarnings
|
|
250
|
+
:warnings="testResult?.warnings"
|
|
251
|
+
:title="t('settings.userSecrets.tokenReachTitle')"
|
|
252
|
+
/>
|
|
253
|
+
|
|
239
254
|
<div class="flex justify-end">
|
|
240
255
|
<UButton
|
|
241
256
|
color="primary"
|