2026-01-26 11:15:52 -05:00

1445 lines
45 KiB
Svelte

<script lang="ts">
import { onMount } from 'svelte';
import { browser } from '$app/environment';
import { PUBLIC_KRATOS_URL } from '$env/static/public';
import type { PageData } from './$types';
import type { Identity, Session, Message } from '@ory/client';
import { kratosAdminClient, kratosClient } from '$lib/kratos';
import axios from 'axios';
import IdentityDetailsModal from '$lib/components/modals/IdentityDetailsModal.svelte';
import IdentityEditModal from '$lib/components/modals/IdentityEditModal.svelte';
import IdentityCreateModal from '$lib/components/modals/IdentityCreateModal.svelte';
import SessionDetailsModal from '$lib/components/modals/SessionDetailsModal.svelte';
import IdentitySessionsModal from '$lib/components/modals/IdentitySessionsModal.svelte';
import MessageDetailsModal from '$lib/components/modals/MessageDetailsModal.svelte';
// Configure axios to send credentials (fallback for non-Kratos endpoints)
const api = axios.create({
baseURL: PUBLIC_KRATOS_URL,
withCredentials: true
});
let { data }: { data: PageData } = $props();
// Tab management
let activeTab = $state<'identities' | 'sessions' | 'recovery' | 'courier' | 'batch'>(
'identities'
);
// Identity management state
let identities = $state<Identity[]>([]);
let identityLoading = $state(false);
let identityError = $state<string | null>(null);
let selectedIdentity = $state<Identity | null>(null);
let editingIdentity = $state<Identity | null>(null);
let showCreateModal = $state(false);
let identityPage = $state(0);
let identityPageSize = $state(50);
let identitySearch = $state('');
let identityHasMore = $state(true);
let identitySessionsModal = $state<{ identity: Identity; sessions: Session[] } | null>(null);
// Session management state
let sessions = $state<Session[]>([]);
let sessionLoading = $state(false);
let sessionError = $state<string | null>(null);
let selectedSession = $state<Session | null>(null);
let sessionPage = $state(0);
let sessionPageSize = $state(50);
let sessionHasMore = $state(true);
// Recovery state
let recoveryLoading = $state(false);
let recoveryError = $state<string | null>(null);
let recoveryResult = $state<any>(null);
let recoveryEmail = $state('');
let recoveryType = $state<'link' | 'code'>('link');
// Courier state
let courierMessages = $state<Message[]>([]);
let courierLoading = $state(false);
let courierError = $state<string | null>(null);
let selectedMessage = $state<Message | null>(null);
let courierStatus = $state<string>('');
let courierRecipient = $state('');
let courierPageToken = $state<string>('');
let courierPageSize = $state(50);
let courierHasMore = $state(true);
let courierNextToken = $state<string | null>(null);
// Create identity form state
let createForm = $state({
schema_id: 'nexus-v2',
traits: {
email: '',
name: { first: '', last: '' },
profile_type: 'team'
}
});
// Batch operations state
let batchJsonInput = $state('');
let batchLoading = $state(false);
let batchError = $state<string | null>(null);
let batchResult = $state<any>(null);
onMount(async () => {
await loadIdentities();
});
// Identity functions
async function loadIdentities() {
identityLoading = true;
identityError = null;
try {
const response = await kratosAdminClient.listIdentities({
page: identityPage,
perPage: identityPageSize
});
identities = response.data;
// Check if there might be more results
identityHasMore = response.data.length === identityPageSize;
} catch (err: any) {
console.error('Error loading identities:', err);
if (err.response) {
identityError = `Failed to load identities: ${err.response.status} ${err.response.statusText}`;
} else {
identityError = 'Failed to load identities. Please try again.';
}
} finally {
identityLoading = false;
}
}
async function nextIdentityPage() {
identityPage++;
await loadIdentities();
}
async function prevIdentityPage() {
if (identityPage > 0) {
identityPage--;
await loadIdentities();
}
}
// CSV Export function
function exportIdentitiesToCSV() {
// Create CSV content
const headers = ['ID', 'Email', 'First Name', 'Last Name', 'Profile Type', 'Verified', 'State', 'Created At', 'Updated At'];
const rows = filteredIdentities.map(i => [
i.id,
i.traits?.email || '',
i.traits?.name?.first || '',
i.traits?.name?.last || '',
i.traits?.profile_type || '',
hasUnverifiedEmail(i) ? 'No' : 'Yes',
i.state || '',
i.created_at || '',
i.updated_at || ''
]);
// Combine headers and rows
const csvContent = [
headers.join(','),
...rows.map(row => row.map(cell => `"${String(cell).replace(/"/g, '""')}"`).join(','))
].join('\n');
// Create blob and download
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
link.setAttribute('href', url);
link.setAttribute('download', `identities-${new Date().toISOString().split('T')[0]}.csv`);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
async function viewIdentity(identity: Identity) {
identityLoading = true;
try {
// Fetch identity with credentials included
// Valid credential types: oidc, password, totp, webauthn, code
const response = await kratosAdminClient.getIdentity({
id: identity.id,
includeCredential: [
'oidc',
'password',
'totp',
'webauthn',
'code'
]
});
selectedIdentity = response.data;
} catch (err: any) {
console.error('Error loading identity details:', err);
selectedIdentity = identity; // Fallback to basic identity
} finally {
identityLoading = false;
}
}
function closeModal() {
selectedIdentity = null;
editingIdentity = null;
showCreateModal = false;
selectedSession = null;
selectedMessage = null;
recoveryResult = null;
identitySessionsModal = null;
batchResult = null;
}
async function deleteIdentity(id: string) {
if (!confirm('Are you sure you want to delete this identity? This action cannot be undone.')) {
return;
}
try {
await kratosAdminClient.deleteIdentity({ id });
await loadIdentities();
if (selectedIdentity?.id === id) {
closeModal();
}
} catch (err: any) {
console.error('Error deleting identity:', err);
if (err.response) {
alert(`Failed to delete identity: ${err.response.status} ${err.response.statusText}`);
} else {
alert('Failed to delete identity. Please try again.');
}
}
}
async function createIdentity() {
identityLoading = true;
identityError = null;
try {
await kratosAdminClient.createIdentity({
createIdentityBody: createForm
});
await loadIdentities();
closeModal();
// Reset form
createForm = {
schema_id: 'nexus-v2',
traits: {
email: '',
name: { first: '', last: '' },
profile_type: 'team'
}
};
} catch (err: any) {
console.error('Error creating identity:', err);
if (err.response) {
identityError = `Failed to create identity: ${err.response.data?.error?.message || err.response.statusText}`;
} else {
identityError = 'Failed to create identity. Please try again.';
}
} finally {
identityLoading = false;
}
}
function startEdit(identity: Identity) {
editingIdentity = JSON.parse(JSON.stringify(identity)); // Deep clone
// Ensure metadata_public exists with default structure
if (editingIdentity && !editingIdentity.metadata_public) {
editingIdentity.metadata_public = {};
}
selectedIdentity = null;
}
// Helper function to create JSON Patch operations
function createJsonPatch(original: Identity, updated: Identity): any[] {
const patches: any[] = [];
// Check if traits changed
if (JSON.stringify(original.traits) !== JSON.stringify(updated.traits)) {
patches.push({ op: 'replace', path: '/traits', value: updated.traits });
}
// Check if state changed
if (original.state !== updated.state) {
patches.push({ op: 'replace', path: '/state', value: updated.state });
}
// Check if metadata_admin changed
if (JSON.stringify(original.metadata_admin) !== JSON.stringify(updated.metadata_admin)) {
patches.push({ op: 'replace', path: '/metadata_admin', value: updated.metadata_admin });
}
// Check if metadata_public changed
if (JSON.stringify(original.metadata_public) !== JSON.stringify(updated.metadata_public)) {
patches.push({ op: 'replace', path: '/metadata_public', value: updated.metadata_public });
}
return patches;
}
async function updateIdentity() {
if (!editingIdentity) return;
identityLoading = true;
identityError = null;
try {
// Store a reference to the editing identity for type safety
const currentIdentity = editingIdentity;
// Find the original identity to compare changes
const original = identities.find((i) => i.id === currentIdentity.id);
if (original) {
// Use PATCH with JSON Patch operations for efficient partial updates
const patches = createJsonPatch(original, currentIdentity);
if (patches.length > 0) {
await kratosAdminClient.patchIdentity({
id: currentIdentity.id,
jsonPatch: patches
});
}
} else {
// Fallback to PUT if we don't have the original
await kratosAdminClient.updateIdentity({
id: currentIdentity.id,
updateIdentityBody: {
schema_id: currentIdentity.schema_id,
traits: currentIdentity.traits,
state: currentIdentity.state || 'active',
metadata_admin: currentIdentity.metadata_admin,
metadata_public: currentIdentity.metadata_public
}
});
}
await loadIdentities();
closeModal();
} catch (err: any) {
console.error('Error updating identity:', err);
if (err.response) {
identityError = `Failed to update identity: ${err.response.data?.error?.message || err.response.statusText}`;
} else {
identityError = 'Failed to update identity. Please try again.';
}
} finally {
identityLoading = false;
}
}
async function deleteIdentitySessions(id: string) {
if (!confirm('Are you sure you want to delete all sessions for this identity?')) {
return;
}
try {
await api.delete(`/admin/identities/${id}/sessions`);
alert('All sessions deleted successfully');
} catch (err: any) {
console.error('Error deleting sessions:', err);
alert(`Failed to delete sessions: ${err.response?.data?.error?.message || 'Unknown error'}`);
}
}
async function viewIdentitySessions(identity: Identity) {
identityLoading = true;
try {
const response = await api.get(`/admin/identities/${identity.id}/sessions`);
identitySessionsModal = {
identity,
sessions: response.data
};
} catch (err: any) {
console.error('Error loading identity sessions:', err);
alert(`Failed to load sessions: ${err.response?.data?.error?.message || 'Unknown error'}`);
} finally {
identityLoading = false;
}
}
async function deleteCredential(identityId: string, type: string, identifier?: string) {
const confirmMsg = identifier
? `Are you sure you want to delete the ${type} credential (${identifier})?`
: `Are you sure you want to delete the ${type} credential?`;
if (!confirm(confirmMsg)) {
return;
}
try {
const params = identifier ? { identifier } : {};
await api.delete(`/admin/identities/${identityId}/credentials/${type}`, { params });
alert('Credential deleted successfully');
// Refresh the identity view
if (selectedIdentity) {
await viewIdentity(selectedIdentity);
}
} catch (err: any) {
console.error('Error deleting credential:', err);
alert(
`Failed to delete credential: ${err.response?.data?.error?.message || 'Unknown error'}`
);
}
}
async function resendVerificationEmail(identity: Identity) {
if (!identity.traits?.email) {
alert('No email address found for this identity');
return;
}
if (!confirm(`Send verification email to ${identity.traits.email}?`)) {
return;
}
identityLoading = true;
try {
// Step 1: Create a new verification flow
const flowResponse = await kratosClient.createBrowserVerificationFlow();
const flow = flowResponse.data;
// Step 2: Extract CSRF token from flow UI
const csrfNode = flow.ui.nodes.find(
(node: any) => node.attributes?.name === 'csrf_token'
);
const csrfToken = (csrfNode?.attributes as any)?.value;
if (!csrfToken) {
throw new Error('CSRF token not found in verification flow');
}
// Step 3: Submit the email with CSRF token to trigger verification email
await kratosClient.updateVerificationFlow({
flow: flow.id,
updateVerificationFlowBody: {
method: 'code',
email: identity.traits.email,
csrf_token: csrfToken
}
});
alert(`Verification email sent successfully to ${identity.traits.email}`);
} catch (err: any) {
console.error('Error sending verification email:', err);
alert(
`Failed to send verification email: ${err.response?.data?.error?.message || err.message || 'Unknown error'}`
);
} finally {
identityLoading = false;
}
}
// Helper function to check if an identity has an unverified email
function hasUnverifiedEmail(identity: Identity): boolean {
return identity.verifiable_addresses?.some((addr) => !addr.verified) || false;
}
// Session functions
async function loadSessions() {
sessionLoading = true;
sessionError = null;
try {
const url = `/admin/sessions?page=${sessionPage}&per_page=${sessionPageSize}&expand=identity`;
const response = await api.get(url);
sessions = response.data;
// Check if there might be more results
sessionHasMore = response.data.length === sessionPageSize;
} catch (err: any) {
console.error('Error loading sessions:', err);
if (err.response) {
sessionError = `Failed to load sessions: ${err.response.status} ${err.response.statusText}`;
} else {
sessionError = 'Failed to load sessions. Please try again.';
}
} finally {
sessionLoading = false;
}
}
async function nextSessionPage() {
sessionPage++;
await loadSessions();
}
async function prevSessionPage() {
if (sessionPage > 0) {
sessionPage--;
await loadSessions();
}
}
async function viewSession(session: Session) {
sessionLoading = true;
try {
// Fetch full session details with all expandables
const response = await api.get(`/admin/sessions/${session.id}`, {
params: {
expand: ['identity', 'devices']
}
});
selectedSession = response.data;
} catch (err: any) {
console.error('Error loading session details:', err);
// Fallback to the session from the list if detailed fetch fails
selectedSession = session;
} finally {
sessionLoading = false;
}
}
async function deleteSession(id: string) {
if (!confirm('Are you sure you want to delete this session?')) {
return;
}
try {
await api.delete(`/admin/sessions/${id}`);
await loadSessions();
if (selectedSession?.id === id) {
closeModal();
}
} catch (err: any) {
console.error('Error deleting session:', err);
alert(`Failed to delete session: ${err.response?.data?.error?.message || 'Unknown error'}`);
}
}
async function extendSession(id: string) {
try {
await api.patch(`/admin/sessions/${id}/extend`);
await loadSessions();
alert('Session extended successfully');
} catch (err: any) {
console.error('Error extending session:', err);
alert(`Failed to extend session: ${err.response?.data?.error?.message || 'Unknown error'}`);
}
}
// Recovery functions
async function createRecovery() {
if (!recoveryEmail) {
alert('Please enter an email address');
return;
}
recoveryLoading = true;
recoveryError = null;
recoveryResult = null;
try {
const endpoint = recoveryType === 'link' ? '/admin/recovery/link' : '/admin/recovery/code';
const response = await api.post(endpoint, {
identity_id: identities.find((i) => i.traits?.email === recoveryEmail)?.id,
expires_in: '1h'
});
recoveryResult = response.data;
} catch (err: any) {
console.error('Error creating recovery:', err);
if (err.response) {
recoveryError = `Failed to create recovery: ${err.response.data?.error?.message || err.response.statusText}`;
} else {
recoveryError = 'Failed to create recovery. Please try again.';
}
} finally {
recoveryLoading = false;
}
}
// Courier functions
async function loadCourierMessages() {
courierLoading = true;
courierError = null;
try {
const params: any = {
page_size: courierPageSize
};
// Only add page_token if we have one (for pagination)
if (courierPageToken) params.page_token = courierPageToken;
if (courierStatus) params.status = courierStatus;
if (courierRecipient) params.recipient = courierRecipient;
const response = await api.get('/admin/courier/messages', { params });
courierMessages = response.data;
// Check if there's a next token for pagination
courierNextToken = response.headers['x-next-page-token'] || null;
courierHasMore = !!courierNextToken;
} catch (err: any) {
console.error('Error loading courier messages:', err);
if (err.response) {
courierError = `Failed to load messages: ${err.response.status} ${err.response.statusText}`;
} else {
courierError = 'Failed to load messages. Please try again.';
}
} finally {
courierLoading = false;
}
}
async function nextCourierPage() {
if (courierNextToken) {
courierPageToken = courierNextToken;
await loadCourierMessages();
}
}
async function resetCourierPage() {
courierPageToken = '';
courierNextToken = null;
await loadCourierMessages();
}
async function viewMessage(message: Message) {
courierLoading = true;
try {
// Fetch full message details with all fields
const response = await api.get(`/admin/courier/messages/${message.id}`);
selectedMessage = response.data;
} catch (err: any) {
console.error('Error loading message details:', err);
// Fallback to the message from the list if detailed fetch fails
selectedMessage = message;
} finally {
courierLoading = false;
}
}
// Batch operations functions
async function batchCreateIdentities() {
if (!batchJsonInput.trim()) {
alert('Please enter JSON data for batch creation');
return;
}
batchLoading = true;
batchError = null;
batchResult = null;
try {
// Parse the JSON input
const identitiesData = JSON.parse(batchJsonInput);
// Validate it's an array
if (!Array.isArray(identitiesData)) {
batchError = 'Input must be an array of identity objects';
return;
}
// Call the batch PATCH endpoint
const response = await api.patch('/admin/identities', { identities: identitiesData });
batchResult = response.data;
await loadIdentities();
// Clear the input on success
batchJsonInput = '';
} catch (err: any) {
console.error('Error batch creating identities:', err);
if (err.response) {
batchError = `Failed to batch create identities: ${err.response.data?.error?.message || err.response.statusText}`;
} else if (err instanceof SyntaxError) {
batchError = `Invalid JSON: ${err.message}`;
} else {
batchError = err.message || 'Failed to batch create identities. Please try again.';
}
} finally {
batchLoading = false;
}
}
// Tab change handler
async function changeTab(tab: typeof activeTab) {
activeTab = tab;
if (tab === 'sessions' && sessions.length === 0) {
await loadSessions();
} else if (tab === 'courier' && courierMessages.length === 0) {
await loadCourierMessages();
}
}
// Computed filtered identities
const filteredIdentities = $derived(
identitySearch
? identities.filter(
(i) =>
i.traits?.email?.toLowerCase().includes(identitySearch.toLowerCase()) ||
i.traits?.name?.first?.toLowerCase().includes(identitySearch.toLowerCase()) ||
i.traits?.name?.last?.toLowerCase().includes(identitySearch.toLowerCase()) ||
i.id.toLowerCase().includes(identitySearch.toLowerCase())
)
: identities
);
</script>
<svelte:head>
<title>Admin Dashboard - Nexus Nexus</title>
</svelte:head>
<div class="min-h-screen bg-slate-100 py-8">
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div class="mb-8">
<h1 class="text-3xl font-bold text-slate-900">Admin Dashboard</h1>
<p class="mt-2 text-sm text-gray-600">
Logged in as: {data.session?.identity?.traits?.email || 'Unknown'}
</p>
</div>
<!-- Tab Navigation -->
<div class="mb-6">
<nav class="flex space-x-4" aria-label="Tabs">
<button
onclick={() => changeTab('identities')}
class="rounded-md px-3 py-2 text-sm font-medium"
class:bg-blue-100={activeTab === 'identities'}
class:text-blue-700={activeTab === 'identities'}
class:text-gray-500={activeTab !== 'identities'}
class:hover:text-gray-700={activeTab !== 'identities'}
>
Identities
</button>
<button
onclick={() => changeTab('sessions')}
class="rounded-md px-3 py-2 text-sm font-medium"
class:bg-blue-100={activeTab === 'sessions'}
class:text-blue-700={activeTab === 'sessions'}
class:text-gray-500={activeTab !== 'sessions'}
class:hover:text-gray-700={activeTab !== 'sessions'}
>
Sessions
</button>
<button
onclick={() => changeTab('recovery')}
class="rounded-md px-3 py-2 text-sm font-medium"
class:bg-blue-100={activeTab === 'recovery'}
class:text-blue-700={activeTab === 'recovery'}
class:text-gray-500={activeTab !== 'recovery'}
class:hover:text-gray-700={activeTab !== 'recovery'}
>
Recovery
</button>
<button
onclick={() => changeTab('courier')}
class="rounded-md px-3 py-2 text-sm font-medium"
class:bg-blue-100={activeTab === 'courier'}
class:text-blue-700={activeTab === 'courier'}
class:text-gray-500={activeTab !== 'courier'}
class:hover:text-gray-700={activeTab !== 'courier'}
>
Messages
</button>
<button
onclick={() => changeTab('batch')}
class="rounded-md px-3 py-2 text-sm font-medium"
class:bg-blue-100={activeTab === 'batch'}
class:text-blue-700={activeTab === 'batch'}
class:text-gray-500={activeTab !== 'batch'}
class:hover:text-gray-700={activeTab !== 'batch'}
>
Batch Operations
</button>
</nav>
</div>
<!-- Identities Tab -->
{#if activeTab === 'identities'}
<div class="rounded-lg bg-slate-50 shadow-md">
<div class="border-b border-slate-200 px-6 py-4">
<div class="flex items-center justify-between">
<h2 class="text-xl font-semibold text-slate-900">User Identities</h2>
<div class="flex space-x-2">
<button
onclick={exportIdentitiesToCSV}
class="rounded-md bg-purple-600 px-4 py-2 text-sm font-medium text-white hover:bg-purple-700"
title="Export filtered identities to CSV"
>
Export CSV
</button>
<button
onclick={() => (showCreateModal = true)}
class="rounded-md bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700"
>
Create Identity
</button>
<button
onclick={loadIdentities}
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
Refresh
</button>
</div>
</div>
<div class="mt-4">
<input
type="text"
bind:value={identitySearch}
placeholder="Search by email, name, or ID..."
class="w-full rounded-lg border border-gray-300 px-4 py-2 focus:border-blue-500 focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
<div class="px-6 py-4">
{#if identityLoading}
<p class="text-center text-gray-500">Loading identities...</p>
{:else if identityError}
<div class="rounded-lg bg-red-50 p-4">
<p class="text-sm text-red-800">{identityError}</p>
</div>
{:else if filteredIdentities.length === 0}
<p class="text-center text-gray-500">No identities found.</p>
{:else}
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-slate-200">
<thead class="bg-slate-100">
<tr>
<th
class="px-4 py-3 text-left text-xs font-medium tracking-wider text-slate-600 uppercase"
>
Email
</th>
<th
class="px-4 py-3 text-left text-xs font-medium tracking-wider text-slate-600 uppercase"
>
Name
</th>
<th
class="px-4 py-3 text-left text-xs font-medium tracking-wider text-slate-600 uppercase"
>
Verified
</th>
<th
class="px-4 py-3 text-left text-xs font-medium tracking-wider text-slate-600 uppercase"
>
Created
</th>
<th
class="px-4 py-3 text-left text-xs font-medium tracking-wider text-slate-600 uppercase"
>
State
</th>
<th
class="px-4 py-3 text-right text-xs font-medium tracking-wider text-slate-600 uppercase"
>
Actions
</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-200 bg-white">
{#each filteredIdentities as identity (identity.id)}
<tr class="hover:bg-slate-50">
<td class="px-4 py-4 text-sm whitespace-nowrap text-gray-900">
{identity.traits?.email || 'N/A'}
</td>
<td class="px-4 py-4 text-sm whitespace-nowrap text-gray-900">
{#if identity.traits?.name}
{identity.traits.name.first || ''} {identity.traits.name.last || ''}
{:else}
N/A
{/if}
</td>
<td class="px-4 py-4 whitespace-nowrap">
{#if hasUnverifiedEmail(identity)}
<span
class="inline-flex rounded-full bg-yellow-100 px-2 text-xs leading-5 font-semibold text-yellow-800"
>
Unverified
</span>
{:else}
<span
class="inline-flex rounded-full bg-green-100 px-2 text-xs leading-5 font-semibold text-green-800"
>
Verified
</span>
{/if}
</td>
<td class="px-4 py-4 text-sm whitespace-nowrap text-gray-500">
{new Date(identity.created_at || '').toLocaleDateString()}
</td>
<td class="px-4 py-4 whitespace-nowrap">
<span
class="inline-flex rounded-full px-2 text-xs leading-5 font-semibold"
class:bg-green-100={identity.state === 'active'}
class:text-green-800={identity.state === 'active'}
class:bg-gray-100={identity.state !== 'active'}
class:text-gray-800={identity.state !== 'active'}
>
{identity.state || 'unknown'}
</span>
</td>
<td class="space-x-2 px-4 py-4 text-right text-sm whitespace-nowrap">
<button
onclick={() => viewIdentity(identity)}
class="text-blue-600 hover:text-blue-900"
>
View
</button>
<button
onclick={() => startEdit(identity)}
class="text-green-600 hover:text-green-900"
>
Edit
</button>
{#if hasUnverifiedEmail(identity)}
<button
onclick={() => resendVerificationEmail(identity)}
class="text-orange-600 hover:text-orange-900"
title="Resend verification email"
>
Verify
</button>
{/if}
<button
onclick={() => viewIdentitySessions(identity)}
class="text-purple-600 hover:text-purple-900"
>
Sessions
</button>
<button
onclick={() => deleteIdentitySessions(identity.id)}
class="text-yellow-600 hover:text-yellow-900"
>
End All
</button>
<button
onclick={() => deleteIdentity(identity.id)}
class="text-red-600 hover:text-red-900"
>
Delete
</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<!-- Pagination Controls -->
{#if browser && !identitySearch}
<div class="mt-4 flex items-center justify-between border-t border-slate-200 pt-4">
<div class="text-sm text-slate-700">
Page {identityPage + 1} ({identities.length} identities)
</div>
<div class="flex space-x-2">
<button
onclick={prevIdentityPage}
disabled={identityPage === 0}
class="rounded-md bg-slate-200 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-300 disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
<button
onclick={nextIdentityPage}
disabled={!identityHasMore}
class="rounded-md bg-slate-200 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-300 disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
</div>
</div>
{/if}
{/if}
</div>
</div>
{/if}
<!-- Sessions Tab -->
{#if activeTab === 'sessions'}
<div class="rounded-lg bg-slate-50 shadow-md">
<div class="border-b border-slate-200 px-6 py-4">
<div class="flex items-center justify-between">
<h2 class="text-xl font-semibold text-slate-900">Active Sessions</h2>
<button
onclick={loadSessions}
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
Refresh
</button>
</div>
</div>
<div class="px-6 py-4">
{#if sessionLoading}
<p class="text-center text-gray-500">Loading sessions...</p>
{:else if sessionError}
<div class="rounded-lg bg-red-50 p-4">
<p class="text-sm text-red-800">{sessionError}</p>
</div>
{:else if sessions.length === 0}
<p class="text-center text-gray-500">No active sessions found.</p>
{:else}
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-slate-200">
<thead class="bg-slate-100">
<tr>
<th
class="px-4 py-3 text-left text-xs font-medium tracking-wider text-slate-600 uppercase"
>
User Email
</th>
<th
class="px-4 py-3 text-left text-xs font-medium tracking-wider text-slate-600 uppercase"
>
Session ID
</th>
<th
class="px-4 py-3 text-left text-xs font-medium tracking-wider text-slate-600 uppercase"
>
Issued At
</th>
<th
class="px-4 py-3 text-left text-xs font-medium tracking-wider text-slate-600 uppercase"
>
Expires At
</th>
<th
class="px-4 py-3 text-right text-xs font-medium tracking-wider text-slate-600 uppercase"
>
Actions
</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-200 bg-white">
{#each sessions as session (session.id)}
<tr class="hover:bg-slate-50">
<td class="px-4 py-4 text-sm whitespace-nowrap text-gray-900">
{session.identity?.traits?.email || 'N/A'}
</td>
<td class="px-4 py-4 font-mono text-sm whitespace-nowrap text-gray-600">
{session.id.substring(0, 8)}...
</td>
<td class="px-4 py-4 text-sm whitespace-nowrap text-gray-500">
{new Date(session.issued_at || '').toLocaleString()}
</td>
<td class="px-4 py-4 text-sm whitespace-nowrap text-gray-500">
{new Date(session.expires_at || '').toLocaleString()}
</td>
<td class="space-x-2 px-4 py-4 text-right text-sm whitespace-nowrap">
<button
onclick={() => viewSession(session)}
class="text-blue-600 hover:text-blue-900"
>
View
</button>
<button
onclick={() => extendSession(session.id)}
class="text-green-600 hover:text-green-900"
>
Extend
</button>
<button
onclick={() => deleteSession(session.id)}
class="text-red-600 hover:text-red-900"
>
Revoke
</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<!-- Pagination Controls -->
{#if browser}
<div class="mt-4 flex items-center justify-between border-t border-slate-200 pt-4">
<div class="text-sm text-slate-700">
Page {sessionPage + 1} ({sessions.length} sessions)
</div>
<div class="flex space-x-2">
<button
onclick={prevSessionPage}
disabled={sessionPage === 0}
class="rounded-md bg-slate-200 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-300 disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
<button
onclick={nextSessionPage}
disabled={!sessionHasMore}
class="rounded-md bg-slate-200 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-300 disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
</div>
</div>
{/if}
{/if}
</div>
</div>
{/if}
<!-- Recovery Tab -->
{#if activeTab === 'recovery'}
<div class="rounded-lg bg-slate-50 shadow-md">
<div class="border-b border-slate-200 px-6 py-4">
<h2 class="text-xl font-semibold text-slate-900">Account Recovery</h2>
<p class="mt-1 text-sm text-slate-600">Generate recovery links or codes for users</p>
</div>
<div class="px-6 py-4">
<div class="max-w-md">
<div class="mb-4">
<label for="recovery-email" class="mb-1 block text-sm font-medium text-gray-700">
User Email
</label>
<input
id="recovery-email"
type="email"
bind:value={recoveryEmail}
placeholder="user@example.com"
class="w-full rounded-lg border border-gray-300 px-4 py-2 focus:border-blue-500 focus:ring-2 focus:ring-blue-500"
/>
</div>
<div class="mb-4">
<label for="recovery-type" class="mb-1 block text-sm font-medium text-gray-700">
Recovery Type
</label>
<select
id="recovery-type"
bind:value={recoveryType}
class="w-full rounded-lg border border-gray-300 px-4 py-2 focus:border-blue-500 focus:ring-2 focus:ring-blue-500"
>
<option value="link">Recovery Link</option>
<option value="code">Recovery Code</option>
</select>
</div>
<button
onclick={createRecovery}
disabled={recoveryLoading}
class="w-full rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{recoveryLoading ? 'Generating...' : 'Generate Recovery'}
</button>
{#if recoveryError}
<div class="mt-4 rounded-lg bg-red-50 p-4">
<p class="text-sm text-red-800">{recoveryError}</p>
</div>
{/if}
{#if recoveryResult}
<div class="mt-4 rounded-lg bg-green-50 p-4">
<p class="mb-2 text-sm font-medium text-green-800">
Recovery generated successfully!
</p>
<div class="rounded bg-white p-3">
<pre class="overflow-x-auto text-xs text-gray-800">{JSON.stringify(
recoveryResult,
null,
2
)}</pre>
</div>
<button
onclick={() => {
navigator.clipboard.writeText(
recoveryResult.recovery_link || recoveryResult.recovery_code
);
alert('Copied to clipboard!');
}}
class="mt-2 rounded bg-green-600 px-3 py-1 text-xs text-white hover:bg-green-700"
>
Copy to Clipboard
</button>
</div>
{/if}
</div>
</div>
</div>
{/if}
<!-- Courier Messages Tab -->
{#if activeTab === 'courier'}
<div class="rounded-lg bg-slate-50 shadow-md">
<div class="border-b border-slate-200 px-6 py-4">
<div class="flex items-center justify-between">
<h2 class="text-xl font-semibold text-slate-900">Courier Messages</h2>
<button
onclick={loadCourierMessages}
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
Refresh
</button>
</div>
<div class="mt-4 flex space-x-4">
<input
type="text"
bind:value={courierRecipient}
placeholder="Filter by recipient email..."
class="flex-1 rounded-lg border border-gray-300 px-4 py-2 focus:border-blue-500 focus:ring-2 focus:ring-blue-500"
/>
<select
bind:value={courierStatus}
onchange={() => {
resetCourierPage();
}}
class="rounded-lg border border-gray-300 px-4 py-2 focus:border-blue-500 focus:ring-2 focus:ring-blue-500"
>
<option value="">All Statuses</option>
<option value="queued">Queued</option>
<option value="sent">Sent</option>
<option value="processing">Processing</option>
<option value="abandoned">Abandoned</option>
</select>
</div>
</div>
<div class="px-6 py-4">
{#if courierLoading}
<p class="text-center text-gray-500">Loading messages...</p>
{:else if courierError}
<div class="rounded-lg bg-red-50 p-4">
<p class="text-sm text-red-800">{courierError}</p>
</div>
{:else if courierMessages.length === 0}
<p class="text-center text-gray-500">No messages found.</p>
{:else}
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-slate-200">
<thead class="bg-slate-100">
<tr>
<th
class="px-4 py-3 text-left text-xs font-medium tracking-wider text-slate-600 uppercase"
>
Recipient
</th>
<th
class="px-4 py-3 text-left text-xs font-medium tracking-wider text-slate-600 uppercase"
>
Subject
</th>
<th
class="px-4 py-3 text-left text-xs font-medium tracking-wider text-slate-600 uppercase"
>
Type
</th>
<th
class="px-4 py-3 text-left text-xs font-medium tracking-wider text-slate-600 uppercase"
>
Status
</th>
<th
class="px-4 py-3 text-left text-xs font-medium tracking-wider text-slate-600 uppercase"
>
Created
</th>
<th
class="px-4 py-3 text-right text-xs font-medium tracking-wider text-slate-600 uppercase"
>
Actions
</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-200 bg-white">
{#each courierMessages as message (message.id)}
<tr class="hover:bg-slate-50">
<td class="px-4 py-4 text-sm whitespace-nowrap text-gray-900">
{message.recipient || 'N/A'}
</td>
<td class="px-4 py-4 text-sm text-gray-900">
{message.subject || 'N/A'}
</td>
<td class="px-4 py-4 text-sm whitespace-nowrap text-gray-500">
{message.template_type || message.channel || 'N/A'}
</td>
<td class="px-4 py-4 whitespace-nowrap">
<span
class="inline-flex rounded-full px-2 text-xs leading-5 font-semibold"
class:bg-green-100={message.status === 'sent'}
class:text-green-800={message.status === 'sent'}
class:bg-yellow-100={message.status === 'queued' ||
message.status === 'processing'}
class:text-yellow-800={message.status === 'queued' ||
message.status === 'processing'}
class:bg-red-100={message.status === 'abandoned'}
class:text-red-800={message.status === 'abandoned'}
>
{message.status || 'unknown'}
</span>
</td>
<td class="px-4 py-4 text-sm whitespace-nowrap text-gray-500">
{message.created_at ? new Date(message.created_at).toLocaleString() : 'N/A'}
</td>
<td class="px-4 py-4 text-right text-sm whitespace-nowrap">
<button
onclick={() => viewMessage(message)}
class="text-blue-600 hover:text-blue-900"
>
View
</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<!-- Pagination Controls -->
{#if browser}
<div class="mt-4 flex items-center justify-between border-t border-slate-200 pt-4">
<div class="text-sm text-slate-700">
{courierMessages.length} messages
{#if courierPageToken}
<button
onclick={resetCourierPage}
class="ml-2 text-blue-600 hover:text-blue-900"
>
(Reset to first page)
</button>
{/if}
</div>
<button
onclick={nextCourierPage}
disabled={!courierHasMore}
class="rounded-md bg-slate-200 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-300 disabled:opacity-50 disabled:cursor-not-allowed"
>
Next Page
</button>
</div>
{/if}
{/if}
</div>
</div>
{/if}
<!-- Batch Operations Tab -->
{#if activeTab === 'batch'}
<div class="rounded-lg bg-slate-50 shadow-md">
<div class="border-b border-slate-200 px-6 py-4">
<h2 class="text-xl font-semibold text-slate-900">Batch Identity Operations</h2>
<p class="mt-1 text-sm text-slate-600">
Create multiple identities at once by providing JSON data
</p>
</div>
<div class="px-6 py-4">
<div class="max-w-4xl">
<div class="mb-4">
<label for="batch-json" class="mb-1 block text-sm font-medium text-gray-700">
Identity JSON Array
</label>
<p class="mb-2 text-xs text-gray-500">
Provide an array of identity objects. Each identity should have schema_id and
traits.
</p>
<textarea
id="batch-json"
bind:value={batchJsonInput}
placeholder={`[\n {\n "schema_id": "default",\n "traits": {\n "email": "user1@example.com",\n "name": {\n "first": "John",\n "last": "Doe"\n }\n }\n },\n {\n "schema_id": "default",\n "traits": {\n "email": "user2@example.com",\n "name": {\n "first": "Jane",\n "last": "Smith"\n }\n }\n }\n]`}
rows="15"
class="w-full rounded-lg border border-gray-300 px-4 py-2 font-mono text-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-500"
></textarea>
</div>
<button
onclick={batchCreateIdentities}
disabled={batchLoading}
class="w-full rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700 disabled:opacity-50"
>
{batchLoading ? 'Creating Identities...' : 'Batch Create Identities'}
</button>
{#if batchError}
<div class="mt-4 rounded-lg bg-red-50 p-4">
<p class="text-sm text-red-800">{batchError}</p>
</div>
{/if}
{#if batchResult}
<div class="mt-4 rounded-lg bg-green-50 p-4">
<p class="mb-2 text-sm font-medium text-green-800">
Batch operation completed successfully!
</p>
<div class="rounded bg-white p-3">
<pre class="overflow-x-auto text-xs text-gray-800">{JSON.stringify(
batchResult,
null,
2
)}</pre>
</div>
<button
onclick={() => (batchResult = null)}
class="mt-2 rounded bg-blue-600 px-3 py-1 text-xs text-white hover:bg-blue-700"
>
Clear Result
</button>
</div>
{/if}
<div class="mt-6 rounded-lg bg-blue-50 p-4">
<h3 class="mb-2 text-sm font-medium text-blue-900">Example JSON Format</h3>
<pre class="overflow-x-auto text-xs text-blue-800">{`[
{
"schema_id": "default",
"traits": {
"email": "user@example.com",
"name": {
"first": "First",
"last": "Last"
}
}
}
]`}</pre>
</div>
</div>
</div>
</div>
{/if}
</div>
</div>
<!-- Modals -->
<IdentityDetailsModal
identity={selectedIdentity}
onClose={closeModal}
onDeleteCredential={deleteCredential}
/>
<IdentityEditModal
identity={editingIdentity}
onClose={closeModal}
onSave={updateIdentity}
loading={identityLoading}
error={identityError}
/>
<IdentityCreateModal
open={showCreateModal}
form={createForm}
onClose={closeModal}
onCreate={createIdentity}
loading={identityLoading}
error={identityError}
/>
<SessionDetailsModal session={selectedSession} onClose={closeModal} />
<IdentitySessionsModal
data={identitySessionsModal}
onClose={closeModal}
onViewSession={(session) => {
selectedSession = session;
identitySessionsModal = null;
}}
onExtendSession={extendSession}
onDeleteSession={deleteSession}
/>
<MessageDetailsModal message={selectedMessage} onClose={closeModal} />