-
Notifications
You must be signed in to change notification settings - Fork 0
feat(ui): add Settings tab with API key management #46
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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())) | ||
|
|
||
| // 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> | ||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||||||||||||||||||
| } | ||||||||||||||||||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
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! |
||||||||||||||||||
| <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> | ||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nowLocalis acomputed()with no reactive dependencies —new Date()is evaluated once at component mount and never again. Theminattribute 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 HTMLminconstraint. ThecustomInFutureguard at submission time catches this, but the staleminmakes the browser's native date-picker mislead the user about what dates are selectable.