Skip to content
Open
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
107 changes: 11 additions & 96 deletions components/User/AdminUserProfilePage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -75,73 +75,6 @@
{{ $t('Sauvegarder') }}
</BrandedButton>
</div>
<div
v-if="user.id === me.id"
class="fr-input-group"
>
<label
class="fr-label"
:for="apiKeyId"
>
{{ $t(`Clé d'API`) }}
<span
v-if="user.apikey"
class="fr-hint-text"
>
{{ $t(`Attention: Si vous supprimez votre clé d'API vous risquez de perdre l'accès aux services de {site}`, { site: config.public.title }) }}
</span>
</label>
<div class="fr-grid-row fr-grid-row--gutters fr-grid-row--middle">
<div class="fr-col-12 fr-col-sm">
<div class="relative">
<input
:id="apiKeyId"
:value="user.apikey"
type="password"
class="fr-input !pr-8"
disabled
>
<div class="absolute right-1 top-1 !mt-0.5 !mr-0.5">
<CopyButton
v-if="user.apikey"
:label="$t(`Copier la clé d'API`)"
:copied-label="$t('Clé API copiée')"
:text="user.apikey"
reverse
/>
</div>
</div>
</div>
<div class="fr-col-auto flex gap-4">
<div class="flex-none">
<BrandedButton
color="secondary"
size="xs"
:disabled="loading"
:icon="RiRecycleLine"
@click="regenerateApiKey"
>
<span v-if="user.apikey">{{ $t('Regénérer') }}</span>
<span v-else>{{ $t('Générer') }}</span>
</BrandedButton>
</div>
<div
v-if="user.apikey"
class="flex-none"
>
<BrandedButton
color="danger"
size="xs"
:disabled="loading"
:icon="RiDeleteBin6Line"
@click="deleteApiKey"
>
{{ $t('Supprimer') }}
</BrandedButton>
</div>
</div>
</div>
</div>
<div
v-if="user.id === me.id"
class="fr-input-group"
Expand Down Expand Up @@ -245,17 +178,26 @@
</template>
</BannerAction>
</PaddedContainer>
<template v-if="user.id === me.id">
<h2 class="uppercase !text-sm !my-5">
{{ $t("Clés d'API") }}
</h2>
<PaddedContainer class="!p-5">
<ApiTokensSection />
</PaddedContainer>
</template>
</div>
</template>

<script setup lang="ts">
import { BannerAction, BrandedButton, CopyButton, PaddedContainer, toast, SearchableSelect } from '@datagouv/components-next'
import { BannerAction, BrandedButton, PaddedContainer, toast, SearchableSelect } from '@datagouv/components-next'
import type { User } from '@datagouv/components-next'
import { RiDeleteBin6Line, RiEditLine, RiRecycleLine, RiSaveLine } from '@remixicon/vue'
import { RiEditLine, RiSaveLine } from '@remixicon/vue'
import DeleteUserModal from './DeleteUserModal.vue'
import ChangePasswordModal from './ChangePasswordModal.vue'
import ChangeEmailModal from './ChangeEmailModal.vue'
import TwoFactorSetupModal from './TwoFactorSetupModal.vue'
import ApiTokensSection from './ApiTokensSection.vue'
import { uploadProfilePicture } from '~/api/users'

const props = defineProps<{
Expand All @@ -271,7 +213,6 @@ const config = useNuxtApp().$config
const { t } = useTranslation()
const { $api } = useNuxtApp()

const apiKeyId = useId()
const emailId = useId()
const passwordId = useId()

Expand Down Expand Up @@ -329,30 +270,4 @@ async function updateUser() {
loading.value = false
}
}

async function regenerateApiKey() {
loading.value = true
try {
await $api<{ apikey: string }>('/api/1/me/apikey', {
method: 'POST',
})
loadMe(me)
}
finally {
loading.value = false
}
}

async function deleteApiKey() {
loading.value = true
try {
await $api('/api/1/me/apikey', {
method: 'DELETE',
})
loadMe(me)
}
finally {
loading.value = false
}
}
</script>
163 changes: 163 additions & 0 deletions components/User/ApiTokensSection.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
<template>
<div>
<SimpleBanner
v-if="newlyCreatedToken"
type="warning"
class="mb-4"
>
<div class="font-bold mb-1">
{{ $t("Copiez ce token maintenant, il ne sera plus affiché.") }}
</div>
<div class="flex items-center gap-2">
<code class="text-sm break-all">{{ newlyCreatedToken }}</code>
<CopyButton
:label="$t('Copier le token')"
:copied-label="$t('Token copié')"
:text="newlyCreatedToken"
/>
</div>
</SimpleBanner>

<div
v-if="tokens.length === 0 && !newlyCreatedToken"
class="text-sm text-gray-500 mb-4"
>
{{ $t("Aucun token API. Créez-en un pour accéder à l'API.") }}
</div>

<div
v-for="token in tokens"
:key="token.id"
class="flex items-center justify-between gap-4 py-3 border-b border-gray-200 last:border-b-0"
>
<div class="min-w-0">
<div class="text-sm truncate">
<span class="font-medium">{{ token.name || `${token.token_prefix}…` }}</span>
<span
v-if="token.name"
class="text-gray-400 ml-1"
>{{ token.token_prefix }}…</span>
</div>
<div class="text-xs text-gray-500">
<span>{{ $t('Créé {date}', { date: formatRelativeIfRecentDate(token.created_at) }) }}</span>
<span v-if="token.last_used_at"> · {{ $t('Utilisé {date}', { date: formatRelativeIfRecentDate(token.last_used_at) }) }}</span>
<span v-else> · {{ $t('Jamais utilisé') }}</span>
<template v-if="token.user_agents.length === 1">
· {{ token.user_agents[0] }}
</template>
<template v-else-if="token.user_agents.length > 1">
·
<button
class="underline hover:text-gray-700"
type="button"
@click="userAgentsModalList = token.user_agents"
>
{{ $t('{n} user agents', { n: token.user_agents.length }) }}
</button>
</template>
</div>
</div>
<BrandedButton
color="danger"
size="xs"
:icon="RiDeleteBin6Line"
:disabled="!!revoking"
icon-only
@click="tokenToRevoke = token"
/>
</div>

<div class="mt-4">
<CreateApiTokenModal @created="onTokenCreated" />
</div>

<ModalClient
:opened="!!tokenToRevoke"
:title="$t('Révoquer ce token ?')"
size="lg"
@close="tokenToRevoke = null"
>
{{ $t('Le token {name} sera définitivement révoqué. Les applications qui l\'utilisent ne pourront plus accéder à l\'API.', { name: tokenToRevoke?.name || `${tokenToRevoke?.token_prefix}…` }) }}
<template #footer>
<div class="w-full flex justify-end gap-2">
<BrandedButton
color="secondary"
:disabled="!!revoking"
@click="tokenToRevoke = null"
>
{{ $t('Annuler') }}
</BrandedButton>
<BrandedButton
color="danger"
:icon="RiDeleteBin6Line"
:loading="!!revoking"
@click="revokeToken(tokenToRevoke!)"
>
{{ $t('Révoquer') }}
</BrandedButton>
</div>
</template>
</ModalClient>

<ModalClient
:opened="!!userAgentsModalList"
:title="$t('User agents')"
size="lg"
@close="userAgentsModalList = null"
>
<ul class="list-disc pl-5 space-y-1">
<li
v-for="(ua, i) in userAgentsModalList"
:key="i"
class="text-sm break-all"
>
{{ ua }}
</li>
</ul>
</ModalClient>
</div>
</template>

<script setup lang="ts">
import { BrandedButton, CopyButton, SimpleBanner, toast, useFormatDate } from '@datagouv/components-next'
import { RiDeleteBin6Line } from '@remixicon/vue'
import type { ApiToken, ApiTokenCreated } from '~/types/api-tokens'
import CreateApiTokenModal from './CreateApiTokenModal.vue'
import ModalClient from '~/components/Modal/Modal.client.vue'

const { t } = useTranslation()
const { $api } = useNuxtApp()
const { formatRelativeIfRecentDate } = useFormatDate()

const tokens = ref<ApiToken[]>([])
const newlyCreatedToken = ref<string | null>(null)
const revoking = ref<string | null>(null)
const tokenToRevoke = ref<ApiToken | null>(null)
const userAgentsModalList = ref<string[] | null>(null)

async function fetchTokens() {
tokens.value = await $api<ApiToken[]>('/api/1/me/tokens/')
}

async function onTokenCreated(created: ApiTokenCreated) {
newlyCreatedToken.value = created.token
await fetchTokens()
}

async function revokeToken(token: ApiToken) {
revoking.value = token.id
try {
await $api(`/api/1/me/tokens/${token.id}/`, {
method: 'DELETE',
})
toast.success(t('Token révoqué.'))
tokenToRevoke.value = null
await fetchTokens()
}
finally {
revoking.value = null
}
}

await fetchTokens()
</script>
92 changes: 92 additions & 0 deletions components/User/CreateApiTokenModal.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
<template>
<ModalWithButton
v-model="isOpen"
:title="$t('Créer un token API')"
size="lg"
form
@submit.prevent="(_$el, _close) => submit(_close)"
>
<template #button="{ attrs, listeners }">
<BrandedButton
color="secondary"
size="xs"
:icon="RiAddLine"
:disabled="loading"
v-bind="attrs"
v-on="listeners"
>
{{ $t('Créer un token') }}
</BrandedButton>
</template>

<template #default>
<InputGroup
v-model="name"
:label="$t('Nom (optionnel)')"
:hint-text="$t('Un nom pour identifier ce token, par exemple « CI/CD » ou « Mon script »')"
/>
</template>

<template #footer="{ close: _close }">
<div class="fr-grid-row fr-grid-row--gutters fr-grid-row--right">
<div class="fr-col-auto">
<BrandedButton
color="secondary"
:disabled="loading"
@click="_close"
>
{{ $t('Annuler') }}
</BrandedButton>
</div>
<div class="fr-col-auto">
<BrandedButton
type="submit"
color="primary"
:loading="loading"
>
{{ $t('Créer') }}
</BrandedButton>
</div>
</div>
</template>
</ModalWithButton>
</template>

<script setup lang="ts">
import { BrandedButton, toast } from '@datagouv/components-next'
import { RiAddLine } from '@remixicon/vue'
import type { ApiTokenCreated } from '~/types/api-tokens'
import InputGroup from '~/components/InputGroup/InputGroup.vue'

const { t } = useTranslation()
const { $api } = useNuxtApp()

const emit = defineEmits<{
created: [token: ApiTokenCreated]
}>()

const isOpen = ref(false)
const loading = ref(false)
const name = ref('')

async function submit(close: () => void) {
loading.value = true
try {
const body: Record<string, string> = {}
if (name.value.trim()) {
body.name = name.value.trim()
}
const result = await $api<ApiTokenCreated>('/api/1/me/tokens/', {
method: 'POST',
body,
})
toast.success(t('Token créé avec succès.'))
name.value = ''
close()
emit('created', result)
}
finally {
loading.value = false
}
}
</script>
Loading
Loading