Add request logging mode, dark mode, Account hub, profile, vehicle CRUD, cards view

- Client: logRequests option + redacted request/response logging (setLogRequests to
  toggle at runtime); enabled via app extra.debugHttp for on-device 401 debugging
- Dark mode (persisted) via ThemeProvider + React Navigation theme
- New "Account" tab: Profile (view), Vehicles (full CRUD), Payment methods (view-only),
  About, dark-mode + request-logging toggles, sign out

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hank 2026-07-06 10:14:24 -07:00
parent 65e9118806
commit 2f9f0f604a
11 changed files with 707 additions and 8 deletions

View file

@ -45,6 +45,10 @@ export interface ParkSmarterClientOptions {
uuid?: () => string;
/** Called when any authenticated request returns 401 (token cleared automatically). */
onUnauthorized?: () => void;
/** Log every request/response (redacted) — useful for on-device debugging via logcat. */
logRequests?: boolean;
/** Where log lines go (default console.log). */
logSink?: (line: string) => void;
}
function resolveEnvironment(
@ -69,6 +73,8 @@ export class ParkSmarterClient {
fetchImpl: options.fetchImpl,
uuid: options.uuid,
onUnauthorized: options.onUnauthorized,
logRequests: options.logRequests,
logSink: options.logSink,
});
}
@ -77,6 +83,11 @@ export class ParkSmarterClient {
this.http.setLocaleCode(localeCode);
}
/** Toggle redacted request/response logging at runtime. */
setLogRequests(on: boolean): void {
this.http.setLogRequests(on);
}
private unwrap<D>(p: Promise<ParkSmarterResponse<D>>): Promise<D> {
return p.then((r) => r.data);
}

View file

@ -106,6 +106,49 @@ export interface HttpClientConfig {
* stored token and route the user back to sign-in. Fired before the error throws.
*/
onUnauthorized?: () => void;
/**
* When true, logs every request/response (method, URL, headers, body, status)
* via `logSink` (default: console.log). Sensitive header/body values are
* redacted. Handy for capturing traffic in logcat while debugging; turn off in
* production.
*/
logRequests?: boolean;
/** Where log lines go when logRequests is on. Defaults to console.log. */
logSink?: (line: string) => void;
}
/** Header/body keys whose VALUES must never be logged in the clear. */
const SENSITIVE_KEYS = new Set(
[
'Auth_Token',
'Application_Token',
'ParkSmarter_SessionId',
'Password',
'OldPassword',
'NewPassword',
'CCNumber',
'CCExpDate',
'CCZip',
'CCFirstSix',
'CCLastFour',
'ProviderAuth',
'ProviderIdentity',
].map((k) => k.toLowerCase()),
);
function redact(obj: unknown): unknown {
if (!obj || typeof obj !== 'object') return obj;
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
if (SENSITIVE_KEYS.has(k.toLowerCase())) {
out[k] = typeof v === 'string' ? `<redacted:${v.length}>` : '<redacted>';
} else if (v && typeof v === 'object') {
out[k] = redact(v);
} else {
out[k] = v;
}
}
return out;
}
function defaultUuid(): string {
@ -141,6 +184,8 @@ export class HttpClient {
fetchImpl: config.fetchImpl ?? globalThis.fetch?.bind(globalThis),
uuid: config.uuid ?? defaultUuid,
onUnauthorized: config.onUnauthorized ?? (() => {}),
logRequests: config.logRequests ?? false,
logSink: config.logSink ?? ((line: string) => console.log(line)),
};
if (!this.cfg.fetchImpl) {
throw new Error(
@ -161,6 +206,11 @@ export class HttpClient {
this.cfg.localeCode = localeCode;
}
/** Toggle request/response logging at runtime (e.g. from a Settings switch). */
setLogRequests(on: boolean) {
this.cfg.logRequests = on;
}
async request<T>(opts: RequestOptions): Promise<ParkSmarterResponse<T>> {
const { method } = opts;
const requestId = this.cfg.uuid();
@ -202,6 +252,15 @@ export class HttpClient {
else opts.signal.addEventListener('abort', () => controller.abort(), { once: true });
}
if (this.cfg.logRequests) {
this.cfg.logSink(
`[PS →] ${method} ${url}\n headers: ${JSON.stringify(redact(headers))}` +
(opts.body !== undefined && opts.body !== null
? `\n body: ${JSON.stringify(redact(opts.body))}`
: ''),
);
}
let res: Response;
try {
res = await this.cfg.fetchImpl(url, {
@ -249,6 +308,14 @@ export class HttpClient {
}
}
if (this.cfg.logRequests) {
const preview =
data && typeof data === 'object'
? JSON.stringify(redact(data)).slice(0, 500)
: String(text).slice(0, 300);
this.cfg.logSink(`[PS ←] ${res.status} ${method} ${opts.path} ${preview}`);
}
if (!res.ok) {
if (res.status === 401) {
// Token invalid/rotated/expired — let the app clear it and re-auth.