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
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// portal-mojo/account — the signed-in user's self-service account surface:
|
|
2
|
+
// AccountModal (+ openAccountModal), its six sections, the shared credential
|
|
3
|
+
// models (PasskeyModel / ApiKeyModel / OAuthConnectionModel — re-exported by
|
|
4
|
+
// portal-mojo/admin unchanged), MeSaveModel, and the self-service wire
|
|
5
|
+
// functions. A page bundle bound to django-mojo contracts like an admin
|
|
6
|
+
// domain, but it never imports the admin barrel and runs no setup at import.
|
|
7
|
+
// Apps ship theme/account.css (plus user-admin.css and group-admin.css for
|
|
8
|
+
// the reused API-key rows and secret reveal).
|
|
9
|
+
export * from './AccountModal';
|
|
10
|
+
export * from './AccountSections';
|
|
11
|
+
export * from './api';
|
|
12
|
+
export * from './dialogs';
|
|
13
|
+
export * from './models';
|
|
14
|
+
export * from './NotificationPreferences';
|
|
15
|
+
export * from './PasskeyList';
|
|
16
|
+
export * from './sections/ApiKeysSection';
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// account/models — the caller-owned credential models shared by the
|
|
2
|
+
// self-service AccountModal and the admin User detail (defined in
|
|
3
|
+
// credential-models.ts, re-exported here and by admin/identity/users/models
|
|
4
|
+
// unchanged) so portal-mojo/account never imports an admin domain. ONE
|
|
5
|
+
// defineModel per endpoint: both surfaces share these cache keys.
|
|
6
|
+
//
|
|
7
|
+
// Plus MeSaveModel — the `/api/user/me` save the account forms autosave
|
|
8
|
+
// through — and the account-only query keys.
|
|
9
|
+
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
10
|
+
import {
|
|
11
|
+
mojoSave, sanitizeMe, useAuthSnapshot, withFreshAuth,
|
|
12
|
+
type Me, type SaveVars,
|
|
13
|
+
} from '../client/runtime';
|
|
14
|
+
import { getNotificationPreferences, getRecoveryCodeStatus, type NotificationPreferencesResponse, type RecoveryCodeStatus } from './api';
|
|
15
|
+
|
|
16
|
+
// ── /api/user/me ──────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* FormView-compatible save for the signed-in user's own row: POSTs ONLY the
|
|
20
|
+
* changed fields to `/api/user/me` (never the `me` row back — the server
|
|
21
|
+
* 403s email/username/is_superuser/permissions even unchanged), sanitizes
|
|
22
|
+
* the answer with sanitizeMe, writes `['me', uid]` and refreshes every
|
|
23
|
+
* `/api/user` cache (UserModel.useSave never touches `me`, and vice versa).
|
|
24
|
+
* The `id` in SaveVars is ignored: the endpoint is always the caller.
|
|
25
|
+
*/
|
|
26
|
+
export const MeSaveModel = {
|
|
27
|
+
useSave: () => {
|
|
28
|
+
const qc = useQueryClient();
|
|
29
|
+
const { uid } = useAuthSnapshot();
|
|
30
|
+
return useMutation<Me, Error, SaveVars>({
|
|
31
|
+
mutationFn: async ({ changes }: SaveVars) => sanitizeMe(await withFreshAuth(() => mojoSave<Me>('/api/user/me', null, changes))),
|
|
32
|
+
onSuccess: (row) => {
|
|
33
|
+
qc.setQueryData(['me', uid], row);
|
|
34
|
+
void qc.invalidateQueries({ queryKey: ['/api/user'] });
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** Refresh `me` (e.g. after a verify/change flow that the server committed). */
|
|
41
|
+
export function invalidateMe(qc: ReturnType<typeof useQueryClient>): Promise<void> {
|
|
42
|
+
return qc.invalidateQueries({ queryKey: ['me'] });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ── Credential models (credential-models.ts) ───────────────────────────
|
|
46
|
+
|
|
47
|
+
export {
|
|
48
|
+
ApiKeyModel, OAuthConnectionModel, PasskeyModel, useGenerateUserApiKey,
|
|
49
|
+
type ApiKeyRow, type GeneratedKeyReceipt, type GenerateUserApiKeyVariables,
|
|
50
|
+
type OAuthConnectionRow, type PasskeyRow,
|
|
51
|
+
} from './credential-models';
|
|
52
|
+
|
|
53
|
+
// ── Account-only queries ──────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
export const accountKeys = {
|
|
56
|
+
/** `{enrolled, remaining}` ONLY — never the masked hints (4 of 12 hex chars each). */
|
|
57
|
+
recoveryStatus: (uid: number | string | null) => ['/api/account/totp/recovery-codes', 'status', uid] as const,
|
|
58
|
+
/** Shared with the admin User detail's Notifications section. */
|
|
59
|
+
notificationPreferences: (uid: number | string | null) => ['/api/account/notification/preferences', uid] as const,
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export function useRecoveryCodeStatus(uid: number | null) {
|
|
63
|
+
return useQuery<RecoveryCodeStatus>({
|
|
64
|
+
queryKey: accountKeys.recoveryStatus(uid),
|
|
65
|
+
queryFn: getRecoveryCodeStatus,
|
|
66
|
+
enabled: uid != null,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function useNotificationPreferences(uid: number | null, enabled = true) {
|
|
71
|
+
return useQuery<NotificationPreferencesResponse>({
|
|
72
|
+
queryKey: accountKeys.notificationPreferences(uid),
|
|
73
|
+
queryFn: getNotificationPreferences,
|
|
74
|
+
enabled: uid != null && enabled,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// API Keys — UserApiKeysSection port (read in full 2026-08-05): the user's
|
|
2
|
+
// key list + caller-only "Generate key" reveal + revoke.
|
|
3
|
+
//
|
|
4
|
+
// Wire (live-measured):
|
|
5
|
+
// · list — GET /api/account/api_keys?user=<id> (label/allowed_ips/expires/
|
|
6
|
+
// is_active/last_used/created; the owner FK filters but never serializes)
|
|
7
|
+
// · generate — caller-only POST /api/auth/generate_api_key. Another user's
|
|
8
|
+
// detail renders no Generate action because no admin-targetable route exists.
|
|
9
|
+
// · revoke — the `revoke` POST_SAVE_ACTION (POST <id> {revoke:{}} → its
|
|
10
|
+
// own {status:true} payload). Deviation from web-mojo's DELETE: the live
|
|
11
|
+
// model is CAN_DELETE=false (DELETE answers 403, measured) — the action
|
|
12
|
+
// IS the kill switch.
|
|
13
|
+
//
|
|
14
|
+
// Shared by the admin User detail (re-exported from
|
|
15
|
+
// admin/identity/users/sections/ApiKeysSection) and the self-service
|
|
16
|
+
// AccountModal (user = me, isSelf). Styled by the app's user-admin.css.
|
|
17
|
+
import { Badge, Eyebrow, fmt, formModal, modal, showSecretDialog, toast } from '../../ui';
|
|
18
|
+
import { ApiKeyModel, useGenerateUserApiKey } from '../credential-models';
|
|
19
|
+
|
|
20
|
+
/** The identity fields the section names a key's owner by. */
|
|
21
|
+
export interface ApiKeysOwner {
|
|
22
|
+
id: number;
|
|
23
|
+
display_name?: string | null;
|
|
24
|
+
email?: string | null;
|
|
25
|
+
username?: string | null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ApiKeysSectionProps {
|
|
29
|
+
user: ApiKeysOwner;
|
|
30
|
+
/** Offer Revoke on active keys. */
|
|
31
|
+
canManage: boolean;
|
|
32
|
+
/** Offer Generate — the generate route is caller-only. */
|
|
33
|
+
isSelf: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function ApiKeysSection({ user, canManage, isSelf }: ApiKeysSectionProps) {
|
|
37
|
+
const { data, isPending } = ApiKeyModel.useList({ user: user.id, size: 25, sort: '-id' });
|
|
38
|
+
const revoke = ApiKeyModel.useAction('revoke');
|
|
39
|
+
const generateKey = useGenerateUserApiKey();
|
|
40
|
+
const rows = data?.rows ?? [];
|
|
41
|
+
|
|
42
|
+
const generate = async () => {
|
|
43
|
+
const form = ApiKeyModel.forms.generate!;
|
|
44
|
+
const dataIn = await formModal({
|
|
45
|
+
...form,
|
|
46
|
+
title: isSelf ? 'Generate API key' : `Generate API key for ${user.display_name || user.email || user.username}`,
|
|
47
|
+
});
|
|
48
|
+
if (!dataIn) return;
|
|
49
|
+
const body: Record<string, unknown> = {
|
|
50
|
+
label: dataIn.label,
|
|
51
|
+
expire_days: parseInt(String(dataIn.expire_days || '90'), 10),
|
|
52
|
+
};
|
|
53
|
+
const ips = String(dataIn.allowed_ips ?? '').trim();
|
|
54
|
+
if (ips) body.allowed_ips = ips.split(',').map((s) => s.trim()).filter(Boolean);
|
|
55
|
+
try {
|
|
56
|
+
await generateKey.mutateAsync({
|
|
57
|
+
changes: body,
|
|
58
|
+
onToken: async (token) => {
|
|
59
|
+
await showSecretDialog({
|
|
60
|
+
title: 'API key created',
|
|
61
|
+
intro: <><b>{String(dataIn.label || 'Unlabeled key')}</b> is ready.</>,
|
|
62
|
+
warning: 'Save this token now. It will not be shown again.',
|
|
63
|
+
secret: token,
|
|
64
|
+
ariaLabel: 'Generated API key',
|
|
65
|
+
});
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
toast.success('API key generated');
|
|
69
|
+
} catch (err) {
|
|
70
|
+
toast.error(err instanceof Error ? err.message : 'Failed to generate API key');
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const revokeKey = async (id: number, label: string) => {
|
|
75
|
+
const ok = await modal.confirm({
|
|
76
|
+
title: 'Revoke API key',
|
|
77
|
+
message: <>Revoke <b>{label || 'this API key'}</b>? Any applications using it will lose access immediately.</>,
|
|
78
|
+
confirmText: 'Revoke',
|
|
79
|
+
danger: true,
|
|
80
|
+
});
|
|
81
|
+
if (!ok) return;
|
|
82
|
+
try {
|
|
83
|
+
await revoke.mutateAsync({ id });
|
|
84
|
+
toast.success('API key revoked');
|
|
85
|
+
} catch (err) {
|
|
86
|
+
toast.error(err instanceof Error ? err.message : 'Failed to revoke API key');
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const nowSec = Math.floor(Date.now() / 1000);
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<>
|
|
94
|
+
<Eyebrow>
|
|
95
|
+
API keys
|
|
96
|
+
{isSelf && (
|
|
97
|
+
<button className="btn btn-primary btn-compact us-eyebrow-action" onClick={() => void generate()}>
|
|
98
|
+
<i className="bi bi-plus-lg" /> Generate key
|
|
99
|
+
</button>
|
|
100
|
+
)}
|
|
101
|
+
</Eyebrow>
|
|
102
|
+
|
|
103
|
+
{isPending && <p className="dim">Loading…</p>}
|
|
104
|
+
{!isPending && rows.length === 0 && (
|
|
105
|
+
<div className="us-empty">
|
|
106
|
+
<i className="bi bi-key" />
|
|
107
|
+
<div>{isSelf ? 'You have no API keys.' : 'No API keys for this user.'}</div>
|
|
108
|
+
</div>
|
|
109
|
+
)}
|
|
110
|
+
{rows.map((k) => {
|
|
111
|
+
const expired = k.expires != null && k.expires < nowSec;
|
|
112
|
+
return (
|
|
113
|
+
<div key={k.id} className="us-key-row">
|
|
114
|
+
<div className="us-row-icon"><i className="bi bi-key" /></div>
|
|
115
|
+
<div className="us-row-info">
|
|
116
|
+
<div className="us-row-title">{k.label || 'Unlabeled key'}</div>
|
|
117
|
+
<div className="us-row-meta">
|
|
118
|
+
Created {fmt.date(k.created)} · Expires {fmt.date(k.expires, 'never')} · Last used {fmt.relative(k.last_used, 'never')}
|
|
119
|
+
{' · '}
|
|
120
|
+
{k.allowed_ips.length > 0 ? <>IPs <code>{k.allowed_ips.join(', ')}</code></> : 'Any IP'}
|
|
121
|
+
</div>
|
|
122
|
+
</div>
|
|
123
|
+
<div className="us-row-actions">
|
|
124
|
+
<Badge tone={!k.is_active ? 'muted' : expired ? 'warning' : 'success'}>
|
|
125
|
+
{!k.is_active ? 'Revoked' : expired ? 'Expired' : 'Active'}
|
|
126
|
+
</Badge>
|
|
127
|
+
{canManage && k.is_active && (
|
|
128
|
+
<button className="btn btn-compact us-danger" onClick={() => void revokeKey(k.id, k.label)}>
|
|
129
|
+
Revoke
|
|
130
|
+
</button>
|
|
131
|
+
)}
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
);
|
|
135
|
+
})}
|
|
136
|
+
</>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
@@ -1,77 +1,3 @@
|
|
|
1
|
-
|
|
2
|
-
import
|
|
3
|
-
|
|
4
|
-
async function copyText(value: string): Promise<boolean> {
|
|
5
|
-
try {
|
|
6
|
-
if (navigator.clipboard?.writeText) {
|
|
7
|
-
await navigator.clipboard.writeText(value);
|
|
8
|
-
return true;
|
|
9
|
-
}
|
|
10
|
-
} catch {
|
|
11
|
-
// Fall through for insecure contexts.
|
|
12
|
-
}
|
|
13
|
-
try {
|
|
14
|
-
const input = document.createElement('textarea');
|
|
15
|
-
input.value = value;
|
|
16
|
-
input.style.position = 'fixed';
|
|
17
|
-
input.style.opacity = '0';
|
|
18
|
-
document.body.appendChild(input);
|
|
19
|
-
input.select();
|
|
20
|
-
const copied = document.execCommand('copy');
|
|
21
|
-
input.remove();
|
|
22
|
-
return copied;
|
|
23
|
-
} catch {
|
|
24
|
-
return false;
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export function SecretBox({ secret, ariaLabel }: { secret: string; ariaLabel: string }) {
|
|
29
|
-
const [copied, setCopied] = useState(false);
|
|
30
|
-
return (
|
|
31
|
-
<div className="ga-secret-box">
|
|
32
|
-
<code className="ga-secret-value" aria-label={ariaLabel}>{secret}</code>
|
|
33
|
-
<button
|
|
34
|
-
type="button"
|
|
35
|
-
className="btn btn-compact"
|
|
36
|
-
title="Copy to clipboard"
|
|
37
|
-
aria-label="Copy to clipboard"
|
|
38
|
-
onClick={() => {
|
|
39
|
-
void copyText(secret).then((ok) => {
|
|
40
|
-
if (!ok) return;
|
|
41
|
-
setCopied(true);
|
|
42
|
-
setTimeout(() => setCopied(false), 1600);
|
|
43
|
-
});
|
|
44
|
-
}}
|
|
45
|
-
>
|
|
46
|
-
<i className={`bi ${copied ? 'bi-check-lg' : 'bi-clipboard'}`} /> {copied ? 'Copied' : 'Copy'}
|
|
47
|
-
</button>
|
|
48
|
-
</div>
|
|
49
|
-
);
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
export interface SecretDialogOptions {
|
|
53
|
-
title: string;
|
|
54
|
-
warning: ReactNode;
|
|
55
|
-
intro?: ReactNode;
|
|
56
|
-
secret: string;
|
|
57
|
-
ariaLabel: string;
|
|
58
|
-
footer?: ReactNode;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
export function showSecretDialog(options: SecretDialogOptions): Promise<unknown> {
|
|
62
|
-
return modal.open((close) => (
|
|
63
|
-
<div className="modal-pad">
|
|
64
|
-
<h2 className="modal-title">{options.title}</h2>
|
|
65
|
-
{options.intro && <div className="ga-secret-intro">{options.intro}</div>}
|
|
66
|
-
<div className="ga-secret-warning" role="alert">
|
|
67
|
-
<i className="bi bi-exclamation-triangle-fill" />
|
|
68
|
-
<span>{options.warning}</span>
|
|
69
|
-
</div>
|
|
70
|
-
<SecretBox secret={options.secret} ariaLabel={options.ariaLabel} />
|
|
71
|
-
{options.footer}
|
|
72
|
-
<div className="modal-actions">
|
|
73
|
-
<button className="btn" onClick={() => close(null)}>Close</button>
|
|
74
|
-
</div>
|
|
75
|
-
</div>
|
|
76
|
-
), { size: 'lg' });
|
|
77
|
-
}
|
|
1
|
+
// Moved to ui/secret-dialog.tsx (shared with portal-mojo/account); the admin
|
|
2
|
+
// credentials surface keeps its import path and export names unchanged.
|
|
3
|
+
export { SecretBox, showSecretDialog, type SecretDialogOptions } from '../../ui/secret-dialog';
|
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
|
2
1
|
import {
|
|
3
|
-
defineModel,
|
|
2
|
+
defineModel,
|
|
4
3
|
sanitizeAvatarRelation, type User,
|
|
5
4
|
} from '../../../client/runtime';
|
|
6
5
|
// ONE defineModel per endpoint (#1291). The device / device-location /
|
|
@@ -100,133 +99,22 @@ export const UserModel = defineModel<UserRow>({
|
|
|
100
99
|
},
|
|
101
100
|
});
|
|
102
101
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
export const ApiKeyModel = defineModel<ApiKeyRow>({
|
|
121
|
-
name: 'api_key',
|
|
122
|
-
endpoint: '/api/account/api_keys',
|
|
123
|
-
permissions: { manageOthers: USER_MANAGE_PERMISSIONS },
|
|
124
|
-
sanitizeRow: sanitizeApiKeyRow,
|
|
125
|
-
forms: {
|
|
126
|
-
generate: {
|
|
127
|
-
title: 'Generate API key',
|
|
128
|
-
submitText: 'Generate',
|
|
129
|
-
fields: [
|
|
130
|
-
{ name: 'label', type: 'text', label: 'Label', required: true, placeholder: 'e.g. CI/CD pipeline, Mobile app' },
|
|
131
|
-
{
|
|
132
|
-
name: 'allowed_ips', type: 'text', label: 'Allowed IPs',
|
|
133
|
-
placeholder: 'e.g. 203.0.113.0/24, 10.0.0.1',
|
|
134
|
-
help: 'Optional. Comma-separated IPs or CIDR ranges; empty allows any.',
|
|
135
|
-
},
|
|
136
|
-
{
|
|
137
|
-
name: 'expire_days', type: 'select', label: 'Expires in', options: [
|
|
138
|
-
{ value: '30', label: '30 days' },
|
|
139
|
-
{ value: '60', label: '60 days' },
|
|
140
|
-
{ value: '90', label: '90 days' },
|
|
141
|
-
{ value: '180', label: '180 days' },
|
|
142
|
-
{ value: '360', label: '360 days (max)' },
|
|
143
|
-
],
|
|
144
|
-
},
|
|
145
|
-
],
|
|
146
|
-
},
|
|
147
|
-
},
|
|
148
|
-
actions: { revoke: { response: 'payload' } },
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
export interface GenerateUserApiKeyVariables {
|
|
152
|
-
changes: Record<string, unknown>;
|
|
153
|
-
/** Receives the token while the mutation is still pending. */
|
|
154
|
-
onToken?: (token: string) => void | Promise<void>;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
interface GeneratedKeyResponse {
|
|
158
|
-
id?: number;
|
|
159
|
-
expires?: number;
|
|
160
|
-
token?: string;
|
|
161
|
-
jti?: string;
|
|
162
|
-
auth_key?: string;
|
|
163
|
-
secret?: string;
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
export interface GeneratedKeyReceipt {
|
|
167
|
-
id: number | null;
|
|
168
|
-
expires: number | null;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
/**
|
|
172
|
-
* Caller-only key generation. The raw response is split before mutation
|
|
173
|
-
* resolution, so TanStack MutationCache receives only non-secret metadata.
|
|
174
|
-
*/
|
|
175
|
-
export function useGenerateUserApiKey() {
|
|
176
|
-
const qc = useQueryClient();
|
|
177
|
-
return useMutation<GeneratedKeyReceipt, Error, GenerateUserApiKeyVariables>({
|
|
178
|
-
mutationFn: async ({ changes, onToken }) => {
|
|
179
|
-
const response = await withFreshAuth(() => mojoCall('/api/auth/generate_api_key', {
|
|
180
|
-
method: 'POST',
|
|
181
|
-
body: changes,
|
|
182
|
-
}));
|
|
183
|
-
const payload = response.data as GeneratedKeyResponse | undefined;
|
|
184
|
-
const created: GeneratedKeyResponse = payload && typeof payload === 'object' ? { ...payload } : {};
|
|
185
|
-
let token = typeof created.token === 'string' && created.token ? created.token : null;
|
|
186
|
-
try {
|
|
187
|
-
if (token && onToken) await onToken(token);
|
|
188
|
-
} finally {
|
|
189
|
-
token = null;
|
|
190
|
-
for (const key of ['token', 'jti', 'auth_key', 'secret'] as const) delete created[key];
|
|
191
|
-
}
|
|
192
|
-
return {
|
|
193
|
-
id: typeof created.id === 'number' ? created.id : null,
|
|
194
|
-
expires: typeof created.expires === 'number' ? created.expires : null,
|
|
195
|
-
};
|
|
196
|
-
},
|
|
197
|
-
onSuccess: () => { void qc.invalidateQueries({ queryKey: ApiKeyModel.keys.root }); },
|
|
198
|
-
});
|
|
199
|
-
}
|
|
102
|
+
// The caller-owned credential models live in portal-mojo/account (shared by
|
|
103
|
+
// the self-service AccountModal) and are re-exported here unchanged.
|
|
104
|
+
export {
|
|
105
|
+
ApiKeyModel,
|
|
106
|
+
OAuthConnectionModel,
|
|
107
|
+
PasskeyModel,
|
|
108
|
+
useGenerateUserApiKey,
|
|
109
|
+
} from '../../../account/credential-models';
|
|
110
|
+
export type {
|
|
111
|
+
ApiKeyRow,
|
|
112
|
+
GeneratedKeyReceipt,
|
|
113
|
+
GenerateUserApiKeyVariables,
|
|
114
|
+
OAuthConnectionRow,
|
|
115
|
+
PasskeyRow,
|
|
116
|
+
} from '../../../account/credential-models';
|
|
200
117
|
|
|
201
118
|
// LoginEventRow / LoginEventModel are re-exported from the canonical module
|
|
202
119
|
// above. The local copy carried a phantom `event_type?: string` field that
|
|
203
120
|
// `UserLoginEvent` has never had — see docs/admin-devices-geoip.md.
|
|
204
|
-
|
|
205
|
-
export interface PasskeyRow {
|
|
206
|
-
id: number;
|
|
207
|
-
friendly_name: string | null;
|
|
208
|
-
credential_id: string;
|
|
209
|
-
rp_id: string;
|
|
210
|
-
is_enabled: boolean;
|
|
211
|
-
sign_count: number;
|
|
212
|
-
transports: string | null;
|
|
213
|
-
aaguid: string | null;
|
|
214
|
-
last_used: number | null;
|
|
215
|
-
created: number;
|
|
216
|
-
}
|
|
217
|
-
export const PasskeyModel = defineModel<PasskeyRow>({
|
|
218
|
-
name: 'passkey', endpoint: '/api/account/passkeys',
|
|
219
|
-
permissions: { view: USER_MANAGE_PERMISSIONS, manage: USER_MANAGE_PERMISSIONS },
|
|
220
|
-
});
|
|
221
|
-
|
|
222
|
-
export interface OAuthConnectionRow {
|
|
223
|
-
id: number;
|
|
224
|
-
provider: string;
|
|
225
|
-
email: string | null;
|
|
226
|
-
is_active: boolean;
|
|
227
|
-
created: number;
|
|
228
|
-
}
|
|
229
|
-
export const OAuthConnectionModel = defineModel<OAuthConnectionRow>({
|
|
230
|
-
name: 'oauth_connection', endpoint: '/api/account/oauth_connection',
|
|
231
|
-
permissions: { view: USER_MANAGE_PERMISSIONS, manage: USER_MANAGE_PERMISSIONS },
|
|
232
|
-
});
|
|
@@ -1,119 +1,3 @@
|
|
|
1
|
-
// API Keys —
|
|
2
|
-
//
|
|
3
|
-
|
|
4
|
-
// Wire (live-measured):
|
|
5
|
-
// · list — GET /api/account/api_keys?user=<id> (label/allowed_ips/expires/
|
|
6
|
-
// is_active/last_used/created; the owner FK filters but never serializes)
|
|
7
|
-
// · generate — caller-only POST /api/auth/generate_api_key. Another user's
|
|
8
|
-
// detail renders no Generate action because no admin-targetable route exists.
|
|
9
|
-
// · revoke — the `revoke` POST_SAVE_ACTION (POST <id> {revoke:{}} → its
|
|
10
|
-
// own {status:true} payload). Deviation from web-mojo's DELETE: the live
|
|
11
|
-
// model is CAN_DELETE=false (DELETE answers 403, measured) — the action
|
|
12
|
-
// IS the kill switch.
|
|
13
|
-
import { Badge, Eyebrow, fmt, formModal, modal, toast } from '../../../../ui';
|
|
14
|
-
import { showSecretDialog } from '../../../credentials';
|
|
15
|
-
import { ApiKeyModel, useGenerateUserApiKey, type UserRow } from '../models';
|
|
16
|
-
|
|
17
|
-
export function ApiKeysSection({ user, canManage, isSelf }: { user: UserRow; canManage: boolean; isSelf: boolean }) {
|
|
18
|
-
const { data, isPending } = ApiKeyModel.useList({ user: user.id, size: 25, sort: '-id' });
|
|
19
|
-
const revoke = ApiKeyModel.useAction('revoke');
|
|
20
|
-
const generateKey = useGenerateUserApiKey();
|
|
21
|
-
const rows = data?.rows ?? [];
|
|
22
|
-
|
|
23
|
-
const generate = async () => {
|
|
24
|
-
const form = ApiKeyModel.forms.generate!;
|
|
25
|
-
const dataIn = await formModal({
|
|
26
|
-
...form,
|
|
27
|
-
title: `Generate API key for ${user.display_name || user.email || user.username}`,
|
|
28
|
-
});
|
|
29
|
-
if (!dataIn) return;
|
|
30
|
-
const body: Record<string, unknown> = {
|
|
31
|
-
label: dataIn.label,
|
|
32
|
-
expire_days: parseInt(String(dataIn.expire_days || '90'), 10),
|
|
33
|
-
};
|
|
34
|
-
const ips = String(dataIn.allowed_ips ?? '').trim();
|
|
35
|
-
if (ips) body.allowed_ips = ips.split(',').map((s) => s.trim()).filter(Boolean);
|
|
36
|
-
try {
|
|
37
|
-
await generateKey.mutateAsync({
|
|
38
|
-
changes: body,
|
|
39
|
-
onToken: async (token) => {
|
|
40
|
-
await showSecretDialog({
|
|
41
|
-
title: 'API key created',
|
|
42
|
-
intro: <><b>{String(dataIn.label || 'Unlabeled key')}</b> is ready.</>,
|
|
43
|
-
warning: 'Save this token now. It will not be shown again.',
|
|
44
|
-
secret: token,
|
|
45
|
-
ariaLabel: 'Generated API key',
|
|
46
|
-
});
|
|
47
|
-
},
|
|
48
|
-
});
|
|
49
|
-
toast.success('API key generated');
|
|
50
|
-
} catch (err) {
|
|
51
|
-
toast.error(err instanceof Error ? err.message : 'Failed to generate API key');
|
|
52
|
-
}
|
|
53
|
-
};
|
|
54
|
-
|
|
55
|
-
const revokeKey = async (id: number, label: string) => {
|
|
56
|
-
const ok = await modal.confirm({
|
|
57
|
-
title: 'Revoke API key',
|
|
58
|
-
message: <>Revoke <b>{label || 'this API key'}</b>? Any applications using it will lose access immediately.</>,
|
|
59
|
-
confirmText: 'Revoke',
|
|
60
|
-
danger: true,
|
|
61
|
-
});
|
|
62
|
-
if (!ok) return;
|
|
63
|
-
try {
|
|
64
|
-
await revoke.mutateAsync({ id });
|
|
65
|
-
toast.success('API key revoked');
|
|
66
|
-
} catch (err) {
|
|
67
|
-
toast.error(err instanceof Error ? err.message : 'Failed to revoke API key');
|
|
68
|
-
}
|
|
69
|
-
};
|
|
70
|
-
|
|
71
|
-
const nowSec = Math.floor(Date.now() / 1000);
|
|
72
|
-
|
|
73
|
-
return (
|
|
74
|
-
<>
|
|
75
|
-
<Eyebrow>
|
|
76
|
-
API keys
|
|
77
|
-
{isSelf && (
|
|
78
|
-
<button className="btn btn-primary btn-compact us-eyebrow-action" onClick={() => void generate()}>
|
|
79
|
-
<i className="bi bi-plus-lg" /> Generate key
|
|
80
|
-
</button>
|
|
81
|
-
)}
|
|
82
|
-
</Eyebrow>
|
|
83
|
-
|
|
84
|
-
{isPending && <p className="dim">Loading…</p>}
|
|
85
|
-
{!isPending && rows.length === 0 && (
|
|
86
|
-
<div className="us-empty">
|
|
87
|
-
<i className="bi bi-key" />
|
|
88
|
-
<div>No API keys for this user.</div>
|
|
89
|
-
</div>
|
|
90
|
-
)}
|
|
91
|
-
{rows.map((k) => {
|
|
92
|
-
const expired = k.expires != null && k.expires < nowSec;
|
|
93
|
-
return (
|
|
94
|
-
<div key={k.id} className="us-key-row">
|
|
95
|
-
<div className="us-row-icon"><i className="bi bi-key" /></div>
|
|
96
|
-
<div className="us-row-info">
|
|
97
|
-
<div className="us-row-title">{k.label || 'Unlabeled key'}</div>
|
|
98
|
-
<div className="us-row-meta">
|
|
99
|
-
Created {fmt.date(k.created)} · Expires {fmt.date(k.expires, 'never')} · Last used {fmt.relative(k.last_used, 'never')}
|
|
100
|
-
{' · '}
|
|
101
|
-
{k.allowed_ips.length > 0 ? <>IPs <code>{k.allowed_ips.join(', ')}</code></> : 'Any IP'}
|
|
102
|
-
</div>
|
|
103
|
-
</div>
|
|
104
|
-
<div className="us-row-actions">
|
|
105
|
-
<Badge tone={!k.is_active ? 'muted' : expired ? 'warning' : 'success'}>
|
|
106
|
-
{!k.is_active ? 'Revoked' : expired ? 'Expired' : 'Active'}
|
|
107
|
-
</Badge>
|
|
108
|
-
{canManage && k.is_active && (
|
|
109
|
-
<button className="btn btn-compact us-danger" onClick={() => void revokeKey(k.id, k.label)}>
|
|
110
|
-
Revoke
|
|
111
|
-
</button>
|
|
112
|
-
)}
|
|
113
|
-
</div>
|
|
114
|
-
</div>
|
|
115
|
-
);
|
|
116
|
-
})}
|
|
117
|
-
</>
|
|
118
|
-
);
|
|
119
|
-
}
|
|
1
|
+
// API Keys — moved to portal-mojo/account (shared with the self-service
|
|
2
|
+
// AccountModal); the admin User detail keeps this import path.
|
|
3
|
+
export { ApiKeysSection, type ApiKeysOwner, type ApiKeysSectionProps } from '../../../../account/sections/ApiKeysSection';
|