-
-
-
+ {/* Show new client secret once */}
+ {newSecret && (
+
+
+
+
+
Client created — save your secret now
+
This secret will not be shown again.
+
+ {newSecret.secret}
+
+
+
+
+
+
+ )}
+
+ {isLoading ? (
+
+
+
+ ) : clients.length === 0 ? (
+
+
+
+ No OIDC clients configured yet.
+
+
+
+ ) : (
+
+ {clients.map((client) => (
+
+
+
+
+
+
+
+
+
{client.name}
+
+
+ {client.client_id}
+
+
+
+
+ {(client.scopes ?? []).map((scope) => (
+
+ {scope}
+
+ ))}
+
+
-
-
{client.name}
-
-
- {client.clientId}
-
-
-
- {client.scopes.map((scope) => (
-
- {scope}
-
- ))}
-
+
+
+
+ handleDelete(client.id)}
+ >
+
+ Delete client
+
+
+
+
+
+
+ Created {new Date(client.created_at).toLocaleDateString()}
+
+
+ {(client.redirect_uris ?? []).length} redirect URI{(client.redirect_uris ?? []).length !== 1 ? "s" : ""}
-
-
-
-
-
-
-
-
-
- View details
-
-
-
- Rotate secret
-
-
-
-
- Delete client
-
-
-
-
-
-
- Created {client.createdAt}
- •
- Last used {client.lastUsed}
-
-
- {client.redirectUris.length} redirect URI{client.redirectUris.length > 1 ? "s" : ""}
-
-
-
-
- ))}
-
+
+
+ ))}
+
+ )}
);
}
diff --git a/src/pages/org/OrgAuditPage.tsx b/src/pages/org/OrgAuditPage.tsx
index 2105d69..a62bee4 100644
--- a/src/pages/org/OrgAuditPage.tsx
+++ b/src/pages/org/OrgAuditPage.tsx
@@ -1,90 +1,77 @@
-import { useState } from "react";
-import { Search, Filter, Download, User, Settings, Key, UserPlus, AlertTriangle } from "lucide-react";
+import { useState, useEffect, useCallback } from "react";
+import { Search, Filter, Download, User, Settings, Key, UserPlus, AlertTriangle, Loader2 } from "lucide-react";
+import { useParams } from "react-router-dom";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { api, AuditLogEntry } from "@/lib/api";
+import { useCurrentOrganizationId } from "@/hooks/useCurrentOrganization";
-const auditEvents = [
- {
- id: "1",
- type: "member_invited",
- actor: "John Doe",
- target: "alice@example.com",
- timestamp: "2024-01-15T10:30:00Z",
- details: "Invited as member",
- },
- {
- id: "2",
- type: "policy_changed",
- actor: "John Doe",
- target: "Password Policy",
- timestamp: "2024-01-15T09:00:00Z",
- details: "Minimum length changed from 8 to 12",
- },
- {
- id: "3",
- type: "member_disabled",
- actor: "Jane Smith",
- target: "bob@example.com",
- timestamp: "2024-01-14T15:45:00Z",
- details: "Account disabled",
- },
- {
- id: "4",
- type: "client_created",
- actor: "John Doe",
- target: "GitLab",
- timestamp: "2024-01-14T12:00:00Z",
- details: "OIDC client created",
- },
- {
- id: "5",
- type: "role_changed",
- actor: "John Doe",
- target: "jane@example.com",
- timestamp: "2024-01-13T09:00:00Z",
- details: "Role changed from member to admin",
- },
-];
-
-const getEventIcon = (type: string) => {
- switch (type) {
- case "member_invited":
- case "role_changed":
- return
;
- case "policy_changed":
- return
;
- case "member_disabled":
- return
;
- case "client_created":
- return
;
- default:
- return
;
+const getEventIcon = (action: string) => {
+ if (action.includes("member") || action.includes("MEMBER")) {
+ return
;
}
+ if (action.includes("policy") || action.includes("POLICY") || action.includes("mfa")) {
+ return
;
+ }
+ if (action.includes("delete") || action.includes("DELETE") || action.includes("disable")) {
+ return
;
+ }
+ if (action.includes("client") || action.includes("oidc") || action.includes("key")) {
+ return
;
+ }
+ return
;
};
-const getEventTitle = (type: string) => {
- switch (type) {
- case "member_invited":
- return "Member invited";
- case "policy_changed":
- return "Policy changed";
- case "member_disabled":
- return "Member disabled";
- case "client_created":
- return "OIDC client created";
- case "role_changed":
- return "Role changed";
- default:
- return "Event";
- }
+const getEventTitle = (action: string) => {
+ const parts = action.split(".");
+ return parts.map(p => p.charAt(0).toUpperCase() + p.slice(1)).join(" ");
+};
+
+const getActionCategory = (action: string): string => {
+ if (action.includes("member") || action.includes("MEMBER")) return "members";
+ if (action.includes("policy") || action.includes("POLICY") || action.includes("mfa")) return "policies";
+ if (action.includes("client") || action.includes("OIDC")) return "clients";
+ return "other";
};
export default function OrgAuditPage() {
+ const params = useParams<{ orgId?: string }>();
+ const { orgId: fallbackOrgId } = useCurrentOrganizationId();
+ const orgId = params.orgId || fallbackOrgId;
+
const [search, setSearch] = useState("");
const [typeFilter, setTypeFilter] = useState("all");
+ const [auditLogs, setAuditLogs] = useState
([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ const fetchAuditLogs = useCallback(async (currentOrgId: string) => {
+ try {
+ setIsLoading(true);
+ setError(null);
+ const response = await api.organizations.getAuditLogs(currentOrgId);
+ setAuditLogs(response.audit_logs || []);
+ } catch (err) {
+ console.error("Failed to fetch audit logs:", err);
+ setError("Failed to load audit logs. Please try again.");
+ } finally {
+ setIsLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ setError(null);
+ setAuditLogs([]);
+ if (!orgId) {
+ setIsLoading(false);
+ return;
+ }
+ fetchAuditLogs(orgId);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [orgId]);
const formatDate = (dateString: string) => {
const date = new Date(dateString);
@@ -96,6 +83,20 @@ export default function OrgAuditPage() {
}).format(date);
};
+ const filteredLogs = auditLogs.filter((log) => {
+ const matchesSearch =
+ search === "" ||
+ log.description?.toLowerCase().includes(search.toLowerCase()) ||
+ log.action.toLowerCase().includes(search.toLowerCase()) ||
+ log.user?.email.toLowerCase().includes(search.toLowerCase());
+
+ const matchesFilter =
+ typeFilter === "all" ||
+ getActionCategory(log.action) === typeFilter;
+
+ return matchesSearch && matchesFilter;
+ });
+
return (
@@ -137,39 +138,65 @@ export default function OrgAuditPage() {
-
- {auditEvents.map((event) => (
-
-
- {getEventIcon(event.type)}
-
-
-
-
- {getEventTitle(event.type)}
-
-
- {event.target}
-
+ {isLoading ? (
+
+
+ Loading audit logs...
+
+ ) : error ? (
+
+ {error}
+
+ ) : filteredLogs.length === 0 ? (
+
+ No audit events found
+
+ ) : (
+
+ {filteredLogs.map((log) => (
+
+
+ {getEventIcon(log.action)}
-
-
by {event.actor}
-
•
-
{event.details}
+
+
+
+ {getEventTitle(log.action)}
+
+ {log.resource_type && (
+
+ {log.resource_type}
+
+ )}
+ {!log.success && (
+
+ Failed
+
+ )}
+
+
+ by {log.user?.full_name || log.user?.email || "System"}
+ {log.description && (
+ <>
+ •
+ {log.description}
+ >
+ )}
+
+
+ {formatDate(log.created_at)}
+
-
- {formatDate(event.timestamp)}
-
-
- ))}
-
+ ))}
+
+ )}
diff --git a/src/pages/org/OrgOverviewPage.tsx b/src/pages/org/OrgOverviewPage.tsx
index c009095..56d6d13 100644
--- a/src/pages/org/OrgOverviewPage.tsx
+++ b/src/pages/org/OrgOverviewPage.tsx
@@ -1,20 +1,33 @@
-import { Building2, Users, Shield, Key, ArrowRight, TrendingUp } from "lucide-react";
+import { useEffect, useState } from "react";
+import { Building2, Users, Shield, Key, ArrowRight, TrendingUp, Loader2 } from "lucide-react";
import { Link } from "react-router-dom";
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
-import { Button } from "@/components/ui/button";
+import { Card, CardContent } from "@/components/ui/card";
+import { api, Organization, OIDCClient } from "@/lib/api";
export default function OrgOverviewPage() {
- // Mock organization data
- const org = {
- name: "Acme Corp",
- createdAt: "January 2024",
- stats: {
- totalMembers: 24,
- activeToday: 18,
- pendingInvites: 3,
- oidcClients: 5,
- },
- };
+ const [org, setOrg] = useState
(null);
+ const [memberCount, setMemberCount] = useState(0);
+ const [clientCount, setClientCount] = useState(0);
+ const [isLoading, setIsLoading] = useState(true);
+
+ useEffect(() => {
+ api.users.organizations()
+ .then(async (data) => {
+ if (!data.organizations.length) return;
+ const first = data.organizations[0];
+ setOrg(first);
+
+ const [membersResp, clientsResp] = await Promise.allSettled([
+ api.organizations.getMembers(first.id),
+ api.organizations.getClients(first.id),
+ ]);
+
+ if (membersResp.status === "fulfilled") setMemberCount(membersResp.value.count);
+ if (clientsResp.status === "fulfilled") setClientCount((clientsResp.value as { clients: OIDCClient[]; count: number }).count);
+ })
+ .catch(console.error)
+ .finally(() => setIsLoading(false));
+ }, []);
const quickLinks = [
{
@@ -37,6 +50,18 @@ export default function OrgOverviewPage() {
},
];
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ const createdAt = org?.created_at
+ ? new Date(org.created_at).toLocaleDateString("en-US", { month: "long", year: "numeric" })
+ : "";
+
return (
@@ -45,42 +70,20 @@ export default function OrgOverviewPage() {
-
{org.name}
-
Created {org.createdAt}
+
{org?.name ?? "Organization"}
+ {createdAt &&
Created {createdAt}
}
{/* Stats */}
-
+
Total Members
-
{org.stats.totalMembers}
-
-
-
-
-
-
-
-
-
-
Active Today
-
{org.stats.activeToday}
-
-
-
-
-
-
-
-
-
-
Pending Invites
-
{org.stats.pendingInvites}
+
{memberCount}
@@ -91,12 +94,23 @@ export default function OrgOverviewPage() {
OIDC Clients
-
{org.stats.oidcClients}
+
{clientCount}
+
+
+
+
+
Org ID
+
{org?.id ?? "—"}
+
+
+
+
+
{/* Quick Links */}
diff --git a/src/pages/org/PrincipalsPage.tsx b/src/pages/org/PrincipalsPage.tsx
new file mode 100644
index 0000000..daf1d6d
--- /dev/null
+++ b/src/pages/org/PrincipalsPage.tsx
@@ -0,0 +1,390 @@
+import { useState, useEffect } from "react";
+import { Search, Plus, MoreHorizontal, Users, Loader2, Trash2, Edit2, Link as LinkIcon, Unlink } from "lucide-react";
+import { useParams } from "react-router-dom";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Card, CardContent } from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Label } from "@/components/ui/label";
+import { Textarea } from "@/components/ui/textarea";
+import { api, Principal, Department } from "@/lib/api";
+import { useCurrentOrganizationId } from "@/hooks/useCurrentOrganization";
+
+export default function PrincipalsPage() {
+ const params = useParams<{ orgId?: string }>();
+ const { orgId: fallbackOrgId } = useCurrentOrganizationId();
+ const orgId = params.orgId || fallbackOrgId;
+
+ const [search, setSearch] = useState("");
+ const [principals, setPrincipals] = useState
([]);
+ const [departments, setDepartments] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
+ const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
+ const [isLinkDialogOpen, setIsLinkDialogOpen] = useState(false);
+ const [editingPrincipal, setEditingPrincipal] = useState(null);
+ const [selectedPrincipalForLink, setSelectedPrincipalForLink] = useState(null);
+ const [selectedDepartmentId, setSelectedDepartmentId] = useState("");
+ const [formData, setFormData] = useState({ name: "", description: "" });
+
+ useEffect(() => {
+ setError(null);
+ setPrincipals([]);
+ setDepartments([]);
+ if (!orgId) {
+ setIsLoading(false);
+ return;
+ }
+ fetchData(orgId);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [orgId]);
+
+ const fetchData = async (currentOrgId: string) => {
+ try {
+ setIsLoading(true);
+ setError(null);
+ const [principalsRes, deptRes] = await Promise.all([
+ api.organizations.getPrincipals(currentOrgId),
+ api.organizations.getDepartments(currentOrgId),
+ ]);
+ setPrincipals(principalsRes.principals || []);
+ setDepartments(deptRes.departments || []);
+ } catch (err) {
+ console.error("Failed to fetch data:", err);
+ setError("Failed to load data. Please try again.");
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const handleCreatePrincipal = async () => {
+ if (!orgId || !formData.name.trim()) return;
+
+ try {
+ await api.organizations.createPrincipal(
+ orgId,
+ formData.name,
+ formData.description || undefined
+ );
+ setFormData({ name: "", description: "" });
+ setIsCreateDialogOpen(false);
+ await fetchData(orgId);
+ } catch (err) {
+ console.error("Failed to create principal:");
+ setError("Failed to create principal.");
+ }
+ };
+
+ const handleUpdatePrincipal = async () => {
+ if (!orgId || !editingPrincipal || !formData.name.trim()) return;
+
+ try {
+ await api.organizations.updatePrincipal(
+ orgId,
+ editingPrincipal.id,
+ {
+ name: formData.name,
+ description: formData.description || undefined,
+ }
+ );
+ setFormData({ name: "", description: "" });
+ setEditingPrincipal(null);
+ setIsEditDialogOpen(false);
+ await fetchData(orgId);
+ } catch (err) {
+ console.error("Failed to update principal:");
+ setError("Failed to update principal.");
+ }
+ };
+
+ const handleDeletePrincipal = async (principalId: string) => {
+ if (!orgId || !confirm("Are you sure you want to delete this principal?")) return;
+
+ try {
+ await api.organizations.deletePrincipal(orgId, principalId);
+ await fetchData(orgId);
+ } catch (err) {
+ console.error("Failed to delete principal:");
+ setError("Failed to delete principal.");
+ }
+ };
+
+ const handleLinkPrincipal = async () => {
+ if (!orgId || !selectedPrincipalForLink || !selectedDepartmentId) return;
+
+ try {
+ await api.organizations.linkPrincipalToDepartment(
+ orgId,
+ selectedPrincipalForLink.id,
+ selectedDepartmentId
+ );
+ setSelectedPrincipalForLink(null);
+ setSelectedDepartmentId("");
+ setIsLinkDialogOpen(false);
+ await fetchData(orgId);
+ } catch (err) {
+ console.error("Failed to link principal:");
+ setError("Failed to link principal to department.");
+ }
+ };
+
+ const openEditDialog = (principal: Principal) => {
+ setEditingPrincipal(principal);
+ setFormData({ name: principal.name, description: principal.description || "" });
+ setIsEditDialogOpen(true);
+ };
+
+ const openLinkDialog = (principal: Principal) => {
+ setSelectedPrincipalForLink(principal);
+ setSelectedDepartmentId("");
+ setIsLinkDialogOpen(true);
+ };
+
+ const filteredPrincipals = principals.filter((principal) => {
+ const searchLower = search.toLowerCase();
+ return (
+ principal.name.toLowerCase().includes(searchLower) ||
+ (principal.description?.toLowerCase().includes(searchLower) ?? false)
+ );
+ });
+
+ return (
+
+
+
+
Principals
+
+ Manage principals and link them to departments
+
+
+
{ setFormData({ name: "", description: "" }); setIsCreateDialogOpen(true); }}>
+
+ Create Principal
+
+
+
+
+
+
+ setSearch(e.target.value)}
+ className="pl-10 max-w-sm"
+ />
+
+
+
+
+
+ {isLoading ? (
+
+
+ Loading principals...
+
+ ) : error ? (
+
+ {error}
+
+ ) : filteredPrincipals.length === 0 ? (
+
+ No principals found
+
+ ) : (
+
+ {filteredPrincipals.map((principal) => (
+
+
+
+
+
+
+ {principal.description && (
+
+ {principal.description}
+
+ )}
+
+ Created {new Date(principal.created_at).toLocaleDateString()}
+
+
+
+
+
+
+
+
+
+ openEditDialog(principal)}>
+
+ Edit
+
+ openLinkDialog(principal)}>
+
+ Link to Department
+
+
+ handleDeletePrincipal(principal.id)}
+ >
+
+ Delete
+
+
+
+
+ ))}
+
+ )}
+
+
+
+ {/* Create Principal Dialog */}
+
+
+ {/* Edit Principal Dialog */}
+
+
+ {/* Link Principal to Department Dialog */}
+
+
+ );
+}
diff --git a/src/pages/user/ActivityPage.tsx b/src/pages/user/ActivityPage.tsx
index a68c42c..cf32b89 100644
--- a/src/pages/user/ActivityPage.tsx
+++ b/src/pages/user/ActivityPage.tsx
@@ -1,104 +1,53 @@
-import { useState } from "react";
-import { LogIn, LogOut, Key, Fingerprint, Smartphone, AlertTriangle, CheckCircle, MapPin } from "lucide-react";
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { useState, useEffect } from "react";
+import { LogIn, LogOut, Key, Fingerprint, Smartphone, AlertTriangle, Loader2, RefreshCw } from "lucide-react";
+import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { api, AuditLogEntry } from "@/lib/api";
-const activityEvents = [
- {
- id: "1",
- type: "login_success",
- method: "password",
- timestamp: "2024-01-15T10:30:00Z",
- location: "San Francisco, CA",
- device: "Chrome on macOS",
- ip: "192.168.1.1",
- },
- {
- id: "2",
- type: "login_success",
- method: "passkey",
- timestamp: "2024-01-14T15:45:00Z",
- location: "San Francisco, CA",
- device: "Safari on iOS",
- ip: "192.168.1.2",
- },
- {
- id: "3",
- type: "login_failed",
- method: "password",
- timestamp: "2024-01-14T12:00:00Z",
- location: "Unknown",
- device: "Firefox on Windows",
- ip: "10.0.0.5",
- },
- {
- id: "4",
- type: "mfa_enabled",
- method: "totp",
- timestamp: "2024-01-13T09:00:00Z",
- location: "San Francisco, CA",
- device: "Chrome on macOS",
- ip: "192.168.1.1",
- },
- {
- id: "5",
- type: "passkey_added",
- method: "passkey",
- timestamp: "2024-01-12T14:30:00Z",
- location: "San Francisco, CA",
- device: "Safari on macOS",
- ip: "192.168.1.1",
- },
-];
-
-const getEventIcon = (type: string, method: string) => {
- switch (type) {
- case "login_success":
- return method === "passkey" ? (
-
- ) : (
-
- );
- case "login_failed":
- return ;
- case "mfa_enabled":
- return ;
- case "passkey_added":
- return ;
- case "logout":
- return ;
- default:
- return ;
+// Map audit log action strings to display info
+const getEventDisplay = (action: string) => {
+ const a = action.toLowerCase();
+ if (a.includes("login") && a.includes("fail")) {
+ return { icon: , title: "Failed login attempt", failed: true };
}
-};
-
-const getEventTitle = (type: string, method: string) => {
- switch (type) {
- case "login_success":
- return `Signed in with ${method}`;
- case "login_failed":
- return "Failed login attempt";
- case "mfa_enabled":
- return "Two-factor authentication enabled";
- case "passkey_added":
- return "Passkey added";
- case "logout":
- return "Signed out";
- default:
- return "Security event";
+ if (a.includes("login") || a.includes("authenticate")) {
+ return { icon: , title: "Signed in", failed: false };
}
-};
-
-const getEventStatus = (type: string) => {
- if (type === "login_failed") {
- return { variant: "destructive" as const, label: "Failed" };
+ if (a.includes("logout") || a.includes("sign_out")) {
+ return { icon: , title: "Signed out", failed: false };
}
- return { variant: "default" as const, label: "Success" };
+ if (a.includes("passkey") || a.includes("webauthn")) {
+ return { icon: , title: "Passkey event", failed: false };
+ }
+ if (a.includes("mfa") || a.includes("totp") || a.includes("2fa")) {
+ return { icon: , title: "MFA event", failed: false };
+ }
+ if (a.includes("ssh")) {
+ return { icon: , title: "SSH key event", failed: false };
+ }
+ return { icon: , title: action.replace(/_/g, " "), failed: !action.includes("success") && a.includes("fail") };
};
export default function ActivityPage() {
const [filter, setFilter] = useState("all");
+ const [events, setEvents] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState("");
+
+ const loadEvents = () => {
+ setIsLoading(true);
+ setError("");
+ api.users.auditLogs({ per_page: "50" })
+ .then((data) => {
+ setEvents(data.audit_logs ?? []);
+ })
+ .catch(() => setError("Failed to load activity. Please try again."))
+ .finally(() => setIsLoading(false));
+ };
+
+ useEffect(() => { loadEvents(); }, []);
const formatDate = (dateString: string) => {
const date = new Date(dateString);
@@ -110,6 +59,16 @@ export default function ActivityPage() {
}).format(date);
};
+ const filteredEvents = events.filter((e) => {
+ if (filter === "all") return true;
+ const a = e.action.toLowerCase();
+ if (filter === "logins")
+ return a.includes("session_create") || a.includes("session_revoke") || a.includes("external_auth") || a.includes("login") || a.includes("logout");
+ if (filter === "security")
+ return a.includes("mfa") || a.includes("passkey") || a.includes("ssh") || a.includes("totp") || a.includes("password") || a.includes("webauthn");
+ return true;
+ });
+
return (
@@ -119,62 +78,84 @@ export default function ActivityPage() {
Your recent account activity and security events
-
+
+
+
+
+
+
-
- {activityEvents.map((event) => {
- const status = getEventStatus(event.type);
- return (
-
-
- {getEventIcon(event.type, event.method)}
-
-
-
-
- {getEventTitle(event.type, event.method)}
-
- {event.type === "login_failed" && (
-
- Failed
-
- )}
+ {isLoading ? (
+
+
+
+ ) : error ? (
+
+ ) : filteredEvents.length === 0 ? (
+
+
No activity events found.
+
+ ) : (
+
+ {filteredEvents.map((event) => {
+ const display = getEventDisplay(event.action);
+ return (
+
+
+ {display.icon}
-
-
{event.device}
-
-
-
{event.location}
-
•
-
{event.ip}
+
+
+
+ {display.title}
+
+ {(!event.success || display.failed) && (
+
+ Failed
+
+ )}
+
+
+ {event.description &&
{event.description}
}
+
+ {event.ip_address && (
+ {event.ip_address}
+ )}
+ {event.user_agent && (
+ {event.user_agent}
+ )}
+
+
+ {formatDate(event.created_at)}
+
-
- {formatDate(event.timestamp)}
-
-
- );
- })}
-
+ );
+ })}
+
+ )}