portal-mojo 0.2.7 → 0.2.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -47,6 +47,9 @@ export type MockUser = User & { created: number; requires_mfa: boolean };
47
47
 
48
48
  const PRIVATE_USER_FIELDS = new Set(['created', 'requires_mfa']);
49
49
 
50
+ /** Users whose password is unusable (OAuth/passkey-only) — `has_password: false`. */
51
+ const passwordlessUsers = new Set<number>();
52
+
50
53
  function serializeUser(u: MockUser, graph: 'list' | 'default' = 'list'): User {
51
54
  const row: Record<string, unknown> = {};
52
55
  for (const [k, v] of Object.entries(u)) {
@@ -56,6 +59,7 @@ function serializeUser(u: MockUser, graph: 'list' | 'default' = 'list'): User {
56
59
  delete row.is_online; // list-graph-only field
57
60
  row.requires_mfa = u.requires_mfa;
58
61
  row.has_passkey = db.passkeys.some((p) => p.user === u.id && p.is_enabled);
62
+ row.has_password = !passwordlessUsers.has(u.id);
59
63
  }
60
64
  return row as unknown as User;
61
65
  }
@@ -4591,10 +4595,13 @@ function runUserAction(user: MockUser, action: string, value: unknown, caller?:
4591
4595
  case 'revoke_sessions':
4592
4596
  // Handler returns its own payload — the response is NOT the row.
4593
4597
  return { status: true, message: 'Sessions revoked. Re-authenticate to continue.' };
4594
- case 'disable_totp':
4598
+ case 'disable_totp': {
4595
4599
  // on_action_disable_totp: clears enrollment (no-ops when nothing
4596
4600
  // is enrolled) and answers its own {status:true} payload.
4601
+ const state = totpState.get(user.id);
4602
+ if (state) state.enabled = false;
4597
4603
  return { status: true };
4604
+ }
4598
4605
  default:
4599
4606
  return null;
4600
4607
  }
@@ -4972,9 +4979,75 @@ function signinFetch(method: string, body: Record<string, unknown>, headers: Rec
4972
4979
  // NO_SAVE_FIELDS parity (account/models/user.py RestMeta): silently dropped,
4973
4980
  // never an error — matching rest.py's strip. NOTE is_dob_verified IS in this
4974
4981
  // set live, which is why the portal ships no DOB force-verify affordance.
4975
- const USER_NO_SAVE = new Set(['id', 'pk', 'auth_key', 'last_activity', 'is_dob_verified', 'created', 'has_passkey', 'is_online']);
4982
+ // Live NO_SAVE_FIELDS (user.py:188-192) plus the graph-only/derived keys the
4983
+ // mock row carries (id/pk/created/has_passkey/has_password/is_online) and
4984
+ // `current_password`, which set_new_password reads off the request, never
4985
+ // off the row.
4986
+ const USER_NO_SAVE = new Set([
4987
+ 'id', 'pk', 'auth_key', 'last_activity', 'is_dob_verified', 'requires_password_change',
4988
+ 'secrets', 'mojo_secrets', 'secret', 'permanent_password', 'protected_metadata', 'unusable_password', 'totp',
4989
+ 'created', 'has_passkey', 'has_password', 'is_online', 'current_password',
4990
+ ]);
4991
+
4992
+ /** user.py ADMIN_ONLY_FIELDS (:80-83) — `users`/`manage_users`/superuser only. */
4993
+ const USER_ADMIN_ONLY_FIELDS = ['is_email_verified', 'is_phone_verified', 'requires_mfa', 'is_active', 'org'];
4994
+
4995
+ /**
4996
+ * The live owner rules a save must pass (user.py on_rest_pre_save :1005-1100,
4997
+ * _handle_existing_user_pre_save, set_is_superuser/set_is_staff :626-634,
4998
+ * set_permissions :636-650, set_new_password :819-828, the rest.py `protected`
4999
+ * metadata guard). Only CHANGED values trip the field guards — except
5000
+ * is_superuser/is_staff, whose setters refuse a non-superuser even unchanged.
5001
+ */
5002
+ function userSaveRefusal(user: MockUser, body: Record<string, unknown>, caller: MockUser | undefined): Record<string, unknown> | null {
5003
+ if (!caller) return null;
5004
+ const superuser = caller.is_superuser === true;
5005
+ const admin = superuser || hasGlobalPermission(caller, ['users', 'manage_users']);
5006
+ const deny = (error: string) => ({ status: false, error, error_code: 403 });
5007
+ const target = user as unknown as Record<string, unknown>;
5008
+ const current = (key: string): unknown => {
5009
+ const value = target[key];
5010
+ if (key === 'org') return value && typeof value === 'object' ? (value as { id?: unknown }).id ?? null : value ?? null;
5011
+ return value ?? null;
5012
+ };
5013
+ const changed = (key: string): boolean => {
5014
+ if (!(key in body)) return false;
5015
+ const next = key === 'org' && body[key] != null && body[key] !== '' ? Number(body[key]) : body[key] ?? null;
5016
+ return JSON.stringify(next) !== JSON.stringify(current(key));
5017
+ };
5018
+ if ('is_superuser' in body && !superuser) return deny('Only a superuser can grant superuser status');
5019
+ if ('is_staff' in body && !superuser) return deny('Only a superuser can grant staff status');
5020
+ if (isPlainObject(body.permissions) && Object.keys(body.permissions).length > 0 && !admin) return deny('permission denied');
5021
+ for (const field of USER_ADMIN_ONLY_FIELDS) {
5022
+ if (changed(field) && !admin) return deny(`You are not allowed to change ${field}`);
5023
+ }
5024
+ if (!admin) {
5025
+ if (changed('email') || changed('username')) return deny('You are not allowed to change email or username');
5026
+ if ('phone_number' in body) {
5027
+ const next = body.phone_number;
5028
+ const normalized = next == null || next === '' ? null : normalizePhone(String(next)) ?? String(next);
5029
+ if (user.phone_number && normalized != null && normalized !== user.phone_number) {
5030
+ return deny('Use the phone change flow to update an existing phone number');
5031
+ }
5032
+ }
5033
+ if (changed('dob') && current('dob') != null) return deny('Date of birth cannot be changed after registration');
5034
+ }
5035
+ if ('password' in body) return deny('You are not allowed to change password');
5036
+ if (isPlainObject(body.metadata) && 'protected' in body.metadata && !superuser) return deny('permission denied');
5037
+ if ('new_password' in body) {
5038
+ if (caller.id !== user.id && !admin) return deny('You are not allowed to change password');
5039
+ const supplied = body.current_password;
5040
+ if (!supplied && !admin) return { status: false, error: 'You must provide your current password', error_code: 400 };
5041
+ if (supplied && (passwordlessUsers.has(user.id) || supplied !== MOCK_PASSWORD)) {
5042
+ return { status: false, error: 'Incorrect current password', error_code: 400 };
5043
+ }
5044
+ }
5045
+ return null;
5046
+ }
4976
5047
 
4977
5048
  function saveUser(user: MockUser, body: Record<string, unknown>, caller?: MockUser): unknown {
5049
+ const refusal = userSaveRefusal(user, body, caller);
5050
+ if (refusal) return refusal;
4978
5051
  const fields: Record<string, unknown> = {};
4979
5052
  const actionEntries: [string, unknown][] = [];
4980
5053
  for (const [key, value] of Object.entries(body)) {
@@ -4989,6 +5062,7 @@ function saveUser(user: MockUser, body: Record<string, unknown>, caller?: MockUs
4989
5062
  if (pw.length < 8) {
4990
5063
  return { status: false, error: 'Password is too weak. Use a longer password or include a mix of uppercase, lowercase, numbers, and special characters', error_code: 400 };
4991
5064
  }
5065
+ passwordlessUsers.delete(user.id);
4992
5066
  }
4993
5067
  // set_org parity: the FK arrives as an id (or null to clear), serializes
4994
5068
  // back as the basic sub-graph.
@@ -5554,16 +5628,35 @@ function b64url(s: string): string {
5554
5628
  return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
5555
5629
  }
5556
5630
 
5631
+ /**
5632
+ * auth_key rotation parity (sessions/revoke, email change confirm): every
5633
+ * token carries the user's current epoch as `ak` once one has been rotated,
5634
+ * and a token minted under an older epoch is dead everywhere — exactly what
5635
+ * rotating the server-side signing key does to every other session.
5636
+ */
5637
+ const authEpochs = new Map<number, number>();
5638
+
5639
+ function rotateAuthKey(userId: number): void {
5640
+ authEpochs.set(userId, (authEpochs.get(userId) ?? 0) + 1);
5641
+ }
5642
+
5643
+ function epochMatches(payload: Record<string, unknown>): boolean {
5644
+ const current = authEpochs.get(Number(payload.uid)) ?? 0;
5645
+ return (typeof payload.ak === 'number' ? payload.ak : 0) === current;
5646
+ }
5647
+
5557
5648
  /** Real-shaped JWT (decodable header.payload) with a fake signature. */
5558
5649
  function mintJwt(user: User, ttlSec: number, extra: Record<string, unknown> = {}): string {
5559
5650
  const now = Math.floor(Date.now() / 1000);
5560
5651
  const header = b64url(JSON.stringify({ alg: 'none', typ: 'JWT' }));
5652
+ const epoch = authEpochs.get(user.id);
5561
5653
  const payload = b64url(JSON.stringify({
5562
5654
  uid: user.id,
5563
5655
  email: user.email,
5564
5656
  name: user.display_name,
5565
5657
  iat: now,
5566
5658
  exp: now + ttlSec,
5659
+ ...(epoch ? { ak: epoch } : {}),
5567
5660
  ...extra,
5568
5661
  }));
5569
5662
  return `${header}.${payload}.mock-signature`;
@@ -5723,7 +5816,7 @@ function authFetch(path: string, body: Record<string, unknown>, params: Params =
5723
5816
  const payload = decodeMockJwt(String(body.refresh_token ?? ''));
5724
5817
  const user = payload && db.users.find((u) => u.id === Number(payload.uid));
5725
5818
  const now = Math.floor(Date.now() / 1000);
5726
- if (!payload || !user || typeof payload.exp !== 'number' || now >= payload.exp) {
5819
+ if (!payload || !user || typeof payload.exp !== 'number' || now >= payload.exp || !epochMatches(payload)) {
5727
5820
  return { status: false, error: 'token is invalid or expired', error_code: 401 };
5728
5821
  }
5729
5822
  // A refresh is NOT a fresh authentication: auth_time carries forward.
@@ -5835,6 +5928,7 @@ export interface MockRequestHistoryEntry {
5835
5928
  }
5836
5929
 
5837
5930
  const requestHistory: MockRequestHistoryEntry[] = [];
5931
+ const OWNER_SCOPED_LISTS = new Set(['/api/account/passkeys', '/api/account/api_keys', '/api/account/oauth_connection', '/api/user/device', '/api/account/logins']);
5838
5932
 
5839
5933
  export function getMockRequestHistory(): MockRequestHistoryEntry[] {
5840
5934
  return requestHistory.map((entry) => ({
@@ -5859,6 +5953,324 @@ export function armMockReauth(method: string, path: string): void {
5859
5953
  armedReauth = { method: method.toUpperCase(), path };
5860
5954
  }
5861
5955
 
5956
+ interface MockOnceArm {
5957
+ method: string; path: string; extraDelayMs: number; stripGrantTokens: boolean;
5958
+ mapData?: (data: Record<string, unknown>) => Record<string, unknown>;
5959
+ }
5960
+ let armedOnce: MockOnceArm | null = null;
5961
+
5962
+ /**
5963
+ * Mock-only one-shot response shaping, matched by BOTH method and path:
5964
+ * `extraDelayMs` holds the answer back (to stage an in-flight race);
5965
+ * `stripGrantTokens` drops access_token/refresh_token from a login grant —
5966
+ * the shape of django-mojo's forced_password_response after auth_key rotated.
5967
+ */
5968
+ export function armMockResponseOnce(method: string, path: string, opts: {
5969
+ extraDelayMs?: number;
5970
+ stripGrantTokens?: boolean;
5971
+ /** Rewrite the envelope's `data` (e.g. pin a known WebAuthn challenge). */
5972
+ mapData?: (data: Record<string, unknown>) => Record<string, unknown>;
5973
+ }): void {
5974
+ armedOnce = { method: method.toUpperCase(), path, extraDelayMs: opts.extraDelayMs ?? 0, stripGrantTokens: opts.stripGrantTokens ?? false, mapData: opts.mapData };
5975
+ }
5976
+
5977
+ // ── Self-service account (the AccountModal wire) ──────────────────────
5978
+ // Shape-level parity with django-mojo account/rest/passkeys.py (register),
5979
+ // rest/totp.py, rest/user.py (email/phone/username change, sessions/revoke)
5980
+ // and rest/verify.py. Every one-time value is fixed so flows are drivable:
5981
+ // TOTP and every emailed/SMS'd code is 123456. The fresh-auth gate on these
5982
+ // paths is the generic armMockReauth(method, path) one-shot above.
5983
+
5984
+ interface MockTotpState {
5985
+ secret: string | null;
5986
+ enabled: boolean;
5987
+ codes: string[];
5988
+ }
5989
+
5990
+ const MOCK_TOTP_SECRET = 'JBSWY3DPEHPK3PXP';
5991
+ const MOCK_ACCOUNT_CODE = '123456';
5992
+ const totpState = new Map<number, MockTotpState>();
5993
+ const pendingPasskeyRegistrations = new Map<string, { uid: number }>();
5994
+ const pendingEmailChanges = new Map<number, { email: string; code: string }>();
5995
+ const pendingPhoneChanges = new Map<string, { uid: number; phone: string; code: string }>();
5996
+ const pendingVerifyCodes = new Map<string, string>(); // `${uid}:email|phone` → code
5997
+ let accountSequence = 0;
5998
+
5999
+ /** The registered-kinds catalogue the preferences GET carries (notification_kinds.py). */
6000
+ const MOCK_NOTIFICATION_KINDS: { kind: string; label: string; description: string; channels: string[] | null }[] = [
6001
+ { kind: 'general', label: 'General', description: 'Messages from this service', channels: null },
6002
+ ];
6003
+
6004
+ /** Seeded users that already require MFA start with an enrolled authenticator. */
6005
+ function mockTotp(user: MockUser): MockTotpState {
6006
+ let state = totpState.get(user.id);
6007
+ if (!state) {
6008
+ state = user.requires_mfa
6009
+ ? { secret: MOCK_TOTP_SECRET, enabled: true, codes: mockRecoveryCodes() }
6010
+ : { secret: null, enabled: false, codes: [] };
6011
+ totpState.set(user.id, state);
6012
+ }
6013
+ return state;
6014
+ }
6015
+
6016
+ function mockRecoveryCodes(): string[] {
6017
+ const hex = () => Math.floor(Math.random() * 0x10000).toString(16).padStart(4, '0');
6018
+ return Array.from({ length: 8 }, () => `${hex()}-${hex()}-${hex()}`);
6019
+ }
6020
+
6021
+ /** A deterministic stand-in QR (a data: SVG — the server sends data:image/png). */
6022
+ function mockQrDataUrl(seed: string): string {
6023
+ const finder = (dx: number, dy: number) => dx === 0 || dx === 6 || dy === 0 || dy === 6 || (dx >= 2 && dx <= 4 && dy >= 2 && dy <= 4);
6024
+ const cells: string[] = [];
6025
+ for (let y = 0; y < 21; y++) {
6026
+ for (let x = 0; x < 21; x++) {
6027
+ let on: boolean;
6028
+ if (x < 7 && y < 7) on = finder(x, y);
6029
+ else if (x > 13 && y < 7) on = finder(x - 14, y);
6030
+ else if (x < 7 && y > 13) on = finder(x, y - 14);
6031
+ else if ((x < 8 && y < 8) || (x > 12 && y < 8) || (x < 8 && y > 12)) on = false;
6032
+ else on = ((seed.charCodeAt((x * 21 + y) % seed.length) * (x + 3) * (y + 7)) % 5) < 2;
6033
+ if (on) cells.push(`<rect x="${x}" y="${y}" width="1" height="1"/>`);
6034
+ }
6035
+ }
6036
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="-2 -2 25 25" shape-rendering="crispEdges"><rect x="-2" y="-2" width="25" height="25" fill="#fff"/><g fill="#000">${cells.join('')}</g></svg>`;
6037
+ return `data:image/svg+xml;base64,${btoa(svg)}`;
6038
+ }
6039
+
6040
+ function missingParam(name: string): Record<string, unknown> {
6041
+ return { status: false, error: `Missing required parameter: ${name}`, error_code: 400 };
6042
+ }
6043
+
6044
+ function serializePasskeyRow(p: MockPasskey): Record<string, unknown> {
6045
+ const owner = db.users.find((u) => u.id === p.user);
6046
+ const { user: _uid, ...rest } = p;
6047
+ return { ...rest, user: owner ? userBasic(owner) : null };
6048
+ }
6049
+
6050
+ /**
6051
+ * The signed-in user's self-service routes. Returns undefined for any path
6052
+ * it does not own so the main dispatcher continues.
6053
+ */
6054
+ function accountSelfFetch(path: string, method: string, opts: MockFetchOpts): unknown {
6055
+ const owned = path.startsWith('/api/account/passkeys/register/')
6056
+ || path === '/api/account/totp' || path.startsWith('/api/account/totp/')
6057
+ || path === '/api/auth/sessions/revoke'
6058
+ || path.startsWith('/api/auth/email/change/') || path.startsWith('/api/auth/phone/change/')
6059
+ || path.startsWith('/api/auth/verify/') || path === '/api/auth/username/change';
6060
+ if (!owned) return undefined;
6061
+ const user = userFromBearer(opts.headers);
6062
+ // django-mojo's email change confirm also takes an unauthenticated ec:
6063
+ // link token; the code flow (the only one the portal drives) needs auth.
6064
+ if (!user) return permissionDenied(401);
6065
+ const body = opts.body ?? {};
6066
+
6067
+ // ── Passkey registration (passkeys.py:59-150) ──
6068
+ if (path === '/api/account/passkeys/register/begin' && method === 'POST') {
6069
+ const challenge_id = `reg-${user.id}-${++accountSequence}`;
6070
+ pendingPasskeyRegistrations.set(challenge_id, { uid: user.id });
6071
+ const exclude = db.passkeys.filter((p) => p.user === user.id)
6072
+ .map((p) => ({ type: 'public-key', id: b64url(p.credential_id) }));
6073
+ return {
6074
+ status: true,
6075
+ data: {
6076
+ challenge_id,
6077
+ publicKey: {
6078
+ rp: { id: 'localhost', name: 'Mojo' },
6079
+ user: { id: b64url(`mock-user-${user.id}`), name: user.username, displayName: user.display_name },
6080
+ challenge: b64url(`mock-registration-${accountSequence}`),
6081
+ pubKeyCredParams: [{ type: 'public-key', alg: -7 }, { type: 'public-key', alg: -257 }],
6082
+ timeout: 60000,
6083
+ attestation: 'none',
6084
+ ...(exclude.length ? { excludeCredentials: exclude } : {}),
6085
+ },
6086
+ expiresAt: new Date(Date.now() + 300_000).toISOString(),
6087
+ },
6088
+ };
6089
+ }
6090
+ if (path === '/api/account/passkeys/register/complete' && method === 'POST') {
6091
+ if (!body.challenge_id) return missingParam('challenge_id');
6092
+ if (!isPlainObject(body.credential)) return missingParam('credential');
6093
+ const challengeId = String(body.challenge_id);
6094
+ const pending = pendingPasskeyRegistrations.get(challengeId);
6095
+ if (!pending) return { status: false, error: 'Challenge not found or expired', error_code: 403 };
6096
+ if (pending.uid !== user.id) return { status: false, error: 'Challenge does not belong to this user', error_code: 403 };
6097
+ const credential = body.credential;
6098
+ const response = isPlainObject(credential.response) ? credential.response : {};
6099
+ if (typeof credential.rawId !== 'string' || credential.id !== credential.rawId) {
6100
+ return { status: false, error: 'Credential id does not match rawId', error_code: 403 };
6101
+ }
6102
+ if (typeof response.clientDataJSON !== 'string' || typeof response.attestationObject !== 'string') {
6103
+ return { status: false, error: 'Invalid registration response', error_code: 403 };
6104
+ }
6105
+ const transports = Array.isArray(credential.transports) ? credential.transports.map(String) : [];
6106
+ const friendly = typeof body.friendly_name === 'string' && body.friendly_name ? body.friendly_name : null;
6107
+ const nowSec = Math.floor(Date.now() / 1000);
6108
+ let row = db.passkeys.find((p) => p.credential_id === credential.rawId);
6109
+ if (row && row.user !== user.id) return { status: false, error: 'Credential already registered to another user', error_code: 403 };
6110
+ if (row) {
6111
+ row.transports = transports.join(',') || null;
6112
+ if (friendly) row.friendly_name = friendly;
6113
+ row.is_enabled = true;
6114
+ row.last_used = null;
6115
+ } else {
6116
+ row = {
6117
+ id: Math.max(70, ...db.passkeys.map((p) => p.id)) + 1,
6118
+ user: user.id,
6119
+ friendly_name: friendly,
6120
+ credential_id: String(credential.rawId),
6121
+ rp_id: 'localhost',
6122
+ is_enabled: true,
6123
+ sign_count: 0,
6124
+ transports: transports.join(',') || null,
6125
+ aaguid: null,
6126
+ last_used: null,
6127
+ created: nowSec,
6128
+ };
6129
+ db.passkeys.unshift(row);
6130
+ }
6131
+ pendingPasskeyRegistrations.delete(challengeId);
6132
+ return { status: true, data: serializePasskeyRow(row), graph: 'default' };
6133
+ }
6134
+
6135
+ // ── TOTP (totp.py:41-139) ──
6136
+ if (path === '/api/account/totp/setup' && method === 'POST') {
6137
+ const state = mockTotp(user);
6138
+ state.secret = MOCK_TOTP_SECRET;
6139
+ state.enabled = false;
6140
+ const uri = `otpauth://totp/Mojo:${encodeURIComponent(user.username)}?secret=${MOCK_TOTP_SECRET}&issuer=Mojo`;
6141
+ return { status: true, data: { secret: MOCK_TOTP_SECRET, uri, qr_code: mockQrDataUrl(uri) } };
6142
+ }
6143
+ if (path === '/api/account/totp/confirm' && method === 'POST') {
6144
+ if (!body.code) return missingParam('code');
6145
+ const state = mockTotp(user);
6146
+ if (!state.secret) return { status: false, error: 'TOTP setup not started. Call /api/account/totp/setup first.', error_code: 400 };
6147
+ if (String(body.code).trim() !== MOCK_TOTP_CODE) return { status: false, error: 'Invalid code', error_code: 400 };
6148
+ state.enabled = true;
6149
+ state.codes = mockRecoveryCodes();
6150
+ user.requires_mfa = true;
6151
+ return { status: true, data: { is_enabled: true, recovery_codes: [...state.codes] } };
6152
+ }
6153
+ if (path === '/api/account/totp' && method === 'DELETE') {
6154
+ // Deliberately leaves requires_mfa alone (totp.py:97-105).
6155
+ mockTotp(user).enabled = false;
6156
+ return { status: true };
6157
+ }
6158
+ if (path === '/api/account/totp/recovery-codes' && method === 'GET') {
6159
+ const state = mockTotp(user);
6160
+ if (!state.enabled) return { status: false, error: 'TOTP is not enabled for this account', error_code: 400 };
6161
+ return { status: true, data: { remaining: state.codes.length, codes: state.codes.map((c) => `${c.slice(0, 4)}-xxxx-xxxx`) } };
6162
+ }
6163
+ if (path === '/api/account/totp/recovery-codes/regenerate' && method === 'POST') {
6164
+ if (!body.code) return missingParam('code');
6165
+ const state = mockTotp(user);
6166
+ if (!state.enabled) return { status: false, error: 'TOTP is not enabled for this account', error_code: 400 };
6167
+ if (String(body.code).trim() !== MOCK_TOTP_CODE) return { status: false, error: 'Invalid TOTP code', error_code: 403 };
6168
+ state.codes = mockRecoveryCodes();
6169
+ return { status: true, data: { is_enabled: true, recovery_codes: [...state.codes] } };
6170
+ }
6171
+
6172
+ // ── Sessions (user.py:2331-2355) ──
6173
+ if (path === '/api/auth/sessions/revoke' && method === 'POST') {
6174
+ rotateAuthKey(user.id);
6175
+ return { status: true, data: { ...tokenPair(user), user: serializeUser(user) } };
6176
+ }
6177
+
6178
+ // ── Email change (user.py:1620-1717, 1953-1994) ──
6179
+ if (path === '/api/auth/email/change/request' && method === 'POST') {
6180
+ const email = String(body.email ?? '').toLowerCase().trim();
6181
+ if (!email) return missingParam('email');
6182
+ if (!/[^@]+@[^@]+\.[^@]+/.test(email)) return { status: false, error: 'Invalid email address', error_code: 400 };
6183
+ if (email === user.email.toLowerCase()) return { status: false, error: 'New email must be different from current email', error_code: 400 };
6184
+ if (db.users.some((u) => u.id !== user.id && u.email.toLowerCase() === email)) return { status: false, error: 'Email already in use', error_code: 400 };
6185
+ pendingEmailChanges.set(user.id, { email, code: MOCK_ACCOUNT_CODE });
6186
+ return body.method === 'code'
6187
+ ? { status: true, message: 'A verification code has been sent to your new email address.' }
6188
+ : { status: true, message: 'A confirmation link has been sent to your new email address.' };
6189
+ }
6190
+ if (path === '/api/auth/email/change/confirm' && method === 'POST') {
6191
+ if (!body.code && !body.token) return { status: false, error: 'token or code is required', error_code: 400 };
6192
+ const pending = pendingEmailChanges.get(user.id);
6193
+ if (!pending || String(body.code ?? '').trim() !== pending.code) return { status: false, error: 'Invalid or expired code', error_code: 400 };
6194
+ pendingEmailChanges.delete(user.id);
6195
+ if (db.users.some((u) => u.id !== user.id && u.email.toLowerCase() === pending.email)) return { status: false, error: 'Email already in use', error_code: 400 };
6196
+ user.email = pending.email;
6197
+ user.is_email_verified = true;
6198
+ rotateAuthKey(user.id);
6199
+ return { status: true, data: { ...tokenPair(user), user: serializeUser(user) } };
6200
+ }
6201
+ if (path === '/api/auth/email/change/cancel' && method === 'POST') {
6202
+ pendingEmailChanges.delete(user.id);
6203
+ return { status: true, message: 'Pending email change has been cancelled.' };
6204
+ }
6205
+
6206
+ // ── Phone change (user.py:2124-2277) ──
6207
+ if (path === '/api/auth/phone/change/request' && method === 'POST') {
6208
+ const raw = String(body.phone_number ?? '').trim();
6209
+ if (!raw) return missingParam('phone_number');
6210
+ const normalized = normalizePhone(raw);
6211
+ if (!normalized) return { status: false, error: 'Invalid phone number format', error_code: 400 };
6212
+ if (normalized === user.phone_number) return { status: false, error: 'New phone number must be different from current phone number', error_code: 400 };
6213
+ if (db.users.some((u) => u.id !== user.id && u.phone_number === normalized)) return { status: false, error: 'Phone number already in use', error_code: 400 };
6214
+ for (const [token, entry] of pendingPhoneChanges) if (entry.uid === user.id) pendingPhoneChanges.delete(token);
6215
+ const session_token = `pc:mock-${user.id}-${++accountSequence}`;
6216
+ pendingPhoneChanges.set(session_token, { uid: user.id, phone: normalized, code: MOCK_ACCOUNT_CODE });
6217
+ // Top-level, NOT under data — exactly the live JsonResponse.
6218
+ return { status: true, session_token, message: 'A verification code has been sent to your new phone number.' };
6219
+ }
6220
+ if (path === '/api/auth/phone/change/confirm' && method === 'POST') {
6221
+ if (!body.session_token) return missingParam('session_token');
6222
+ if (!body.code) return missingParam('code');
6223
+ const pending = pendingPhoneChanges.get(String(body.session_token));
6224
+ if (!pending || String(body.code).trim() !== pending.code) return { status: false, error: 'Invalid or expired code', error_code: 400 };
6225
+ if (pending.uid !== user.id) return { status: false, error: 'Session mismatch', error_code: 403 };
6226
+ pendingPhoneChanges.delete(String(body.session_token));
6227
+ if (db.users.some((u) => u.id !== user.id && u.phone_number === pending.phone)) return { status: false, error: 'Phone number is no longer available', error_code: 400 };
6228
+ user.phone_number = pending.phone;
6229
+ user.is_phone_verified = true;
6230
+ return { status: true, message: 'Phone number updated successfully.' };
6231
+ }
6232
+ if (path === '/api/auth/phone/change/cancel' && method === 'POST') {
6233
+ for (const [token, entry] of pendingPhoneChanges) if (entry.uid === user.id) pendingPhoneChanges.delete(token);
6234
+ return { status: true, message: 'Pending phone number change has been cancelled.' };
6235
+ }
6236
+
6237
+ // ── Verification (verify.py) ──
6238
+ const verify = path.match(/^\/api\/auth\/verify\/(email|phone)\/(send|confirm)$/);
6239
+ if (verify && method === 'POST') {
6240
+ const channel = verify[1] as 'email' | 'phone';
6241
+ const key = `${user.id}:${channel}`;
6242
+ if (verify[2] === 'send') {
6243
+ if (channel === 'email') {
6244
+ if (user.is_email_verified) return { status: true, message: 'Email is already verified' };
6245
+ if (!user.email) return { status: false, error: 'No email address on account', error_code: 400 };
6246
+ } else {
6247
+ if (user.is_phone_verified) return { status: true, message: 'Phone is already verified' };
6248
+ if (!user.phone_number) return { status: false, error: 'No phone number on account', error_code: 400 };
6249
+ }
6250
+ pendingVerifyCodes.set(key, MOCK_ACCOUNT_CODE);
6251
+ return { status: true, message: channel === 'email' && body.method !== 'code' ? 'Verification email sent' : 'Verification code sent' };
6252
+ }
6253
+ if (!body.code) return missingParam('code');
6254
+ if (pendingVerifyCodes.get(key) !== String(body.code).trim()) return { status: false, error: 'Invalid or expired code', error_code: 400 };
6255
+ pendingVerifyCodes.delete(key);
6256
+ if (channel === 'email') user.is_email_verified = true;
6257
+ else user.is_phone_verified = true;
6258
+ return { status: true, message: channel === 'email' ? 'Email verified' : 'Phone verified' };
6259
+ }
6260
+
6261
+ // ── Username change (user.py:2280-2324) ──
6262
+ if (path === '/api/auth/username/change' && method === 'POST') {
6263
+ const next = String(body.username ?? '').toLowerCase().trim();
6264
+ if (!next) return missingParam('username');
6265
+ if (next === user.username) return { status: false, error: 'New username must be different from current username', error_code: 400 };
6266
+ if (!/^[a-z0-9._@+-]{3,}$/.test(next)) return { status: false, error: 'Invalid username', error_code: 400 };
6267
+ if (db.users.some((u) => u.id !== user.id && u.username === next)) return { status: false, error: 'Username already taken', error_code: 400 };
6268
+ user.username = next;
6269
+ return { status: true, data: { username: user.username } };
6270
+ }
6271
+ return { status: false, error: `Method not allowed: ${method} ${path}`, error_code: 405 };
6272
+ }
6273
+
5862
6274
  let dnsConfigMalformed = false;
5863
6275
  let dnsAcmeMode: 'production' | 'staging' | 'unconfigured' = 'staging';
5864
6276
  let dnsRegistrarMode: 'ready' | 'disabled' | 'contact-missing' = 'ready';
@@ -6042,7 +6454,7 @@ function userFromBearer(headers: Record<string, string> | undefined): MockUser |
6042
6454
  if (!bearer) return undefined;
6043
6455
  const payload = decodeMockJwt(bearer);
6044
6456
  const now = Math.floor(Date.now() / 1000);
6045
- if (!payload || typeof payload.exp !== 'number' || now >= payload.exp) return undefined;
6457
+ if (!payload || typeof payload.exp !== 'number' || now >= payload.exp || !epochMatches(payload)) return undefined;
6046
6458
  return db.users.find((u) => u.id === Number(payload.uid));
6047
6459
  }
6048
6460
 
@@ -7948,6 +8360,23 @@ async function messagingFetch(path:string,opts:MockFetchOpts):Promise<unknown|un
7948
8360
 
7949
8361
  /** Mock transport. Same signature the real fetch path resolves through. */
7950
8362
  export async function mockFetch(path: string, opts: MockFetchOpts): Promise<unknown> {
8363
+ const method = (opts.method ?? 'GET').toUpperCase();
8364
+ const arm = armedOnce?.method === method && armedOnce.path === path ? armedOnce : null;
8365
+ if (arm) armedOnce = null;
8366
+ const result = await mockFetchInner(path, opts);
8367
+ if (!arm) return result;
8368
+ if (arm.extraDelayMs > 0) await mockDelay(arm.extraDelayMs, opts.signal);
8369
+ if (arm.mapData && result && typeof result === 'object' && (result as { data?: unknown }).data && typeof (result as { data?: unknown }).data === 'object') {
8370
+ return { ...(result as Record<string, unknown>), data: arm.mapData((result as { data: Record<string, unknown> }).data) };
8371
+ }
8372
+ if (arm.stripGrantTokens && result && typeof result === 'object' && (result as { data?: unknown }).data && typeof (result as { data?: unknown }).data === 'object') {
8373
+ const { access_token: _a, refresh_token: _r, ...rest } = (result as { data: Record<string, unknown> }).data;
8374
+ return { ...(result as Record<string, unknown>), data: { ...rest, requires_password_change: true } };
8375
+ }
8376
+ return result;
8377
+ }
8378
+
8379
+ async function mockFetchInner(path: string, opts: MockFetchOpts): Promise<unknown> {
7951
8380
  const method = (opts.method ?? 'GET').toUpperCase();
7952
8381
  const key = `${method} ${path}`;
7953
8382
  callCounts.set(key, (callCounts.get(key) ?? 0) + 1);
@@ -7958,7 +8387,11 @@ export async function mockFetch(path: string, opts: MockFetchOpts): Promise<unkn
7958
8387
  || path.startsWith('/api/aws/cloudwatch/')
7959
8388
  || path.startsWith('/api/assistant/memory/')
7960
8389
  ? { ...(opts.params ?? {}) }
7961
- : undefined;
8390
+ // Owner-scoped credential/device lists: only the owner filter (a
8391
+ // users-grant caller is served EVERY row without it).
8392
+ : method === 'GET' && OWNER_SCOPED_LISTS.has(path)
8393
+ ? { user: opts.params?.user }
8394
+ : undefined;
7962
8395
  const locationObservables = path.startsWith('/api/location/') ? {
7963
8396
  ...('input' in (opts.params ?? {}) ? { input_length: String(opts.params?.input ?? '').length } : {}),
7964
8397
  ...('session_token' in (opts.params ?? {}) ? { has_session_token: Boolean(opts.params?.session_token) } : {}),
@@ -8152,6 +8585,8 @@ export async function mockFetch(path: string, opts: MockFetchOpts): Promise<unkn
8152
8585
  if (storageResult !== undefined) return storageResult;
8153
8586
  const cloudWatchResult = cloudWatchFetch(path, opts);
8154
8587
  if (cloudWatchResult !== undefined) return cloudWatchResult;
8588
+ const accountResult = accountSelfFetch(path, method, opts);
8589
+ if (accountResult !== undefined) return accountResult;
8155
8590
  if (path === '/api/auth/generate_api_key') {
8156
8591
  // account/rest/user_api_key.py generate_api_key: mints a long-lived
8157
8592
  // key for the CALLER (@requires_auth — needs the bearer, unlike the
@@ -9773,10 +10208,16 @@ export async function mockFetch(path: string, opts: MockFetchOpts): Promise<unkn
9773
10208
  return { ...result, graph: 'default', data: (result.data as unknown as MockIncidentEvent[]).map(serializeIncidentEvent) };
9774
10209
  }
9775
10210
  // ── Passkeys — /api/account/passkeys (save: friendly_name/is_enabled) ──
10211
+ // Passkey VIEW/SAVE_PERMS carry `owner` beside the users tier
10212
+ // (models/rest.py:927-954): without a global grant a caller reaches only
10213
+ // their own rows; with one, the list is unscoped unless ?user= filters it.
9776
10214
  const onePasskey = path.match(/^\/api\/account\/passkeys\/(\d+)$/);
9777
10215
  if (onePasskey) {
10216
+ const caller = userFromBearer(opts.headers);
10217
+ if (!caller) return permissionDenied(401);
9778
10218
  const pk = db.passkeys.find((p) => p.id === Number(onePasskey[1]));
9779
10219
  if (!pk) return { status: false, error: 'Passkey not found', error_code: 404 };
10220
+ if (pk.user !== caller.id && !hasGlobalPermission(caller, ['users', 'manage_users'])) return permissionDenied();
9780
10221
  if (opts.method === 'DELETE') {
9781
10222
  db.passkeys = db.passkeys.filter((p) => p.id !== pk.id);
9782
10223
  return { status: 'deleted' };
@@ -9791,8 +10232,13 @@ export async function mockFetch(path: string, opts: MockFetchOpts): Promise<unkn
9791
10232
  return { status: true, data: { ...rest, user: owner ? userBasic(owner) : null }, graph: 'default' };
9792
10233
  }
9793
10234
  if (path === '/api/account/passkeys') {
10235
+ const caller = userFromBearer(opts.headers);
10236
+ if (!caller) return permissionDenied(401);
10237
+ const visible = hasGlobalPermission(caller, ['users', 'manage_users'])
10238
+ ? db.passkeys
10239
+ : db.passkeys.filter((p) => p.user === caller.id);
9794
10240
  const result = listRows(
9795
- db.passkeys as unknown as Record<string, unknown>[],
10241
+ visible as unknown as Record<string, unknown>[],
9796
10242
  opts.params ?? {},
9797
10243
  (p) => `${p.friendly_name ?? ''} ${p.credential_id}`,
9798
10244
  '-created',
@@ -9850,13 +10296,32 @@ export async function mockFetch(path: string, opts: MockFetchOpts): Promise<unkn
9850
10296
  db.notificationPrefs.set(targetId, current);
9851
10297
  return { status: true, data: { preferences: current } };
9852
10298
  }
9853
- return { status: true, data: { preferences: db.notificationPrefs.get(targetId) ?? {} } };
10299
+ // `"*"` is the reserved per-channel master switch: stored and merged
10300
+ // like any kind; the GET also carries the registered kinds catalogue
10301
+ // and the valid channels (django-mojo notification kinds registry).
10302
+ return {
10303
+ status: true,
10304
+ data: {
10305
+ preferences: db.notificationPrefs.get(targetId) ?? {},
10306
+ kinds: MOCK_NOTIFICATION_KINDS.map((k) => ({ ...k, channels: k.channels ? [...k.channels] : null })),
10307
+ channels: ['email', 'in_app', 'push'],
10308
+ },
10309
+ };
9854
10310
  }
9855
10311
  if (path === '/api/user/me') {
9856
10312
  // The first authed mock endpoint — meaningless without a session,
9857
10313
  // exactly like the real backend's @requires_auth.
9858
10314
  const user = userFromBearer(opts.headers);
9859
10315
  if (!user) return { status: false, error: 'permission denied', error_code: 401 };
10316
+ // on_user_me → User.on_rest_request(request, request.user.pk): a POST
10317
+ // is the owner's own save through the SAME rules as /api/user/<id>.
10318
+ if (method === 'DELETE') return permissionDenied();
10319
+ if (method === 'POST') {
10320
+ const body = opts.body ?? {};
10321
+ if (Object.keys(body).some((key) => USER_ACTIONS.has(key))
10322
+ && !hasGlobalPermission(user, ['users', 'manage_users'])) return permissionDenied();
10323
+ return saveUser(user, body, user);
10324
+ }
9860
10325
  return { status: true, data: meDict(user) };
9861
10326
  }
9862
10327
  const memberMatch = path.match(/^\/api\/group\/(\d+)\/member$/);
@@ -99,13 +99,18 @@ export function FileField(props: FileFieldProps) {
99
99
  }
100
100
  }, [queue]);
101
101
 
102
- useEffect(() => () => {
103
- mounted.current = false;
104
- generation.current += 1;
105
- editController.current?.abort();
106
- revokeLocal();
107
- if (desired.current !== undefined && candidate.current != null) reportOrphan(candidate.current);
108
- queue.cancelAll();
102
+ useEffect(() => {
103
+ // Re-arm on (re)mount: StrictMode's probe unmount runs the cleanup
104
+ // below, and without this the remount stays "unmounted" forever.
105
+ mounted.current = true;
106
+ return () => {
107
+ mounted.current = false;
108
+ generation.current += 1;
109
+ editController.current?.abort();
110
+ revokeLocal();
111
+ if (desired.current !== undefined && candidate.current != null) reportOrphan(candidate.current);
112
+ queue.cancelAll();
113
+ };
109
114
  }, [queue, reportOrphan, revokeLocal]);
110
115
 
111
116
  const active = queue.snapshot.activeCount > 0 || queue.snapshot.queuedCount > 0;
@@ -96,8 +96,12 @@ function Avatar({ src, name, className, onError }: {
96
96
  );
97
97
  }
98
98
 
99
- /** Stored avatar capability for `avatarId`, fetched while mounted only. */
100
- function useAvatarUrl(avatarId: number | null): [string | null, () => void] {
99
+ /**
100
+ * Stored avatar capability for `avatarId`, fetched while mounted only and
101
+ * kept in component state (never a Query cache). Returns `[src, onError]`;
102
+ * wire `onError` to the <img> so a dead capability falls back to initials.
103
+ */
104
+ export function useAvatarUrl(avatarId: number | null): [string | null, () => void] {
101
105
  const [fetched, setFetched] = useState<{ id: number; src: string | null } | null>(null);
102
106
  const [failedSrc, setFailedSrc] = useState<string | null>(null);
103
107