Skip to content

Commit 0505a49

Browse files
authored
feat: new API keys (#962)
- Require opendatateam/udata#3636 <img width="2090" height="179" alt="image" src="https://github.com/user-attachments/assets/adb9240d-456f-4476-a0de-89fc4f620f17" /> <img width="1248" height="459" alt="image" src="https://github.com/user-attachments/assets/3ef4f09c-61da-4f53-a9bb-da422359115b" /> <img width="2095" height="329" alt="image" src="https://github.com/user-attachments/assets/2b943ad2-084a-402e-b4dd-0b8174c9b6cf" /> <img width="2092" height="418" alt="image" src="https://github.com/user-attachments/assets/8595708e-1c9f-40db-9e8f-200fe48260ec" /> <img width="902" height="325" alt="image" src="https://github.com/user-attachments/assets/2e872621-e0db-47ba-9de9-1fc5fbe9c3b2" />
1 parent 1ba54de commit 0505a49

File tree

6 files changed

+297
-98
lines changed

6 files changed

+297
-98
lines changed

components/User/AdminUserProfilePage.vue

Lines changed: 11 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -75,73 +75,6 @@
7575
{{ $t('Sauvegarder') }}
7676
</BrandedButton>
7777
</div>
78-
<div
79-
v-if="user.id === me.id"
80-
class="fr-input-group"
81-
>
82-
<label
83-
class="fr-label"
84-
:for="apiKeyId"
85-
>
86-
{{ $t(`Clé d'API`) }}
87-
<span
88-
v-if="user.apikey"
89-
class="fr-hint-text"
90-
>
91-
{{ $t(`Attention: Si vous supprimez votre clé d'API vous risquez de perdre l'accès aux services de {site}`, { site: config.public.title }) }}
92-
</span>
93-
</label>
94-
<div class="fr-grid-row fr-grid-row--gutters fr-grid-row--middle">
95-
<div class="fr-col-12 fr-col-sm">
96-
<div class="relative">
97-
<input
98-
:id="apiKeyId"
99-
:value="user.apikey"
100-
type="password"
101-
class="fr-input !pr-8"
102-
disabled
103-
>
104-
<div class="absolute right-1 top-1 !mt-0.5 !mr-0.5">
105-
<CopyButton
106-
v-if="user.apikey"
107-
:label="$t(`Copier la clé d'API`)"
108-
:copied-label="$t('Clé API copiée')"
109-
:text="user.apikey"
110-
reverse
111-
/>
112-
</div>
113-
</div>
114-
</div>
115-
<div class="fr-col-auto flex gap-4">
116-
<div class="flex-none">
117-
<BrandedButton
118-
color="secondary"
119-
size="xs"
120-
:disabled="loading"
121-
:icon="RiRecycleLine"
122-
@click="regenerateApiKey"
123-
>
124-
<span v-if="user.apikey">{{ $t('Regénérer') }}</span>
125-
<span v-else>{{ $t('Générer') }}</span>
126-
</BrandedButton>
127-
</div>
128-
<div
129-
v-if="user.apikey"
130-
class="flex-none"
131-
>
132-
<BrandedButton
133-
color="danger"
134-
size="xs"
135-
:disabled="loading"
136-
:icon="RiDeleteBin6Line"
137-
@click="deleteApiKey"
138-
>
139-
{{ $t('Supprimer') }}
140-
</BrandedButton>
141-
</div>
142-
</div>
143-
</div>
144-
</div>
14578
<div
14679
v-if="user.id === me.id"
14780
class="fr-input-group"
@@ -245,17 +178,26 @@
245178
</template>
246179
</BannerAction>
247180
</PaddedContainer>
181+
<template v-if="user.id === me.id">
182+
<h2 class="uppercase !text-sm !my-5">
183+
{{ $t("Clés d'API") }}
184+
</h2>
185+
<PaddedContainer class="!p-5">
186+
<ApiTokensSection />
187+
</PaddedContainer>
188+
</template>
248189
</div>
249190
</template>
250191

251192
<script setup lang="ts">
252-
import { BannerAction, BrandedButton, CopyButton, PaddedContainer, toast, SearchableSelect } from '@datagouv/components-next'
193+
import { BannerAction, BrandedButton, PaddedContainer, toast, SearchableSelect } from '@datagouv/components-next'
253194
import type { User } from '@datagouv/components-next'
254-
import { RiDeleteBin6Line, RiEditLine, RiRecycleLine, RiSaveLine } from '@remixicon/vue'
195+
import { RiEditLine, RiSaveLine } from '@remixicon/vue'
255196
import DeleteUserModal from './DeleteUserModal.vue'
256197
import ChangePasswordModal from './ChangePasswordModal.vue'
257198
import ChangeEmailModal from './ChangeEmailModal.vue'
258199
import TwoFactorSetupModal from './TwoFactorSetupModal.vue'
200+
import ApiTokensSection from './ApiTokensSection.vue'
259201
import { uploadProfilePicture } from '~/api/users'
260202
261203
const props = defineProps<{
@@ -271,7 +213,6 @@ const config = useNuxtApp().$config
271213
const { t } = useTranslation()
272214
const { $api } = useNuxtApp()
273215
274-
const apiKeyId = useId()
275216
const emailId = useId()
276217
const passwordId = useId()
277218
@@ -329,30 +270,4 @@ async function updateUser() {
329270
loading.value = false
330271
}
331272
}
332-
333-
async function regenerateApiKey() {
334-
loading.value = true
335-
try {
336-
await $api<{ apikey: string }>('/api/1/me/apikey', {
337-
method: 'POST',
338-
})
339-
loadMe(me)
340-
}
341-
finally {
342-
loading.value = false
343-
}
344-
}
345-
346-
async function deleteApiKey() {
347-
loading.value = true
348-
try {
349-
await $api('/api/1/me/apikey', {
350-
method: 'DELETE',
351-
})
352-
loadMe(me)
353-
}
354-
finally {
355-
loading.value = false
356-
}
357-
}
358273
</script>
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
<template>
2+
<div>
3+
<SimpleBanner
4+
v-if="newlyCreatedToken"
5+
type="warning"
6+
class="mb-4"
7+
>
8+
<div class="font-bold mb-1">
9+
{{ $t("Copiez ce token maintenant, il ne sera plus affiché.") }}
10+
</div>
11+
<div class="flex items-center gap-2">
12+
<code class="text-sm break-all">{{ newlyCreatedToken }}</code>
13+
<CopyButton
14+
:label="$t('Copier le token')"
15+
:copied-label="$t('Token copié')"
16+
:text="newlyCreatedToken"
17+
/>
18+
</div>
19+
</SimpleBanner>
20+
21+
<div
22+
v-if="tokens.length === 0 && !newlyCreatedToken"
23+
class="text-sm text-gray-500 mb-4"
24+
>
25+
{{ $t("Aucun token API. Créez-en un pour accéder à l'API.") }}
26+
</div>
27+
28+
<div
29+
v-for="token in tokens"
30+
:key="token.id"
31+
class="flex items-center justify-between gap-4 py-3 border-b border-gray-200 last:border-b-0"
32+
>
33+
<div class="min-w-0">
34+
<div class="text-sm truncate">
35+
<span class="font-medium">{{ token.name || `${token.token_prefix}…` }}</span>
36+
<span
37+
v-if="token.name"
38+
class="text-gray-400 ml-1"
39+
>{{ token.token_prefix }}…</span>
40+
</div>
41+
<div class="text-xs text-gray-500">
42+
<span>{{ $t('Créé {date}', { date: formatRelativeIfRecentDate(token.created_at) }) }}</span>
43+
<span v-if="token.last_used_at"> · {{ $t('Utilisé {date}', { date: formatRelativeIfRecentDate(token.last_used_at) }) }}</span>
44+
<span v-else> · {{ $t('Jamais utilisé') }}</span>
45+
<span v-if="token.expires_at"> · {{ $t('Expire le {date}', { date: formatDate(token.expires_at) }) }}</span>
46+
<template v-if="token.user_agents.length === 1">
47+
· {{ token.user_agents[0] }}
48+
</template>
49+
<template v-else-if="token.user_agents.length > 1">
50+
·
51+
<button
52+
class="underline hover:text-gray-700"
53+
type="button"
54+
@click="userAgentsModalList = token.user_agents"
55+
>
56+
{{ $t('{n} user agents', { n: token.user_agents.length }) }}
57+
</button>
58+
</template>
59+
</div>
60+
</div>
61+
<BrandedButton
62+
color="danger"
63+
size="xs"
64+
:icon="RiDeleteBin6Line"
65+
:disabled="revoking"
66+
icon-only
67+
@click="tokenToRevoke = token"
68+
/>
69+
</div>
70+
71+
<div class="mt-4">
72+
<CreateApiTokenModal @created="onTokenCreated" />
73+
</div>
74+
75+
<ModalClient
76+
:opened="!!tokenToRevoke"
77+
:title="$t('Révoquer ce token ?')"
78+
size="lg"
79+
@close="tokenToRevoke = null"
80+
>
81+
{{ $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}…` }) }}
82+
<template #footer>
83+
<div class="w-full flex justify-end gap-2">
84+
<BrandedButton
85+
color="secondary"
86+
:disabled="revoking"
87+
@click="tokenToRevoke = null"
88+
>
89+
{{ $t('Annuler') }}
90+
</BrandedButton>
91+
<BrandedButton
92+
color="danger"
93+
:icon="RiDeleteBin6Line"
94+
:loading="revoking"
95+
@click="revokeToken(tokenToRevoke!)"
96+
>
97+
{{ $t('Révoquer') }}
98+
</BrandedButton>
99+
</div>
100+
</template>
101+
</ModalClient>
102+
103+
<ModalClient
104+
:opened="!!userAgentsModalList"
105+
:title="$t('User agents')"
106+
size="lg"
107+
@close="userAgentsModalList = null"
108+
>
109+
<ul class="list-disc pl-5 space-y-1">
110+
<li
111+
v-for="(ua, i) in userAgentsModalList"
112+
:key="i"
113+
class="text-sm break-all"
114+
>
115+
{{ ua }}
116+
</li>
117+
</ul>
118+
</ModalClient>
119+
</div>
120+
</template>
121+
122+
<script setup lang="ts">
123+
import { BrandedButton, CopyButton, SimpleBanner, toast, useFormatDate } from '@datagouv/components-next'
124+
import { RiDeleteBin6Line } from '@remixicon/vue'
125+
import type { ApiToken, ApiTokenCreated } from '~/types/api-tokens'
126+
import CreateApiTokenModal from './CreateApiTokenModal.vue'
127+
import ModalClient from '~/components/Modal/Modal.client.vue'
128+
129+
const { t } = useTranslation()
130+
const { $api } = useNuxtApp()
131+
const { formatDate, formatRelativeIfRecentDate } = useFormatDate()
132+
133+
const tokens = ref<ApiToken[]>([])
134+
const newlyCreatedToken = ref<string | null>(null)
135+
const revoking = ref(false)
136+
const tokenToRevoke = ref<ApiToken | null>(null)
137+
const userAgentsModalList = ref<string[] | null>(null)
138+
139+
async function fetchTokens() {
140+
tokens.value = await $api<ApiToken[]>('/api/1/me/api_tokens/')
141+
}
142+
143+
async function onTokenCreated(created: ApiTokenCreated) {
144+
newlyCreatedToken.value = created.token
145+
await fetchTokens()
146+
}
147+
148+
async function revokeToken(token: ApiToken) {
149+
revoking.value = true
150+
try {
151+
await $api(`/api/1/me/api_tokens/${token.id}/`, {
152+
method: 'DELETE',
153+
})
154+
toast.success(t('Token révoqué.'))
155+
tokenToRevoke.value = null
156+
await fetchTokens()
157+
}
158+
catch {
159+
toast.error(t('Impossible de révoquer le token.'))
160+
}
161+
finally {
162+
revoking.value = false
163+
}
164+
}
165+
166+
await fetchTokens()
167+
</script>

0 commit comments

Comments
 (0)