@jeffusion/bungee-core 5.15.1 → 5.15.2

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.
@@ -2,7 +2,6 @@
2
2
  import { onMount } from 'svelte';
3
3
  import { isLoading, _, locale } from 'svelte-i18n';
4
4
  import { getPluginText } from '$utils/plugin-i18n';
5
- import * as Tooltip from '$components/ui/tooltip';
6
5
  import { requestPluginControl, ApiError } from '$api/client';
7
6
  import { getConfigSnapshot } from '$api/config';
8
7
  import { PluginsAPI } from '$api/plugins';
@@ -14,11 +13,12 @@
14
13
  let tab = $state('keys'), keys = $state<ApiKey[]>([]), routes = $state<{id:string;path:string}[]>([]), protectedIds = $state<string[]>([]);
15
14
  let loading = $state(true), busy = $state(false), error = $state<Message | null>(null), status = $state<Message | null>(null), ready = $state(false), inactive = $state(false);
16
15
  let createOpen = $state(false), policyOpen = $state(false), secretOpen = $state(false), deleteOpen = $state(false);
17
- let target = $state<ApiKey | null>(null), name = $state(''), expires = $state(''), allRoutes = $state(false), allowed = $state<string[]>([]), models = $state(''), review = $state(false);
16
+ let target = $state<ApiKey | null>(null), name = $state(''), expires = $state(''), allRoutes = $state(false), allowed = $state<string[]>([]), models = $state('');
18
17
  let secret = $state(''), copied = $state(false), secretStatus = $state<Message | null>(null), policyLoading = $state(false), policyReady = $state(false);
19
18
  let routeConfigOpen = $state(false), selectedRoute = $state<{id:string;path:string}|null>(null);
20
- let pendingRouteKey = $state<ApiKey|null>(null);
21
19
  let routeKeyBindings = $state<Record<string,{id:string;name:string}[]>>({});
20
+ let unrestrictedKeyIds = $state<string[]>([]);
21
+ const isKeyApplied = (routeId:string, keyId:string) => unrestrictedKeyIds.includes(keyId) || !!routeKeyBindings[routeId]?.some(key => key.id === keyId);
22
22
  type Message = { key: string; values?: Record<string, string | number | string[] | Message>; append?: Message };
23
23
  const message = (key: string, values?: Message['values']): Message => ({ key, values });
24
24
  const t = (key: string, values?: Record<string, string | number>) => $isLoading ? '' : getPluginText(key, pluginName, (id, options) => $_(id, { ...options, values }));
@@ -32,11 +32,9 @@
32
32
  constructor(content: Message) { super(content.key); this.content = content; }
33
33
  }
34
34
  const keyStatusText = (key: ApiKey) => t(keyStatus(key) === '有效' ? 'ui.keyActive' : keyStatus(key) === '已过期' ? 'ui.keyExpired' : 'ui.keyRevoked');
35
- function publicBlockedReason(id:string) { const bindings = routeKeyBindings[id] ?? []; return protectedIds.includes(id) && bindings.length ? message('ui.publicBlockedReason', { keys: bindings.map(key => key.name) }) : null; }
36
35
  function errorMessage(e: unknown): Message {
37
36
  if (e instanceof LocalizedError) return e.content;
38
37
  if (e instanceof ApiError && (e.body as any)?.error === 'key_name_exists') return message('ui.keyNameExists');
39
- if (e instanceof ApiError && (e.body as any)?.error === 'route_has_api_keys') return message('ui.routeHasKeys');
40
38
  if (e instanceof ApiError && (e.body as any)?.persisted) return message('ui.persistedPending');
41
39
  if (e instanceof ApiError) {
42
40
  const code = (e.body as any)?.error;
@@ -51,7 +49,6 @@
51
49
  return message('ui.operationFailed');
52
50
  }
53
51
  const guard = latestRequest();
54
- let selectedPublic = $derived(routes.filter(route => (allRoutes || allowed.includes(route.id)) && !protectedIds.includes(route.id)));
55
52
  const control = <T,>(path:string,method:'GET'|'PUT',body?:unknown) => requestPluginControl<T>(pluginName,path,method,body);
56
53
  function failure(e:unknown) { if (e instanceof ApiError && (e.body as any)?.ready === false) ready = false; error = errorMessage(e); }
57
54
  async function refresh() {
@@ -61,15 +58,15 @@
61
58
  keys = credentials.keys; routes = config.config.logical_configuration.routes.map(route => ({id:route.id,path:route.path}));
62
59
  inactive = !plugins.find(plugin => plugin.name === pluginName)?.enabled;
63
60
  if (inactive) { ready = false; status = message('ui.inactive'); return; }
64
- const protection = await control<{protectedRouteIds:string[];routeKeyBindings:Record<string,{id:string;name:string}[]>;ready?:boolean}>('/routes','GET');
65
- protectedIds = protection.protectedRouteIds; routeKeyBindings = protection.routeKeyBindings; ready = protection.ready !== false;
61
+ const protection = await control<{protectedRouteIds:string[];routeKeyBindings:Record<string,{id:string;name:string}[]>;unrestrictedKeyIds?:string[];ready?:boolean}>('/routes','GET');
62
+ protectedIds = protection.protectedRouteIds; routeKeyBindings = protection.routeKeyBindings; unrestrictedKeyIds = protection.unrestrictedKeyIds ?? []; ready = protection.ready !== false;
66
63
  status = ready ? null : message('ui.configurationPending');
67
64
  } catch(e) { ready = false; failure(e); } finally { loading = false; }
68
65
  }
69
66
  onMount(() => { void refresh(); return () => guard.invalidate(); });
70
- function newKey(routeId?:string) { if (secretOpen) return; name = ''; expires = ''; allRoutes = false; allowed = routeId ? [routeId] : []; models = ''; review = false; error = null; createOpen = true; }
67
+ function newKey(routeId?:string) { if (secretOpen) return; name = ''; expires = ''; allRoutes = false; allowed = routeId ? [routeId] : []; models = ''; error = null; createOpen = true; }
71
68
  async function edit(key:ApiKey) {
72
- target = key; name = key.name; expires = localDate(key.expiresAt); copied = false; policyOpen = true; policyLoading = true; policyReady = false; error = null; review = false;
69
+ target = key; name = key.name; expires = localDate(key.expiresAt); copied = false; policyOpen = true; policyLoading = true; policyReady = false; error = null;
73
70
  const generation = guard.begin();
74
71
  try {
75
72
  const result = await control<{value:{routes:string[]|null;models:string[]|null}|null}>(`/keys/${encodeURIComponent(key.id)}`,'GET');
@@ -77,18 +74,14 @@
77
74
  allRoutes = result.value?.routes == null; allowed = result.value?.routes ?? []; models = result.value?.models?.join('\n') ?? ''; policyReady = true;
78
75
  } catch(e) { if (guard.current(generation)) failure(e); } finally { if (guard.current(generation)) policyLoading = false; }
79
76
  }
80
- async function applyRouteKey(key:ApiKey, confirmed=false) {
77
+ async function applyRouteKey(key:ApiKey) {
81
78
  if (busy || !selectedRoute) return;
82
- if (!protectedIds.includes(selectedRoute.id) && !confirmed) { pendingRouteKey = key; return; }
83
79
  busy = true; error = null;
84
80
  try {
85
- const result = await control<{protectedRouteIds:string[];routeKeyBindings:Record<string,{id:string;name:string}[]>;ready?:boolean;published?:boolean}>('/route-key','PUT',{routeId:selectedRoute.id,keyId:key.id,protect:confirmed});
86
- protectedIds = result.protectedRouteIds; routeKeyBindings = result.routeKeyBindings; ready = result.ready !== false;
87
- status = message(publicationMessage(result)); routeConfigOpen = false; pendingRouteKey = null;
88
- } catch(e) {
89
- if (e instanceof ApiError && (e.body as any)?.error === 'route_protection_confirmation_required') pendingRouteKey = key;
90
- else failure(e);
91
- } finally { busy = false; }
81
+ const result = await control<{protectedRouteIds:string[];routeKeyBindings:Record<string,{id:string;name:string}[]>;unrestrictedKeyIds?:string[];ready?:boolean;published?:boolean}>('/route-key','PUT',{routeId:selectedRoute.id,keyId:key.id});
82
+ protectedIds = result.protectedRouteIds; routeKeyBindings = result.routeKeyBindings; unrestrictedKeyIds = result.unrestrictedKeyIds ?? []; ready = result.ready !== false;
83
+ status = message(publicationMessage(result)); routeConfigOpen = false;
84
+ } catch(e) { failure(e); } finally { busy = false; }
92
85
  }
93
86
  function toggleRoute(id:string,checked:boolean) { allowed = checked ? [...allowed,id] : allowed.filter(value => value !== id); }
94
87
  function localDate(value:number|null) { return value === null ? '' : new Date(value - new Date(value).getTimezoneOffset()*60000).toISOString().slice(0,16); }
@@ -110,22 +103,13 @@
110
103
  const result = includeDetails ? await keysApi.update(id,{name,expiresAt:expiration(),...scope}) : await control<{ready?:boolean;published?:boolean}>(`/keys/${encodeURIComponent(id)}`,'PUT',scope);
111
104
  if ('key' in result) { const key = result.key as ApiKey; keys = keys.map(item => item.id === id ? key : item); target = key; }
112
105
  if (result.ready === false || result.published === false) throw new LocalizedError(message('ui.keyRoutesPending'));
113
- if (selectedPublic.length) {
114
- try {
115
- const result = await control<{protectedRouteIds:string[];ready?:boolean;published?:boolean}>('/routes','PUT',{protectedRouteIds:[...new Set([...protectedIds,...selectedPublic.map(route => route.id)])]});
116
- protectedIds = result.protectedRouteIds;
117
- if (result.ready === false || result.published === false) throw new LocalizedError(message('ui.protectionPending'));
118
- } catch (e) { throw new LocalizedError(message('ui.keySavedProtectionIncomplete', { reason: errorMessage(e) })); }
119
- }
120
- const protection = await control<{protectedRouteIds:string[];routeKeyBindings:Record<string,{id:string;name:string}[]>}>('/routes','GET');
121
- protectedIds = protection.protectedRouteIds; routeKeyBindings = protection.routeKeyBindings;
106
+ const protection = await control<{protectedRouteIds:string[];routeKeyBindings:Record<string,{id:string;name:string}[]>;unrestrictedKeyIds?:string[]}>('/routes','GET');
107
+ protectedIds = protection.protectedRouteIds; routeKeyBindings = protection.routeKeyBindings; unrestrictedKeyIds = protection.unrestrictedKeyIds ?? [];
122
108
  }
123
109
  async function create(event:SubmitEvent) {
124
110
  event.preventDefault(); if (busy) return;
125
111
  const expiresAt = expires ? new Date(expires).getTime() : null;
126
112
  if (expiresAt !== null && (!Number.isFinite(expiresAt) || expiresAt <= Date.now())) { error = message('ui.expirationFuture'); return; }
127
- if (selectedPublic.length && !review) { review = true; error = null; return; }
128
- const protectionCount = selectedPublic.length;
129
113
  busy = true; error = null; status = null; secretStatus = null;
130
114
  let stage = 'ui.stageCreate';
131
115
  try {
@@ -138,7 +122,7 @@
138
122
  if (created.ready === false || created.published === false) { ready = false; secretStatus = message('ui.createdPending'); return; }
139
123
  stage = 'ui.stageSavePolicy';
140
124
  await savePolicy(created.key.id);
141
- secretStatus = message(protectionCount ? 'ui.createdProtected' : 'ui.createdPublic');
125
+ secretStatus = message('ui.createdPublic');
142
126
  status = secretStatus;
143
127
  } catch(e) { failure(e); if (secret) secretStatus = message('ui.createdIncomplete', { stage: message(stage), reason: error ?? message('ui.operationFailed') }); }
144
128
  finally { busy = false; }
@@ -146,16 +130,14 @@
146
130
  async function update(event:SubmitEvent) {
147
131
  event.preventDefault(); if (busy || policyLoading || !policyReady || !target) return;
148
132
  try { expiration(); } catch(e) { failure(e); return; }
149
- if (selectedPublic.length && !review) { review = true; error = null; return; }
150
133
  busy = true; error = null; status = null;
151
134
  try { await savePolicy(target.id,true); policyOpen = false; status = message('ui.policyPublished'); }
152
135
  catch(e) { failure(e); } finally { busy = false; }
153
136
  }
154
137
  async function protect(id:string,checked:boolean) {
155
138
  if (busy) return;
156
- if (!checked && publicBlockedReason(id)) { error = publicBlockedReason(id); return; }
157
139
  busy = true; error = null; status = null;
158
- try { const result = await control<{protectedRouteIds:string[];routeKeyBindings:Record<string,{id:string;name:string}[]>;ready?:boolean;published?:boolean}>('/routes','PUT',{protectedRouteIds:checked ? [...new Set([...protectedIds,id])] : protectedIds.filter(value => value !== id)}); protectedIds = result.protectedRouteIds; routeKeyBindings = result.routeKeyBindings; status = message(publicationMessage(result)); ready = result.ready !== false; }
140
+ try { const result = await control<{protectedRouteIds:string[];routeKeyBindings:Record<string,{id:string;name:string}[]>;unrestrictedKeyIds?:string[];ready?:boolean;published?:boolean}>('/routes','PUT',{protectedRouteIds:checked ? [...new Set([...protectedIds,id])] : protectedIds.filter(value => value !== id)}); protectedIds = result.protectedRouteIds; routeKeyBindings = result.routeKeyBindings; unrestrictedKeyIds = result.unrestrictedKeyIds ?? []; status = message(publicationMessage(result)); ready = result.ready !== false; }
159
141
  catch(e) { await refresh(); failure(e); } finally { busy = false; }
160
142
  }
161
143
  async function removeKey() {
@@ -178,26 +160,8 @@
178
160
  {:else}
179
161
  <PanelCard title={t('ui.routeAccess')} tag={t('ui.routeCount', { count: routes.length })}>
180
162
  <p class="text-sm text-zinc-400 mb-4">{t('ui.routeHelp')}</p>
181
- {#if loading}<LoadingIndicator label={t('ui.loadingRoutes')} height="sm" />{:else if !routes.length}<p class="text-sm text-zinc-400">{t('ui.noRoutes')}</p>{:else}<div class="overflow-x-auto"><table class="w-full text-left text-sm"><thead class="text-zinc-400"><tr><th class="px-2 py-3">{t('ui.route')}</th><th class="px-2 py-3">{t('ui.public')}</th><th class="px-2 py-3">{t('ui.actions')}</th></tr></thead><tbody>{#each routes as route (route.id)}<tr class="border-t border-carbon-600"><td class="px-2 py-3 font-mono">{route.path}</td><td class="px-2 py-3">{#if publicBlockedReason(route.id)}
182
- <Tooltip.Root openDelay={200} closeOnEscape>
183
- <Tooltip.Trigger asChild let:builder>
184
- <!-- Disabled controls cannot receive focus; the group exposes their explanation to keyboard users. -->
185
- <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
186
- <span {...builder} use:builder.action tabindex="0" role="group" aria-label={t('ui.cannotMakePublic', { path: route.path })} class="inline-flex outline-none focus-visible:ring-2 focus-visible:ring-nexus-500">
187
- <BSwitch checked={false} description={t('ui.isRoutePublic', { path: route.path })} disabled />
188
- </span>
189
- </Tooltip.Trigger>
190
- <Tooltip.Content class="max-w-xs break-words" side="top">
191
- <div class="space-y-2">
192
- <p class="font-semibold">{t('ui.publicBlockedTitle')}</p>
193
- <ul class="list-disc pl-4 space-y-1">{#each routeKeyBindings[route.id] ?? [] as key (key.id)}<li>{key.name}</li>{/each}</ul>
194
- <p class="text-carbon-300">{t('ui.publicBlockedHelp')}</p>
195
- </div>
196
- </Tooltip.Content>
197
- </Tooltip.Root>
198
- {:else}
199
- <BSwitch checked={!protectedIds.includes(route.id)} description={t('ui.isRoutePublic', { path: route.path })} disabled={busy || loading || !ready} onchange={isPublic => protect(route.id,!isPublic)} />
200
- {/if}</td><td class="px-2 py-3"><button class="nx-btn-ghost nx-btn-sm" disabled={busy || loading || !ready || secretOpen} onclick={() => { selectedRoute = route; pendingRouteKey = null; error = null; routeConfigOpen = true; }}>{t('ui.configureKey')}</button></td></tr>{/each}</tbody></table></div>{/if}
163
+ {#if loading}<LoadingIndicator label={t('ui.loadingRoutes')} height="sm" />{:else if !routes.length}<p class="text-sm text-zinc-400">{t('ui.noRoutes')}</p>{:else}<div class="overflow-x-auto"><table class="w-full text-left text-sm"><thead class="text-zinc-400"><tr><th class="px-2 py-3">{t('ui.route')}</th><th class="px-2 py-3">{t('ui.public')}</th><th class="px-2 py-3">{t('ui.actions')}</th></tr></thead><tbody>{#each routes as route (route.id)}<tr class="border-t border-carbon-600"><td class="px-2 py-3 font-mono">{route.path}</td><td class="px-2 py-3"><BSwitch checked={!protectedIds.includes(route.id)} description={t('ui.isRoutePublic', { path: route.path })} disabled={busy || loading || !ready} onchange={isPublic => protect(route.id,!isPublic)} />
164
+ </td><td class="px-2 py-3"><button class="nx-btn-ghost nx-btn-sm" disabled={busy || loading || !ready || secretOpen} onclick={() => { selectedRoute = route; error = null; routeConfigOpen = true; }}>{t('ui.configureKey')}</button></td></tr>{/each}</tbody></table></div>{/if}
201
165
  </PanelCard>
202
166
  {/if}
203
167
  </div>
@@ -213,12 +177,12 @@
213
177
  {#each routes as route (route.id)}
214
178
  <label class="flex min-h-11 cursor-pointer items-center justify-between gap-3 px-3 py-2 hover:bg-carbon-700">
215
179
  <span class="flex min-w-0 items-center gap-3"><input type="checkbox" class="accent-nexus-500" checked={allRoutes || allowed.includes(route.id)} disabled={busy || policyLoading || allRoutes} onchange={event => toggleRoute(route.id,event.currentTarget.checked)} /><span class="font-mono text-sm text-zinc-200 break-all">{route.path}</span></span>
216
- <StatusBadge variant={protectedIds.includes(route.id) ? 'active' : (allRoutes || allowed.includes(route.id)) ? 'standby' : 'muted'}>{protectedIds.includes(route.id) ? t('ui.requiresKey') : (allRoutes || allowed.includes(route.id)) ? t('ui.publicToProtected') : t('ui.publicRoute')}</StatusBadge>
180
+ <StatusBadge variant={protectedIds.includes(route.id) ? 'active' : 'muted'}>{protectedIds.includes(route.id) ? t('ui.requiresKey') : t('ui.publicRoute')}</StatusBadge>
217
181
  </label>
218
182
  {:else}<p class="p-3 text-sm text-zinc-400">{t('ui.createRoutesFirst')}</p>{/each}
219
183
  {#each allowed.filter(id => !routes.some(route => route.id === id)) as id}<label class="flex gap-2 p-3 text-sm text-amber-400"><input type="checkbox" checked disabled={busy} onchange={() => allowed = allowed.filter(value => value !== id)} />{t('ui.deletedRoute', { id })}</label>{/each}
220
184
  </div>{/if}
221
- {#if selectedPublic.length}<p class="text-sm text-amber-400">{t('ui.selectedPublicWarning', { count: selectedPublic.length })}</p>{:else if !allRoutes && !allowed.length}<p class="text-sm text-zinc-400">{t('ui.noRoutesSelected')}</p>{/if}
185
+ {#if !allRoutes && !allowed.length}<p class="text-sm text-zinc-400">{t('ui.noRoutesSelected')}</p>{/if}
222
186
  </fieldset>
223
187
  <fieldset class="space-y-2 border-t border-carbon-600 pt-4">
224
188
  <legend class="nx-field-label">{t('ui.modelScopeOptional')}</legend>
@@ -226,30 +190,20 @@
226
190
  <p class="text-sm text-zinc-400">{t('ui.modelsHelp')}</p>
227
191
  </fieldset>
228
192
  {/snippet}
229
- {#snippet reviewChanges()}
230
- <div class="space-y-4" role="region" aria-label={t('ui.reviewTitle')}>
231
- <p class="text-sm text-zinc-200">{t('ui.reviewWarning', { count: selectedPublic.length })}</p>
232
- <div class="border border-carbon-600 divide-y divide-carbon-600">{#each selectedPublic as route (route.id)}<div class="flex flex-wrap items-center justify-between gap-3 p-3"><span class="font-mono text-sm text-zinc-200 break-all">{route.path}</span><StatusBadge variant="standby">{t('ui.publicToProtected')}</StatusBadge></div>{/each}</div>
233
- <p class="text-sm text-zinc-400">{t('ui.reviewHelp')}</p>
234
- <dl class="grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-sm"><dt class="text-zinc-400">{t('ui.keyName')}</dt><dd class="text-zinc-200 break-all">{name}</dd><dt class="text-zinc-400">{t('ui.modelScope')}</dt><dd class="text-zinc-200 whitespace-pre-wrap break-all">{models.trim() || t('ui.allModels')}</dd></dl>
235
- </div>
236
- {/snippet}
237
- <IndustrialDialog bind:open={createOpen} title={review ? t('ui.reviewTitle') : t('ui.createKey')} description={review ? t('ui.createReviewHelp') : t('ui.createHelp')} width="48rem" {busy} scrollBody>
193
+ <IndustrialDialog bind:open={createOpen} title={t('ui.createKey')} description={t('ui.createHelp')} width="48rem" {busy} scrollBody>
238
194
  {#snippet body()}
239
195
  <form id="create-api-key" class="space-y-5" onsubmit={create}>
240
- {#if review}{@render reviewChanges()}{:else}
241
- {@render basicFields()}
242
- {@render routeFields()}
243
- {/if}
196
+ {@render basicFields()}
197
+ {@render routeFields()}
244
198
  {#if error}<p role="alert" class="text-sm text-red-400">{displayMessage(error)}</p>{/if}
245
199
  </form>
246
200
  {/snippet}
247
- {#snippet footer()}{#if review}<button class="nx-btn-ghost" disabled={busy} onclick={() => review = false}>{t('ui.back')}</button>{/if}<button class="nx-btn-ghost" disabled={busy} onclick={() => { createOpen = false; review = false; }}>{t('ui.cancel')}</button><button class="nx-btn-primary" form="create-api-key" disabled={busy}>{busy ? t('ui.saving') : !review && selectedPublic.length ? t('ui.next') : t('ui.save')}</button>{/snippet}
201
+ {#snippet footer()}<button class="nx-btn-ghost" disabled={busy} onclick={() => { createOpen = false; }}>{t('ui.cancel')}</button><button class="nx-btn-primary" form="create-api-key" disabled={busy}>{busy ? t('ui.saving') : t('ui.save')}</button>{/snippet}
248
202
  </IndustrialDialog>
249
- <IndustrialDialog bind:open={policyOpen} title={review ? t('ui.reviewTitle') : t('ui.editKey', { name: target?.name ?? '' })} description={review ? t('ui.editReviewHelp') : t('ui.editHelp')} width="48rem" {busy} scrollBody onOpenChange={open => { if (!open) { guard.invalidate(); } }}>
250
- {#snippet body()}{#if policyLoading}<LoadingIndicator label={t('ui.loadingPolicy')} height="sm" />{:else}<form id="key-route-policy" class="space-y-5" onsubmit={update}>{#if review}{@render reviewChanges()}{:else}{@render basicFields()}
251
- {@render routeFields()}{/if}{#if error}<p role="alert" class="text-sm text-red-400">{displayMessage(error)}</p>{/if}</form>{/if}{/snippet}
252
- {#snippet footer()}{#if review}<button class="nx-btn-ghost" disabled={busy} onclick={() => review = false}>{t('ui.back')}</button>{/if}<button class="nx-btn-ghost" disabled={busy} onclick={() => { policyOpen = false; review = false; guard.invalidate(); }}>{t('ui.cancel')}</button><button class="nx-btn-primary" form="key-route-policy" disabled={busy || policyLoading || !policyReady || !target || target.revokedAt !== null}>{busy ? t('ui.saving') : !review && selectedPublic.length ? t('ui.next') : t('ui.save')}</button>{/snippet}
203
+ <IndustrialDialog bind:open={policyOpen} title={t('ui.editKey', { name: target?.name ?? '' })} description={t('ui.editHelp')} width="48rem" {busy} scrollBody onOpenChange={open => { if (!open) { guard.invalidate(); } }}>
204
+ {#snippet body()}{#if policyLoading}<LoadingIndicator label={t('ui.loadingPolicy')} height="sm" />{:else}<form id="key-route-policy" class="space-y-5" onsubmit={update}>{@render basicFields()}
205
+ {@render routeFields()}{#if error}<p role="alert" class="text-sm text-red-400">{displayMessage(error)}</p>{/if}</form>{/if}{/snippet}
206
+ {#snippet footer()}<button class="nx-btn-ghost" disabled={busy} onclick={() => { policyOpen = false; guard.invalidate(); }}>{t('ui.cancel')}</button><button class="nx-btn-primary" form="key-route-policy" disabled={busy || policyLoading || !policyReady || !target || target.revokedAt !== null}>{busy ? t('ui.saving') : t('ui.save')}</button>{/snippet}
253
207
  </IndustrialDialog>
254
208
  <IndustrialDialog bind:open={secretOpen} title={t('ui.viewKey')} description={t('ui.viewHelp')} busy={busy} onOpenChange={open => { if (!open) { secret = ''; secretStatus = null; guard.invalidate(); } }}>
255
209
  {#snippet body()}{#if secret}<code class="block select-all break-all border border-carbon-600 bg-carbon-950 p-3 font-mono text-zinc-200">{secret}</code>{:else if busy}<LoadingIndicator label={t('ui.loadingKeys')} height="sm" />{/if}{#if secretStatus}<p role="status" class="text-sm text-zinc-300 mt-3">{displayMessage(secretStatus)}</p>{/if}{#if copied}<p class="text-sm text-emerald-400 mt-3">{t('ui.copied')}</p>{/if}{/snippet}
@@ -262,9 +216,6 @@
262
216
 
263
217
  <IndustrialDialog bind:open={routeConfigOpen} title={t('ui.configureKey')} description={t('ui.configureDescription', { path: selectedRoute?.path ?? '' })} {busy} scrollBody>
264
218
  {#snippet body()}
265
- {#if pendingRouteKey}
266
- <p class="text-sm text-zinc-200">{#each t('ui.applyRouteWarning', { path: '\uE000', name: '\uE001' }).split(/(\uE000|\uE001)/) as part}{#if part === '\uE000'}<strong>{selectedRoute?.path}</strong>{:else if part === '\uE001'}<strong>{pendingRouteKey.name}</strong>{:else}{part}{/if}{/each}</p>
267
- {:else}
268
219
  <div class="space-y-4">
269
220
  <button class="nx-btn-primary" disabled={busy} onclick={() => { routeConfigOpen = false; newKey(selectedRoute?.id); }}>{t('ui.newKey')}</button>
270
221
  <div class="space-y-2">
@@ -274,14 +225,13 @@
274
225
  {#each keys.filter(key => key.revokedAt === null) as key (key.id)}
275
226
  <div class="flex items-center justify-between gap-3 py-3">
276
227
  <div class="min-w-0"><p class="text-sm text-zinc-200 break-all">{key.name}</p><p class="font-mono text-xs text-zinc-400">{key.prefix}… · {keyStatusText(key)}</p></div>
277
- <button class="nx-btn-ghost nx-btn-sm" disabled={busy || !!routeKeyBindings[selectedRoute?.id ?? '']?.some(item => item.id === key.id)} onclick={() => void applyRouteKey(key)}>{routeKeyBindings[selectedRoute?.id ?? '']?.some(item => item.id === key.id) ? t('ui.applied') : t('ui.apply')}</button>
228
+ <button class="nx-btn-ghost nx-btn-sm" disabled={busy || isKeyApplied(selectedRoute?.id ?? '',key.id)} onclick={() => void applyRouteKey(key)}>{isKeyApplied(selectedRoute?.id ?? '',key.id) ? t('ui.applied') : t('ui.apply')}</button>
278
229
  </div>
279
230
  {:else}<p class="py-3 text-sm text-zinc-400">{t('ui.noUsableKeys')}</p>{/each}
280
231
  </div>
281
232
  </div>
282
233
  </div>
283
- {/if}
284
234
  {#if error}<p role="alert" class="text-sm text-red-400 mt-3">{displayMessage(error)}</p>{/if}
285
235
  {/snippet}
286
- {#snippet footer()}<button class="nx-btn-ghost" disabled={busy} onclick={() => routeConfigOpen = false}>{t('ui.cancel')}</button>{#if pendingRouteKey}<button class="nx-btn-primary" disabled={busy} onclick={() => pendingRouteKey && applyRouteKey(pendingRouteKey,true)}>{busy ? t('ui.saving') : t('ui.apply')}</button>{/if}{/snippet}
236
+ {#snippet footer()}<button class="nx-btn-ghost" disabled={busy} onclick={() => routeConfigOpen = false}>{t('ui.cancel')}</button>{/snippet}
287
237
  </IndustrialDialog>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jeffusion/bungee-core",
3
- "version": "5.15.1",
3
+ "version": "5.15.2",
4
4
  "description": "Bungee reverse proxy core engine",
5
5
  "main": "./dist/main.js",
6
6
  "types": "./dist/main.d.ts",
@@ -12,7 +12,7 @@
12
12
  "test": "bun test"
13
13
  },
14
14
  "dependencies": {
15
- "@jeffusion/bungee-types": "^5.15.1",
15
+ "@jeffusion/bungee-types": "^5.15.2",
16
16
  "@types/lodash-es": "^4.17.12",
17
17
  "acorn": "^8.15.0",
18
18
  "canonicalize": "3.0.0",