mirror of
https://github.com/seanmorley15/AdventureLog.git
synced 2026-03-24 17:22:10 -04:00
feat: add API key management to settings page
- Implemented API key creation, deletion, and display functionality. - Updated the settings page to fetch and show existing API keys. - Added UI elements for creating new API keys and copying them to clipboard. - Enhanced request handling to ensure proper trailing slashes for API endpoints.
This commit is contained in:
@@ -37,4 +37,21 @@ class DisableCSRFForMobileLoginSignup(MiddlewareMixin):
|
||||
is_login_or_signup = request.path in ['/auth/browser/v1/auth/login', '/auth/browser/v1/auth/signup']
|
||||
if is_mobile and is_login_or_signup:
|
||||
setattr(request, '_dont_enforce_csrf_checks', True)
|
||||
|
||||
|
||||
class DisableCSRFForAPIKeyMiddleware(MiddlewareMixin):
|
||||
"""Exempt requests carrying an AdventureLog API key from CSRF enforcement.
|
||||
|
||||
DRF's own SessionAuthentication is the only built-in class that enforces
|
||||
CSRF, so this middleware is mainly a safety net for non-DRF views and to
|
||||
ensure the Django CSRF middleware itself doesn't reject API-key requests
|
||||
before they reach DRF.
|
||||
"""
|
||||
|
||||
def process_request(self, request):
|
||||
if request.headers.get('X-API-Key'):
|
||||
setattr(request, '_dont_enforce_csrf_checks', True)
|
||||
return
|
||||
|
||||
auth_header = request.headers.get('Authorization', '')
|
||||
if auth_header.lower().startswith('api-key '):
|
||||
setattr(request, '_dont_enforce_csrf_checks', True)
|
||||
|
||||
@@ -83,6 +83,7 @@ MIDDLEWARE = (
|
||||
'whitenoise.middleware.WhiteNoiseMiddleware',
|
||||
'adventures.middleware.XSessionTokenMiddleware',
|
||||
'adventures.middleware.DisableCSRFForSessionTokenMiddleware',
|
||||
'adventures.middleware.DisableCSRFForAPIKeyMiddleware',
|
||||
'adventures.middleware.DisableCSRFForMobileLoginSignup',
|
||||
'corsheaders.middleware.CorsMiddleware',
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
@@ -318,6 +319,7 @@ ACCOUNT_RATE_LIMITS = {
|
||||
# ---------------------------------------------------------------------------
|
||||
REST_FRAMEWORK = {
|
||||
'DEFAULT_AUTHENTICATION_CLASSES': (
|
||||
'users.authentication.APIKeyAuthentication',
|
||||
'rest_framework.authentication.SessionAuthentication',
|
||||
),
|
||||
'DEFAULT_SCHEMA_CLASS': 'rest_framework.schemas.coreapi.AutoSchema',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from django.urls import include, re_path, path
|
||||
from django.contrib import admin
|
||||
from django.views.generic import RedirectView, TemplateView
|
||||
from users.views import IsRegistrationDisabled, PublicUserListView, PublicUserDetailView, UserMetadataView, UpdateUserMetadataView, EnabledSocialProvidersView, DisablePasswordAuthenticationView
|
||||
from users.views import IsRegistrationDisabled, PublicUserListView, PublicUserDetailView, UserMetadataView, UpdateUserMetadataView, EnabledSocialProvidersView, DisablePasswordAuthenticationView, APIKeyListCreateView, APIKeyDetailView
|
||||
from .views import get_csrf_token, get_public_url, serve_protected_media
|
||||
from drf_yasg.views import get_schema_view
|
||||
from drf_yasg import openapi
|
||||
@@ -31,6 +31,10 @@ urlpatterns = [
|
||||
|
||||
path('auth/disable-password/', DisablePasswordAuthenticationView.as_view(), name='disable-password-authentication'),
|
||||
|
||||
# API key management
|
||||
path('auth/api-keys/', APIKeyListCreateView.as_view(), name='api-key-list-create'),
|
||||
path('auth/api-keys/<uuid:pk>/', APIKeyDetailView.as_view(), name='api-key-detail'),
|
||||
|
||||
path('csrf/', get_csrf_token, name='get_csrf_token'),
|
||||
path('public-url/', get_public_url, name='get_public_url'),
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from django.contrib import admin
|
||||
from allauth.account.decorators import secure_admin_login
|
||||
from django.contrib.sessions.models import Session
|
||||
from users.models import APIKey
|
||||
|
||||
admin.autodiscover()
|
||||
admin.site.login = secure_admin_login(admin.site.login)
|
||||
@@ -10,4 +11,5 @@ class SessionAdmin(admin.ModelAdmin):
|
||||
return obj.get_decoded()
|
||||
list_display = ['session_key', '_session_data', 'expire_date']
|
||||
|
||||
admin.site.register(APIKey)
|
||||
admin.site.register(Session, SessionAdmin)
|
||||
52
backend/server/users/authentication.py
Normal file
52
backend/server/users/authentication.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""
|
||||
Custom DRF authentication backend for AdventureLog API keys.
|
||||
|
||||
Clients may supply their key via either of these headers:
|
||||
|
||||
Authorization: Api-Key al_xxxxxxxxxxxxxxxx...
|
||||
X-API-Key: al_xxxxxxxxxxxxxxxx...
|
||||
|
||||
Session-based CSRF enforcement is performed by DRF's built-in
|
||||
``SessionAuthentication`` class only. Requests authenticated via this
|
||||
class are never subject to CSRF checks, which is the correct behaviour
|
||||
for token-based API access.
|
||||
"""
|
||||
|
||||
from rest_framework.authentication import BaseAuthentication
|
||||
from rest_framework.exceptions import AuthenticationFailed
|
||||
|
||||
|
||||
class APIKeyAuthentication(BaseAuthentication):
|
||||
"""Authenticate a request using an AdventureLog API key."""
|
||||
|
||||
def authenticate(self, request):
|
||||
raw_key = self._extract_key(request)
|
||||
if raw_key is None:
|
||||
# Signal to DRF that this scheme was not attempted so other
|
||||
# authenticators can still run.
|
||||
return None
|
||||
|
||||
from .models import APIKey
|
||||
|
||||
api_key = APIKey.authenticate(raw_key)
|
||||
if api_key is None:
|
||||
raise AuthenticationFailed("Invalid or expired API key.")
|
||||
|
||||
return (api_key.user, api_key)
|
||||
|
||||
def authenticate_header(self, request):
|
||||
return "Api-Key"
|
||||
|
||||
@staticmethod
|
||||
def _extract_key(request) -> str | None:
|
||||
# Prefer X-API-Key header for simplicity.
|
||||
key = request.META.get("HTTP_X_API_KEY")
|
||||
if key:
|
||||
return key.strip()
|
||||
|
||||
# Also accept "Authorization: Api-Key <token>"
|
||||
auth_header = request.META.get("HTTP_AUTHORIZATION", "")
|
||||
if auth_header.lower().startswith("api-key "):
|
||||
return auth_header[8:].strip()
|
||||
|
||||
return None
|
||||
31
backend/server/users/migrations/0007_apikey.py
Normal file
31
backend/server/users/migrations/0007_apikey.py
Normal file
@@ -0,0 +1,31 @@
|
||||
# Generated by Django 5.2.11 on 2026-03-16 18:54
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('users', '0006_customuser_default_currency'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='APIKey',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('name', models.CharField(max_length=100)),
|
||||
('key_prefix', models.CharField(editable=False, max_length=12)),
|
||||
('key_hash', models.CharField(editable=False, max_length=64, unique=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('last_used_at', models.DateTimeField(blank=True, null=True)),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='api_keys', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -1,3 +1,5 @@
|
||||
import hashlib
|
||||
import secrets
|
||||
import uuid
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
from django.db import models
|
||||
@@ -38,4 +40,69 @@ class CustomUser(AbstractUser):
|
||||
|
||||
|
||||
def __str__(self):
|
||||
return self.username
|
||||
return self.username
|
||||
|
||||
|
||||
class APIKey(models.Model):
|
||||
"""
|
||||
Personal API keys for authenticating programmatic access.
|
||||
|
||||
Security design:
|
||||
- A 32-byte cryptographically random token is generated with the prefix ``al_``.
|
||||
- Only a SHA-256 hash of the full token is persisted; the plaintext is returned
|
||||
exactly once at creation time and never stored.
|
||||
- The first 12 characters of the token are kept as ``key_prefix`` so users can
|
||||
identify their keys without revealing the secret.
|
||||
"""
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
user = models.ForeignKey(
|
||||
CustomUser, on_delete=models.CASCADE, related_name='api_keys'
|
||||
)
|
||||
name = models.CharField(max_length=100)
|
||||
key_prefix = models.CharField(max_length=12, editable=False)
|
||||
key_hash = models.CharField(max_length=64, unique=True, editable=False)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
last_used_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-created_at']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user.username} – {self.name} ({self.key_prefix}…)"
|
||||
|
||||
@classmethod
|
||||
def generate(cls, user, name: str) -> tuple['APIKey', str]:
|
||||
"""
|
||||
Create a new APIKey for *user* with the given *name*.
|
||||
|
||||
Returns a ``(instance, raw_key)`` tuple. The raw key is shown to the
|
||||
user once and must never be stored anywhere after that.
|
||||
"""
|
||||
raw_key = f"al_{secrets.token_urlsafe(32)}"
|
||||
key_hash = hashlib.sha256(raw_key.encode()).hexdigest()
|
||||
key_prefix = raw_key[:12]
|
||||
instance = cls.objects.create(
|
||||
user=user,
|
||||
name=name,
|
||||
key_prefix=key_prefix,
|
||||
key_hash=key_hash,
|
||||
)
|
||||
return instance, raw_key
|
||||
|
||||
@classmethod
|
||||
def authenticate(cls, raw_key: str):
|
||||
"""
|
||||
Look up an APIKey by its raw value.
|
||||
|
||||
Returns the matching ``APIKey`` instance (updating ``last_used_at``) or
|
||||
``None`` if not found.
|
||||
"""
|
||||
key_hash = hashlib.sha256(raw_key.encode()).hexdigest()
|
||||
try:
|
||||
api_key = cls.objects.select_related('user').get(key_hash=key_hash)
|
||||
except cls.DoesNotExist:
|
||||
return None
|
||||
from django.utils import timezone
|
||||
cls.objects.filter(pk=api_key.pk).update(last_used_at=timezone.now())
|
||||
return api_key
|
||||
@@ -126,5 +126,27 @@ class CustomUserDetailsSerializer(UserDetailsSerializer):
|
||||
representation.pop('pk', None)
|
||||
# Remove the email field
|
||||
representation.pop('email', None)
|
||||
|
||||
|
||||
return representation
|
||||
|
||||
|
||||
from .models import APIKey
|
||||
|
||||
|
||||
class APIKeySerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
Read serializer for APIKey – never exposes the key_hash or the raw token.
|
||||
The raw token is injected by the view only at creation time via an extra
|
||||
``key`` field that is *not* part of the model serializer.
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
model = APIKey
|
||||
fields = ['id', 'name', 'key_prefix', 'created_at', 'last_used_at']
|
||||
read_only_fields = ['id', 'key_prefix', 'created_at', 'last_used_at']
|
||||
|
||||
|
||||
class APIKeyCreateSerializer(serializers.Serializer):
|
||||
"""Write serializer – only accepts a ``name`` for the new key."""
|
||||
|
||||
name = serializers.CharField(max_length=100, required=True)
|
||||
|
||||
@@ -3,7 +3,7 @@ from rest_framework.views import APIView
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from .serializers import ChangeEmailSerializer
|
||||
from .serializers import ChangeEmailSerializer, APIKeySerializer, APIKeyCreateSerializer
|
||||
from drf_yasg.utils import swagger_auto_schema
|
||||
from drf_yasg import openapi
|
||||
from django.conf import settings
|
||||
@@ -14,6 +14,7 @@ from allauth.socialaccount.models import SocialApp
|
||||
from adventures.serializers import LocationSerializer, CollectionSerializer
|
||||
from adventures.models import Location, Collection
|
||||
from allauth.socialaccount.models import SocialAccount
|
||||
from .models import APIKey
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
@@ -212,4 +213,80 @@ class DisablePasswordAuthenticationView(APIView):
|
||||
user.disable_password = False
|
||||
user.save()
|
||||
return Response({"detail": "Password authentication enabled."}, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
|
||||
class APIKeyListCreateView(APIView):
|
||||
"""
|
||||
List the current user's API keys or create a new one.
|
||||
|
||||
GET /auth/api-keys/ → list of keys (name, prefix, created_at, last_used_at)
|
||||
POST /auth/api-keys/ → create a new key; returns the raw token **once**
|
||||
"""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
@swagger_auto_schema(
|
||||
responses={200: APIKeySerializer(many=True)},
|
||||
operation_description="List all API keys for the authenticated user.",
|
||||
)
|
||||
def get(self, request):
|
||||
keys = APIKey.objects.filter(user=request.user)
|
||||
serializer = APIKeySerializer(keys, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
@swagger_auto_schema(
|
||||
request_body=APIKeyCreateSerializer,
|
||||
responses={
|
||||
201: openapi.Response(
|
||||
"API key created. The ``key`` field is returned only once.",
|
||||
openapi.Schema(
|
||||
type=openapi.TYPE_OBJECT,
|
||||
properties={
|
||||
"id": openapi.Schema(type=openapi.TYPE_STRING, format="uuid"),
|
||||
"name": openapi.Schema(type=openapi.TYPE_STRING),
|
||||
"key_prefix": openapi.Schema(type=openapi.TYPE_STRING),
|
||||
"created_at": openapi.Schema(type=openapi.TYPE_STRING, format="date-time"),
|
||||
"last_used_at": openapi.Schema(type=openapi.TYPE_STRING, format="date-time", x_nullable=True),
|
||||
"key": openapi.Schema(
|
||||
type=openapi.TYPE_STRING,
|
||||
description="Full API key – shown once, never stored.",
|
||||
),
|
||||
},
|
||||
),
|
||||
),
|
||||
400: "Bad request – name is required.",
|
||||
},
|
||||
operation_description="Create a new API key. Copy the returned ``key`` immediately; it will not be shown again.",
|
||||
)
|
||||
def post(self, request):
|
||||
serializer = APIKeyCreateSerializer(data=request.data)
|
||||
if not serializer.is_valid():
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
api_key, raw_key = APIKey.generate(
|
||||
user=request.user,
|
||||
name=serializer.validated_data["name"],
|
||||
)
|
||||
response_data = APIKeySerializer(api_key).data
|
||||
response_data["key"] = raw_key
|
||||
return Response(response_data, status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
class APIKeyDetailView(APIView):
|
||||
"""
|
||||
DELETE /auth/api-keys/<id>/ → revoke (delete) an API key
|
||||
"""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
@swagger_auto_schema(
|
||||
responses={
|
||||
204: "API key deleted.",
|
||||
404: "Not found.",
|
||||
},
|
||||
operation_description="Revoke an API key by its ID.",
|
||||
)
|
||||
def delete(self, request, pk):
|
||||
api_key = get_object_or_404(APIKey, pk=pk, user=request.user)
|
||||
api_key.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@@ -298,6 +298,14 @@ export type ImmichIntegration = {
|
||||
copy_locally: boolean;
|
||||
};
|
||||
|
||||
export type APIKey = {
|
||||
id: string;
|
||||
name: string;
|
||||
key_prefix: string;
|
||||
created_at: string;
|
||||
last_used_at: string | null;
|
||||
};
|
||||
|
||||
export type ImmichAlbum = {
|
||||
albumName: string;
|
||||
description: string;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -748,7 +748,8 @@
|
||||
"locations": {
|
||||
"location": "Locaţie",
|
||||
"locations": "Locații",
|
||||
"my_locations": "Locațiile mele"
|
||||
"my_locations": "Locațiile mele",
|
||||
"best_happened_at": "Cel mai bine s-a întâmplat la"
|
||||
},
|
||||
"lodging": {
|
||||
"apartment": "Apartament",
|
||||
@@ -1139,5 +1140,25 @@
|
||||
"visit_remove_failed": "Nu s-a eliminat vizita",
|
||||
"visit_to": "Vizită la",
|
||||
"your_random_adventure_awaits": "Aventura ta aleatorie vă așteaptă!"
|
||||
},
|
||||
"api_keys": {
|
||||
"copied": "Copiat!",
|
||||
"copy": "Copiere cheie",
|
||||
"create": "Creați cheia",
|
||||
"create_error": "Nu s-a putut crea cheia API.",
|
||||
"created": "Creat",
|
||||
"description": "Creați chei API personale pentru acces programatic. \nCheile sunt afișate o singură dată la momentul creării.",
|
||||
"dismiss": "Respingeți",
|
||||
"key_created": "Cheia API creată cu succes.",
|
||||
"key_name_placeholder": "Numele cheii (de exemplu, Asistentul de acasă)",
|
||||
"key_revoked": "Cheia API revocată.",
|
||||
"last_used": "Ultima utilizare",
|
||||
"never_used": "Nu a fost folosit niciodată",
|
||||
"new_key_title": "Salvați noua cheie API",
|
||||
"new_key_warning": "Această cheie nu va fi afișată din nou. \nCopiați-l și depozitați-l într-un loc sigur.",
|
||||
"no_keys": "Încă nu există chei API.",
|
||||
"revoke": "Revoca",
|
||||
"revoke_error": "Cheia API nu a fost revocată.",
|
||||
"title": "Chei API"
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -44,10 +44,15 @@ async function handleRequest(
|
||||
let targetUrl = `${endpoint}/auth/${path}`;
|
||||
|
||||
const add_trailing_slash_list = ['disable-password'];
|
||||
const add_trailing_slash_prefixes = ['api-keys'];
|
||||
|
||||
// Ensure the path ends with a trailing slash
|
||||
if ((requreTrailingSlash && !targetUrl.endsWith('/')) || add_trailing_slash_list.includes(path)) {
|
||||
targetUrl += '/';
|
||||
if (
|
||||
(requreTrailingSlash && !targetUrl.endsWith('/')) ||
|
||||
add_trailing_slash_list.includes(path) ||
|
||||
add_trailing_slash_prefixes.some((p) => path === p || path.startsWith(p + '/'))
|
||||
) {
|
||||
if (!targetUrl.endsWith('/')) targetUrl += '/';
|
||||
}
|
||||
|
||||
// Append query parameters to the path correctly
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { fail, redirect, type Actions } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from '../$types';
|
||||
const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL'];
|
||||
import type { ImmichIntegration, User } from '$lib/types';
|
||||
import type { APIKey, ImmichIntegration, User } from '$lib/types';
|
||||
import { fetchCSRFToken } from '$lib/index.server';
|
||||
const endpoint = PUBLIC_SERVER_URL || 'http://localhost:8000';
|
||||
|
||||
@@ -94,6 +94,16 @@ export const load: PageServerLoad = async (event) => {
|
||||
publicUrl = publicUrlJson.PUBLIC_URL;
|
||||
}
|
||||
|
||||
let apiKeys: APIKey[] = [];
|
||||
let apiKeysFetch = await fetch(`${endpoint}/auth/api-keys/`, {
|
||||
headers: {
|
||||
Cookie: `sessionid=${sessionId}`
|
||||
}
|
||||
});
|
||||
if (apiKeysFetch.ok) {
|
||||
apiKeys = await apiKeysFetch.json();
|
||||
}
|
||||
|
||||
return {
|
||||
props: {
|
||||
user,
|
||||
@@ -106,7 +116,8 @@ export const load: PageServerLoad = async (event) => {
|
||||
stravaGlobalEnabled,
|
||||
stravaUserEnabled,
|
||||
wandererEnabled,
|
||||
wandererExpired
|
||||
wandererExpired,
|
||||
apiKeys
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { page } from '$app/stores';
|
||||
import { addToast } from '$lib/toasts';
|
||||
import { CURRENCY_LABELS, CURRENCY_OPTIONS } from '$lib/money';
|
||||
import type { ImmichIntegration, User } from '$lib/types.js';
|
||||
import type { ImmichIntegration, User, APIKey } from '$lib/types.js';
|
||||
import { onMount } from 'svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import { t } from 'svelte-i18n';
|
||||
@@ -76,6 +76,11 @@
|
||||
|
||||
let isMFAModalOpen: boolean = false;
|
||||
|
||||
let apiKeys: APIKey[] = data.props.apiKeys ?? [];
|
||||
let newApiKeyName: string = '';
|
||||
let newlyCreatedKey: string | null = null;
|
||||
let keyCopied = false;
|
||||
|
||||
const sections = [
|
||||
{ id: 'profile', icon: '👤', label: () => $t('navbar.profile') },
|
||||
{ id: 'security', icon: '🔒', label: () => $t('settings.security') },
|
||||
@@ -400,6 +405,54 @@
|
||||
newWandererIntegration.password = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function createApiKey() {
|
||||
if (!newApiKeyName.trim()) return;
|
||||
const res = await fetch('/auth/api-keys/', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: newApiKeyName.trim() })
|
||||
});
|
||||
if (res.ok) {
|
||||
const created = await res.json();
|
||||
newlyCreatedKey = created.key;
|
||||
keyCopied = false;
|
||||
apiKeys = [
|
||||
...apiKeys,
|
||||
{
|
||||
id: created.id,
|
||||
name: created.name,
|
||||
key_prefix: created.key_prefix,
|
||||
created_at: created.created_at,
|
||||
last_used_at: created.last_used_at
|
||||
}
|
||||
];
|
||||
newApiKeyName = '';
|
||||
} else {
|
||||
addToast('error', $t('api_keys.create_error'));
|
||||
}
|
||||
}
|
||||
|
||||
async function copyKey() {
|
||||
if (!newlyCreatedKey) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(newlyCreatedKey);
|
||||
keyCopied = true;
|
||||
setTimeout(() => (keyCopied = false), 2000);
|
||||
} catch {
|
||||
addToast('error', 'Could not copy — please select the key and copy manually.');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteApiKey(id: string) {
|
||||
const res = await fetch(`/auth/api-keys/${id}/`, { method: 'DELETE' });
|
||||
if (res.ok) {
|
||||
apiKeys = apiKeys.filter((k) => k.id !== id);
|
||||
addToast('success', $t('api_keys.key_revoked'));
|
||||
} else {
|
||||
addToast('error', $t('api_keys.revoke_error'));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if isMFAModalOpen}
|
||||
@@ -854,6 +907,181 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- API Keys -->
|
||||
<div class="bg-base-100 rounded-2xl shadow-xl p-8 mt-8">
|
||||
<div class="flex items-center gap-4 mb-6">
|
||||
<div class="p-3 bg-warning/10 rounded-xl">
|
||||
<span class="text-2xl">🔑</span>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold">{$t('api_keys.title')}</h2>
|
||||
<p class="text-base-content/70">
|
||||
{$t('api_keys.description')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Newly created key banner -->
|
||||
{#if newlyCreatedKey}
|
||||
<div
|
||||
class="mb-6 rounded-2xl border border-warning/40 bg-warning/5 overflow-hidden"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="flex items-center justify-between px-5 py-3 bg-warning/10 border-b border-warning/20"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4 text-warning shrink-0"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-sm font-semibold text-warning"
|
||||
>{$t('api_keys.new_key_title')}</span
|
||||
>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-ghost btn-xs text-base-content/50 hover:text-base-content"
|
||||
on:click={() => {
|
||||
newlyCreatedKey = null;
|
||||
keyCopied = false;
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Key body -->
|
||||
<div class="px-5 py-4">
|
||||
<div
|
||||
class="flex items-center gap-2 bg-base-200 rounded-xl border border-base-300 px-4 py-3"
|
||||
>
|
||||
<code class="flex-1 text-sm font-mono break-all text-base-content select-all"
|
||||
>{newlyCreatedKey}</code
|
||||
>
|
||||
<button
|
||||
class="btn btn-sm shrink-0 transition-all {keyCopied
|
||||
? 'btn-success'
|
||||
: 'btn-ghost'}"
|
||||
on:click={copyKey}
|
||||
title="Copy to clipboard"
|
||||
>
|
||||
{#if keyCopied}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
{$t('api_keys.copied')}
|
||||
{:else}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
{$t('api_keys.copy')}
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-base-content/50 mt-2 pl-1">
|
||||
Use this key in the <code class="font-mono">X-API-Key</code> header or as
|
||||
<code class="font-mono">Authorization: Api-Key <token></code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Existing keys list -->
|
||||
{#if apiKeys.length > 0}
|
||||
<div class="space-y-3 mb-6">
|
||||
{#each apiKeys as key (key.id)}
|
||||
<div class="flex items-center justify-between p-4 bg-base-200 rounded-xl gap-4">
|
||||
<div class="min-w-0">
|
||||
<p class="font-semibold truncate">{key.name}</p>
|
||||
<p class="text-sm text-base-content/60 font-mono">
|
||||
{key.key_prefix}…
|
||||
</p>
|
||||
<p class="text-xs text-base-content/50 mt-0.5">
|
||||
{$t('api_keys.created')} {new Date(key.created_at).toLocaleDateString()}
|
||||
{#if key.last_used_at}
|
||||
· {$t('api_keys.last_used')} {new Date(key.last_used_at).toLocaleDateString()}
|
||||
{:else}
|
||||
· {$t('api_keys.never_used')}
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-error btn-sm shrink-0"
|
||||
on:click={() => deleteApiKey(key.id)}
|
||||
>
|
||||
{$t('api_keys.revoke')}
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-base-content/50 mb-6">{$t('api_keys.no_keys')}</p>
|
||||
{/if}
|
||||
|
||||
<!-- Create new key form -->
|
||||
<div class="flex gap-3">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newApiKeyName}
|
||||
placeholder={$t('api_keys.key_name_placeholder')}
|
||||
class="input input-bordered input-primary flex-1"
|
||||
maxlength="100"
|
||||
/>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
on:click={createApiKey}
|
||||
disabled={!newApiKeyName.trim()}
|
||||
>
|
||||
{$t('api_keys.create')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Emails Section -->
|
||||
|
||||
Reference in New Issue
Block a user