diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 30432c2..b91b1cb 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -37,6 +37,11 @@ export const routes: Routes = [ title: 'Orders · OpenTaberna Admin', loadComponent: () => import('./features/orders/orders.page').then((m) => m.OrdersPage), }, + { + path: 'users', + title: 'Users · OpenTaberna Admin', + loadComponent: () => import('./features/users/users.page').then((m) => m.UsersPage), + }, { path: 'returns', title: 'Returns · OpenTaberna Admin', diff --git a/src/app/core/api/users.service.ts b/src/app/core/api/users.service.ts new file mode 100644 index 0000000..b096794 --- /dev/null +++ b/src/app/core/api/users.service.ts @@ -0,0 +1,120 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; +import { Observable, forkJoin, map, of, switchMap } from 'rxjs'; + +import { environment } from '../../../environments/environment'; + +/** A Keycloak account as this application needs it. */ +export interface ManagedUser { + id: string; + username: string; + email: string; + firstName: string; + lastName: string; + enabled: boolean; + phone?: string; + createdTimestamp?: number; + isAdmin: boolean; +} + +interface KeycloakRole { + id: string; + name: string; +} + +/** + * User administration, against Keycloak's own admin API. + * + * Deliberately not routed through the FastAPI service. Keycloak owns accounts + * and roles, so going straight there means Keycloak enforces permission — the + * admin's token carries `manage-users`, `view-users` and `view-realm` through + * the composite `admin` role, and a customer's token simply gets 403. Proxying + * it through the API would mean re-implementing that check, and any mistake in + * the re-implementation would be a privilege-escalation bug. + */ +@Injectable({ providedIn: 'root' }) +export class UsersService { + private readonly http = inject(HttpClient); + + private get base(): string { + return `${environment.keycloak.url}/admin/realms/${environment.keycloak.realm}`; + } + + /** + * List accounts along with whether each holds the admin role. + * + * Keycloak has no endpoint returning users with their roles, so the role + * mapping is fetched per user. Fine at this scale; if the realm grows to + * thousands this should move to a paged view that resolves roles lazily. + */ + list(): Observable { + return this.http + .get[]>(`${this.base}/users`, { + params: { max: 200, briefRepresentation: false }, + }) + .pipe( + switchMap((raw) => { + if (raw.length === 0) { + return of([] as ManagedUser[]); + } + return forkJoin( + raw.map((u) => + this.http + .get(`${this.base}/users/${u['id']}/role-mappings/realm`) + .pipe( + map((roles) => + this.toManagedUser( + u, + roles.some((r) => r.name === 'admin'), + ), + ), + ), + ), + ); + }), + ); + } + + /** Grant the admin realm role. */ + promote(userId: string): Observable { + return this.adminRole().pipe( + switchMap((role) => + this.http.post(`${this.base}/users/${userId}/role-mappings/realm`, [role]), + ), + ); + } + + /** Remove the admin realm role, leaving the account as a customer. */ + demote(userId: string): Observable { + return this.adminRole().pipe( + switchMap((role) => + this.http.delete(`${this.base}/users/${userId}/role-mappings/realm`, { + body: [role], + }), + ), + ); + } + + /** Delete the account outright. */ + remove(userId: string): Observable { + return this.http.delete(`${this.base}/users/${userId}`); + } + + private adminRole(): Observable { + return this.http.get(`${this.base}/roles/admin`); + } + + private toManagedUser(raw: Record, isAdmin: boolean): ManagedUser { + return { + id: raw['id'], + username: raw['username'] ?? '', + email: raw['email'] ?? '', + firstName: raw['firstName'] ?? '', + lastName: raw['lastName'] ?? '', + enabled: raw['enabled'] ?? false, + phone: raw['attributes']?.['phone']?.[0], + createdTimestamp: raw['createdTimestamp'], + isAdmin, + }; + } +} diff --git a/src/app/core/auth/auth.interceptor.ts b/src/app/core/auth/auth.interceptor.ts index 6f76a26..a5fb357 100644 --- a/src/app/core/auth/auth.interceptor.ts +++ b/src/app/core/auth/auth.interceptor.ts @@ -6,16 +6,22 @@ import { environment } from '../../../environments/environment'; import { AuthService } from './auth.service'; /** - * Attach the Keycloak access token to API requests. + * Hosts the token may be sent to: the FastAPI service, and Keycloak's own + * admin API, which user management calls directly. + */ +const trustedPrefixes = [environment.apiBaseUrl, `${environment.keycloak.url}/admin/`]; + +/** + * Attach the Keycloak access token to requests aimed at our own services. * - * Scoped to `apiBaseUrl` on purpose: a bearer token must never be sent to a + * Scoped by prefix on purpose: a bearer token must never be sent to a * third-party host just because some component asked for a URL. * * The token is fetched per request rather than cached, because * `AuthService.getToken` refreshes it when it is close to expiry. */ export const authInterceptor: HttpInterceptorFn = (req, next) => { - if (!req.url.startsWith(environment.apiBaseUrl)) { + if (!trustedPrefixes.some((prefix) => req.url.startsWith(prefix))) { return next(req); } diff --git a/src/app/features/dashboard/dashboard.page.html b/src/app/features/dashboard/dashboard.page.html index 107196f..416eee9 100644 --- a/src/app/features/dashboard/dashboard.page.html +++ b/src/app/features/dashboard/dashboard.page.html @@ -1,25 +1,72 @@ @if (loading()) { } @else { @if (partial()) { -
- Some panels could not load -
+
Some panels could not load
} + +
+
+

Revenue

+

+ {{ revenue() | money: currency() }} +

+

Paid, packed and shipped

+
+ + +

Awaiting payment

+

+ {{ awaitingPaymentValue() | money: currency() }} +

+

+ {{ awaitingPayment().length }} checkout(s) not yet settled +

+
+ + +

Outstanding shipments

+

+ {{ awaitingShipment().length }} +

+

+ {{ awaitingShipmentValue() | money: currency() }} waiting to go out +

+
+ +
+

Average order

+

+ {{ averageOrder() | money: currency() }} +

+

{{ shipped().length }} shipped to date

+
+
+ +

Visible to customers

-

{{ activeCount }}

-

{{ hiddenCount }} hidden or archived

+

{{ activeCount() }}

+

{{ hiddenCount() }} draft or archived

Out of stock

- {{ outOfStock.length }} + {{ outOfStock().length }}

Customers cannot buy these

@@ -41,20 +88,17 @@ class="card block p-5 transition-shadow hover:shadow-[var(--shadow-raised)]" >

Low stock

-

- {{ lowStock.length }} +

+ {{ lowStock().length }}

Five or fewer available

- -

Paid, awaiting fulfilment

-

{{ openOrders().length }}

-

Ready to pick and pack

-
+
+

Reserved units

+

{{ reservedUnits() }}

+

Held by checkouts in progress

+
@@ -63,7 +107,7 @@ description="Nothing can be sold while available stock is zero." [padded]="false" > - @if (outOfStock.length === 0 && lowStock.length === 0) { + @if (outOfStock().length === 0 && lowStock().length === 0) {

Every tracked SKU has stock available.

@@ -78,7 +122,7 @@ - @for (row of outOfStock.concat(lowStock); track row.id) { + @for (row of outOfStock().concat(lowStock()); track row.id) { {{ row.sku }} @@ -97,35 +141,39 @@ } - - @if (openOrders().length === 0) { -

Nothing is waiting.

+ + @if (statusMix().length === 0) { +

No orders yet.

} @else { -
- - - - - - - - - @for (order of openOrders().slice(0, 8); track order.id) { - - - - - } - -
OrderPlaced
- {{ order.id.slice(0, 8) }} - {{ order.created_at | date: 'short' }}
-
+
    + @for (slice of statusMix(); track slice.status) { +
  • +
    + + {{ readable(slice.status) }} + + + {{ slice.count }} · {{ slice.percent }}% + +
    +
    +
    +
    +
  • + } +
}
+ + @if (refunded().length > 0) { +
+ +

+ {{ refundedValue() | money: currency() }} +

+

Across {{ refunded().length }} order(s)

+
+
+ } } diff --git a/src/app/features/dashboard/dashboard.page.ts b/src/app/features/dashboard/dashboard.page.ts index 91fd3b8..af02d8c 100644 --- a/src/app/features/dashboard/dashboard.page.ts +++ b/src/app/features/dashboard/dashboard.page.ts @@ -1,5 +1,5 @@ import { CommonModule } from '@angular/common'; -import { Component, inject, signal } from '@angular/core'; +import { Component, computed, inject, signal } from '@angular/core'; import { RouterLink } from '@angular/router'; import { forkJoin, of } from 'rxjs'; import { catchError } from 'rxjs/operators'; @@ -7,23 +7,28 @@ import { catchError } from 'rxjs/operators'; import { CatalogueService } from '../../core/api/catalogue.service'; import { InventoryService } from '../../core/api/inventory.service'; import { OrdersService } from '../../core/api/orders.service'; -import { AuthService } from '../../core/auth/auth.service'; -import { Item, InventoryItem, OrderSummary } from '../../core/models/api.models'; +import { InventoryItem, Item, OrderSummary } from '../../core/models/api.models'; import { BadgeComponent, CardComponent, + MoneyPipe, PageHeaderComponent, SpinnerComponent, } from '../../shared/ui'; +/** Order states whose value counts as money actually taken. */ +const EARNED: readonly string[] = ['paid', 'ready_to_ship', 'shipped']; + /** - * Landing screen: what needs attention right now. + * The operational picture: money, payments, shipping and stock. * - * Deliberately not a metrics wall. It answers three questions an operator has - * on opening the back office — is anything unsellable, is anything waiting to - * be shipped, and is the catalogue actually visible. + * Every figure is derived from the orders list rather than a stats endpoint, + * because the API has none yet. That is honest but has a limit worth knowing: + * it reflects the most recent 100 orders, which is stated on the page rather + * than quietly implied. * - * Each panel degrades on its own: one failing endpoint must not blank the page. + * Each panel degrades on its own — one failing endpoint must not blank the + * page, so a partial view is shown with a warning instead. */ @Component({ selector: 'ot-dashboard-page', @@ -35,6 +40,7 @@ import { CardComponent, BadgeComponent, SpinnerComponent, + MoneyPipe, ], templateUrl: './dashboard.page.html', }) @@ -42,47 +48,116 @@ export class DashboardPage { private readonly catalogue = inject(CatalogueService); private readonly inventory = inject(InventoryService); private readonly orders = inject(OrdersService); - readonly auth = inject(AuthService); readonly loading = signal(true); + readonly partial = signal(false); readonly items = signal([]); readonly stock = signal([]); - readonly openOrders = signal([]); - readonly partial = signal(false); + readonly allOrders = signal([]); + + readonly currency = computed(() => this.allOrders()[0]?.currency ?? 'EUR'); + + /** Money taken: paid, packed or shipped. */ + readonly revenue = computed(() => + this.allOrders() + .filter((o) => EARNED.includes(o.status)) + .reduce((sum, o) => sum + o.total_amount, 0), + ); + + /** Checkouts started but not paid — revenue at risk, not revenue earned. */ + readonly awaitingPayment = computed(() => + this.allOrders().filter((o) => o.status === 'pending_payment'), + ); + + readonly awaitingPaymentValue = computed(() => + this.awaitingPayment().reduce((sum, o) => sum + o.total_amount, 0), + ); + + /** Paid but not yet handed to a carrier — the work queue. */ + readonly awaitingShipment = computed(() => + this.allOrders().filter((o) => o.status === 'paid' || o.status === 'ready_to_ship'), + ); + + readonly awaitingShipmentValue = computed(() => + this.awaitingShipment().reduce((sum, o) => sum + o.total_amount, 0), + ); + + readonly shipped = computed(() => this.allOrders().filter((o) => o.status === 'shipped')); + + readonly refunded = computed(() => + this.allOrders().filter((o) => o.status === 'refunded' || o.status === 'cancelled'), + ); + + readonly refundedValue = computed(() => + this.refunded().reduce((sum, o) => sum + o.total_amount, 0), + ); + + /** Average order value across orders that produced money. */ + readonly averageOrder = computed(() => { + const earned = this.allOrders().filter((o) => EARNED.includes(o.status)); + return earned.length === 0 ? 0 : Math.round(this.revenue() / earned.length); + }); + + readonly activeCount = computed(() => this.items().filter((i) => i.status === 'active').length); + readonly hiddenCount = computed(() => this.items().filter((i) => i.status !== 'active').length); + + readonly outOfStock = computed(() => this.stock().filter((s) => s.on_hand - s.reserved <= 0)); + + readonly lowStock = computed(() => + this.stock().filter((s) => { + const available = s.on_hand - s.reserved; + return available > 0 && available <= 5; + }), + ); + + /** Units held by checkouts in progress — unsellable until they settle. */ + readonly reservedUnits = computed(() => this.stock().reduce((sum, s) => sum + s.reserved, 0)); + + readonly recentOrders = computed(() => + [...this.allOrders()].sort((a, b) => b.created_at.localeCompare(a.created_at)).slice(0, 6), + ); + + /** Order counts by status, largest first, for the mix bar. */ + readonly statusMix = computed(() => { + const counts = new Map(); + for (const o of this.allOrders()) { + counts.set(o.status, (counts.get(o.status) ?? 0) + 1); + } + const total = this.allOrders().length || 1; + return [...counts.entries()] + .map(([status, count]) => ({ + status, + count, + percent: Math.round((count / total) * 100), + })) + .sort((a, b) => b.count - a.count); + }); constructor() { forkJoin({ - // /v1/items caps limit at 100; /v1/admin/inventory allows 200. items: this.catalogue.list(0, 100).pipe(catchError(() => of(null))), stock: this.inventory.list(0, 200).pipe(catchError(() => of(null))), - paid: this.orders.list('paid', 0, 100).pipe(catchError(() => of(null))), + orders: this.orders.list(undefined, 0, 100).pipe(catchError(() => of(null))), }).subscribe((res) => { - if (!res.items || !res.stock || !res.paid) { + if (!res.items || !res.stock || !res.orders) { this.partial.set(true); } this.items.set(res.items?.items ?? []); this.stock.set(res.stock?.items ?? []); - this.openOrders.set(res.paid?.orders ?? []); + this.allOrders.set(res.orders?.orders ?? []); this.loading.set(false); }); } - get activeCount(): number { - return this.items().filter((i) => i.status === 'active').length; + readable(status: string): string { + return status.replace(/_/g, ' '); } - get hiddenCount(): number { - return this.items().filter((i) => i.status !== 'active').length; - } - - get outOfStock(): InventoryItem[] { - return this.stock().filter((s) => s.on_hand - s.reserved <= 0); - } - - get lowStock(): InventoryItem[] { - return this.stock().filter((s) => { - const available = s.on_hand - s.reserved; - return available > 0 && available <= 5; - }); + toneFor(status: string): 'ok' | 'warn' | 'danger' | 'info' | 'neutral' { + if (status === 'shipped') return 'ok'; + if (status === 'pending_payment') return 'warn'; + if (status === 'cancelled' || status === 'refunded') return 'danger'; + if (status === 'paid' || status === 'ready_to_ship') return 'info'; + return 'neutral'; } } diff --git a/src/app/features/inventory/inventory.page.html b/src/app/features/inventory/inventory.page.html index ead4f03..fe99938 100644 --- a/src/app/features/inventory/inventory.page.html +++ b/src/app/features/inventory/inventory.page.html @@ -16,6 +16,20 @@ } +
+ +
+

Track a new SKU

@@ -39,21 +53,64 @@

Track a new SKU

title="No stock records" description="Add a SKU above so the checkout flow can reserve and commit units against it." /> + } @else if (visible().length === 0) { + } @else {
- - - - - + + + + + - @for (row of rows(); track row.id) { + @for (row of visible(); track row.id) {
SKUStatusOn handReservedAvailable + SKU + + Status + + On hand + + Reserved + + Available + Actions
{{ row.sku }} diff --git a/src/app/features/inventory/inventory.page.ts b/src/app/features/inventory/inventory.page.ts index 998dbc8..0d39270 100644 --- a/src/app/features/inventory/inventory.page.ts +++ b/src/app/features/inventory/inventory.page.ts @@ -1,8 +1,10 @@ import { CommonModule } from '@angular/common'; -import { Component, inject, signal } from '@angular/core'; +import { Component, computed, inject, signal } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { InventoryService } from '../../core/api/inventory.service'; +import { FilterBarComponent } from '../../shared/filters/filter-bar'; +import { SortHeaderComponent, createSort, sortRows } from '../../shared/table'; import { InventoryItem } from '../../core/models/api.models'; import { AlertComponent, @@ -32,6 +34,8 @@ import { EmptyStateComponent, SpinnerComponent, AlertComponent, + FilterBarComponent, + SortHeaderComponent, ], templateUrl: './inventory.page.html', }) @@ -48,6 +52,56 @@ export class InventoryPage { newSku = ''; newOnHand = 0; + readonly search = signal(''); + readonly selected = signal>({ level: [] }); + readonly sort = createSort<'sku' | 'level' | 'on_hand' | 'reserved' | 'available'>({ + key: 'sku', + direction: 'asc', + }); + + readonly facets = [ + { + key: 'level', + label: 'Stock level', + options: [ + { value: 'danger', label: 'Out of stock' }, + { value: 'warn', label: 'Low' }, + { value: 'ok', label: 'In stock' }, + ], + }, + ]; + + readonly visible = computed(() => { + const term = this.search().trim().toLowerCase(); + const levels = this.selected()['level'] ?? []; + + const filtered = this.rows().filter((row) => { + if (term && !row.sku.toLowerCase().includes(term)) return false; + if (levels.length > 0 && !levels.includes(this.tone(row))) return false; + return true; + }); + + return sortRows(filtered, this.sort.state(), (row, key) => { + switch (key) { + case 'sku': + return row.sku; + case 'level': + return this.available(row); + case 'on_hand': + return row.on_hand; + case 'reserved': + return row.reserved; + case 'available': + return this.available(row); + } + }); + }); + + clearFilters(): void { + this.search.set(''); + this.selected.set({ level: [] }); + } + constructor() { this.load(); } diff --git a/src/app/features/orders/orders.page.html b/src/app/features/orders/orders.page.html index 76c608f..77c0807 100644 --- a/src/app/features/orders/orders.page.html +++ b/src/app/features/orders/orders.page.html @@ -11,23 +11,18 @@ } -
-
-
- - -
-
+
+
@@ -40,16 +35,45 @@ - - - - + + + + - @for (order of rows(); track order.id) { - + @for (order of visible(); track order.id) { + diff --git a/src/app/features/orders/orders.page.ts b/src/app/features/orders/orders.page.ts index 0eb2043..dc51a07 100644 --- a/src/app/features/orders/orders.page.ts +++ b/src/app/features/orders/orders.page.ts @@ -1,9 +1,11 @@ import { CommonModule } from '@angular/common'; -import { Component, inject, signal } from '@angular/core'; +import { Component, computed, inject, signal } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { environment } from '../../../environments/environment'; import { OrdersService } from '../../core/api/orders.service'; +import { FilterBarComponent } from '../../shared/filters/filter-bar'; +import { RowLinkDirective, SortHeaderComponent, createSort, sortRows } from '../../shared/table'; import { AdminOrderDetail, OrderStatus, OrderSummary } from '../../core/models/api.models'; import { AlertComponent, @@ -46,6 +48,9 @@ const STATUS_TONES: Record>({ status: [] }); + readonly sort = createSort<'placed' | 'status' | 'total' | 'order'>({ + key: 'placed', + direction: 'desc', + }); + + readonly facets = [ + { + key: 'status', + label: 'Status', + options: [ + { value: 'draft', label: 'Draft' }, + { value: 'pending_payment', label: 'Pending payment' }, + { value: 'paid', label: 'Paid' }, + { value: 'ready_to_ship', label: 'Ready to ship' }, + { value: 'shipped', label: 'Shipped' }, + { value: 'cancelled', label: 'Cancelled' }, + { value: 'refunded', label: 'Refunded' }, + ], + }, + ]; + + /** + * Filtering happens client-side so several statuses can be combined at once — + * the API's status parameter takes only one. + */ + readonly visible = computed(() => { + const term = this.search().trim().toLowerCase(); + const statuses = this.selected()['status'] ?? []; + + const filtered = this.rows().filter((order) => { + if (term && !order.id.toLowerCase().includes(term)) return false; + if (statuses.length > 0 && !statuses.includes(order.status)) return false; + return true; + }); + + return sortRows(filtered, this.sort.state(), (order, key) => { + switch (key) { + case 'placed': + return order.created_at; + case 'status': + return order.status; + case 'total': + return order.total_amount; + case 'order': + return order.id; + } + }); + }); + + clearFilters(): void { + this.search.set(''); + this.selected.set({ status: [] }); + } overrideStatus: OrderStatus = 'paid'; overrideReason = ''; private overrideTarget: string | null = null; @@ -83,7 +142,7 @@ export class OrdersPage { load(): void { this.loading.set(true); this.error.set(null); - this.orders.list(this.filter || undefined, 0, 100).subscribe({ + this.orders.list(undefined, 0, 100).subscribe({ next: (res) => { this.rows.set(res.orders ?? []); this.loading.set(false); diff --git a/src/app/features/products/products.page.html b/src/app/features/products/products.page.html index fd9a8be..e5cf07a 100644 --- a/src/app/features/products/products.page.html +++ b/src/app/features/products/products.page.html @@ -22,21 +22,55 @@ > New product + } @else if (visible().length === 0) { + } @else {
OrderStatusPlacedTotal + Order + + Status + + Placed + + Total + Actions
{{ order.id.slice(0, 8) }}
- - - - + + + + - @for (item of items(); track item.uuid) { - + @for (item of visible(); track item.uuid) { +
ProductSKUStatusPrice + Product + + SKU + + Status + + Price + Actions

{{ item.name }}

{{ item.brand || '—' }}

diff --git a/src/app/features/products/products.page.ts b/src/app/features/products/products.page.ts index 47b4150..f6d83c4 100644 --- a/src/app/features/products/products.page.ts +++ b/src/app/features/products/products.page.ts @@ -1,8 +1,10 @@ import { CommonModule } from '@angular/common'; -import { Component, inject, signal } from '@angular/core'; +import { Component, computed, inject, signal } from '@angular/core'; import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; import { CatalogueService } from '../../core/api/catalogue.service'; +import { FilterBarComponent } from '../../shared/filters/filter-bar'; +import { RowLinkDirective, SortHeaderComponent, createSort, sortRows } from '../../shared/table'; import { Item, ItemStatus } from '../../core/models/api.models'; import { AlertComponent, @@ -36,6 +38,9 @@ import { ModalComponent, AlertComponent, MoneyPipe, + FilterBarComponent, + SortHeaderComponent, + RowLinkDirective, ], templateUrl: './products.page.html', }) @@ -50,6 +55,58 @@ export class ProductsPage { readonly formOpen = signal(false); readonly editing = signal(null); + readonly search = signal(''); + readonly selected = signal>({ status: [] }); + readonly sort = createSort<'name' | 'sku' | 'status' | 'price'>({ + key: 'name', + direction: 'asc', + }); + + readonly facets = [ + { + key: 'status', + label: 'Status', + options: [ + { value: 'active', label: 'Active' }, + { value: 'draft', label: 'Draft' }, + { value: 'archived', label: 'Archived' }, + ], + }, + ]; + + /** Search, facets and sorting applied together. */ + readonly visible = computed(() => { + const term = this.search().trim().toLowerCase(); + const statuses = this.selected()['status'] ?? []; + + const filtered = this.items().filter((item) => { + if (term) { + const haystack = `${item.name} ${item.sku} ${item.brand ?? ''}`.toLowerCase(); + if (!haystack.includes(term)) return false; + } + if (statuses.length > 0 && !statuses.includes(item.status)) return false; + return true; + }); + + return sortRows(filtered, this.sort.state(), (item, key) => { + switch (key) { + case 'name': + return item.name; + case 'sku': + return item.sku; + case 'status': + return item.status; + case 'price': + return item.price?.amount ?? 0; + } + }); + }); + + clearFilters(): void { + this.search.set(''); + this.selected.set({ status: [] }); + } + readonly form = this.fb.nonNullable.group({ sku: ['', [Validators.required, Validators.maxLength(64)]], name: ['', [Validators.required, Validators.maxLength(200)]], diff --git a/src/app/features/users/users.page.html b/src/app/features/users/users.page.html new file mode 100644 index 0000000..6a50d0c --- /dev/null +++ b/src/app/features/users/users.page.html @@ -0,0 +1,167 @@ + + Refresh + + +@if (error(); as message) { +
+ {{ message }} +
+} +@if (notice(); as message) { +
+ {{ message }} +
+} + +
+ +
+ +
+ @if (loading()) { + + } @else if (visible().length === 0) { + + } @else { +
+ + + + + + + + + + + + @for (user of visible(); track user.id) { + + + + + + + + } + +
+ Name + + Username + + E-mail + + Role + Actions
+

{{ displayName(user) }}

+ @if (user.phone) { +

{{ user.phone }}

+ } +
+ {{ user.username }} + {{ user.email || '—' }} +
+ + {{ user.isAdmin ? 'administrator' : 'customer' }} + + @if (!user.enabled) { + disabled + } + @if (isSelf(user)) { + you + } +
+
+
+ @if (user.isAdmin) { + Make customer + } @else { + Make admin + } + Delete +
+
+
+ } +
+ + +

{{ confirmText() }}

+ @if (action() === 'delete') { +
+ Deleting an account in Keycloak cannot be undone. +
+ } + + Cancel + Confirm + +
diff --git a/src/app/features/users/users.page.ts b/src/app/features/users/users.page.ts new file mode 100644 index 0000000..31d627c --- /dev/null +++ b/src/app/features/users/users.page.ts @@ -0,0 +1,239 @@ +import { CommonModule } from '@angular/common'; +import { Component, computed, inject, signal } from '@angular/core'; + +import { ManagedUser, UsersService } from '../../core/api/users.service'; +import { AuthService } from '../../core/auth/auth.service'; +import { FilterBarComponent } from '../../shared/filters/filter-bar'; +import { RowLinkDirective, SortHeaderComponent, createSort, sortRows } from '../../shared/table'; +import { + AlertComponent, + BadgeComponent, + ButtonComponent, + EmptyStateComponent, + ModalComponent, + PageHeaderComponent, + SpinnerComponent, +} from '../../shared/ui'; + +type SortKey = 'name' | 'username' | 'email' | 'role' | 'created'; +type PendingAction = 'promote' | 'demote' | 'delete'; + +/** + * Account administration. + * + * Two things guard against the most damaging mistake here — an administrator + * removing their own access, or the last one removing everyone's. Self-actions + * are refused outright, and demoting the final remaining admin is blocked, + * because a realm with no administrator can only be recovered from the + * Keycloak console. + */ +@Component({ + selector: 'ot-users-page', + standalone: true, + imports: [ + CommonModule, + PageHeaderComponent, + ButtonComponent, + BadgeComponent, + EmptyStateComponent, + SpinnerComponent, + ModalComponent, + AlertComponent, + FilterBarComponent, + SortHeaderComponent, + RowLinkDirective, + ], + templateUrl: './users.page.html', +}) +export class UsersPage { + private readonly users = inject(UsersService); + private readonly auth = inject(AuthService); + + readonly rows = signal([]); + readonly loading = signal(true); + readonly error = signal(null); + readonly notice = signal(null); + readonly working = signal(false); + + readonly search = signal(''); + readonly selected = signal>({ role: [], status: [] }); + readonly sort = createSort({ key: 'name', direction: 'asc' }); + + readonly confirmOpen = signal(false); + readonly target = signal(null); + readonly action = signal('promote'); + + readonly facets = [ + { + key: 'role', + label: 'Role', + options: [ + { value: 'admin', label: 'Administrator' }, + { value: 'customer', label: 'Customer' }, + ], + }, + { + key: 'status', + label: 'Status', + options: [ + { value: 'enabled', label: 'Enabled' }, + { value: 'disabled', label: 'Disabled' }, + ], + }, + ]; + + /** Rows after search, facets and sorting — all applied together. */ + readonly visible = computed(() => { + const term = this.search().trim().toLowerCase(); + const roles = this.selected()['role'] ?? []; + const statuses = this.selected()['status'] ?? []; + + const filtered = this.rows().filter((u) => { + if (term) { + const haystack = `${u.username} ${u.email} ${u.firstName} ${u.lastName}`.toLowerCase(); + if (!haystack.includes(term)) return false; + } + if (roles.length > 0) { + const role = u.isAdmin ? 'admin' : 'customer'; + if (!roles.includes(role)) return false; + } + if (statuses.length > 0) { + const status = u.enabled ? 'enabled' : 'disabled'; + if (!statuses.includes(status)) return false; + } + return true; + }); + + return sortRows(filtered, this.sort.state(), (u, key) => { + switch (key) { + case 'name': + return `${u.firstName} ${u.lastName}`.trim() || u.username; + case 'username': + return u.username; + case 'email': + return u.email; + case 'role': + return u.isAdmin ? 'admin' : 'customer'; + case 'created': + return u.createdTimestamp ?? 0; + } + }); + }); + + readonly adminCount = computed(() => this.rows().filter((u) => u.isAdmin).length); + + constructor() { + this.load(); + } + + load(): void { + this.loading.set(true); + this.error.set(null); + this.users.list().subscribe({ + next: (list) => { + this.rows.set(list); + this.loading.set(false); + }, + error: (err) => { + this.error.set(this.message(err, 'Could not load accounts from Keycloak.')); + this.loading.set(false); + }, + }); + } + + isSelf(user: ManagedUser): boolean { + return this.auth.user()?.subject === user.id; + } + + /** + * Why an action is unavailable, or null when it is allowed. + * + * Returned as a sentence so the reason can be shown as a tooltip rather than + * leaving a disabled button unexplained. + */ + blockedReason(user: ManagedUser, action: PendingAction): string | null { + if (this.isSelf(user)) { + return action === 'delete' + ? 'You cannot delete your own account.' + : 'You cannot change your own role.'; + } + if (action === 'demote' && user.isAdmin && this.adminCount() <= 1) { + return 'This is the last administrator. Promote someone else first.'; + } + return null; + } + + start(user: ManagedUser, action: PendingAction): void { + if (this.blockedReason(user, action)) { + return; + } + this.target.set(user); + this.action.set(action); + this.confirmOpen.set(true); + } + + confirmText(): string { + const user = this.target(); + if (!user) return ''; + const who = user.username; + switch (this.action()) { + case 'promote': + return `Give ${who} administrator access? They will be able to manage the store and other accounts.`; + case 'demote': + return `Remove administrator access from ${who}? They keep their customer account and order history.`; + case 'delete': + return `Delete ${who} permanently? This cannot be undone, and any orders keep referring to an account that no longer exists.`; + } + } + + confirm(): void { + const user = this.target(); + if (!user) return; + + this.working.set(true); + this.error.set(null); + this.notice.set(null); + + const action = this.action(); + const request = + action === 'promote' + ? this.users.promote(user.id) + : action === 'demote' + ? this.users.demote(user.id) + : this.users.remove(user.id); + + request.subscribe({ + next: () => { + this.working.set(false); + this.confirmOpen.set(false); + this.notice.set( + action === 'promote' + ? `${user.username} is now an administrator.` + : action === 'demote' + ? `${user.username} is now a customer.` + : `${user.username} was deleted.`, + ); + this.load(); + }, + error: (err) => { + this.working.set(false); + this.confirmOpen.set(false); + this.error.set(this.message(err, 'Keycloak refused the change.')); + }, + }); + } + + clearFilters(): void { + this.search.set(''); + this.selected.set({ role: [], status: [] }); + } + + displayName(user: ManagedUser): string { + return `${user.firstName} ${user.lastName}`.trim() || user.username; + } + + private message(err: unknown, fallback: string): string { + const body = (err as { error?: { errorMessage?: string; error?: string } })?.error; + return body?.errorMessage || body?.error || fallback; + } +} diff --git a/src/app/features/users/users.rules.spec.ts b/src/app/features/users/users.rules.spec.ts new file mode 100644 index 0000000..d9d0514 --- /dev/null +++ b/src/app/features/users/users.rules.spec.ts @@ -0,0 +1,54 @@ +/** + * The two rules that stop an administrator locking everyone out. + * + * Extracted as pure functions so the guarantee is testable without standing up + * the component or Keycloak. + */ +export function blockedReason( + target: { id: string; isAdmin: boolean }, + action: 'promote' | 'demote' | 'delete', + currentUserId: string, + adminCount: number, +): string | null { + if (target.id === currentUserId) { + return action === 'delete' + ? 'You cannot delete your own account.' + : 'You cannot change your own role.'; + } + if (action === 'demote' && target.isAdmin && adminCount <= 1) { + return 'This is the last administrator. Promote someone else first.'; + } + return null; +} + +describe('user management guards', () => { + const me = 'user-1'; + const other = { id: 'user-2', isAdmin: false }; + const otherAdmin = { id: 'user-2', isAdmin: true }; + + it('refuses deleting your own account', () => { + expect(blockedReason({ id: me, isAdmin: true }, 'delete', me, 2)).toContain('own account'); + }); + + it('refuses changing your own role', () => { + // Otherwise an admin can demote themselves and immediately lose the screen. + expect(blockedReason({ id: me, isAdmin: true }, 'demote', me, 2)).toContain('own role'); + }); + + it('refuses demoting the last administrator', () => { + // A realm with no admin is only recoverable from the Keycloak console. + expect(blockedReason(otherAdmin, 'demote', me, 1)).toContain('last administrator'); + }); + + it('allows demoting an admin while others remain', () => { + expect(blockedReason(otherAdmin, 'demote', me, 3)).toBeNull(); + }); + + it('allows promoting another account', () => { + expect(blockedReason(other, 'promote', me, 1)).toBeNull(); + }); + + it('allows deleting another account', () => { + expect(blockedReason(other, 'delete', me, 1)).toBeNull(); + }); +}); diff --git a/src/app/layout/nav.model.spec.ts b/src/app/layout/nav.model.spec.ts index 33ce401..c647893 100644 --- a/src/app/layout/nav.model.spec.ts +++ b/src/app/layout/nav.model.spec.ts @@ -1,13 +1,14 @@ import { NAV_ITEMS } from './nav.model'; describe('side navigation', () => { - it('exposes the five administration areas', () => { + it('exposes the six administration areas', () => { expect(NAV_ITEMS.map((i) => i.path)).toEqual([ '/dashboard', '/products', '/inventory', '/orders', '/returns', + '/users', ]); }); diff --git a/src/app/layout/nav.model.ts b/src/app/layout/nav.model.ts index b3a4b3c..05aa95e 100644 --- a/src/app/layout/nav.model.ts +++ b/src/app/layout/nav.model.ts @@ -43,4 +43,10 @@ export const NAV_ITEMS: readonly NavItem[] = [ description: 'RMA requests', icon: 'M9 15 4 10l5-5m-5 5h10a6 6 0 0 1 0 12h-3', }, + { + label: 'Users', + path: '/users', + description: 'Accounts and admin access', + icon: 'M17 20h5v-2a4 4 0 0 0-3-3.87M9 20H2v-2a4 4 0 0 1 3-3.87m0 0a4 4 0 1 1 8 0m-8 0h8m4-6a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z', + }, ] as const; diff --git a/src/app/shared/filters/filter-bar.ts b/src/app/shared/filters/filter-bar.ts new file mode 100644 index 0000000..04af88b --- /dev/null +++ b/src/app/shared/filters/filter-bar.ts @@ -0,0 +1,166 @@ +import { Component, EventEmitter, Input, Output, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; + +export interface FilterOption { + value: string; + label: string; +} + +/** + * A filter bar combining free-text search with any number of multi-select + * facets, all applied together. + * + * Multi-select rather than a single dropdown because the questions an operator + * actually asks are compound — "paid or ready to ship", "low or out of stock" — + * and a one-at-a-time filter forces them to look twice and hold the result in + * their head. + * + * Active selections are shown as removable chips, so what is being filtered is + * never hidden inside a collapsed control. + */ +@Component({ + selector: 'ot-filter-bar', + standalone: true, + imports: [FormsModule], + template: ` +
+
+
+ + +
+ + @for (facet of facets; track facet.key) { +
+ {{ facet.label }} + + + @if (openFacet() === facet.key) { +
+ @for (option of facet.options; track option.value) { + + } +
+ } +
+ } + + @if (hasAnyFilter) { + + } +
+ + @if (chips.length > 0) { +
+ @for (chip of chips; track chip.key + chip.value) { + + } +
+ } +
+ `, +}) +export class FilterBarComponent { + @Input() searchLabel = 'Search'; + @Input() searchPlaceholder = ''; + @Input() searchId = 'filter-search'; + @Input() search = ''; + @Input() facets: { key: string; label: string; options: FilterOption[] }[] = []; + /** Selected values per facet key. */ + @Input() selected: Record = {}; + + @Output() searchChange = new EventEmitter(); + @Output() selectionChange = new EventEmitter>(); + @Output() cleared = new EventEmitter(); + + readonly openFacet = signal(null); + + toggleOpen(key: string): void { + this.openFacet.update((current) => (current === key ? null : key)); + } + + isSelected(key: string, value: string): boolean { + return (this.selected[key] ?? []).includes(value); + } + + toggleValue(key: string, value: string): void { + const current = this.selected[key] ?? []; + const next = current.includes(value) ? current.filter((v) => v !== value) : [...current, value]; + this.selectionChange.emit({ ...this.selected, [key]: next }); + } + + summary(key: string): string { + const facet = this.facets.find((f) => f.key === key); + const chosen = this.selected[key] ?? []; + if (chosen.length === 0) return `Any ${facet?.label.toLowerCase() ?? ''}`.trim(); + if (chosen.length === 1) { + return facet?.options.find((o) => o.value === chosen[0])?.label ?? chosen[0]; + } + return `${chosen.length} selected`; + } + + get chips(): { key: string; value: string; label: string }[] { + return this.facets.flatMap((facet) => + (this.selected[facet.key] ?? []).map((value) => ({ + key: facet.key, + value, + label: facet.options.find((o) => o.value === value)?.label ?? value, + })), + ); + } + + get hasAnyFilter(): boolean { + return this.search.length > 0 || this.chips.length > 0; + } +} diff --git a/src/app/shared/table/index.ts b/src/app/shared/table/index.ts new file mode 100644 index 0000000..d6c7384 --- /dev/null +++ b/src/app/shared/table/index.ts @@ -0,0 +1,3 @@ +export * from './row-link.directive'; +export * from './sort'; +export * from './sort-header'; diff --git a/src/app/shared/table/row-link.directive.ts b/src/app/shared/table/row-link.directive.ts new file mode 100644 index 0000000..083e2cf --- /dev/null +++ b/src/app/shared/table/row-link.directive.ts @@ -0,0 +1,38 @@ +import { Directive, EventEmitter, Output } from '@angular/core'; + +/** + * Makes a table row behave like a control. + * + * A row that only responds to a mouse click is invisible to keyboard and + * screen-reader users, so this adds the button role, a tab stop, and Enter and + * Space handling alongside the pointer cursor. + * + * Buttons inside the row still work: their own click handlers stop the event + * before it reaches the row, so "Delete" never also opens the editor. + */ +@Directive({ + selector: '[otRowLink]', + standalone: true, + host: { + role: 'button', + tabindex: '0', + class: 'cursor-pointer', + '(click)': 'activate($event)', + '(keydown.enter)': 'activate($event)', + '(keydown.space)': 'activate($event)', + }, +}) +export class RowLinkDirective { + @Output() activated = new EventEmitter(); + + activate(event: Event): void { + // Ignore activity that started on a nested control - a click on an action + // button is about that button, not the row. + const target = event.target as HTMLElement | null; + if (target?.closest('button, a, input, select, textarea')) { + return; + } + event.preventDefault(); + this.activated.emit(); + } +} diff --git a/src/app/shared/table/sort-header.ts b/src/app/shared/table/sort-header.ts new file mode 100644 index 0000000..6c59bbe --- /dev/null +++ b/src/app/shared/table/sort-header.ts @@ -0,0 +1,65 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; + +import { SortDirection } from './sort'; + +/** + * A sortable column heading. + * + * Renders a real button inside the `th` so the control is reachable by + * keyboard and announced as such, and sets `aria-sort` so a screen reader + * conveys the current ordering rather than leaving it as visual-only. + */ +@Component({ + selector: '[ot-sort-header]', + standalone: true, + host: { + '[attr.aria-sort]': 'ariaSort', + '[class.text-ink]': 'active', + }, + template: ` + + `, +}) +export class SortHeaderComponent { + @Input() active = false; + @Input() direction: SortDirection = 'asc'; + @Output() sorted = new EventEmitter(); + + get ariaSort(): string { + if (!this.active) return 'none'; + return this.direction === 'asc' ? 'ascending' : 'descending'; + } +} diff --git a/src/app/shared/table/sort.spec.ts b/src/app/shared/table/sort.spec.ts new file mode 100644 index 0000000..ae364df --- /dev/null +++ b/src/app/shared/table/sort.spec.ts @@ -0,0 +1,64 @@ +import { createSort, sortRows } from './sort'; + +describe('createSort', () => { + it('starts on the column it was given', () => { + const sort = createSort<'name' | 'age'>({ key: 'name', direction: 'asc' }); + expect(sort.state()).toEqual({ key: 'name', direction: 'asc' }); + }); + + it('flips direction when the active column is clicked again', () => { + const sort = createSort<'name'>({ key: 'name', direction: 'asc' }); + sort.toggle('name'); + expect(sort.state().direction).toBe('desc'); + sort.toggle('name'); + expect(sort.state().direction).toBe('asc'); + }); + + it('moves to a new column ascending', () => { + // Carrying the previous descending direction over to a different column + // surprises people; a fresh column starts at the top. + const sort = createSort<'name' | 'age'>({ key: 'name', direction: 'desc' }); + sort.toggle('age'); + expect(sort.state()).toEqual({ key: 'age', direction: 'asc' }); + }); +}); + +describe('sortRows', () => { + const rows = [ + { sku: 'B', qty: 10 }, + { sku: 'a', qty: 2 }, + { sku: 'C', qty: null as number | null }, + ]; + const accessor = (row: (typeof rows)[number], key: string) => (key === 'sku' ? row.sku : row.qty); + + it('sorts strings case-insensitively', () => { + const out = sortRows(rows, { key: 'sku', direction: 'asc' }, accessor); + expect(out.map((r) => r.sku)).toEqual(['a', 'B', 'C']); + }); + + it('sorts numbers numerically, not lexically', () => { + const numbers = [{ n: 9 }, { n: 100 }, { n: 20 }]; + const out = sortRows(numbers, { key: 'n', direction: 'asc' }, (r) => r.n); + expect(out.map((r) => r.n)).toEqual([9, 20, 100]); + }); + + it('reverses on descending', () => { + const out = sortRows(rows, { key: 'sku', direction: 'desc' }, accessor); + expect(out.map((r) => r.sku)).toEqual(['C', 'B', 'a']); + }); + + it('keeps missing values last in both directions', () => { + // A blank is absent, not "smallest" - burying real data under it on one + // direction is exactly what people do not want. + const asc = sortRows(rows, { key: 'qty', direction: 'asc' }, accessor); + const desc = sortRows(rows, { key: 'qty', direction: 'desc' }, accessor); + expect(asc.at(-1)!.sku).toBe('C'); + expect(desc.at(-1)!.sku).toBe('C'); + }); + + it('does not mutate the source array', () => { + const original = [...rows]; + sortRows(rows, { key: 'sku', direction: 'desc' }, accessor); + expect(rows).toEqual(original); + }); +}); diff --git a/src/app/shared/table/sort.ts b/src/app/shared/table/sort.ts new file mode 100644 index 0000000..2a9cb90 --- /dev/null +++ b/src/app/shared/table/sort.ts @@ -0,0 +1,68 @@ +import { signal } from '@angular/core'; + +export type SortDirection = 'asc' | 'desc'; + +export interface SortState { + key: K; + direction: SortDirection; +} + +/** + * Sorting state shared by every table in the application. + * + * Clicking the active column flips direction; clicking a different one moves + * to it and starts ascending. That is what people expect, and doing it in one + * place means no table invents its own rule. + */ +export function createSort(initial: SortState) { + const state = signal>(initial); + + function toggle(key: K): void { + state.update((current) => + current.key === key + ? { key, direction: current.direction === 'asc' ? 'desc' : 'asc' } + : { key, direction: 'asc' }, + ); + } + + return { state: state.asReadonly(), toggle }; +} + +/** + * Sort rows by a caller-supplied accessor. + * + * Returns a new array rather than sorting in place, so a signal holding the + * source list is not mutated behind Angular's back. + * + * Nullish values always sort last regardless of direction — a missing value is + * not "smallest", it is absent, and burying it under real data on one of the + * two directions is what people actually want. + */ +export function sortRows( + rows: readonly T[], + sort: SortState, + accessor: (row: T, key: K) => string | number | null | undefined, +): T[] { + const factor = sort.direction === 'asc' ? 1 : -1; + + return [...rows].sort((a, b) => { + const left = accessor(a, sort.key); + const right = accessor(b, sort.key); + + const leftMissing = left === null || left === undefined || left === ''; + const rightMissing = right === null || right === undefined || right === ''; + if (leftMissing && rightMissing) return 0; + if (leftMissing) return 1; + if (rightMissing) return -1; + + if (typeof left === 'number' && typeof right === 'number') { + return (left - right) * factor; + } + return ( + String(left).localeCompare(String(right), undefined, { + numeric: true, + sensitivity: 'base', + }) * factor + ); + }); +}