@cat-factory/app 0.102.0 → 0.104.0

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.
@@ -1,5 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import { computed } from 'vue'
3
+ import BackendMisconfiguredScreen from '~/components/auth/BackendMisconfiguredScreen.vue'
3
4
  import LoginScreen from '~/components/auth/LoginScreen.vue'
4
5
 
5
6
  // Resolves auth state once on mount, then either renders the app (auth off, or
@@ -25,6 +26,8 @@ onMounted(() => auth.bootstrap())
25
26
  <span class="text-sm">{{ t('auth.gate.loading') }}</span>
26
27
  </div>
27
28
 
29
+ <BackendMisconfiguredScreen v-else-if="auth.isMisconfigured" />
30
+
28
31
  <slot v-else-if="isPublicRoute" />
29
32
 
30
33
  <LoginScreen v-else-if="auth.needsLogin" />
@@ -0,0 +1,55 @@
1
+ <script setup lang="ts">
2
+ import type { ConfigProblem } from '@cat-factory/contracts'
3
+
4
+ // Shown when the backend booted into its misconfiguration fallback: it couldn't start normally
5
+ // because one or more mandatory environment variables / bindings are missing or invalid. We list
6
+ // each one with what it is for and how to fill it, so the developer can fix their env and reload
7
+ // rather than staring at a generic "can't reach the backend" panel. The `problems` never carry a
8
+ // secret value — only the variable name, its meaning, and the remedy.
9
+ const auth = useAuthStore()
10
+ const { t } = useI18n()
11
+
12
+ const problems = computed<ConfigProblem[]>(() => auth.misconfigured?.problems ?? [])
13
+
14
+ function reload() {
15
+ window.location.reload()
16
+ }
17
+ </script>
18
+
19
+ <template>
20
+ <div
21
+ class="flex h-screen w-screen flex-col items-center justify-center bg-slate-950 p-6 text-slate-200"
22
+ >
23
+ <div class="w-full max-w-2xl">
24
+ <div class="mb-6 text-center">
25
+ <UIcon name="i-lucide-server-cog" class="mx-auto mb-3 h-10 w-10 text-amber-400" />
26
+ <h1 class="text-lg font-semibold">{{ t('app.misconfigured.title') }}</h1>
27
+ <p class="mx-auto mt-2 max-w-lg text-sm text-slate-400">
28
+ {{ t('app.misconfigured.intro') }}
29
+ </p>
30
+ </div>
31
+
32
+ <ul class="space-y-3">
33
+ <li
34
+ v-for="problem in problems"
35
+ :key="problem.key"
36
+ class="rounded-lg border border-slate-800 bg-slate-900/60 p-4"
37
+ >
38
+ <code class="text-sm font-semibold text-amber-300">{{ problem.key }}</code>
39
+ <p class="mt-1 text-sm text-slate-300">{{ problem.summary }}</p>
40
+ <p class="mt-2 text-sm text-slate-400">
41
+ <span class="font-medium text-slate-300">{{ t('app.misconfigured.howToFix') }}</span>
42
+ {{ problem.remedy }}
43
+ </p>
44
+ </li>
45
+ </ul>
46
+
47
+ <div class="mt-6 flex items-center justify-center gap-3">
48
+ <UButton color="primary" icon="i-lucide-rotate-ccw" @click="reload">
49
+ {{ t('app.misconfigured.reload') }}
50
+ </UButton>
51
+ </div>
52
+ <p class="mt-4 text-center text-xs text-slate-500">{{ t('app.misconfigured.hint') }}</p>
53
+ </div>
54
+ </div>
55
+ </template>
@@ -129,7 +129,11 @@ async function saveStorage() {
129
129
  ...(cs.forcePathStyle ? { forcePathStyle: true } : {}),
130
130
  }
131
131
  }
132
- const input: Parameters<typeof store.save>[1] = { config: { contentStorage: config } }
132
+ // `config` fully REPLACES the stored non-secret config, so carry the rest forward
133
+ // (e.g. the model-family policy) — only `contentStorage` is edited here.
134
+ const input: Parameters<typeof store.save>[1] = {
135
+ config: { ...store.view?.config, contentStorage: config },
136
+ }
133
137
  if (backend === 's3') {
134
138
  const id = cs.accessKeyId.trim()
135
139
  const key = cs.secretAccessKey.trim()
@@ -0,0 +1,247 @@
1
+ <script setup lang="ts">
2
+ import { computed, onMounted, ref, watch } from 'vue'
3
+ import { MODEL_FAMILY_POLICY_PRESETS } from '@cat-factory/contracts'
4
+ import type {
5
+ AccountRegion,
6
+ ModelFamily,
7
+ ModelFamilyPolicy,
8
+ ModelPolicyMode,
9
+ } from '~/types/accountSettings'
10
+
11
+ // Account-wide model-family allow/block policy (admin only). Constrains which LLM families
12
+ // the account's teams may run; a residency-guaranteed route (`trustedProviders`) can exempt
13
+ // an otherwise-blocked family. Region-grouped built-in presets are one-click templates the
14
+ // admin applies into the editable policy. Mounted only where the deployment supports it
15
+ // (hosted / mothership — never plain local mode), gated by the parent on the infra flag.
16
+ const props = defineProps<{ accountId: string }>()
17
+
18
+ const store = useAccountSettingsStore()
19
+ const toast = useToast()
20
+ const { t } = useI18n()
21
+
22
+ // The family / region / mode / trusted-provider domains, pinned to the contract unions so a
23
+ // new enum member fails the typecheck here (via the exhaustive label maps below).
24
+ const FAMILIES = [
25
+ 'claude',
26
+ 'openai',
27
+ 'gemini',
28
+ 'llama',
29
+ 'qwen',
30
+ 'kimi',
31
+ 'deepseek',
32
+ 'glm',
33
+ ] as const satisfies readonly ModelFamily[]
34
+ const REGIONS = ['usa', 'europe', 'china', 'other'] as const satisfies readonly AccountRegion[]
35
+ const MODES = ['off', 'blocklist', 'allowlist'] as const satisfies readonly ModelPolicyMode[]
36
+ const TRUSTED_PROVIDERS = ['bedrock'] as const
37
+
38
+ // Exhaustive enum→key maps (drift guard): every member resolves to a static literal `t()`
39
+ // key, so adding a family/region/mode without a label fails the typecheck on the Record.
40
+ const familyLabels = computed<Record<ModelFamily, string>>(() => ({
41
+ claude: t('settings.modelPolicy.families.claude'),
42
+ openai: t('settings.modelPolicy.families.openai'),
43
+ gemini: t('settings.modelPolicy.families.gemini'),
44
+ llama: t('settings.modelPolicy.families.llama'),
45
+ qwen: t('settings.modelPolicy.families.qwen'),
46
+ kimi: t('settings.modelPolicy.families.kimi'),
47
+ deepseek: t('settings.modelPolicy.families.deepseek'),
48
+ glm: t('settings.modelPolicy.families.glm'),
49
+ }))
50
+ const regionLabels = computed<Record<AccountRegion, string>>(() => ({
51
+ usa: t('settings.modelPolicy.regions.usa'),
52
+ europe: t('settings.modelPolicy.regions.europe'),
53
+ china: t('settings.modelPolicy.regions.china'),
54
+ other: t('settings.modelPolicy.regions.other'),
55
+ }))
56
+ const modeLabels = computed<Record<ModelPolicyMode, string>>(() => ({
57
+ off: t('settings.modelPolicy.modes.off'),
58
+ blocklist: t('settings.modelPolicy.modes.blocklist'),
59
+ allowlist: t('settings.modelPolicy.modes.allowlist'),
60
+ }))
61
+ const providerLabels = computed<Record<(typeof TRUSTED_PROVIDERS)[number], string>>(() => ({
62
+ bedrock: t('settings.modelPolicy.providers.bedrock'),
63
+ }))
64
+
65
+ const modeItems = computed(() => MODES.map((m) => ({ label: modeLabels.value[m], value: m })))
66
+ const regionItems = computed(() => REGIONS.map((r) => ({ label: regionLabels.value[r], value: r })))
67
+
68
+ // Editable local state (hydrated from the stored policy).
69
+ const mode = ref<ModelPolicyMode>('off')
70
+ const region = ref<AccountRegion>('other')
71
+ const families = ref<ModelFamily[]>([])
72
+ const trusted = ref<string[]>([])
73
+ const saving = ref(false)
74
+
75
+ function hydrate() {
76
+ const p = store.view?.config?.modelPolicy
77
+ mode.value = p?.mode ?? 'off'
78
+ region.value = p?.region ?? 'other'
79
+ families.value = [...(p?.families ?? [])]
80
+ trusted.value = [...(p?.trustedProviders ?? [])]
81
+ }
82
+
83
+ // The presets offered for the currently-selected region.
84
+ const regionPresets = computed(() =>
85
+ MODEL_FAMILY_POLICY_PRESETS.filter((preset) => preset.region === region.value),
86
+ )
87
+
88
+ function toggleFamily(fam: ModelFamily) {
89
+ families.value = families.value.includes(fam)
90
+ ? families.value.filter((x) => x !== fam)
91
+ : [...families.value, fam]
92
+ }
93
+ function toggleTrusted(provider: string) {
94
+ trusted.value = trusted.value.includes(provider)
95
+ ? trusted.value.filter((x) => x !== provider)
96
+ : [...trusted.value, provider]
97
+ }
98
+
99
+ function applyPreset(policy: ModelFamilyPolicy) {
100
+ mode.value = policy.mode
101
+ families.value = [...policy.families]
102
+ trusted.value = [...policy.trustedProviders]
103
+ }
104
+
105
+ onMounted(async () => {
106
+ // The sibling deployment-settings panel loads the store on mount too; guard against a
107
+ // double-load being unnecessary by only loading when the view isn't present yet.
108
+ if (!store.view && store.available !== false) {
109
+ try {
110
+ await store.load(props.accountId)
111
+ } catch {
112
+ // The deployment-settings panel surfaces the load error; stay quiet here.
113
+ }
114
+ }
115
+ hydrate()
116
+ })
117
+ watch(() => store.view, hydrate)
118
+
119
+ async function save() {
120
+ const policy: ModelFamilyPolicy = {
121
+ mode: mode.value,
122
+ // Families are irrelevant when the policy is off; keep them so a later re-enable
123
+ // restores the admin's selection rather than silently emptying it.
124
+ families: families.value,
125
+ trustedProviders: trusted.value,
126
+ region: region.value,
127
+ }
128
+ saving.value = true
129
+ try {
130
+ // `config` fully replaces the stored non-secret config, so carry the rest forward.
131
+ await store.save(props.accountId, { config: { ...store.view?.config, modelPolicy: policy } })
132
+ toast.add({
133
+ title: t('settings.modelPolicy.saved'),
134
+ icon: 'i-lucide-check',
135
+ color: 'success',
136
+ })
137
+ } catch (e) {
138
+ toast.add({
139
+ title: t('settings.modelPolicy.saveFailed'),
140
+ description: e instanceof Error ? e.message : String(e),
141
+ color: 'error',
142
+ })
143
+ } finally {
144
+ saving.value = false
145
+ }
146
+ }
147
+ </script>
148
+
149
+ <template>
150
+ <section
151
+ v-if="store.available !== false"
152
+ data-testid="account-model-policy"
153
+ class="space-y-3 border-t border-slate-800 pt-6"
154
+ >
155
+ <div>
156
+ <h4 class="text-sm font-semibold text-slate-200">{{ t('settings.modelPolicy.title') }}</h4>
157
+ <p class="text-[11px] text-slate-400">{{ t('settings.modelPolicy.description') }}</p>
158
+ </div>
159
+
160
+ <!-- Region + apply-preset templates -->
161
+ <div class="space-y-2">
162
+ <label class="text-[11px] font-medium text-slate-300">
163
+ {{ t('settings.modelPolicy.regionLabel') }}
164
+ </label>
165
+ <div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
166
+ <USelect v-model="region" :items="regionItems" value-key="value" size="sm" />
167
+ </div>
168
+ <div v-if="regionPresets.length" class="flex flex-wrap items-center gap-2">
169
+ <span class="text-[11px] text-slate-400">{{ t('settings.modelPolicy.applyPreset') }}</span>
170
+ <UButton
171
+ v-for="preset in regionPresets"
172
+ :key="preset.id"
173
+ color="neutral"
174
+ variant="subtle"
175
+ size="xs"
176
+ icon="i-lucide-wand-2"
177
+ :title="t(`settings.modelPolicy.presets.${preset.id}.description`)"
178
+ @click="applyPreset(preset.policy)"
179
+ >
180
+ {{ t(`settings.modelPolicy.presets.${preset.id}.label`) }}
181
+ </UButton>
182
+ </div>
183
+ </div>
184
+
185
+ <!-- Mode -->
186
+ <div class="space-y-2">
187
+ <label class="text-[11px] font-medium text-slate-300">
188
+ {{ t('settings.modelPolicy.modeLabel') }}
189
+ </label>
190
+ <div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
191
+ <USelect v-model="mode" :items="modeItems" value-key="value" size="sm" />
192
+ </div>
193
+ </div>
194
+
195
+ <!-- Families -->
196
+ <div v-if="mode !== 'off'" class="space-y-2">
197
+ <label class="text-[11px] font-medium text-slate-300">
198
+ {{
199
+ mode === 'blocklist'
200
+ ? t('settings.modelPolicy.familiesBlockLabel')
201
+ : t('settings.modelPolicy.familiesAllowLabel')
202
+ }}
203
+ </label>
204
+ <div class="grid grid-cols-2 gap-1 sm:grid-cols-4">
205
+ <UCheckbox
206
+ v-for="fam in FAMILIES"
207
+ :key="fam"
208
+ :model-value="families.includes(fam)"
209
+ :label="familyLabels[fam]"
210
+ size="sm"
211
+ @update:model-value="toggleFamily(fam)"
212
+ />
213
+ </div>
214
+ </div>
215
+
216
+ <!-- Trusted (residency-guaranteed) routes -->
217
+ <div v-if="mode !== 'off'" class="space-y-2">
218
+ <label class="text-[11px] font-medium text-slate-300">
219
+ {{ t('settings.modelPolicy.trustedLabel') }}
220
+ </label>
221
+ <p class="text-[11px] text-slate-400">{{ t('settings.modelPolicy.trustedHint') }}</p>
222
+ <div class="grid grid-cols-2 gap-1 sm:grid-cols-4">
223
+ <UCheckbox
224
+ v-for="provider in TRUSTED_PROVIDERS"
225
+ :key="provider"
226
+ :model-value="trusted.includes(provider)"
227
+ :label="providerLabels[provider]"
228
+ size="sm"
229
+ @update:model-value="toggleTrusted(provider)"
230
+ />
231
+ </div>
232
+ </div>
233
+
234
+ <div class="flex gap-2">
235
+ <UButton
236
+ color="primary"
237
+ size="xs"
238
+ icon="i-lucide-save"
239
+ :loading="saving"
240
+ data-testid="account-model-policy-save"
241
+ @click="save"
242
+ >
243
+ {{ t('common.save') }}
244
+ </UButton>
245
+ </div>
246
+ </section>
247
+ </template>
@@ -4,6 +4,7 @@ import { apiErrorEnvelope } from '~/composables/api/errors'
4
4
  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
+ import AccountModelPolicySettings from '~/components/layout/AccountModelPolicySettings.vue'
7
8
  import SecretInput from '~/components/common/SecretInput.vue'
8
9
 
9
10
  // Team settings for an org account: the member roster (with combinable admin /
@@ -13,11 +14,16 @@ import SecretInput from '~/components/common/SecretInput.vue'
13
14
  const props = defineProps<{ accountId: string }>()
14
15
 
15
16
  const accounts = useAccountsStore()
17
+ const auth = useAuthStore()
16
18
  const toast = useToast()
17
19
  const { t, te } = useI18n()
18
20
  const { confirmAction, toastDone } = useConfirmAction()
19
21
  const busy = ref(false)
20
22
 
23
+ // The account-wide model-family policy is a hosted / mothership-only control (the backend
24
+ // reports `false` in plain local mode, where there is no account admin to govern).
25
+ const modelPolicySupported = computed(() => auth.infrastructure?.modelPolicy?.supported ?? false)
26
+
21
27
  const ROLE_ITEMS = computed<{ label: string; value: AccountRole }[]>(() => [
22
28
  { label: t('layout.accountTeam.roles.admin'), value: 'admin' },
23
29
  { label: t('layout.accountTeam.roles.developer'), value: 'developer' },
@@ -326,5 +332,10 @@ async function disconnectEmail() {
326
332
  <section v-if="isAdmin">
327
333
  <AccountDeploymentSettings :account-id="accountId" />
328
334
  </section>
335
+
336
+ <!-- account-wide model-family allow/block policy (admin-only; hosted/mothership only) -->
337
+ <section v-if="isAdmin && modelPolicySupported">
338
+ <AccountModelPolicySettings :account-id="accountId" />
339
+ </section>
329
340
  </div>
330
341
  </template>
@@ -48,6 +48,8 @@ const CONFLICT_TITLE_KEYS: Record<
48
48
  provision_type_unhandled: 'errors.conflict.title.provision_type_unhandled',
49
49
  preset_unsatisfiable: 'errors.conflict.title.preset_unsatisfiable',
50
50
  visual_pipeline_no_frontend: 'errors.conflict.title.visual_pipeline_no_frontend',
51
+ model_policy_blocked: 'errors.conflict.title.model_policy_blocked',
52
+ model_policy_unsupported: 'errors.conflict.title.model_policy_unsupported',
51
53
  deployer_required_before_tester: 'errors.conflict.title.deployer_required_before_tester',
52
54
  }
53
55
 
@@ -1,4 +1,8 @@
1
- import type { InfrastructureCapabilities, LocalModeConfig } from '@cat-factory/contracts'
1
+ import type {
2
+ BackendMisconfigured,
3
+ InfrastructureCapabilities,
4
+ LocalModeConfig,
5
+ } from '@cat-factory/contracts'
2
6
  import { defineStore } from 'pinia'
3
7
  import { computed, ref } from 'vue'
4
8
  import type { AuthUser } from '~/types/domain'
@@ -60,6 +64,14 @@ export const useAuthStore = defineStore(
60
64
  const autoLoginProvider = ref<'github' | 'gitlab' | null>(null)
61
65
  /** True once the initial auth handshake has settled. */
62
66
  const ready = ref(false)
67
+ /**
68
+ * Set when the backend answered its boot handshake but reported that it is MISCONFIGURED — it
69
+ * failed to start normally because a mandatory env var / binding is missing, and is serving the
70
+ * fallback backend that lists the problems (each carries only a name + meaning + remedy, never a
71
+ * secret). Present ⇒ the SPA renders the dedicated misconfiguration screen instead of the
72
+ * login/board. Null on a normally-booted backend.
73
+ */
74
+ const misconfigured = ref<BackendMisconfigured | null>(null)
63
75
  /**
64
76
  * Mothership mode: the last mothership sign-in failure (node unreachable / rejected session),
65
77
  * or null. Set when the post-OAuth connect exchange fails, so the login screen can tell the
@@ -84,6 +96,9 @@ export const useAuthStore = defineStore(
84
96
  /** May the app render? True when auth is off, or on with a known user. */
85
97
  const isAuthenticated = computed(() => !required.value || user.value !== null)
86
98
 
99
+ /** Whether the backend reported itself misconfigured (drives the dedicated error screen). */
100
+ const isMisconfigured = computed(() => misconfigured.value !== null)
101
+
87
102
  /**
88
103
  * Whether the SPA must show the login screen before the board.
89
104
  *
@@ -176,7 +191,14 @@ export const useAuthStore = defineStore(
176
191
  testingNoAuth.value = config.testingNoAuth ?? false
177
192
  localMode.value = config.localMode ?? null
178
193
  infrastructure.value = config.infrastructure ?? null
194
+ misconfigured.value = config.misconfigured ?? null
179
195
  configLoaded.value = true
196
+ // A misconfigured backend serves only the fallback app; there's no session/board to
197
+ // resolve, so settle here and let the SPA render the misconfiguration screen.
198
+ if (misconfigured.value) {
199
+ ready.value = true
200
+ return
201
+ }
180
202
  } catch {
181
203
  // Backend unreachable — let the board's own error UI handle it (configLoaded stays
182
204
  // false, so we never mistake this for an unauthenticated session and gate it).
@@ -335,8 +357,10 @@ export const useAuthStore = defineStore(
335
357
  ready,
336
358
  mothershipError,
337
359
  configLoaded,
360
+ misconfigured,
338
361
  isLocalFacade,
339
362
  isAuthenticated,
363
+ isMisconfigured,
340
364
  needsLogin,
341
365
  bootstrap,
342
366
  login,
@@ -15,4 +15,9 @@ export type {
15
15
  ContentStorageConfig,
16
16
  ContentStorageSummary,
17
17
  ContentStorageCapability,
18
+ // Account-wide model-family allow/block policy.
19
+ ModelFamily,
20
+ ModelFamilyPolicy,
21
+ ModelPolicyMode,
22
+ AccountRegion,
18
23
  } from '@cat-factory/contracts'
@@ -745,6 +745,65 @@
745
745
  "title": "Diesen Runner entfernen?",
746
746
  "body": "\"{name}\" wird entfernt. Dies kann nicht rückgängig gemacht werden."
747
747
  }
748
+ },
749
+ "modelPolicy": {
750
+ "title": "Modellzugriffsrichtlinie",
751
+ "description": "Beschränkt, welche Modellfamilien die Teams dieses Kontos ausführen dürfen. Das Datenresidenz-Risiko hängt von der Bereitstellungsroute ab, daher kann eine vertrauenswürdige (residenzgarantierte) Route eine andernfalls blockierte Familie freigeben.",
752
+ "regionLabel": "Betriebsregion",
753
+ "applyPreset": "Vorlage anwenden:",
754
+ "modeLabel": "Richtlinienmodus",
755
+ "modes": {
756
+ "off": "Keine Einschränkung",
757
+ "blocklist": "Aufgeführte Familien blockieren",
758
+ "allowlist": "Nur aufgeführte Familien zulassen"
759
+ },
760
+ "familiesBlockLabel": "Blockierte Familien",
761
+ "familiesAllowLabel": "Zugelassene Familien",
762
+ "trustedLabel": "Vertrauenswürdige (residenzgarantierte) Routen",
763
+ "trustedHint": "Eine blockierte Familie bleibt verfügbar, wenn sie über eine dieser Routen bereitgestellt wird.",
764
+ "families": {
765
+ "claude": "Claude (Anthropic)",
766
+ "openai": "OpenAI / ChatGPT",
767
+ "gemini": "Gemini (Google)",
768
+ "llama": "Llama (Meta)",
769
+ "qwen": "Qwen (Alibaba)",
770
+ "kimi": "Kimi (Moonshot)",
771
+ "deepseek": "DeepSeek",
772
+ "glm": "GLM (Z.ai)"
773
+ },
774
+ "regions": {
775
+ "usa": "Vereinigte Staaten",
776
+ "europe": "Europa",
777
+ "china": "China",
778
+ "other": "Andere"
779
+ },
780
+ "providers": {
781
+ "bedrock": "AWS Bedrock"
782
+ },
783
+ "presets": {
784
+ "us-block-cn": {
785
+ "label": "In China gehostete Familien blockieren",
786
+ "description": "Blockiert DeepSeek, Qwen, Kimi und GLM; erlaubt sie nur über eine residenzgarantierte Route (z. B. Bedrock)."
787
+ },
788
+ "eu-block-cn": {
789
+ "label": "EU: in China gehostete Familien blockieren",
790
+ "description": "Blockiert DeepSeek, Qwen, Kimi und GLM; eine residenzgarantierte EU-Route (z. B. Bedrock) bleibt zulässig."
791
+ },
792
+ "eu-guaranteed-only": {
793
+ "label": "EU: nur residenzgarantierte Routen",
794
+ "description": "Erlaubt ein Modell nur, wenn es über eine residenzgarantierte Route bereitgestellt wird."
795
+ },
796
+ "cn-prefer-domestic": {
797
+ "label": "In China gehostete Familien bevorzugen",
798
+ "description": "Erlaubt in China gehostete Familien (plus Llama) und jede residenzgarantierte Route."
799
+ },
800
+ "other-block-cn": {
801
+ "label": "In China gehostete Familien blockieren",
802
+ "description": "Blockiert DeepSeek, Qwen, Kimi und GLM; erlaubt sie nur über eine residenzgarantierte Route."
803
+ }
804
+ },
805
+ "saved": "Modellzugriffsrichtlinie gespeichert",
806
+ "saveFailed": "Modellzugriffsrichtlinie konnte nicht gespeichert werden"
748
807
  }
749
808
  },
750
809
  "inspector": {
@@ -3493,7 +3552,14 @@
3493
3552
  "loadingBoard": "Board wird geladen…",
3494
3553
  "backendUnreachable": "Backend nicht erreichbar",
3495
3554
  "reconnecting": "Verbindung wird wiederhergestellt…",
3496
- "offline": "Keine Live-Updates"
3555
+ "offline": "Keine Live-Updates",
3556
+ "misconfigured": {
3557
+ "title": "Backend nicht konfiguriert",
3558
+ "intro": "Der Server wurde gestartet, kann aber erst laufen, wenn die folgenden Einstellungen vorhanden sind. Ergänze die fehlenden Werte in deiner Umgebung und lade neu.",
3559
+ "howToFix": "So behebst du es:",
3560
+ "reload": "Neu laden",
3561
+ "hint": "Hier werden nur Variablennamen und deren Einrichtung angezeigt, niemals geheime Werte."
3562
+ }
3497
3563
  },
3498
3564
  "language": {
3499
3565
  "switcher": "Sprache",
@@ -3591,6 +3657,8 @@
3591
3657
  "provision_type_unhandled": "Kein Handler für diesen Bereitstellungstyp",
3592
3658
  "preset_unsatisfiable": "Modell-Preset kann diese Pipeline nicht ausführen",
3593
3659
  "visual_pipeline_no_frontend": "Kein Frontend zum Testen",
3660
+ "model_policy_blocked": "Modell durch Kontorichtlinie blockiert",
3661
+ "model_policy_unsupported": "Modellrichtlinie hier nicht verfügbar",
3594
3662
  "deployer_required_before_tester": "Füge einen Deployer vor dem Tester hinzu"
3595
3663
  },
3596
3664
  "fallbackMessage": "Diese Aktion steht im Konflikt mit dem aktuellen Zustand.",
@@ -4,7 +4,14 @@
4
4
  "loadingBoard": "Loading board…",
5
5
  "backendUnreachable": "Can't reach the backend",
6
6
  "reconnecting": "Reconnecting…",
7
- "offline": "Not receiving live updates"
7
+ "offline": "Not receiving live updates",
8
+ "misconfigured": {
9
+ "title": "Backend not configured",
10
+ "intro": "The server started but can't run until the settings below are provided. Add the missing values to your environment, then reload.",
11
+ "howToFix": "How to fix:",
12
+ "reload": "Reload",
13
+ "hint": "Only variable names and how to set them are shown here, never any secret values."
14
+ }
8
15
  },
9
16
  "language": {
10
17
  "switcher": "Language",
@@ -439,6 +446,8 @@
439
446
  "provision_type_unhandled": "No handler for this provision type",
440
447
  "preset_unsatisfiable": "Model preset can't run this pipeline",
441
448
  "visual_pipeline_no_frontend": "No frontend to test",
449
+ "model_policy_blocked": "Model blocked by account policy",
450
+ "model_policy_unsupported": "Model policy not available here",
442
451
  "deployer_required_before_tester": "Add a Deployer before the Tester"
443
452
  },
444
453
  "fallbackMessage": "This action conflicts with the current state.",
@@ -2442,6 +2451,65 @@
2442
2451
  "title": "Remove this runner?",
2443
2452
  "body": "\"{name}\" will be removed. This can't be undone."
2444
2453
  }
2454
+ },
2455
+ "modelPolicy": {
2456
+ "title": "Model access policy",
2457
+ "description": "Restrict which model families this account’s teams can run. Data-residency risk depends on the serving route, so a trusted (residency-guaranteed) route can exempt an otherwise-blocked family.",
2458
+ "regionLabel": "Operating region",
2459
+ "applyPreset": "Apply preset:",
2460
+ "modeLabel": "Policy mode",
2461
+ "modes": {
2462
+ "off": "No restriction",
2463
+ "blocklist": "Block listed families",
2464
+ "allowlist": "Allow only listed families"
2465
+ },
2466
+ "familiesBlockLabel": "Blocked families",
2467
+ "familiesAllowLabel": "Allowed families",
2468
+ "trustedLabel": "Trusted (residency-guaranteed) routes",
2469
+ "trustedHint": "A blocked family stays available when it is served over one of these routes.",
2470
+ "families": {
2471
+ "claude": "Claude (Anthropic)",
2472
+ "openai": "OpenAI / ChatGPT",
2473
+ "gemini": "Gemini (Google)",
2474
+ "llama": "Llama (Meta)",
2475
+ "qwen": "Qwen (Alibaba)",
2476
+ "kimi": "Kimi (Moonshot)",
2477
+ "deepseek": "DeepSeek",
2478
+ "glm": "GLM (Z.ai)"
2479
+ },
2480
+ "regions": {
2481
+ "usa": "United States",
2482
+ "europe": "Europe",
2483
+ "china": "China",
2484
+ "other": "Other"
2485
+ },
2486
+ "providers": {
2487
+ "bedrock": "AWS Bedrock"
2488
+ },
2489
+ "presets": {
2490
+ "us-block-cn": {
2491
+ "label": "Block China-hosted families",
2492
+ "description": "Block DeepSeek, Qwen, Kimi and GLM; allow them only over a residency-guaranteed route (e.g. Bedrock)."
2493
+ },
2494
+ "eu-block-cn": {
2495
+ "label": "EU: block China-hosted families",
2496
+ "description": "Block DeepSeek, Qwen, Kimi and GLM; a residency-guaranteed EU route (e.g. Bedrock) stays allowed."
2497
+ },
2498
+ "eu-guaranteed-only": {
2499
+ "label": "EU: residency-guaranteed routes only",
2500
+ "description": "Allow a model only when it is served over a residency-guaranteed route."
2501
+ },
2502
+ "cn-prefer-domestic": {
2503
+ "label": "Prefer China-hosted families",
2504
+ "description": "Allow China-hosted families (plus Llama) and any residency-guaranteed route."
2505
+ },
2506
+ "other-block-cn": {
2507
+ "label": "Block China-hosted families",
2508
+ "description": "Block DeepSeek, Qwen, Kimi and GLM; allow them only over a residency-guaranteed route."
2509
+ }
2510
+ },
2511
+ "saved": "Model access policy saved",
2512
+ "saveFailed": "Could not save the model access policy"
2445
2513
  }
2446
2514
  },
2447
2515
  "providers": {