Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 137 additions & 0 deletions apps/ui/app/components/ApiKeyForm.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
<script setup lang="ts">
import type { ApiKey, ApiKeyInput } from '~/types/management'

const props = defineProps<{
initial?: ApiKey | null
submitting?: boolean
error?: string | null
}>()
const emit = defineEmits<{ submit: [ApiKeyInput], cancel: [] }>()

const editing = computed(() => Boolean(props.initial))

type Preset = 'never' | '30d' | '90d' | '1y' | 'custom'
const PRESET_DAYS: Record<'30d' | '90d' | '1y', number> = { '30d': 30, '90d': 90, '1y': 365 }

const name = ref(props.initial?.name ?? '')
const description = ref(props.initial?.description ?? '')
// An existing key already carries an absolute instant, so editing always lands in
// "custom" with that date prefilled; new keys default to never-expiring.
const preset = ref<Preset>(props.initial?.expiresAt ? 'custom' : 'never')
const customAt = ref(props.initial?.expiresAt ? toDatetimeLocal(props.initial.expiresAt) : '')

// datetime-local wants local wall-clock `YYYY-MM-DDTHH:mm`, not an ISO instant.
function toDatetimeLocal(iso: string): string {
const d = new Date(iso)
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
}

const nowLocal = computed(() => toDatetimeLocal(new Date().toISOString()))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 nowLocal is a computed() with no reactive dependencies — new Date() is evaluated once at component mount and never again. The min attribute on the datetime-local input will lag behind real time, so a user who opens the form, waits several minutes, then picks a "custom" date could select a datetime that is technically in the past relative to current time yet still passes the HTML min constraint. The customInFuture guard at submission time catches this, but the stale min makes the browser's native date-picker mislead the user about what dates are selectable.

Suggested change
const nowLocal = computed(() => toDatetimeLocal(new Date().toISOString()))
// Refresh every minute so the min constraint stays current without a reactive
// dependency on a non-reactive value.
const nowLocal = ref(toDatetimeLocal(new Date().toISOString()))
let _nowTimer: ReturnType<typeof setInterval> | undefined
onMounted(() => { _nowTimer = setInterval(() => { nowLocal.value = toDatetimeLocal(new Date().toISOString()) }, 60_000) })
onUnmounted(() => clearInterval(_nowTimer))

Fix in Claude Code Fix in Codex


// The UI owns the "must be in the future" guard — the backend validates ISO shape
// only, so a past date would save and then reject on every use.
const customInFuture = computed(() => {
if (preset.value !== 'custom')
return true
const t = new Date(customAt.value).getTime()
return !Number.isNaN(t) && t > Date.now()
})
const valid = computed(() => preset.value !== 'custom' || (Boolean(customAt.value) && customInFuture.value))

function resolveExpiresAt(): string | null {
if (preset.value === 'never')
return null
if (preset.value === 'custom')
return new Date(customAt.value).toISOString()
return new Date(Date.now() + PRESET_DAYS[preset.value] * 86_400_000).toISOString()
}

function submit() {
if (!valid.value)
return
emit('submit', {
name: name.value.trim() || null,
description: description.value.trim() || null,
expiresAt: resolveExpiresAt(),
})
}
</script>

<template>
<form class="space-y-4" @submit.prevent="submit">
<div>
<label class="mb-1 block text-sm text-slate-300">Name <span class="text-slate-600">(optional)</span></label>
<input
v-model="name"
maxlength="100"
placeholder="Radarr on the NAS"
class="w-full rounded-lg border border-slate-700 bg-slate-950 px-3 py-2 text-sm outline-none focus:border-brand-500"
>
<p class="mt-1 text-xs text-slate-600">
Name it so you can recognize it later — the key itself is only shown once.
</p>
</div>

<div>
<label class="mb-1 block text-sm text-slate-300">Description <span class="text-slate-600">(optional)</span></label>
<textarea
v-model="description"
maxlength="500"
rows="2"
placeholder="What this key is used for"
class="w-full resize-none rounded-lg border border-slate-700 bg-slate-950 px-3 py-2 text-sm outline-none focus:border-brand-500"
/>
</div>

<div>
<label class="mb-1 block text-sm text-slate-300">Expiration</label>
<select
v-model="preset"
class="w-full rounded-lg border border-slate-700 bg-slate-950 px-3 py-2 text-sm outline-none focus:border-brand-500"
>
<option value="never">
Never expires
</option>
<option value="30d">
In 30 days
</option>
<option value="90d">
In 90 days
</option>
<option value="1y">
In 1 year
</option>
<option value="custom">
Custom date…
</option>
</select>
<div v-if="preset === 'custom'" class="mt-2">
<input
v-model="customAt"
type="datetime-local"
:min="nowLocal"
class="w-full rounded-lg border border-slate-700 bg-slate-950 px-3 py-2 text-sm outline-none focus:border-brand-500"
>
<p v-if="customAt && !customInFuture" class="mt-1 text-xs text-rose-300">
Pick a date in the future.
</p>
</div>
</div>

<FormAlert v-if="error" :message="error" />

<div class="flex justify-end gap-2 pt-2">
<button type="button" class="rounded-lg px-4 py-2 text-sm text-slate-400 hover:text-slate-100" @click="emit('cancel')">
Cancel
</button>
<button
type="submit"
:disabled="!valid || submitting"
class="rounded-lg bg-brand-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-brand-500 disabled:cursor-not-allowed disabled:opacity-50"
>
{{ submitting ? 'Saving…' : editing ? 'Save' : 'Create key' }}
</button>
</div>
</form>
</template>
1 change: 1 addition & 0 deletions apps/ui/app/layouts/default.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const links = [
{ to: '/peers', label: 'Peers', icon: '⇄' },
{ to: '/servers', label: 'Servers', icon: '▤' },
{ to: '/downloads', label: 'Downloads', icon: '↓' },
{ to: '/settings', label: 'Settings', icon: '⚙' },
]

const loggingOut = ref(false)
Expand Down
241 changes: 241 additions & 0 deletions apps/ui/app/pages/settings.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
<script setup lang="ts">
import type { ApiKey, ApiKeyInput, CreatedApiKey } from '~/types/management'

const { request, extractError } = useManagement()

const { data, pending, error, refresh } = await useAsyncData('api-keys', () =>
request<ApiKey[]>('api-keys'))

const keys = computed(() => data.value ?? [])

const showForm = ref(false)
const editTarget = ref<ApiKey | null>(null)
const submitting = ref(false)
const formError = ref<string | null>(null)

// The one-time reveal: set after a successful create, cleared when dismissed.
const created = ref<CreatedApiKey | null>(null)
const copied = ref(false)

const confirmTarget = ref<ApiKey | null>(null)
const revoking = ref(false)
const revokeError = ref<string | null>(null)

// Expiration as an at-a-glance signal rather than a raw timestamp.
function expiryInfo(key: ApiKey): { label: string, tone: 'muted' | 'warn' | 'dead' } {
if (!key.expiresAt)
return { label: 'Never expires', tone: 'muted' }
const ms = new Date(key.expiresAt).getTime() - Date.now()
if (ms <= 0)
return { label: 'Expired', tone: 'dead' }
const days = Math.ceil(ms / 86_400_000)
if (days <= 7)
return { label: `Expires in ${days}d`, tone: 'warn' }
return { label: `Expires ${new Date(key.expiresAt).toLocaleDateString()}`, tone: 'muted' }
}
const toneClass: Record<'muted' | 'warn' | 'dead', string> = {
muted: 'text-slate-500',
warn: 'text-amber-300',
dead: 'text-rose-300',
}

function openAdd() {
editTarget.value = null
formError.value = null
showForm.value = true
}
function openEdit(key: ApiKey) {
editTarget.value = key
formError.value = null
showForm.value = true
}

async function submit(input: ApiKeyInput) {
submitting.value = true
formError.value = null
try {
if (editTarget.value) {
await request(`api-keys/${editTarget.value.id}`, { method: 'PATCH', body: input })
showForm.value = false
}
else {
const key = await request<CreatedApiKey>('api-keys', { method: 'POST', body: input })
showForm.value = false
// Hand straight off to the reveal — this is the only chance to copy it.
created.value = key
}
await refresh()
}
catch (err) {
formError.value = extractError(err, 'Could not save the key.')
}
finally {
submitting.value = false
}
}

async function copyKey() {
if (!created.value)
return
await navigator.clipboard.writeText(created.value.key)
copied.value = true
setTimeout(() => (copied.value = false), 2000)
}

function dismissReveal() {
created.value = null
copied.value = false
}

function closeConfirm() {
confirmTarget.value = null
revokeError.value = null
Comment on lines +85 to +92

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Clipboard failure silently swallowed — user may lose the key

navigator.clipboard.writeText rejects when the Clipboard API is unavailable (non-HTTPS context, browser permissions denied, Safari private mode, etc.). The await is not wrapped in a try/catch, so the rejection propagates unhandled and copied.value is never set to true. The button keeps its "Copy" label and the user gets no error feedback. Because this is the only time the plaintext key is shown, a user who clicks "Copy", sees no change, and then clicks "Done" will lose the key permanently with no warning.

Fix in Claude Code Fix in Codex

}
async function confirmRevoke() {
if (!confirmTarget.value)
return
revoking.value = true
revokeError.value = null
try {
await request(`api-keys/${confirmTarget.value.id}`, { method: 'DELETE' })
confirmTarget.value = null
await refresh()
}
catch (err) {
revokeError.value = extractError(err, 'Could not revoke the key.')
}
finally {
revoking.value = false
}
}
</script>

<template>
<div>
<PageHeader title="Settings" subtitle="Configure this Jack instance." />

<section>
<div class="mb-3 flex items-end justify-between gap-4">
<div>
<h2 class="text-sm font-medium text-slate-200">
API keys
</h2>
<p class="mt-0.5 text-xs text-slate-500">
Keys external tools use to authenticate with Jack's API.
</p>
</div>
<button class="shrink-0 rounded-lg bg-brand-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-brand-500" @click="openAdd">
Create key
</button>
</div>

<div v-if="error" class="rounded-xl border border-rose-900/60 bg-rose-950/30 p-4 text-sm text-rose-200">
Failed to load API keys.
</div>

<div v-else-if="pending" class="text-sm text-slate-500">
Loading…
</div>

<div v-else-if="keys.length === 0" class="rounded-xl border border-slate-800 bg-slate-900/40 p-8 text-center text-sm text-slate-500">
No API keys yet. Create one to let external tools authenticate with Jack.
</div>

<div v-else class="space-y-2">
<div
v-for="key in keys"
:key="key.id"
class="rounded-xl border border-slate-800 bg-slate-900/40 px-4 py-3.5"
>
<div class="flex items-center gap-3">
<div class="min-w-0 flex-1">
<div class="flex items-baseline gap-2">
<span v-if="key.name" class="truncate font-medium" :title="key.name">{{ key.name }}</span>
<span v-else class="truncate font-medium text-slate-500">Unnamed key</span>
<span class="shrink-0 font-mono text-xs text-slate-600">#{{ key.id }}</span>
</div>
<p v-if="key.description" class="truncate text-xs text-slate-500" :title="key.description">
{{ key.description }}
</p>
</div>
<div class="shrink-0 text-right">
<p class="text-xs font-medium" :class="toneClass[expiryInfo(key).tone]">
{{ expiryInfo(key).label }}
</p>
Comment on lines +162 to +164

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 expiryInfo(key) is called twice per key row in the template (once to read .tone, once to read .label). Each call recomputes Date.now() and new Date(key.expiresAt). Consider extracting to a single call per row.

Suggested change
<p class="text-xs font-medium" :class="toneClass[expiryInfo(key).tone]">
{{ expiryInfo(key).label }}
</p>
<template v-for="exp in [expiryInfo(key)]" :key="exp.label">
<p class="text-xs font-medium" :class="toneClass[exp.tone]">
{{ exp.label }}
</p>
</template>

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code Fix in Codex

<p class="text-xs text-slate-600" :title="formatDate(key.createdAt)">
Created {{ formatAgo(key.createdAt) }} ago
</p>
</div>
<div class="shrink-0">
<button class="text-xs font-medium text-slate-400 hover:text-slate-100" @click="openEdit(key)">
Edit
</button>
<button class="ml-3 text-xs font-medium text-slate-400 hover:text-rose-400" @click="confirmTarget = key">
Revoke
</button>
</div>
</div>
</div>
</div>
</section>

<Modal v-if="showForm" :title="editTarget ? 'Edit API key' : 'Create API key'" @close="showForm = false">
<ApiKeyForm
:initial="editTarget"
:submitting="submitting"
:error="formError"
@submit="submit"
@cancel="showForm = false"
/>
</Modal>

<Modal v-if="created" title="API key created" @close="dismissReveal">
<div class="space-y-4">
<div class="rounded-lg border border-amber-900/60 bg-amber-950/30 px-3.5 py-3 text-sm leading-relaxed text-amber-200">
Copy this key now — it's the only time it's shown. Store it somewhere safe; you won't be able to view it again.
</div>

<div>
<label class="mb-1 block text-sm text-slate-300">{{ created.name || 'Unnamed key' }}</label>
<div class="flex items-stretch gap-2">
<code class="min-w-0 flex-1 select-all break-all rounded-lg border border-slate-700 bg-slate-950 px-3 py-2.5 font-mono text-sm text-emerald-300">{{ created.key }}</code>
<button
class="shrink-0 rounded-lg bg-brand-600 px-4 text-sm font-medium text-white transition hover:bg-brand-500"
@click="copyKey"
>
{{ copied ? 'Copied' : 'Copy' }}
</button>
</div>
</div>

<div class="flex justify-end pt-1">
<button class="rounded-lg bg-slate-800 px-4 py-2 text-sm font-medium text-slate-100 transition hover:bg-slate-700" @click="dismissReveal">
Done
</button>
</div>
</div>
</Modal>

<Modal v-if="confirmTarget" title="Revoke API key" @close="closeConfirm">
<p class="text-sm text-slate-300">
Revoke <strong>{{ confirmTarget.name || `key #${confirmTarget.id}` }}</strong>? Any tool using this key
stops working immediately. This can't be undone.
</p>
<p v-if="revokeError" class="mt-3 rounded-lg border border-rose-900/60 bg-rose-950/30 p-3 text-sm text-rose-200">
{{ revokeError }}
</p>
<div class="mt-5 flex justify-end gap-2">
<button class="rounded-lg px-4 py-2 text-sm text-slate-400 hover:text-slate-100" @click="closeConfirm">
Cancel
</button>
<button
:disabled="revoking"
class="rounded-lg bg-rose-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-rose-500 disabled:opacity-50"
@click="confirmRevoke"
>
{{ revoking ? 'Revoking…' : 'Revoke' }}
</button>
</div>
</Modal>
</div>
</template>
Loading