feat(KAN-37): rebuild security and application key settings
This commit is contained in:
@@ -1,198 +1,318 @@
|
||||
<script setup lang="ts">
|
||||
import { Form, Head } from '@inertiajs/vue3';
|
||||
import UserApplicationController from '@/actions/App/Http/Controllers/Settings/UserApplicationController';
|
||||
import Heading from '@/components/Heading.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Head, useForm } from '@inertiajs/vue3';
|
||||
import { Check, Copy, KeyRound, Trash2 } from 'lucide-vue-next';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
destroy,
|
||||
store,
|
||||
} from '@/actions/App/Http/Controllers/Settings/UserApplicationController';
|
||||
import AuthFormFeedback from '@/components/AuthFormFeedback.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useFormProtection } from '@/composables/useFormProtection';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import SettingsLayout from '@/layouts/settings/Layout.vue';
|
||||
import { edit as editApplications } from '@/routes/applications/index';
|
||||
import { edit } from '@/routes/applications/index';
|
||||
import type { BreadcrumbItem, SettingsApplicationsPageProps } from '@/types';
|
||||
|
||||
defineProps<SettingsApplicationsPageProps>();
|
||||
const props = defineProps<SettingsApplicationsPageProps>();
|
||||
const { t, locale } = useLocalization();
|
||||
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
||||
{ title: t('navigation.applications'), href: edit() },
|
||||
]);
|
||||
const form = useForm({ name: '' });
|
||||
const protection = useFormProtection(form);
|
||||
const deleteForm = useForm({});
|
||||
const deleteProtection = useFormProtection(deleteForm);
|
||||
const selected = ref<
|
||||
SettingsApplicationsPageProps['applications'][number] | null
|
||||
>(null);
|
||||
const copied = ref(false);
|
||||
const copyError = ref(false);
|
||||
|
||||
const breadcrumbItems: BreadcrumbItem[] = [
|
||||
{
|
||||
title: 'Applications',
|
||||
href: editApplications(),
|
||||
},
|
||||
];
|
||||
|
||||
const formatDate = (value: string | null): string => {
|
||||
if (value === null) {
|
||||
return 'Never';
|
||||
function formatDate(value: string | null): string {
|
||||
if (!value) {
|
||||
return t('applications.never');
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
return new Intl.DateTimeFormat(locale.value, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
}).format(new Date(value));
|
||||
};
|
||||
}
|
||||
function createApplication(): void {
|
||||
protection.submit(store(), {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
form.reset();
|
||||
},
|
||||
});
|
||||
}
|
||||
function closeDelete(open: boolean): void {
|
||||
if (!open && !deleteForm.processing) {
|
||||
selected.value = null;
|
||||
deleteProtection.clearFeedback();
|
||||
}
|
||||
}
|
||||
function deleteApplication(): void {
|
||||
if (!selected.value) {
|
||||
return;
|
||||
}
|
||||
deleteProtection.submit(destroy(selected.value.id), {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
selected.value = null;
|
||||
},
|
||||
});
|
||||
}
|
||||
async function copyKey(): Promise<void> {
|
||||
const key = props.applicationKey;
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
copyError.value = false;
|
||||
try {
|
||||
await navigator.clipboard.writeText(key);
|
||||
if (props.applicationKey === key) {
|
||||
copied.value = true;
|
||||
}
|
||||
} catch {
|
||||
if (props.applicationKey === key) {
|
||||
copyError.value = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
watch(
|
||||
() => props.applicationKey,
|
||||
() => {
|
||||
copied.value = false;
|
||||
copyError.value = false;
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout :breadcrumbs="breadcrumbItems">
|
||||
<Head title="Applications" />
|
||||
|
||||
<AppLayout :breadcrumbs="breadcrumbs">
|
||||
<Head :title="t('applications.title')" />
|
||||
<SettingsLayout>
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="space-y-2">
|
||||
<Heading
|
||||
variant="small"
|
||||
title="Applications"
|
||||
description="Create API keys for tools that should use KanbanGit with your account permissions."
|
||||
/>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
Keys are shown once. Delete an application when a tool
|
||||
should lose access.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="status"
|
||||
class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100"
|
||||
>
|
||||
{{ status }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="applicationKey"
|
||||
class="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-950 dark:border-amber-900/70 dark:bg-amber-950/30 dark:text-amber-100"
|
||||
>
|
||||
<p class="font-medium">Save this API key now.</p>
|
||||
<p class="mt-1">
|
||||
It will not be shown again after you leave this page.
|
||||
</p>
|
||||
<div class="gap-stack grid">
|
||||
<h2 class="text-title">{{ t('applications.title') }}</h2>
|
||||
<p class="text-muted-foreground">
|
||||
{{ t('applications.description') }}
|
||||
</p>
|
||||
</div>
|
||||
<p
|
||||
v-if="status"
|
||||
role="status"
|
||||
class="bg-success/10 text-success-foreground rounded-lg border p-4 text-sm"
|
||||
>
|
||||
{{ status }}
|
||||
</p>
|
||||
<section
|
||||
v-if="applicationKey"
|
||||
class="bg-warning/10 gap-stack grid min-w-0 rounded-xl border p-5"
|
||||
aria-labelledby="application-key-title"
|
||||
>
|
||||
<h3 id="application-key-title" class="font-semibold">
|
||||
{{ t('applications.saveKey') }}
|
||||
</h3>
|
||||
<p class="text-body text-muted-foreground">
|
||||
{{ t('applications.keyOnce') }}
|
||||
</p>
|
||||
<Label for="application-key" class="sr-only">{{
|
||||
t('applications.apiKey')
|
||||
}}</Label>
|
||||
<div class="flex min-w-0 flex-wrap gap-2">
|
||||
<Input
|
||||
class="mt-3 font-mono text-xs"
|
||||
id="application-key"
|
||||
:model-value="applicationKey"
|
||||
readonly
|
||||
class="min-w-0 basis-full font-mono text-xs sm:flex-1 sm:basis-0"
|
||||
/>
|
||||
<Button variant="outline" @click="copyKey"
|
||||
><Check v-if="copied" /><Copy v-else />{{
|
||||
t(copied ? 'security.copied' : 'security.copyKey')
|
||||
}}</Button
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 xl:grid-cols-[24rem_minmax(0,1fr)]">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Create application</CardTitle>
|
||||
<CardDescription>
|
||||
Name the tool that will receive an API key.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<Form
|
||||
v-bind="UserApplicationController.store.form()"
|
||||
class="space-y-4"
|
||||
v-slot="{ errors, processing }"
|
||||
<p
|
||||
v-if="copied"
|
||||
role="status"
|
||||
class="text-caption text-success-foreground"
|
||||
>
|
||||
{{ t('security.copied') }}
|
||||
</p>
|
||||
<InputError
|
||||
v-if="copyError"
|
||||
:message="t('security.copyError')"
|
||||
/>
|
||||
</section>
|
||||
<Card class="gap-0 overflow-hidden rounded-xl py-0 shadow-none">
|
||||
<CardHeader class="gap-stack border-b p-5 sm:p-6">
|
||||
<h3 class="flex items-center gap-2 font-semibold">
|
||||
<KeyRound class="text-primary size-5" />{{
|
||||
t('applications.create')
|
||||
}}
|
||||
</h3>
|
||||
<p class="text-muted-foreground">
|
||||
{{ t('applications.nameDescription') }}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent class="p-5 sm:p-6">
|
||||
<form
|
||||
class="gap-column grid"
|
||||
:aria-busy="form.processing"
|
||||
@submit.prevent="createApplication"
|
||||
>
|
||||
<AuthFormFeedback
|
||||
:state="protection.state.value"
|
||||
:response-status="protection.responseStatus.value"
|
||||
:loading-title="t('applications.creating')"
|
||||
@retry="createApplication"
|
||||
/>
|
||||
<div class="grid gap-2">
|
||||
<Label for="application-name">{{
|
||||
t('applications.name')
|
||||
}}</Label>
|
||||
<Input
|
||||
id="application-name"
|
||||
v-model="form.name"
|
||||
name="name"
|
||||
required
|
||||
maxlength="255"
|
||||
autocomplete="off"
|
||||
:placeholder="t('applications.namePlaceholder')"
|
||||
:disabled="form.processing"
|
||||
:aria-invalid="!!form.errors.name"
|
||||
aria-describedby="application-name-error"
|
||||
/>
|
||||
<InputError
|
||||
id="application-name-error"
|
||||
:message="form.errors.name"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
class="justify-self-start"
|
||||
:loading="form.processing"
|
||||
:loading-label="t('applications.creating')"
|
||||
>{{ t('applications.create') }}</Button
|
||||
>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card class="gap-0 overflow-hidden rounded-xl py-0 shadow-none">
|
||||
<CardHeader class="gap-stack border-b p-5 sm:p-6">
|
||||
<h3 class="font-semibold">{{ t('applications.saved') }}</h3>
|
||||
<p class="text-muted-foreground">
|
||||
{{ t('applications.hiddenKeys') }}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent class="gap-stack grid p-5 sm:p-6">
|
||||
<p
|
||||
v-if="!applications.length"
|
||||
class="text-body text-muted-foreground rounded-lg border border-dashed p-6 text-center"
|
||||
>
|
||||
{{ t('applications.empty') }}
|
||||
</p>
|
||||
<article
|
||||
v-for="application in applications"
|
||||
:key="application.id"
|
||||
class="flex min-w-0 flex-wrap items-start justify-between gap-4 rounded-lg border p-4"
|
||||
>
|
||||
<div class="gap-stack grid min-w-0 flex-1 basis-40">
|
||||
<h4 class="break-words font-semibold">
|
||||
{{ application.name }}
|
||||
</h4>
|
||||
<dl
|
||||
class="gap-stack text-caption text-muted-foreground grid"
|
||||
>
|
||||
<div class="grid gap-2">
|
||||
<Label for="name">Application name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
required
|
||||
autofocus
|
||||
placeholder="Claude Desktop"
|
||||
/>
|
||||
<InputError :message="errors.name" />
|
||||
<div>
|
||||
<dt class="inline font-medium">
|
||||
{{ t('applications.created') }}
|
||||
</dt>
|
||||
<dd class="inline">
|
||||
{{ formatDate(application.created_at) }}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<Button type="submit" :disabled="processing">
|
||||
Create application
|
||||
</Button>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card class="gap-0 overflow-hidden">
|
||||
<CardHeader class="border-b">
|
||||
<CardTitle>Saved applications</CardTitle>
|
||||
<CardDescription>
|
||||
Existing keys stay hidden. Create a new
|
||||
application when another key is needed.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent class="space-y-4 p-6">
|
||||
<div
|
||||
v-if="applications.length === 0"
|
||||
class="border-border text-muted-foreground rounded-xl border border-dashed px-4 py-6 text-sm"
|
||||
>
|
||||
No applications have been created yet.
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="application in applications"
|
||||
:key="application.id"
|
||||
class="border-border rounded-xl border p-4"
|
||||
>
|
||||
<div
|
||||
class="flex flex-wrap items-start justify-between gap-4"
|
||||
>
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-2"
|
||||
>
|
||||
<Badge>API key</Badge>
|
||||
<span class="text-sm font-medium">
|
||||
{{ application.name }}
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
class="text-muted-foreground text-xs"
|
||||
>
|
||||
Created:
|
||||
{{
|
||||
formatDate(
|
||||
application.created_at,
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<p
|
||||
class="text-muted-foreground text-xs"
|
||||
>
|
||||
Last used:
|
||||
{{
|
||||
formatDate(
|
||||
application.last_used_at,
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Form
|
||||
v-bind="
|
||||
UserApplicationController.destroy.form(
|
||||
application.id,
|
||||
)
|
||||
"
|
||||
v-slot="{ processing }"
|
||||
>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
:disabled="processing"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</Form>
|
||||
<div>
|
||||
<dt class="inline font-medium">
|
||||
{{ t('applications.lastUsed') }}
|
||||
</dt>
|
||||
<dd class="inline">
|
||||
{{
|
||||
formatDate(application.last_used_at)
|
||||
}}
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="text-destructive"
|
||||
:aria-label="
|
||||
t('applications.deleteNamed', {
|
||||
name: application.name,
|
||||
})
|
||||
"
|
||||
@click="selected = application"
|
||||
><Trash2 />{{ t('applications.delete') }}</Button
|
||||
>
|
||||
</article>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Dialog :open="!!selected" @update:open="closeDelete">
|
||||
<DialogContent
|
||||
:show-close-button="!deleteForm.processing"
|
||||
@escape-key-down="
|
||||
deleteForm.processing && $event.preventDefault()
|
||||
"
|
||||
@interact-outside="
|
||||
deleteForm.processing && $event.preventDefault()
|
||||
"
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{
|
||||
t('applications.deleteTitle')
|
||||
}}</DialogTitle>
|
||||
<DialogDescription>{{
|
||||
t('applications.deleteDescription', {
|
||||
name: selected?.name ?? '',
|
||||
})
|
||||
}}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<AuthFormFeedback
|
||||
:state="deleteProtection.state.value"
|
||||
:response-status="deleteProtection.responseStatus.value"
|
||||
@retry="deleteApplication"
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
:disabled="deleteForm.processing"
|
||||
@click="closeDelete(false)"
|
||||
>{{ t('common.cancel') }}</Button
|
||||
>
|
||||
<Button
|
||||
variant="destructive"
|
||||
:loading="deleteForm.processing"
|
||||
@click="deleteApplication"
|
||||
>{{ t('applications.delete') }}</Button
|
||||
>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</SettingsLayout>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user