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.
- package/docs/README.md +1 -0
- package/docs/account.md +260 -0
- package/docs/auth-pages.md +8 -3
- package/docs/auth.md +45 -2
- package/docs/feedback.md +9 -0
- package/docs/idioms.md +12 -0
- package/package.json +2 -1
- package/src/account/AccountModal.tsx +275 -0
- package/src/account/AccountSections.tsx +370 -0
- package/src/account/NotificationPreferences.tsx +152 -0
- package/src/account/PasskeyList.tsx +144 -0
- package/src/account/api.ts +236 -0
- package/src/account/credential-models.ts +153 -0
- package/src/account/dialogs.tsx +822 -0
- package/src/account/index.ts +16 -0
- package/src/account/models.ts +76 -0
- package/src/account/sections/ApiKeysSection.tsx +138 -0
- package/src/admin/credentials/secret-dialog.tsx +3 -77
- package/src/admin/identity/users/models.ts +16 -128
- package/src/admin/identity/users/sections/ApiKeysSection.tsx +3 -119
- package/src/admin/identity/users/sections/NotificationsSection.tsx +23 -102
- package/src/admin/identity/users/sections/PersonalSection.tsx +18 -5
- package/src/admin/identity/users/sections/actions.tsx +7 -100
- package/src/client/auth.ts +192 -5
- package/src/client/mock.ts +472 -7
- package/src/ui/FileField.tsx +12 -7
- package/src/ui/UserMenu.tsx +6 -2
- package/src/ui/index.ts +1 -0
- package/src/ui/modal.tsx +76 -0
- package/src/ui/secret-dialog.tsx +83 -0
- package/src/ui/toast.tsx +20 -2
package/src/client/mock.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
:
|
|
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
|
-
|
|
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
|
-
|
|
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$/);
|
package/src/ui/FileField.tsx
CHANGED
|
@@ -99,13 +99,18 @@ export function FileField(props: FileFieldProps) {
|
|
|
99
99
|
}
|
|
100
100
|
}, [queue]);
|
|
101
101
|
|
|
102
|
-
useEffect(() =>
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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;
|
package/src/ui/UserMenu.tsx
CHANGED
|
@@ -96,8 +96,12 @@ function Avatar({ src, name, className, onError }: {
|
|
|
96
96
|
);
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
-
/**
|
|
100
|
-
|
|
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
|
|