Stage M2
This commit is contained in:
@@ -19,7 +19,8 @@
|
||||
"dev": "tsc -p tsconfig.json --watch --preserveWatchOutput",
|
||||
"clean": "rm -rf dist .turbo *.tsbuildinfo",
|
||||
"lint": "eslint src",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"test": "jest --passWithNoTests"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sport/types": "workspace:*"
|
||||
@@ -29,6 +30,17 @@
|
||||
"@sport/eslint-config": "workspace:*",
|
||||
"@types/node": "^22.19.0",
|
||||
"eslint": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
"typescript": "catalog:",
|
||||
"jest": "^30.2.0",
|
||||
"ts-jest": "^29.4.6",
|
||||
"@types/jest": "^30.0.0"
|
||||
},
|
||||
"jest": {
|
||||
"preset": "ts-jest",
|
||||
"testEnvironment": "node",
|
||||
"roots": [
|
||||
"<rootDir>/src"
|
||||
],
|
||||
"testRegex": ".*\\.spec\\.ts$"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { HttpClient, type HttpClientOptions } from './http-client';
|
||||
import { createAdminResource, type AdminResource } from './resources/admin';
|
||||
import { createAuthResource, type AuthResource } from './resources/auth';
|
||||
import { createCatalogResource, type CatalogResource } from './resources/catalog';
|
||||
import { createHealthResource, type HealthResource } from './resources/health';
|
||||
|
||||
@@ -12,6 +14,8 @@ export interface ApiClient {
|
||||
readonly http: HttpClient;
|
||||
readonly health: HealthResource;
|
||||
readonly catalog: CatalogResource;
|
||||
readonly auth: AuthResource;
|
||||
readonly admin: AdminResource;
|
||||
}
|
||||
|
||||
export function createApiClient(options: HttpClientOptions): ApiClient {
|
||||
@@ -21,5 +25,7 @@ export function createApiClient(options: HttpClientOptions): ApiClient {
|
||||
http,
|
||||
health: createHealthResource(http),
|
||||
catalog: createCatalogResource(http),
|
||||
auth: createAuthResource(http),
|
||||
admin: createAdminResource(http),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { HttpClient } from './http-client';
|
||||
|
||||
/**
|
||||
* Regression tests for two bugs that only manifested in a browser, and so
|
||||
* survived every server-side and curl-based check.
|
||||
*/
|
||||
describe('HttpClient', () => {
|
||||
const okResponse = () =>
|
||||
new Response(JSON.stringify({ success: true, data: { ok: true }, meta: {} }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
|
||||
it('calls the default fetch with the global receiver, not the client instance', async () => {
|
||||
// Browsers require `fetch` to be invoked with Window as `this`. Storing
|
||||
// `globalThis.fetch` on the instance and calling `this.fetchImpl(...)`
|
||||
// passes the HttpClient as the receiver and throws "Illegal invocation" —
|
||||
// in the browser only. Node does not care, which is precisely why every
|
||||
// server-side check and every curl passed while the browser was broken.
|
||||
const original = globalThis.fetch;
|
||||
const receivers: unknown[] = [];
|
||||
|
||||
globalThis.fetch = function (this: unknown) {
|
||||
// Pushed rather than assigned to a local: capturing the receiver is the
|
||||
// point of the test, and a plain alias trips `no-this-alias`.
|
||||
receivers.push(this);
|
||||
return Promise.resolve(okResponse());
|
||||
} as unknown as typeof fetch;
|
||||
|
||||
try {
|
||||
// No fetchImpl — exercise the default path, which is the one that broke.
|
||||
const client = new HttpClient({ baseUrl: 'http://api.test' });
|
||||
await client.get('/thing');
|
||||
} finally {
|
||||
globalThis.fetch = original;
|
||||
}
|
||||
|
||||
expect(receivers).toHaveLength(1);
|
||||
expect(receivers[0]).toBe(globalThis);
|
||||
expect(receivers[0]).not.toBeInstanceOf(HttpClient);
|
||||
});
|
||||
|
||||
it('does not retry a request that opted out, so refresh cannot recurse', async () => {
|
||||
// `onUnauthorized` refreshes by calling the refresh endpoint. If that call
|
||||
// is itself retryable, its own 401 triggers another refresh — an unbounded
|
||||
// loop that hammers the API from the browser.
|
||||
let calls = 0;
|
||||
let refreshes = 0;
|
||||
|
||||
const fetchImpl = (() => {
|
||||
calls += 1;
|
||||
return Promise.resolve(new Response('{}', { status: 401 }));
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const client = new HttpClient({
|
||||
baseUrl: 'http://api.test',
|
||||
fetchImpl,
|
||||
onUnauthorized: () => {
|
||||
refreshes += 1;
|
||||
return false;
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
client.post('/auth/refresh', undefined, { skipAuthRetry: true }),
|
||||
).rejects.toThrow();
|
||||
|
||||
expect(calls).toBe(1);
|
||||
expect(refreshes).toBe(0);
|
||||
});
|
||||
|
||||
it('still offers one retry for ordinary requests', async () => {
|
||||
let calls = 0;
|
||||
|
||||
const fetchImpl = (() => {
|
||||
calls += 1;
|
||||
return Promise.resolve(calls === 1 ? new Response('{}', { status: 401 }) : okResponse());
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const client = new HttpClient({
|
||||
baseUrl: 'http://api.test',
|
||||
fetchImpl,
|
||||
onUnauthorized: () => true,
|
||||
});
|
||||
|
||||
await expect(client.get('/protected')).resolves.toEqual({ ok: true });
|
||||
expect(calls).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,15 @@ import { API_ERROR_CODES, type ApiResponse } from '@sport/types';
|
||||
import { ApiClientError } from './errors';
|
||||
|
||||
export interface HttpClientOptions {
|
||||
/** Origin only, e.g. `http://localhost:4000`. The version prefix is added here. */
|
||||
/**
|
||||
* Origin, e.g. `http://localhost:4000` — or an empty string to call the
|
||||
* current page's own origin.
|
||||
*
|
||||
* Same-origin is what the browser client uses: the refresh cookie is
|
||||
* `SameSite=Lax` and therefore only sent first-party, so requests must go
|
||||
* through the app's own host (Nginx in production, a Next.js rewrite in
|
||||
* development) rather than directly to the API host.
|
||||
*/
|
||||
baseUrl: string;
|
||||
/** Defaults to `v1`. */
|
||||
apiVersion?: string;
|
||||
@@ -13,7 +21,13 @@ export interface HttpClientOptions {
|
||||
onUnauthorized?: () => boolean | Promise<boolean>;
|
||||
defaultHeaders?: Record<string, string>;
|
||||
timeoutMs?: number;
|
||||
/** Injectable for tests and for runtimes with a patched fetch (Next.js). */
|
||||
/**
|
||||
* Injectable for tests and for runtimes with a patched fetch.
|
||||
*
|
||||
* Must be independently callable — pass a mock, or `window.fetch.bind(window)`.
|
||||
* A bare `window.fetch` reference throws "Illegal invocation" in the browser
|
||||
* because it loses its `Window` receiver.
|
||||
*/
|
||||
fetchImpl?: typeof fetch;
|
||||
}
|
||||
|
||||
@@ -26,6 +40,15 @@ export interface RequestOptions {
|
||||
cache?: RequestCache;
|
||||
/** Send cookies (used by the refresh-token flow). */
|
||||
credentials?: RequestCredentials;
|
||||
/**
|
||||
* Opts this request out of the automatic re-authentication retry.
|
||||
*
|
||||
* MANDATORY on the auth endpoints themselves. `onUnauthorized` refreshes by
|
||||
* calling `/auth/refresh`; if that call is itself eligible for the retry,
|
||||
* its own 401 triggers another refresh, which 401s, which triggers another —
|
||||
* an unbounded loop that hammers the API from the browser. Ask how I know.
|
||||
*/
|
||||
skipAuthRetry?: boolean;
|
||||
}
|
||||
|
||||
type Method = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
|
||||
@@ -42,7 +65,17 @@ export class HttpClient {
|
||||
this.options = options;
|
||||
this.baseUrl = options.baseUrl.replace(/\/+$/, '');
|
||||
this.apiVersion = options.apiVersion ?? 'v1';
|
||||
this.fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
||||
/**
|
||||
* Wrapped, never stored bare.
|
||||
*
|
||||
* `globalThis.fetch` must be invoked with `Window` as its receiver in the
|
||||
* browser. Assigning it to an instance property and calling
|
||||
* `this.fetchImpl(...)` invokes it with the HttpClient as `this`, which
|
||||
* throws "Illegal invocation" — in the browser only. Node does not care,
|
||||
* so server-side rendering and curl both worked while every click in the
|
||||
* browser silently failed as a network error.
|
||||
*/
|
||||
this.fetchImpl = options.fetchImpl ?? ((input, init) => globalThis.fetch(input, init));
|
||||
}
|
||||
|
||||
get<T>(path: string, options?: RequestOptions): Promise<T> {
|
||||
@@ -106,7 +139,12 @@ export class HttpClient {
|
||||
throw ApiClientError.network(cause);
|
||||
}
|
||||
|
||||
if (response.status === 401 && !isRetry && this.options.onUnauthorized) {
|
||||
if (
|
||||
response.status === 401 &&
|
||||
!isRetry &&
|
||||
!options?.skipAuthRetry &&
|
||||
this.options.onUnauthorized
|
||||
) {
|
||||
const shouldRetry = await this.options.onUnauthorized();
|
||||
if (shouldRetry) {
|
||||
return this.request<T>(method, path, body, options, true);
|
||||
@@ -122,6 +160,17 @@ export class HttpClient {
|
||||
try {
|
||||
payload = (await response.json()) as ApiResponse<T>;
|
||||
} catch (cause) {
|
||||
/**
|
||||
* A non-JSON body on a 5xx means the request never reached the API —
|
||||
* a reverse proxy or dev rewrite answered with an HTML error page while
|
||||
* the upstream was down or restarting. Reporting that as "unreadable
|
||||
* response" sends people hunting for a serialisation bug; it is an
|
||||
* availability problem, and the offline message says so.
|
||||
*/
|
||||
if (response.status >= 500) {
|
||||
throw ApiClientError.network(cause);
|
||||
}
|
||||
|
||||
throw new ApiClientError({
|
||||
code: API_ERROR_CODES.INTERNAL_ERROR,
|
||||
message: 'The server returned an unreadable response.',
|
||||
@@ -140,7 +189,14 @@ export class HttpClient {
|
||||
|
||||
private buildUrl(path: string, query?: RequestOptions['query']): string {
|
||||
const normalized = path.startsWith('/') ? path : `/${path}`;
|
||||
const url = new URL(`${this.baseUrl}/api/${this.apiVersion}${normalized}`);
|
||||
const url = new URL(
|
||||
`${this.baseUrl}/api/${this.apiVersion}${normalized}`,
|
||||
// Only used when baseUrl is relative. On the server a relative baseUrl is
|
||||
// a configuration error, and `new URL` will say so loudly.
|
||||
this.baseUrl.startsWith('http')
|
||||
? undefined
|
||||
: (globalThis as { location?: { origin: string } }).location?.origin,
|
||||
);
|
||||
|
||||
for (const [key, value] of Object.entries(query ?? {})) {
|
||||
if (value === undefined || value === null || value === '') continue;
|
||||
|
||||
@@ -16,4 +16,12 @@ export { HttpClient } from './http-client';
|
||||
export type { HttpClientOptions, RequestOptions } from './http-client';
|
||||
export { createApiClient } from './create-client';
|
||||
export type { CatalogResource, ProductListQuery } from './resources/catalog';
|
||||
export type { AuthResource, LoginCredentials } from './resources/auth';
|
||||
export type {
|
||||
AdminResource,
|
||||
CreateUserPayload,
|
||||
RolePayload,
|
||||
UpdateUserPayload,
|
||||
UserListParams,
|
||||
} from './resources/admin';
|
||||
export type { ApiClient } from './create-client';
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { OffsetPaginated, PermissionGroup, RoleDetail, UserSummary } from '@sport/types';
|
||||
|
||||
import type { HttpClient } from '../http-client';
|
||||
|
||||
export interface UserListParams {
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
q?: string;
|
||||
type?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface CreateUserPayload {
|
||||
email: string;
|
||||
password: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
phone?: string;
|
||||
type: 'STAFF' | 'ADMIN' | 'SUPER_ADMIN';
|
||||
roleIds: string[];
|
||||
}
|
||||
|
||||
export interface UpdateUserPayload {
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
phone?: string | null;
|
||||
status?: 'ACTIVE' | 'INVITED' | 'SUSPENDED';
|
||||
type?: 'STAFF' | 'ADMIN' | 'SUPER_ADMIN';
|
||||
roleIds?: string[];
|
||||
}
|
||||
|
||||
export interface RolePayload {
|
||||
key?: string;
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
permissions?: string[];
|
||||
}
|
||||
|
||||
/** Back-office administration. Every call requires an admin-audience token. */
|
||||
export interface AdminResource {
|
||||
listUsers(params?: UserListParams): Promise<OffsetPaginated<UserSummary>>;
|
||||
getUser(id: string): Promise<UserSummary>;
|
||||
createUser(payload: CreateUserPayload): Promise<UserSummary>;
|
||||
updateUser(id: string, payload: UpdateUserPayload): Promise<UserSummary>;
|
||||
resetUserPassword(id: string, password: string): Promise<{ ok: true }>;
|
||||
|
||||
listRoles(): Promise<RoleDetail[]>;
|
||||
getRole(id: string): Promise<RoleDetail>;
|
||||
listPermissions(): Promise<PermissionGroup[]>;
|
||||
createRole(
|
||||
payload: Required<Pick<RolePayload, 'key' | 'name'>> & RolePayload,
|
||||
): Promise<RoleDetail>;
|
||||
updateRole(id: string, payload: RolePayload): Promise<RoleDetail>;
|
||||
deleteRole(id: string): Promise<void>;
|
||||
}
|
||||
|
||||
export function createAdminResource(http: HttpClient): AdminResource {
|
||||
// Administration data is never cached: an operator editing permissions must
|
||||
// see the result of their own change immediately.
|
||||
const uncached = { cache: 'no-store' } as const;
|
||||
|
||||
return {
|
||||
listUsers: (params = {}) =>
|
||||
http.get<OffsetPaginated<UserSummary>>('/admin/users', { ...uncached, query: { ...params } }),
|
||||
getUser: (id) => http.get<UserSummary>(`/admin/users/${encodeURIComponent(id)}`, uncached),
|
||||
createUser: (payload) => http.post<UserSummary>('/admin/users', payload, uncached),
|
||||
updateUser: (id, payload) =>
|
||||
http.patch<UserSummary>(`/admin/users/${encodeURIComponent(id)}`, payload, uncached),
|
||||
resetUserPassword: (id, password) =>
|
||||
http.post<{ ok: true }>(
|
||||
`/admin/users/${encodeURIComponent(id)}/password`,
|
||||
{ password },
|
||||
uncached,
|
||||
),
|
||||
|
||||
listRoles: () => http.get<RoleDetail[]>('/admin/roles', uncached),
|
||||
getRole: (id) => http.get<RoleDetail>(`/admin/roles/${encodeURIComponent(id)}`, uncached),
|
||||
listPermissions: () => http.get<PermissionGroup[]>('/admin/roles/permissions', uncached),
|
||||
createRole: (payload) => http.post<RoleDetail>('/admin/roles', payload, uncached),
|
||||
updateRole: (id, payload) =>
|
||||
http.patch<RoleDetail>(`/admin/roles/${encodeURIComponent(id)}`, payload, uncached),
|
||||
deleteRole: (id) => http.delete<void>(`/admin/roles/${encodeURIComponent(id)}`, uncached),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { CurrentUser, LoginResult, RefreshResult, SessionSummary } from '@sport/types';
|
||||
|
||||
import type { HttpClient } from '../http-client';
|
||||
|
||||
export interface LoginCredentials {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Storefront and admin have separate endpoints because they issue separate
|
||||
* cookies for separate audiences. Exposing them as one method with a flag would
|
||||
* make it far too easy to point a customer credential at the admin surface.
|
||||
*/
|
||||
export interface AuthResource {
|
||||
login(credentials: LoginCredentials): Promise<LoginResult>;
|
||||
adminLogin(credentials: LoginCredentials): Promise<LoginResult>;
|
||||
refresh(): Promise<RefreshResult>;
|
||||
adminRefresh(): Promise<RefreshResult>;
|
||||
logout(): Promise<void>;
|
||||
adminLogout(): Promise<void>;
|
||||
me(): Promise<CurrentUser>;
|
||||
sessions(): Promise<SessionSummary[]>;
|
||||
}
|
||||
|
||||
export function createAuthResource(http: HttpClient): AuthResource {
|
||||
/**
|
||||
* `cache: 'no-store'` on every call. An authentication response must never be
|
||||
* served from a cache — not Next's data cache, not a CDN, not the browser's.
|
||||
*/
|
||||
const uncached = {
|
||||
cache: 'no-store',
|
||||
/**
|
||||
* Every auth endpoint opts out of the 401 retry. Refresh is the mechanism
|
||||
* the retry *uses*, so letting it retry itself recurses without bound;
|
||||
* login and logout have nothing to re-authenticate with either.
|
||||
*/
|
||||
skipAuthRetry: true,
|
||||
} as const;
|
||||
|
||||
return {
|
||||
login: (credentials) => http.post<LoginResult>('/auth/login', credentials, uncached),
|
||||
adminLogin: (credentials) => http.post<LoginResult>('/auth/admin/login', credentials, uncached),
|
||||
refresh: () => http.post<RefreshResult>('/auth/refresh', undefined, uncached),
|
||||
adminRefresh: () => http.post<RefreshResult>('/auth/admin/refresh', undefined, uncached),
|
||||
logout: () => http.post<void>('/auth/logout', undefined, uncached),
|
||||
adminLogout: () => http.post<void>('/auth/admin/logout', undefined, uncached),
|
||||
me: () => http.get<CurrentUser>('/auth/me', uncached),
|
||||
sessions: () => http.get<SessionSummary[]>('/auth/sessions', uncached),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { Id, IsoDateTime, Nullable } from '../primitives';
|
||||
|
||||
import type { UserType } from './actors';
|
||||
import type { Permission } from './permissions';
|
||||
|
||||
/**
|
||||
* The `/auth/me` payload — everything a frontend needs to render a session.
|
||||
*
|
||||
* Note what is absent: no password hash, no session id, no refresh token, no
|
||||
* internal flags. This is the whole of what a client is allowed to know about
|
||||
* itself, and it is assembled explicitly rather than by spreading a database
|
||||
* row, so a new column can never leak by accident.
|
||||
*/
|
||||
export interface CurrentUser {
|
||||
readonly id: Id;
|
||||
readonly email: string;
|
||||
readonly type: UserType;
|
||||
readonly firstName: Nullable<string>;
|
||||
readonly lastName: Nullable<string>;
|
||||
readonly displayName: string;
|
||||
readonly avatarUrl: Nullable<string>;
|
||||
readonly roles: readonly string[];
|
||||
readonly permissions: readonly Permission[];
|
||||
readonly lastLoginAt: Nullable<IsoDateTime>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Login response.
|
||||
*
|
||||
* The access token is returned in the body because the client holds it in
|
||||
* memory. The refresh token is NOT here — it is set as an httpOnly cookie the
|
||||
* page's JavaScript cannot read, which is the entire point: an XSS can steal
|
||||
* whatever is in memory, but it cannot steal the credential that mints new
|
||||
* sessions.
|
||||
*/
|
||||
export interface LoginResult {
|
||||
readonly user: CurrentUser;
|
||||
readonly accessToken: string;
|
||||
readonly accessTokenExpiresAt: IsoDateTime;
|
||||
}
|
||||
|
||||
/** Returned by the refresh endpoint; the rotated cookie rides along with it. */
|
||||
export interface RefreshResult {
|
||||
readonly accessToken: string;
|
||||
readonly accessTokenExpiresAt: IsoDateTime;
|
||||
}
|
||||
|
||||
/** One signed-in device. Surfaced so a user can review and revoke sessions. */
|
||||
export interface SessionSummary {
|
||||
readonly id: Id;
|
||||
readonly userAgent: Nullable<string>;
|
||||
readonly ipAddress: Nullable<string>;
|
||||
readonly createdAt: IsoDateTime;
|
||||
readonly expiresAt: IsoDateTime;
|
||||
readonly isCurrent: boolean;
|
||||
}
|
||||
@@ -13,6 +13,8 @@ export * from './api/pagination';
|
||||
export * from './auth/actors';
|
||||
export * from './auth/permissions';
|
||||
export * from './auth/tokens';
|
||||
export * from './auth/session';
|
||||
export * from './users/user';
|
||||
export * from './catalog/product';
|
||||
export * from './catalog/variant';
|
||||
export * from './catalog/taxonomy';
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { UserType } from '../auth/actors';
|
||||
import type { Permission } from '../auth/permissions';
|
||||
import type { Id, IsoDateTime, Nullable } from '../primitives';
|
||||
|
||||
export const USER_STATUSES = {
|
||||
ACTIVE: 'ACTIVE',
|
||||
INVITED: 'INVITED',
|
||||
SUSPENDED: 'SUSPENDED',
|
||||
} as const;
|
||||
|
||||
export type UserStatus = (typeof USER_STATUSES)[keyof typeof USER_STATUSES];
|
||||
|
||||
/** Row shape for the back-office user table. */
|
||||
export interface UserSummary {
|
||||
readonly id: Id;
|
||||
readonly email: string;
|
||||
readonly type: UserType;
|
||||
readonly status: UserStatus;
|
||||
readonly firstName: Nullable<string>;
|
||||
readonly lastName: Nullable<string>;
|
||||
readonly displayName: string;
|
||||
readonly roles: readonly RoleSummary[];
|
||||
readonly lastLoginAt: Nullable<IsoDateTime>;
|
||||
readonly createdAt: IsoDateTime;
|
||||
}
|
||||
|
||||
export interface RoleSummary {
|
||||
readonly id: Id;
|
||||
readonly key: string;
|
||||
readonly name: string;
|
||||
/** System roles are seeded from code and cannot be deleted or renamed. */
|
||||
readonly isSystem: boolean;
|
||||
}
|
||||
|
||||
export interface RoleDetail extends RoleSummary {
|
||||
readonly description: Nullable<string>;
|
||||
readonly permissions: readonly Permission[];
|
||||
readonly userCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The permission catalog, grouped by resource for the role editor.
|
||||
*
|
||||
* Served from the API rather than read from @sport/types in the browser so the
|
||||
* admin always shows exactly what the running backend enforces — a deploy skew
|
||||
* shows up as a missing checkbox, not as a silently ineffective grant.
|
||||
*/
|
||||
export interface PermissionGroup {
|
||||
readonly resource: string;
|
||||
readonly permissions: readonly PermissionInfo[];
|
||||
}
|
||||
|
||||
export interface PermissionInfo {
|
||||
readonly key: Permission;
|
||||
readonly resource: string;
|
||||
readonly action: string;
|
||||
}
|
||||
@@ -28,5 +28,12 @@ export const registerSchema = z.object({
|
||||
acceptsMarketing: z.boolean().default(false),
|
||||
});
|
||||
|
||||
/**
|
||||
* The refresh token travels as an httpOnly cookie, never in a body — so this
|
||||
* schema is deliberately empty. It exists to document that the endpoint takes
|
||||
* no client-supplied input, which is what makes it safe to expose unauthenticated.
|
||||
*/
|
||||
export const refreshSchema = z.object({});
|
||||
|
||||
export type LoginInput = z.infer<typeof loginSchema>;
|
||||
export type RegisterInput = z.infer<typeof registerSchema>;
|
||||
|
||||
@@ -15,3 +15,4 @@ export * from './common';
|
||||
export * from './pagination';
|
||||
export * from './auth';
|
||||
export * from './catalog';
|
||||
export * from './users';
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { passwordSchema } from './auth';
|
||||
import { anyIdSchema, emailSchema, phoneSchema } from './common';
|
||||
import { offsetPageQuerySchema } from './pagination';
|
||||
|
||||
/**
|
||||
* Back-office user and role management.
|
||||
*
|
||||
* Note what is *not* here: no `permissions` array on a user. Permissions are
|
||||
* granted only through roles (ADR-0007). Allowing per-user overrides would make
|
||||
* "who can refund an order?" unanswerable without inspecting every account.
|
||||
*/
|
||||
|
||||
export const userTypeSchema = z.enum(['CUSTOMER', 'STAFF', 'ADMIN', 'SUPER_ADMIN']);
|
||||
export const userStatusSchema = z.enum(['ACTIVE', 'INVITED', 'SUSPENDED']);
|
||||
|
||||
/** Back-office accounts only — customers are created by registration. */
|
||||
export const backOfficeUserTypeSchema = z.enum(['STAFF', 'ADMIN', 'SUPER_ADMIN']);
|
||||
|
||||
export const userListQuerySchema = offsetPageQuerySchema.extend({
|
||||
q: z.string().trim().max(120).optional(),
|
||||
type: userTypeSchema.optional(),
|
||||
status: userStatusSchema.optional(),
|
||||
});
|
||||
|
||||
export const createUserSchema = z.object({
|
||||
email: emailSchema,
|
||||
password: passwordSchema,
|
||||
firstName: z.string().trim().min(1).max(80),
|
||||
lastName: z.string().trim().min(1).max(80),
|
||||
phone: phoneSchema.optional(),
|
||||
type: backOfficeUserTypeSchema,
|
||||
roleIds: z.array(anyIdSchema).default([]),
|
||||
});
|
||||
|
||||
export const updateUserSchema = z.object({
|
||||
firstName: z.string().trim().min(1).max(80).optional(),
|
||||
lastName: z.string().trim().min(1).max(80).optional(),
|
||||
phone: phoneSchema.nullish(),
|
||||
status: userStatusSchema.optional(),
|
||||
type: backOfficeUserTypeSchema.optional(),
|
||||
roleIds: z.array(anyIdSchema).optional(),
|
||||
});
|
||||
|
||||
/** An operator resetting someone else's password — no current password needed. */
|
||||
export const resetUserPasswordSchema = z.object({
|
||||
password: passwordSchema,
|
||||
});
|
||||
|
||||
/** A user changing their own password — proves possession of the current one. */
|
||||
export const changePasswordSchema = z.object({
|
||||
currentPassword: z.string().min(1),
|
||||
newPassword: passwordSchema,
|
||||
});
|
||||
|
||||
export const roleKeySchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(2)
|
||||
.max(64)
|
||||
.regex(/^[a-z][a-z0-9_]*$/, 'Use lowercase letters, digits and underscores');
|
||||
|
||||
export const createRoleSchema = z.object({
|
||||
key: roleKeySchema,
|
||||
name: z.string().trim().min(2).max(120),
|
||||
description: z.string().trim().max(500).nullish(),
|
||||
permissions: z.array(z.string().max(64)).default([]),
|
||||
});
|
||||
|
||||
export const updateRoleSchema = z.object({
|
||||
name: z.string().trim().min(2).max(120).optional(),
|
||||
description: z.string().trim().max(500).nullish(),
|
||||
permissions: z.array(z.string().max(64)).optional(),
|
||||
});
|
||||
|
||||
export type UserListQuery = z.output<typeof userListQuerySchema>;
|
||||
export type CreateUserInput = z.output<typeof createUserSchema>;
|
||||
export type UpdateUserInput = z.output<typeof updateUserSchema>;
|
||||
export type CreateRoleInput = z.output<typeof createRoleSchema>;
|
||||
export type UpdateRoleInput = z.output<typeof updateRoleSchema>;
|
||||
export type ChangePasswordInput = z.output<typeof changePasswordSchema>;
|
||||
Reference in New Issue
Block a user