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:
Sean Morley
2026-03-16 15:14:32 -04:00
parent 5f5830a8a2
commit 1dcf99be7d
33 changed files with 22639 additions and 21694 deletions

View File

@@ -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)

View File

@@ -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',

View File

@@ -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'),

View File

@@ -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)

View 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

View 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'],
},
),
]

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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;

View File

File diff suppressed because it is too large Load Diff

View File

File diff suppressed because it is too large Load Diff

View File

File diff suppressed because it is too large Load Diff

View File

File diff suppressed because it is too large Load Diff

View File

File diff suppressed because it is too large Load Diff

View File

File diff suppressed because it is too large Load Diff

View File

File diff suppressed because it is too large Load Diff

View File

File diff suppressed because it is too large Load Diff

View File

File diff suppressed because it is too large Load Diff

View File

File diff suppressed because it is too large Load Diff

View File

File diff suppressed because it is too large Load Diff

View File

File diff suppressed because it is too large Load Diff

View File

File diff suppressed because it is too large Load Diff

View File

@@ -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"
}
}

View File

File diff suppressed because it is too large Load Diff

View File

File diff suppressed because it is too large Load Diff

View File

File diff suppressed because it is too large Load Diff

View File

File diff suppressed because it is too large Load Diff

View File

File diff suppressed because it is too large Load Diff

View File

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -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
}
};
};

View File

@@ -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 &lt;token&gt;</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 -->