mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 23:19:40 +02:00
feat(auth): add PasskeyManager component and production config
- PasskeyManager.svelte: reusable component for listing, registering, renaming, and deleting passkeys (German defaults, fully translatable) - Production env: WEBAUTHN_RP_ID=mana.how and WEBAUTHN_ORIGINS for all *.mana.how subdomains in docker-compose.macmini.yml - Local DB: passkeys table created via direct SQL Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3091da914e
commit
c4d55209a4
3 changed files with 864 additions and 0 deletions
|
|
@ -229,6 +229,9 @@ services:
|
|||
STORAGE_BACKEND_URL: http://storage-backend:3035
|
||||
ADMIN_SERVICE_KEY: ${MANA_CORE_SERVICE_KEY}
|
||||
MANA_LLM_URL: http://mana-llm:3025
|
||||
# WebAuthn / Passkeys
|
||||
WEBAUTHN_RP_ID: mana.how
|
||||
WEBAUTHN_ORIGINS: https://mana.how,https://calendar.mana.how,https://chat.mana.how,https://clock.mana.how,https://contacts.mana.how,https://context.mana.how,https://manadeck.mana.how,https://mukke.mana.how,https://nutriphi.mana.how,https://photos.mana.how,https://picture.mana.how,https://planta.mana.how,https://playground.mana.how,https://presi.mana.how,https://questions.mana.how,https://skilltree.mana.how,https://storage.mana.how,https://todo.mana.how,https://zitare.mana.how
|
||||
volumes:
|
||||
- analytics_data:/data/analytics
|
||||
ports:
|
||||
|
|
|
|||
859
packages/shared-auth-ui/src/components/PasskeyManager.svelte
Normal file
859
packages/shared-auth-ui/src/components/PasskeyManager.svelte
Normal file
|
|
@ -0,0 +1,859 @@
|
|||
<script lang="ts">
|
||||
/** Translation strings for the PasskeyManager */
|
||||
export interface PasskeyManagerTranslations {
|
||||
title: string;
|
||||
noPasskeys: string;
|
||||
registerButton: string;
|
||||
renameButton: string;
|
||||
deleteButton: string;
|
||||
cancelButton: string;
|
||||
saveButton: string;
|
||||
confirmDeleteTitle: string;
|
||||
confirmDeleteMessage: string;
|
||||
created: string;
|
||||
lastUsed: string;
|
||||
never: string;
|
||||
backedUp: string;
|
||||
notBackedUp: string;
|
||||
browserNotSupported: string;
|
||||
registerNamePlaceholder: string;
|
||||
registerNameLabel: string;
|
||||
registerTitle: string;
|
||||
renamePlaceholder: string;
|
||||
errorGeneric: string;
|
||||
daysAgo: (days: number) => string;
|
||||
hoursAgo: (hours: number) => string;
|
||||
minutesAgo: (minutes: number) => string;
|
||||
justNow: string;
|
||||
}
|
||||
|
||||
interface Passkey {
|
||||
id: string;
|
||||
credentialId: string;
|
||||
deviceType: string;
|
||||
backedUp: boolean;
|
||||
friendlyName: string | null;
|
||||
lastUsedAt: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
passkeys: Passkey[];
|
||||
onRegister: (friendlyName?: string) => Promise<{ success: boolean; error?: string }>;
|
||||
onDelete: (passkeyId: string) => Promise<{ success: boolean; error?: string }>;
|
||||
onRename: (
|
||||
passkeyId: string,
|
||||
friendlyName: string
|
||||
) => Promise<{ success: boolean; error?: string }>;
|
||||
onRefresh: () => Promise<void>;
|
||||
passkeyAvailable: boolean;
|
||||
primaryColor?: string;
|
||||
translations?: Partial<PasskeyManagerTranslations>;
|
||||
}
|
||||
|
||||
const defaultTranslations: PasskeyManagerTranslations = {
|
||||
title: 'Passkeys',
|
||||
noPasskeys: 'Noch keine Passkeys registriert.',
|
||||
registerButton: 'Neuen Passkey registrieren',
|
||||
renameButton: 'Umbenennen',
|
||||
deleteButton: 'Löschen',
|
||||
cancelButton: 'Abbrechen',
|
||||
saveButton: 'Speichern',
|
||||
confirmDeleteTitle: 'Passkey löschen',
|
||||
confirmDeleteMessage:
|
||||
'Möchtest du diesen Passkey wirklich löschen? Du kannst dich dann nicht mehr damit anmelden.',
|
||||
created: 'Erstellt',
|
||||
lastUsed: 'Zuletzt',
|
||||
never: 'Nie verwendet',
|
||||
backedUp: 'Synchronisiert',
|
||||
notBackedUp: 'Nur auf diesem Gerät',
|
||||
browserNotSupported:
|
||||
'Dein Browser unterstützt keine Passkeys. Bitte verwende einen aktuellen Browser wie Chrome, Safari oder Firefox.',
|
||||
registerNamePlaceholder: 'z.B. MacBook Pro, iPhone',
|
||||
registerNameLabel: 'Name für den Passkey (optional)',
|
||||
registerTitle: 'Neuen Passkey registrieren',
|
||||
renamePlaceholder: 'Neuer Name',
|
||||
errorGeneric: 'Ein Fehler ist aufgetreten.',
|
||||
daysAgo: (days: number) => (days === 1 ? 'vor 1 Tag' : `vor ${days} Tagen`),
|
||||
hoursAgo: (hours: number) => (hours === 1 ? 'vor 1 Stunde' : `vor ${hours} Stunden`),
|
||||
minutesAgo: (minutes: number) => (minutes === 1 ? 'vor 1 Minute' : `vor ${minutes} Minuten`),
|
||||
justNow: 'Gerade eben',
|
||||
};
|
||||
|
||||
let {
|
||||
passkeys,
|
||||
onRegister,
|
||||
onDelete,
|
||||
onRename,
|
||||
onRefresh,
|
||||
passkeyAvailable,
|
||||
primaryColor = '#6366f1',
|
||||
translations,
|
||||
}: Props = $props();
|
||||
|
||||
const t = $derived({ ...defaultTranslations, ...translations });
|
||||
|
||||
// State
|
||||
let editingId = $state<string | null>(null);
|
||||
let editName = $state('');
|
||||
let deletingId = $state<string | null>(null);
|
||||
let showRegisterForm = $state(false);
|
||||
let registerName = $state('');
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
const date = new Date(dateStr);
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const year = date.getFullYear();
|
||||
return `${day}.${month}.${year}`;
|
||||
}
|
||||
|
||||
function formatRelativeTime(dateStr: string | null): string {
|
||||
if (!dateStr) return t.never;
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMinutes = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMinutes < 1) return t.justNow;
|
||||
if (diffMinutes < 60) return t.minutesAgo(diffMinutes);
|
||||
if (diffHours < 24) return t.hoursAgo(diffHours);
|
||||
return t.daysAgo(diffDays);
|
||||
}
|
||||
|
||||
function getDeviceIcon(deviceType: string): string {
|
||||
const type = deviceType.toLowerCase();
|
||||
if (type.includes('phone') || type.includes('mobile')) return '\u{1F4F1}';
|
||||
if (type.includes('tablet')) return '\u{1F4F1}';
|
||||
return '\u{1F511}';
|
||||
}
|
||||
|
||||
function getDisplayName(passkey: Passkey): string {
|
||||
return passkey.friendlyName || 'Passkey';
|
||||
}
|
||||
|
||||
function startRename(passkey: Passkey) {
|
||||
editingId = passkey.id;
|
||||
editName = passkey.friendlyName || '';
|
||||
error = null;
|
||||
}
|
||||
|
||||
function cancelRename() {
|
||||
editingId = null;
|
||||
editName = '';
|
||||
}
|
||||
|
||||
async function saveRename(passkeyId: string) {
|
||||
if (!editName.trim()) return;
|
||||
loading = true;
|
||||
error = null;
|
||||
const result = await onRename(passkeyId, editName.trim());
|
||||
loading = false;
|
||||
if (result.success) {
|
||||
editingId = null;
|
||||
editName = '';
|
||||
await onRefresh();
|
||||
} else {
|
||||
error = result.error || t.errorGeneric;
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(passkeyId: string) {
|
||||
deletingId = passkeyId;
|
||||
error = null;
|
||||
}
|
||||
|
||||
function cancelDelete() {
|
||||
deletingId = null;
|
||||
}
|
||||
|
||||
async function executeDelete() {
|
||||
if (!deletingId) return;
|
||||
loading = true;
|
||||
error = null;
|
||||
const result = await onDelete(deletingId);
|
||||
loading = false;
|
||||
if (result.success) {
|
||||
deletingId = null;
|
||||
await onRefresh();
|
||||
} else {
|
||||
error = result.error || t.errorGeneric;
|
||||
}
|
||||
}
|
||||
|
||||
function openRegisterForm() {
|
||||
showRegisterForm = true;
|
||||
registerName = '';
|
||||
error = null;
|
||||
}
|
||||
|
||||
function cancelRegister() {
|
||||
showRegisterForm = false;
|
||||
registerName = '';
|
||||
}
|
||||
|
||||
async function handleRegister() {
|
||||
loading = true;
|
||||
error = null;
|
||||
const result = await onRegister(registerName.trim() || undefined);
|
||||
loading = false;
|
||||
if (result.success) {
|
||||
showRegisterForm = false;
|
||||
registerName = '';
|
||||
await onRefresh();
|
||||
} else {
|
||||
error = result.error || t.errorGeneric;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="passkey-manager" style:--primary-color={primaryColor}>
|
||||
<h3 class="pm-title">{t.title}</h3>
|
||||
|
||||
{#if !passkeyAvailable}
|
||||
<div class="pm-warning" role="alert">
|
||||
<span class="pm-warning-icon">{'\u26A0\uFE0F'}</span>
|
||||
<p>{t.browserNotSupported}</p>
|
||||
</div>
|
||||
{:else}
|
||||
{#if error}
|
||||
<div class="pm-error" role="alert">
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if passkeys.length === 0}
|
||||
<p class="pm-empty">{t.noPasskeys}</p>
|
||||
{:else}
|
||||
<ul class="pm-list">
|
||||
{#each passkeys as passkey (passkey.id)}
|
||||
<li class="pm-item">
|
||||
{#if deletingId === passkey.id}
|
||||
<div class="pm-confirm-delete">
|
||||
<p class="pm-confirm-title">{t.confirmDeleteTitle}</p>
|
||||
<p class="pm-confirm-message">{t.confirmDeleteMessage}</p>
|
||||
<div class="pm-confirm-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="pm-btn pm-btn-cancel"
|
||||
onclick={cancelDelete}
|
||||
disabled={loading}
|
||||
>
|
||||
{t.cancelButton}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pm-btn pm-btn-danger"
|
||||
onclick={executeDelete}
|
||||
disabled={loading}
|
||||
>
|
||||
{#if loading}
|
||||
<svg
|
||||
class="pm-spinner"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" stroke-opacity="0.25" />
|
||||
<path d="M12 2a10 10 0 0 1 10 10" stroke-linecap="round" />
|
||||
</svg>
|
||||
{/if}
|
||||
{t.deleteButton}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else if editingId === passkey.id}
|
||||
<div class="pm-rename-form">
|
||||
<div class="pm-rename-input-row">
|
||||
<input
|
||||
type="text"
|
||||
class="pm-input"
|
||||
bind:value={editName}
|
||||
placeholder={t.renamePlaceholder}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') saveRename(passkey.id);
|
||||
if (e.key === 'Escape') cancelRename();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div class="pm-rename-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="pm-btn pm-btn-cancel"
|
||||
onclick={cancelRename}
|
||||
disabled={loading}
|
||||
>
|
||||
{t.cancelButton}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pm-btn pm-btn-primary"
|
||||
onclick={() => saveRename(passkey.id)}
|
||||
disabled={loading || !editName.trim()}
|
||||
>
|
||||
{t.saveButton}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="pm-item-header">
|
||||
<span class="pm-item-icon">{getDeviceIcon(passkey.deviceType)}</span>
|
||||
<div class="pm-item-info">
|
||||
<span class="pm-item-name">{getDisplayName(passkey)}</span>
|
||||
<span class="pm-item-meta">
|
||||
{t.created}: {formatDate(passkey.createdAt)}
|
||||
</span>
|
||||
<span class="pm-item-meta">
|
||||
{t.lastUsed}: {formatRelativeTime(passkey.lastUsedAt)}
|
||||
</span>
|
||||
{#if passkey.backedUp}
|
||||
<span class="pm-item-badge pm-badge-synced">{t.backedUp}</span>
|
||||
{:else}
|
||||
<span class="pm-item-badge pm-badge-local">{t.notBackedUp}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="pm-item-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="pm-btn pm-btn-ghost"
|
||||
onclick={() => startRename(passkey)}
|
||||
>
|
||||
{t.renameButton}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pm-btn pm-btn-ghost pm-btn-ghost-danger"
|
||||
onclick={() => confirmDelete(passkey.id)}
|
||||
>
|
||||
{t.deleteButton}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
{#if showRegisterForm}
|
||||
<div class="pm-register-form">
|
||||
<p class="pm-register-title">{t.registerTitle}</p>
|
||||
<label class="pm-register-label" for="passkey-name">
|
||||
{t.registerNameLabel}
|
||||
</label>
|
||||
<input
|
||||
id="passkey-name"
|
||||
type="text"
|
||||
class="pm-input"
|
||||
bind:value={registerName}
|
||||
placeholder={t.registerNamePlaceholder}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') handleRegister();
|
||||
if (e.key === 'Escape') cancelRegister();
|
||||
}}
|
||||
/>
|
||||
<div class="pm-register-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="pm-btn pm-btn-cancel"
|
||||
onclick={cancelRegister}
|
||||
disabled={loading}
|
||||
>
|
||||
{t.cancelButton}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pm-btn pm-btn-primary"
|
||||
onclick={handleRegister}
|
||||
disabled={loading}
|
||||
>
|
||||
{#if loading}
|
||||
<svg
|
||||
class="pm-spinner"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" stroke-opacity="0.25" />
|
||||
<path d="M12 2a10 10 0 0 1 10 10" stroke-linecap="round" />
|
||||
</svg>
|
||||
{/if}
|
||||
{t.registerButton}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
class="pm-btn pm-btn-register"
|
||||
onclick={openRegisterForm}
|
||||
disabled={loading}
|
||||
>
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
{t.registerButton}
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.passkey-manager {
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
.pm-title {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 1rem;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
:global(.dark) .pm-title {
|
||||
color: #f3f4f6;
|
||||
}
|
||||
|
||||
/* Warning */
|
||||
.pm-warning {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: #fef3c7;
|
||||
border: 1px solid #f59e0b;
|
||||
border-radius: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
:global(.dark) .pm-warning {
|
||||
background: #78350f;
|
||||
border-color: #b45309;
|
||||
}
|
||||
|
||||
.pm-warning-icon {
|
||||
flex-shrink: 0;
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.pm-warning p {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
:global(.dark) .pm-warning p {
|
||||
color: #fef3c7;
|
||||
}
|
||||
|
||||
/* Error */
|
||||
.pm-error {
|
||||
padding: 0.625rem 0.875rem;
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fca5a5;
|
||||
border-radius: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
:global(.dark) .pm-error {
|
||||
background: #450a0a;
|
||||
border-color: #991b1b;
|
||||
}
|
||||
|
||||
.pm-error p {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
:global(.dark) .pm-error p {
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.pm-empty {
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
:global(.dark) .pm-empty {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
/* Passkey list */
|
||||
.pm-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0 0 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.pm-item {
|
||||
padding: 0.875rem 1rem;
|
||||
background: #f9fafb;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.625rem;
|
||||
transition: border-color 150ms ease;
|
||||
}
|
||||
|
||||
.pm-item:hover {
|
||||
border-color: #d1d5db;
|
||||
}
|
||||
|
||||
:global(.dark) .pm-item {
|
||||
background: #1f2937;
|
||||
border-color: #374151;
|
||||
}
|
||||
|
||||
:global(.dark) .pm-item:hover {
|
||||
border-color: #4b5563;
|
||||
}
|
||||
|
||||
/* Item header */
|
||||
.pm-item-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.pm-item-icon {
|
||||
font-size: 1.25rem;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
|
||||
.pm-item-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.pm-item-name {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 500;
|
||||
color: #111827;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
:global(.dark) .pm-item-name {
|
||||
color: #f9fafb;
|
||||
}
|
||||
|
||||
.pm-item-meta {
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
:global(.dark) .pm-item-meta {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
/* Badges */
|
||||
.pm-item-badge {
|
||||
display: inline-block;
|
||||
font-size: 0.6875rem;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
margin-top: 0.25rem;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.pm-badge-synced {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
:global(.dark) .pm-badge-synced {
|
||||
background: #052e16;
|
||||
color: #86efac;
|
||||
}
|
||||
|
||||
.pm-badge-local {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
:global(.dark) .pm-badge-local {
|
||||
background: #451a03;
|
||||
color: #fcd34d;
|
||||
}
|
||||
|
||||
/* Item actions */
|
||||
.pm-item-actions {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
margin-top: 0.625rem;
|
||||
padding-top: 0.625rem;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
:global(.dark) .pm-item-actions {
|
||||
border-top-color: #374151;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.pm-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.4375rem 0.875rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.pm-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.pm-btn-primary {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.pm-btn-primary:hover:not(:disabled) {
|
||||
filter: brightness(0.9);
|
||||
}
|
||||
|
||||
.pm-btn-cancel {
|
||||
background: transparent;
|
||||
color: #6b7280;
|
||||
border-color: #d1d5db;
|
||||
}
|
||||
|
||||
.pm-btn-cancel:hover:not(:disabled) {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
:global(.dark) .pm-btn-cancel {
|
||||
color: #9ca3af;
|
||||
border-color: #4b5563;
|
||||
}
|
||||
|
||||
:global(.dark) .pm-btn-cancel:hover:not(:disabled) {
|
||||
background: #374151;
|
||||
}
|
||||
|
||||
.pm-btn-danger {
|
||||
background: #dc2626;
|
||||
color: white;
|
||||
border-color: #dc2626;
|
||||
}
|
||||
|
||||
.pm-btn-danger:hover:not(:disabled) {
|
||||
background: #b91c1c;
|
||||
}
|
||||
|
||||
.pm-btn-ghost {
|
||||
background: transparent;
|
||||
color: #6b7280;
|
||||
padding: 0.3125rem 0.625rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.pm-btn-ghost:hover:not(:disabled) {
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
:global(.dark) .pm-btn-ghost {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
:global(.dark) .pm-btn-ghost:hover:not(:disabled) {
|
||||
background: #374151;
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
.pm-btn-ghost-danger:hover:not(:disabled) {
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
:global(.dark) .pm-btn-ghost-danger:hover:not(:disabled) {
|
||||
background: #450a0a;
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.pm-btn-register {
|
||||
width: 100%;
|
||||
padding: 0.625rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
background: transparent;
|
||||
color: var(--primary-color);
|
||||
border: 1px dashed var(--primary-color);
|
||||
border-radius: 0.625rem;
|
||||
}
|
||||
|
||||
.pm-btn-register:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--primary-color) 8%, transparent);
|
||||
}
|
||||
|
||||
/* Input */
|
||||
.pm-input {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.5rem;
|
||||
background: white;
|
||||
color: #111827;
|
||||
outline: none;
|
||||
transition: border-color 150ms ease;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.pm-input:focus {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary-color) 20%, transparent);
|
||||
}
|
||||
|
||||
:global(.dark) .pm-input {
|
||||
background: #111827;
|
||||
border-color: #4b5563;
|
||||
color: #f3f4f6;
|
||||
}
|
||||
|
||||
:global(.dark) .pm-input:focus {
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
/* Rename form */
|
||||
.pm-rename-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.pm-rename-input-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.pm-rename-actions {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* Confirm delete */
|
||||
.pm-confirm-delete {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.pm-confirm-title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: #dc2626;
|
||||
margin: 0 0 0.25rem;
|
||||
}
|
||||
|
||||
.pm-confirm-message {
|
||||
font-size: 0.8125rem;
|
||||
color: #6b7280;
|
||||
margin: 0 0 0.75rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
:global(.dark) .pm-confirm-message {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.pm-confirm-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Register form */
|
||||
.pm-register-form {
|
||||
padding: 1rem;
|
||||
background: #f9fafb;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.625rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
:global(.dark) .pm-register-form {
|
||||
background: #1f2937;
|
||||
border-color: #374151;
|
||||
}
|
||||
|
||||
.pm-register-title {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
:global(.dark) .pm-register-title {
|
||||
color: #f3f4f6;
|
||||
}
|
||||
|
||||
.pm-register-label {
|
||||
font-size: 0.8125rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
:global(.dark) .pm-register-label {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.pm-register-actions {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
justify-content: flex-end;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* Spinner */
|
||||
.pm-spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
animation: pm-spin 0.75s linear infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@keyframes pm-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -8,6 +8,7 @@ export { default as GuestWelcomeModal } from './components/GuestWelcomeModal.sve
|
|||
export { default as AuthGateModal } from './components/AuthGateModal.svelte';
|
||||
export { default as SessionExpiredBanner } from './components/SessionExpiredBanner.svelte';
|
||||
export { default as AuthGate } from './components/AuthGate.svelte';
|
||||
export { default as PasskeyManager } from './components/PasskeyManager.svelte';
|
||||
|
||||
// Utilities
|
||||
export {
|
||||
|
|
@ -26,3 +27,4 @@ export type {
|
|||
AuthGateAction,
|
||||
AuthGateTranslations,
|
||||
} from './types';
|
||||
export type { PasskeyManagerTranslations } from './components/PasskeyManager.svelte';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue