+
+
+
+
+
+ @if (loading()) {
+ Loading…
+ } @else if (error(); as msg) {
+ {{ msg }}
+ } @else if (page(); as p) {
+ {{ resultRange() }}
+ }
+
+
+ @if (page(); as p) { @if (p.items.length === 0) {
+ No users match the current filters.
+ } @else {
+
+
+
+
+ | Display name |
+ Username |
+ Audience |
+ First seen |
+ Last seen |
+ OID |
+
+
+
+ @for (user of p.items; track user.oid) {
+
+ | {{ user.displayName }} |
+ {{ user.username }} |
+
+ {{ user.audience }}
+ |
+ {{ formatTimestamp(user.firstSeenAt) }} |
+ {{ formatTimestamp(user.lastSeenAt) }} |
+ {{ user.oid }} |
+
+ }
+
+
+
+
+
+ } }
+
diff --git a/apps/portal-admin/src/app/pages/users/users.scss b/apps/portal-admin/src/app/pages/users/users.scss
new file mode 100644
index 0000000..bb7f5ad
--- /dev/null
+++ b/apps/portal-admin/src/app/pages/users/users.scss
@@ -0,0 +1,285 @@
+.users {
+ max-width: 1200px;
+ margin: 0 auto;
+}
+
+.users-header {
+ margin-bottom: 1.5rem;
+}
+
+.title {
+ font-size: 1.5rem;
+ font-weight: 600;
+ margin: 0 0 0.25rem;
+}
+
+.intro {
+ margin: 0;
+ font-size: 0.875rem;
+ color: #6b7280;
+
+ code {
+ padding: 0.0625rem 0.25rem;
+ border-radius: 0.25rem;
+ background-color: rgba(0, 0, 0, 0.05);
+ font-family: ui-monospace, monospace;
+ font-size: 0.875em;
+ }
+
+ a {
+ color: var(--color-brand-primary-600, #2563eb);
+ text-decoration: underline;
+ }
+}
+
+:host-context(.dark) .intro {
+ color: #9ca3af;
+ code {
+ background-color: rgba(255, 255, 255, 0.08);
+ }
+ a {
+ color: var(--color-brand-primary-300, #93c5fd);
+ }
+}
+
+.filters {
+ padding: 1rem;
+ margin-bottom: 1rem;
+ background-color: #ffffff;
+ border: 1px solid #e5e7eb;
+ border-radius: 0.5rem;
+}
+
+:host-context(.dark) .filters {
+ background-color: #111827;
+ border-color: #1f2937;
+}
+
+.filters-heading {
+ margin: 0 0 0.75rem;
+ font-size: 1rem;
+ font-weight: 600;
+}
+
+.filters-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+ gap: 0.75rem;
+}
+
+.filter {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+}
+
+.filter-label {
+ font-size: 0.75rem;
+ font-weight: 500;
+ color: #6b7280;
+}
+
+:host-context(.dark) .filter-label {
+ color: #9ca3af;
+}
+
+.filter input,
+.filter select {
+ height: 2rem;
+ padding: 0 0.5rem;
+ border: 1px solid #d1d5db;
+ border-radius: 0.25rem;
+ background-color: #ffffff;
+ color: inherit;
+ font-size: 0.875rem;
+
+ &:focus-visible {
+ outline: 2px solid var(--color-brand-primary-500, #2563eb);
+ outline-offset: 1px;
+ border-color: transparent;
+ }
+}
+
+:host-context(.dark) .filter input,
+:host-context(.dark) .filter select {
+ background-color: #0f172a;
+ border-color: #374151;
+ color: #e5e7eb;
+}
+
+.filters-actions {
+ display: flex;
+ gap: 0.5rem;
+ margin-top: 0.875rem;
+}
+
+.btn {
+ display: inline-flex;
+ align-items: center;
+ height: 2.25rem;
+ padding: 0 0.875rem;
+ border: 1px solid transparent;
+ border-radius: 0.25rem;
+ font-size: 0.875rem;
+ font-weight: 500;
+ cursor: pointer;
+
+ &:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+
+ &:focus-visible {
+ outline: 2px solid var(--color-brand-primary-500, #2563eb);
+ outline-offset: 2px;
+ }
+}
+
+.btn--primary {
+ background-color: var(--color-brand-primary-600, #2563eb);
+ color: #ffffff;
+
+ &:hover:not(:disabled) {
+ background-color: var(--color-brand-primary-700, #1d4ed8);
+ }
+}
+
+.btn--secondary {
+ background-color: transparent;
+ border-color: #d1d5db;
+ color: inherit;
+
+ &:hover:not(:disabled) {
+ background-color: rgba(0, 0, 0, 0.04);
+ }
+}
+
+:host-context(.dark) .btn--secondary:hover:not(:disabled) {
+ background-color: rgba(255, 255, 255, 0.06);
+}
+
+.status-bar {
+ margin-bottom: 0.5rem;
+ min-height: 1.25rem;
+ font-size: 0.875rem;
+ color: #6b7280;
+}
+
+.status-line--error {
+ color: #b91c1c;
+ font-weight: 500;
+}
+
+:host-context(.dark) .status-bar {
+ color: #9ca3af;
+}
+
+:host-context(.dark) .status-line--error {
+ color: #fca5a5;
+}
+
+.empty {
+ padding: 1.5rem;
+ text-align: center;
+ color: #6b7280;
+ background-color: #ffffff;
+ border: 1px dashed #d1d5db;
+ border-radius: 0.5rem;
+}
+
+:host-context(.dark) .empty {
+ background-color: #111827;
+ border-color: #374151;
+ color: #9ca3af;
+}
+
+.table-wrap {
+ overflow-x: auto;
+ background-color: #ffffff;
+ border: 1px solid #e5e7eb;
+ border-radius: 0.5rem;
+}
+
+:host-context(.dark) .table-wrap {
+ background-color: #111827;
+ border-color: #1f2937;
+}
+
+.users-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.875rem;
+
+ th,
+ td {
+ text-align: left;
+ padding: 0.5rem 0.75rem;
+ border-bottom: 1px solid #f3f4f6;
+ vertical-align: top;
+ }
+
+ th {
+ font-weight: 600;
+ background-color: #f9fafb;
+ position: sticky;
+ top: 0;
+ }
+}
+
+:host-context(.dark) .users-table {
+ th,
+ td {
+ border-bottom-color: #1f2937;
+ }
+ th {
+ background-color: #1f2937;
+ }
+}
+
+.cell-name {
+ font-weight: 500;
+}
+
+.cell-username,
+.cell-timestamp,
+.cell-oid {
+ font-family: ui-monospace, monospace;
+}
+
+.cell-oid {
+ word-break: break-all;
+ font-size: 0.75rem;
+ color: #6b7280;
+}
+
+:host-context(.dark) .cell-oid {
+ color: #9ca3af;
+}
+
+.cell-timestamp {
+ white-space: nowrap;
+ font-size: 0.8125rem;
+}
+
+.aud-badge {
+ display: inline-block;
+ padding: 0.125rem 0.375rem;
+ border-radius: 0.25rem;
+ background-color: rgba(0, 0, 0, 0.06);
+ color: #1f2937;
+ font-size: 0.6875rem;
+ font-weight: 600;
+ text-transform: uppercase;
+}
+
+:host-context(.dark) .aud-badge {
+ background-color: rgba(255, 255, 255, 0.08);
+ color: #e5e7eb;
+}
+
+.pagination {
+ display: flex;
+ gap: 0.5rem;
+ justify-content: flex-end;
+ margin-top: 0.75rem;
+}
diff --git a/apps/portal-admin/src/app/pages/users/users.spec.ts b/apps/portal-admin/src/app/pages/users/users.spec.ts
new file mode 100644
index 0000000..2f6d7db
--- /dev/null
+++ b/apps/portal-admin/src/app/pages/users/users.spec.ts
@@ -0,0 +1,183 @@
+import { TestBed } from '@angular/core/testing';
+import { of, throwError } from 'rxjs';
+import {
+ AdminUsersService,
+ type AdminUsersPage,
+ type AdminUsersQuery,
+} from './admin-users.service';
+import { UsersPage } from './users';
+
+const PAGE: AdminUsersPage = {
+ items: [
+ {
+ oid: 'jane-oid',
+ tid: 'tenant-1',
+ audience: 'workforce',
+ username: 'jane.doe@apf.example',
+ displayName: 'Jane Doe',
+ firstSeenAt: '2026-05-10T10:00:00.000Z',
+ lastSeenAt: '2026-05-14T08:30:00.000Z',
+ },
+ {
+ oid: 'admin-oid',
+ tid: 'tenant-1',
+ audience: 'workforce',
+ username: 'admin@apf.example',
+ displayName: 'Admin Smith',
+ firstSeenAt: '2026-05-09T08:00:00.000Z',
+ lastSeenAt: '2026-05-13T17:45:00.000Z',
+ },
+ ],
+ total: 137,
+ limit: 50,
+ offset: 0,
+};
+
+interface Fixture {
+ fixture: ReturnType