Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/app/app.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
120 changes: 120 additions & 0 deletions src/app/core/api/users.service.ts
Original file line number Diff line number Diff line change
@@ -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<ManagedUser[]> {
return this.http
.get<Record<string, any>[]>(`${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<KeycloakRole[]>(`${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<void> {
return this.adminRole().pipe(
switchMap((role) =>
this.http.post<void>(`${this.base}/users/${userId}/role-mappings/realm`, [role]),
),
);
}

/** Remove the admin realm role, leaving the account as a customer. */
demote(userId: string): Observable<void> {
return this.adminRole().pipe(
switchMap((role) =>
this.http.delete<void>(`${this.base}/users/${userId}/role-mappings/realm`, {
body: [role],
}),
),
);
}

/** Delete the account outright. */
remove(userId: string): Observable<void> {
return this.http.delete<void>(`${this.base}/users/${userId}`);
}

private adminRole(): Observable<KeycloakRole> {
return this.http.get<KeycloakRole>(`${this.base}/roles/admin`);
}

private toManagedUser(raw: Record<string, any>, 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,
};
}
}
12 changes: 9 additions & 3 deletions src/app/core/auth/auth.interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
144 changes: 96 additions & 48 deletions src/app/features/dashboard/dashboard.page.html
Original file line number Diff line number Diff line change
@@ -1,25 +1,72 @@
<ot-page-header
[title]="'Good to see you, ' + (auth.user()?.displayName || 'admin')"
description="What needs attention in the store right now."
title="Overview"
description="Money, fulfilment and stock across the most recent 100 orders."
/>

@if (loading()) {
<ot-spinner label="Gathering the shop's state…" />
} @else {
@if (partial()) {
<div class="mb-4">
<ot-badge tone="warn">Some panels could not load</ot-badge>
</div>
<div class="mb-4"><ot-badge tone="warn">Some panels could not load</ot-badge></div>
}

<!-- Money -->
<div class="mb-4 grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<div class="card p-5">
<p class="text-xs uppercase tracking-wide text-ink-subtle">Revenue</p>
<p class="mt-1 text-3xl font-semibold tabular-nums text-ink">
{{ revenue() | money: currency() }}
</p>
<p class="mt-1 text-xs text-ink-muted">Paid, packed and shipped</p>
</div>

<a
routerLink="/orders"
class="card block p-5 transition-shadow hover:shadow-[var(--shadow-raised)]"
>
<p class="text-xs uppercase tracking-wide text-ink-subtle">Awaiting payment</p>
<p
class="mt-1 text-3xl font-semibold tabular-nums"
[class.text-warn]="awaitingPayment().length > 0"
>
{{ awaitingPaymentValue() | money: currency() }}
</p>
<p class="mt-1 text-xs text-ink-muted">
{{ awaitingPayment().length }} checkout(s) not yet settled
</p>
</a>

<a
routerLink="/orders"
class="card block p-5 transition-shadow hover:shadow-[var(--shadow-raised)]"
>
<p class="text-xs uppercase tracking-wide text-ink-subtle">Outstanding shipments</p>
<p class="mt-1 text-3xl font-semibold tabular-nums text-ink">
{{ awaitingShipment().length }}
</p>
<p class="mt-1 text-xs text-ink-muted">
{{ awaitingShipmentValue() | money: currency() }} waiting to go out
</p>
</a>

<div class="card p-5">
<p class="text-xs uppercase tracking-wide text-ink-subtle">Average order</p>
<p class="mt-1 text-3xl font-semibold tabular-nums text-ink">
{{ averageOrder() | money: currency() }}
</p>
<p class="mt-1 text-xs text-ink-muted">{{ shipped().length }} shipped to date</p>
</div>
</div>

<!-- Catalogue and stock -->
<div class="mb-6 grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<a
routerLink="/products"
class="card block p-5 transition-shadow hover:shadow-[var(--shadow-raised)]"
>
<p class="text-xs uppercase tracking-wide text-ink-subtle">Visible to customers</p>
<p class="mt-1 text-3xl font-semibold tabular-nums text-ink">{{ activeCount }}</p>
<p class="mt-1 text-xs text-ink-muted">{{ hiddenCount }} hidden or archived</p>
<p class="mt-1 text-3xl font-semibold tabular-nums text-ink">{{ activeCount() }}</p>
<p class="mt-1 text-xs text-ink-muted">{{ hiddenCount() }} draft or archived</p>
</a>

<a
Expand All @@ -29,9 +76,9 @@
<p class="text-xs uppercase tracking-wide text-ink-subtle">Out of stock</p>
<p
class="mt-1 text-3xl font-semibold tabular-nums"
[class.text-danger]="outOfStock.length > 0"
[class.text-danger]="outOfStock().length > 0"
>
{{ outOfStock.length }}
{{ outOfStock().length }}
</p>
<p class="mt-1 text-xs text-ink-muted">Customers cannot buy these</p>
</a>
Expand All @@ -41,20 +88,17 @@
class="card block p-5 transition-shadow hover:shadow-[var(--shadow-raised)]"
>
<p class="text-xs uppercase tracking-wide text-ink-subtle">Low stock</p>
<p class="mt-1 text-3xl font-semibold tabular-nums" [class.text-warn]="lowStock.length > 0">
{{ lowStock.length }}
<p class="mt-1 text-3xl font-semibold tabular-nums" [class.text-warn]="lowStock().length > 0">
{{ lowStock().length }}
</p>
<p class="mt-1 text-xs text-ink-muted">Five or fewer available</p>
</a>

<a
routerLink="/orders"
class="card block p-5 transition-shadow hover:shadow-[var(--shadow-raised)]"
>
<p class="text-xs uppercase tracking-wide text-ink-subtle">Paid, awaiting fulfilment</p>
<p class="mt-1 text-3xl font-semibold tabular-nums text-ink">{{ openOrders().length }}</p>
<p class="mt-1 text-xs text-ink-muted">Ready to pick and pack</p>
</a>
<div class="card p-5">
<p class="text-xs uppercase tracking-wide text-ink-subtle">Reserved units</p>
<p class="mt-1 text-3xl font-semibold tabular-nums text-ink">{{ reservedUnits() }}</p>
<p class="mt-1 text-xs text-ink-muted">Held by checkouts in progress</p>
</div>
</div>

<div class="grid gap-6 lg:grid-cols-2">
Expand All @@ -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) {
<p class="px-5 py-8 text-center text-sm text-ink-muted">
Every tracked SKU has stock available.
</p>
Expand All @@ -78,7 +122,7 @@
</tr>
</thead>
<tbody>
@for (row of outOfStock.concat(lowStock); track row.id) {
@for (row of outOfStock().concat(lowStock()); track row.id) {
<tr>
<td>
<code class="text-xs text-ink-muted">{{ row.sku }}</code>
Expand All @@ -97,35 +141,39 @@
}
</ot-card>

<ot-card
title="Waiting to ship"
description="Paid orders that have not been handed to a carrier."
[padded]="false"
>
@if (openOrders().length === 0) {
<p class="px-5 py-8 text-center text-sm text-ink-muted">Nothing is waiting.</p>
<ot-card title="Order mix" description="Where the recent orders currently sit.">
@if (statusMix().length === 0) {
<p class="py-6 text-center text-sm text-ink-muted">No orders yet.</p>
} @else {
<div class="table-scroll">
<table class="data-table">
<thead>
<tr>
<th>Order</th>
<th>Placed</th>
</tr>
</thead>
<tbody>
@for (order of openOrders().slice(0, 8); track order.id) {
<tr>
<td>
<code class="text-xs text-ink-muted">{{ order.id.slice(0, 8) }}</code>
</td>
<td class="text-ink-muted">{{ order.created_at | date: 'short' }}</td>
</tr>
}
</tbody>
</table>
</div>
<ul class="space-y-3">
@for (slice of statusMix(); track slice.status) {
<li>
<div class="mb-1 flex items-center justify-between text-sm">
<span class="flex items-center gap-2">
<ot-badge [tone]="toneFor(slice.status)">{{ readable(slice.status) }}</ot-badge>
</span>
<span class="tabular-nums text-ink-muted">
{{ slice.count }} · {{ slice.percent }}%
</span>
</div>
<div class="h-1.5 overflow-hidden rounded-full bg-surface-sunken">
<div class="h-full rounded-full bg-brand-500" [style.width.%]="slice.percent"></div>
</div>
</li>
}
</ul>
}
</ot-card>
</div>

@if (refunded().length > 0) {
<div class="mt-6">
<ot-card title="Cancelled and refunded" description="Value that did not complete.">
<p class="text-2xl font-semibold tabular-nums text-danger">
{{ refundedValue() | money: currency() }}
</p>
<p class="mt-1 text-xs text-ink-muted">Across {{ refunded().length }} order(s)</p>
</ot-card>
</div>
}
}
Loading
Loading