1445 lines
45 KiB
Svelte
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} />
|