create-aphex 0.1.0
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/README.md +56 -0
- package/dist/index.js +160 -0
- package/package.json +40 -0
- package/templates/base/.dockerignore +44 -0
- package/templates/base/.env.example +38 -0
- package/templates/base/CHANGELOG.md +118 -0
- package/templates/base/Dockerfile +69 -0
- package/templates/base/README.md +147 -0
- package/templates/base/aphex.config.ts +45 -0
- package/templates/base/docker-compose.yml +31 -0
- package/templates/base/drizzle/0000_tiny_redwing.sql +226 -0
- package/templates/base/drizzle/meta/0000_snapshot.json +1379 -0
- package/templates/base/drizzle/meta/_journal.json +13 -0
- package/templates/base/drizzle.config.ts +17 -0
- package/templates/base/package.json +81 -0
- package/templates/base/prod.docker-compose.yml +61 -0
- package/templates/base/src/app.css +32 -0
- package/templates/base/src/app.d.ts +26 -0
- package/templates/base/src/app.html +11 -0
- package/templates/base/src/hooks.server.ts +52 -0
- package/templates/base/src/lib/assets/favicon.svg +5 -0
- package/templates/base/src/lib/auth-client.ts +19 -0
- package/templates/base/src/lib/index.ts +1 -0
- package/templates/base/src/lib/schemaTypes/index.ts +11 -0
- package/templates/base/src/lib/schemaTypes/post.ts +47 -0
- package/templates/base/src/lib/server/auth/better-auth/instance.ts +165 -0
- package/templates/base/src/lib/server/auth/index.ts +25 -0
- package/templates/base/src/lib/server/auth/instance.ts +7 -0
- package/templates/base/src/lib/server/auth/service.ts +486 -0
- package/templates/base/src/lib/server/cache/index.ts +10 -0
- package/templates/base/src/lib/server/db/auth-schema.ts +86 -0
- package/templates/base/src/lib/server/db/cms-schema.ts +23 -0
- package/templates/base/src/lib/server/db/index.ts +55 -0
- package/templates/base/src/lib/server/db/schema.ts +3 -0
- package/templates/base/src/lib/server/email/components/EmailLayout.svelte +24 -0
- package/templates/base/src/lib/server/email/components/EmailVerification.svelte +26 -0
- package/templates/base/src/lib/server/email/components/Invitation.svelte +30 -0
- package/templates/base/src/lib/server/email/components/PasswordReset.svelte +28 -0
- package/templates/base/src/lib/server/email/index.ts +28 -0
- package/templates/base/src/lib/server/email/invitation-hook.ts +56 -0
- package/templates/base/src/lib/server/email/renderer.ts +13 -0
- package/templates/base/src/lib/server/email/templates/email-verification.ts +8 -0
- package/templates/base/src/lib/server/email/templates/invitation.ts +8 -0
- package/templates/base/src/lib/server/email/templates/password-reset.ts +7 -0
- package/templates/base/src/lib/server/services/organization.ts +68 -0
- package/templates/base/src/lib/server/storage/index.ts +28 -0
- package/templates/base/src/lib/stores/activeTab.svelte.ts +14 -0
- package/templates/base/src/lib/utils/imageBuilder.ts +3 -0
- package/templates/base/src/lib/utils/slug.ts +33 -0
- package/templates/base/src/routes/(protected)/admin/+layout.server.ts +86 -0
- package/templates/base/src/routes/(protected)/admin/+layout.svelte +78 -0
- package/templates/base/src/routes/(protected)/admin/+page.server.ts +60 -0
- package/templates/base/src/routes/(protected)/admin/+page.svelte +34 -0
- package/templates/base/src/routes/(protected)/admin/organizations/+page.server.ts +39 -0
- package/templates/base/src/routes/(protected)/admin/organizations/+page.svelte +31 -0
- package/templates/base/src/routes/(protected)/admin/organizations/_components/CreateOrganization.svelte +141 -0
- package/templates/base/src/routes/(protected)/admin/organizations/_components/OrganizationsList.svelte +102 -0
- package/templates/base/src/routes/(protected)/admin/settings/+layout.server.ts +42 -0
- package/templates/base/src/routes/(protected)/admin/settings/+layout.svelte +116 -0
- package/templates/base/src/routes/(protected)/admin/settings/+page.server.ts +16 -0
- package/templates/base/src/routes/(protected)/admin/settings/+page.svelte +32 -0
- package/templates/base/src/routes/(protected)/admin/settings/_components/AccountSettings.svelte +177 -0
- package/templates/base/src/routes/(protected)/admin/settings/_components/ApiKeysSettings.svelte +472 -0
- package/templates/base/src/routes/(protected)/admin/settings/_components/OrganizationsSettings.svelte +182 -0
- package/templates/base/src/routes/(protected)/admin/settings/account/+page.server.ts +25 -0
- package/templates/base/src/routes/(protected)/admin/settings/account/+page.svelte +23 -0
- package/templates/base/src/routes/(protected)/admin/settings/api-keys/+page.server.ts +54 -0
- package/templates/base/src/routes/(protected)/admin/settings/api-keys/+page.svelte +14 -0
- package/templates/base/src/routes/(protected)/admin/settings/members/+page.server.ts +31 -0
- package/templates/base/src/routes/(protected)/admin/settings/members/+page.svelte +338 -0
- package/templates/base/src/routes/(protected)/admin/settings/roles/+page.server.ts +17 -0
- package/templates/base/src/routes/(protected)/admin/settings/roles/+page.svelte +339 -0
- package/templates/base/src/routes/+layout.svelte +12 -0
- package/templates/base/src/routes/+page.svelte +2 -0
- package/templates/base/src/routes/api/[...slug]/+server.ts +35 -0
- package/templates/base/src/routes/api/instance-settings/+server.ts +70 -0
- package/templates/base/src/routes/api/invitations/+server.ts +54 -0
- package/templates/base/src/routes/api/invitations/[id]/accept/+server.ts +54 -0
- package/templates/base/src/routes/api/invitations/[id]/reject/+server.ts +49 -0
- package/templates/base/src/routes/api/settings/api-keys/+server.ts +57 -0
- package/templates/base/src/routes/api/settings/api-keys/[id]/+server.ts +53 -0
- package/templates/base/src/routes/god-mode/+layout.server.ts +32 -0
- package/templates/base/src/routes/god-mode/+layout.svelte +155 -0
- package/templates/base/src/routes/god-mode/+page.server.ts +10 -0
- package/templates/base/src/routes/god-mode/+page.svelte +32 -0
- package/templates/base/src/routes/god-mode/_components/CreateOrganization.svelte +141 -0
- package/templates/base/src/routes/god-mode/_components/OrganizationsList.svelte +169 -0
- package/templates/base/src/routes/god-mode/organizations/+page.server.ts +43 -0
- package/templates/base/src/routes/god-mode/organizations/+page.svelte +74 -0
- package/templates/base/src/routes/invitations/+page.server.ts +48 -0
- package/templates/base/src/routes/invitations/+page.svelte +264 -0
- package/templates/base/src/routes/invite/[token]/+page.server.ts +59 -0
- package/templates/base/src/routes/invite/[token]/+page.svelte +109 -0
- package/templates/base/src/routes/login/+page.server.ts +14 -0
- package/templates/base/src/routes/login/+page.svelte +332 -0
- package/templates/base/src/routes/media/[id]/[filename]/+server.ts +3 -0
- package/templates/base/src/routes/reset-password/[token]/+page.server.ts +15 -0
- package/templates/base/src/routes/reset-password/[token]/+page.svelte +179 -0
- package/templates/base/src/routes/verify-email/+page.svelte +41 -0
- package/templates/base/static/images/aphex-darkmode.png +0 -0
- package/templates/base/static/images/aphex-lightmode.png +0 -0
- package/templates/base/static/robots.txt +3 -0
- package/templates/base/svelte.config.js +19 -0
- package/templates/base/tsconfig.json +20 -0
- package/templates/base/vite.config.ts +138 -0
package/templates/base/src/routes/(protected)/admin/settings/_components/ApiKeysSettings.svelte
ADDED
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Button } from '@aphexcms/ui/shadcn/button';
|
|
3
|
+
import { Input } from '@aphexcms/ui/shadcn/input';
|
|
4
|
+
import { Label } from '@aphexcms/ui/shadcn/label';
|
|
5
|
+
import { Badge } from '@aphexcms/ui/shadcn/badge';
|
|
6
|
+
import {
|
|
7
|
+
Dialog,
|
|
8
|
+
DialogContent,
|
|
9
|
+
DialogDescription,
|
|
10
|
+
DialogFooter,
|
|
11
|
+
DialogHeader,
|
|
12
|
+
DialogTitle,
|
|
13
|
+
DialogTrigger
|
|
14
|
+
} from '@aphexcms/ui/shadcn/dialog';
|
|
15
|
+
import * as Select from '@aphexcms/ui/shadcn/select';
|
|
16
|
+
import * as Card from '@aphexcms/ui/shadcn/card';
|
|
17
|
+
import * as Tooltip from '@aphexcms/ui/shadcn/tooltip';
|
|
18
|
+
import * as Collapsible from '@aphexcms/ui/shadcn/collapsible';
|
|
19
|
+
import { Separator } from '@aphexcms/ui/shadcn/separator';
|
|
20
|
+
import { apiKeys as apiKeysApi } from '@aphexcms/cms-core/client';
|
|
21
|
+
import { confirmDialog, usePermissions } from '@aphexcms/cms-core/client';
|
|
22
|
+
import { invalidateAll } from '$app/navigation';
|
|
23
|
+
import { toast } from 'svelte-sonner';
|
|
24
|
+
import { KeyRound, Copy, Trash2, Plus, ChevronDown } from '@lucide/svelte';
|
|
25
|
+
|
|
26
|
+
type ApiKey = {
|
|
27
|
+
id: string;
|
|
28
|
+
name: string | null;
|
|
29
|
+
permissions: ('read' | 'write')[];
|
|
30
|
+
createdAt: Date | null;
|
|
31
|
+
lastRequest: Date | null;
|
|
32
|
+
expiresAt: Date | null;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
type Props = {
|
|
36
|
+
apiKeys: ApiKey[];
|
|
37
|
+
organizationRole?: string | null;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
let { apiKeys, organizationRole }: Props = $props();
|
|
41
|
+
|
|
42
|
+
// Capability-driven gate — any role (built-in or custom) with apiKey.manage
|
|
43
|
+
// gets the create/delete affordances. The legacy `organizationRole` prop is
|
|
44
|
+
// kept in the signature for backwards compat but no longer drives the UI.
|
|
45
|
+
void organizationRole;
|
|
46
|
+
const perms = usePermissions();
|
|
47
|
+
const canManageApiKeys = $derived(perms.can('apiKey.manage'));
|
|
48
|
+
|
|
49
|
+
let createDialogOpen = $state(false);
|
|
50
|
+
let newKeyName = $state('');
|
|
51
|
+
let newKeyMode = $state<'read' | 'write'>('read');
|
|
52
|
+
const newKeyPermissions = $derived<('read' | 'write')[]>(
|
|
53
|
+
newKeyMode === 'write' ? ['read', 'write'] : ['read']
|
|
54
|
+
);
|
|
55
|
+
let newKeyExpiresValue = $state('365');
|
|
56
|
+
let newKeyExpiresInDays = $state<number | undefined>(365);
|
|
57
|
+
let createdKey = $state<{ key: string; name: string } | null>(null);
|
|
58
|
+
let isCreating = $state(false);
|
|
59
|
+
|
|
60
|
+
const expirationOptions = [
|
|
61
|
+
{ value: '30', label: '30 days' },
|
|
62
|
+
{ value: '90', label: '90 days' },
|
|
63
|
+
{ value: '365', label: '1 year' },
|
|
64
|
+
{ value: 'never', label: 'Never' }
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
const expirationTriggerContent = $derived(
|
|
68
|
+
expirationOptions.find((opt) => opt.value === newKeyExpiresValue)?.label ?? '1 year'
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
async function createApiKey() {
|
|
72
|
+
if (!newKeyName.trim()) {
|
|
73
|
+
toast.error('Please enter a key name');
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
isCreating = true;
|
|
78
|
+
try {
|
|
79
|
+
const result = await apiKeysApi.create({
|
|
80
|
+
name: newKeyName.trim(),
|
|
81
|
+
permissions: newKeyPermissions,
|
|
82
|
+
expiresInDays: newKeyExpiresInDays
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
if (!result.success || !result.data) {
|
|
86
|
+
throw new Error(result.error || 'Failed to create API key');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
createdKey = { key: result.data.apiKey.key, name: result.data.apiKey.name ?? newKeyName };
|
|
90
|
+
|
|
91
|
+
// Reset form
|
|
92
|
+
newKeyName = '';
|
|
93
|
+
newKeyMode = 'read';
|
|
94
|
+
newKeyExpiresValue = '365';
|
|
95
|
+
newKeyExpiresInDays = 365;
|
|
96
|
+
|
|
97
|
+
// Refresh the list
|
|
98
|
+
await invalidateAll();
|
|
99
|
+
} catch (error) {
|
|
100
|
+
toast.error(error instanceof Error ? error.message : 'Failed to create API key');
|
|
101
|
+
} finally {
|
|
102
|
+
isCreating = false;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function deleteApiKey(id: string, name: string) {
|
|
107
|
+
const confirmed = await confirmDialog({
|
|
108
|
+
title: `Delete "${name}"?`,
|
|
109
|
+
description: 'This action cannot be undone.',
|
|
110
|
+
confirmText: 'Delete',
|
|
111
|
+
variant: 'destructive'
|
|
112
|
+
});
|
|
113
|
+
if (!confirmed) return;
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
const result = await apiKeysApi.remove(id);
|
|
117
|
+
|
|
118
|
+
if (!result.success) {
|
|
119
|
+
throw new Error(result.error || 'Failed to delete API key');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
toast.success(`API key "${name}" deleted`);
|
|
123
|
+
await invalidateAll();
|
|
124
|
+
} catch (error) {
|
|
125
|
+
toast.error(error instanceof Error ? error.message : 'Failed to delete API key');
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function copyToClipboard(text: string) {
|
|
130
|
+
navigator.clipboard.writeText(text);
|
|
131
|
+
toast.success('Copied to clipboard!');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function formatDate(date: Date | null | undefined) {
|
|
135
|
+
if (!date) return 'Never';
|
|
136
|
+
return new Date(date).toLocaleDateString('en-US', {
|
|
137
|
+
year: 'numeric',
|
|
138
|
+
month: 'short',
|
|
139
|
+
day: 'numeric'
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
</script>
|
|
143
|
+
|
|
144
|
+
<Card.Root>
|
|
145
|
+
<Card.Header>
|
|
146
|
+
<div class="flex items-center justify-between">
|
|
147
|
+
<div>
|
|
148
|
+
<Card.Title>API Keys</Card.Title>
|
|
149
|
+
<Card.Description>API keys allow programmatic access to your CMS content</Card.Description>
|
|
150
|
+
</div>
|
|
151
|
+
{#if canManageApiKeys}
|
|
152
|
+
<Dialog bind:open={createDialogOpen}>
|
|
153
|
+
{#if apiKeys.length > 0}
|
|
154
|
+
<!-- Only show the top-right trigger when there's already a list.
|
|
155
|
+
The empty state renders its own inline Create button that
|
|
156
|
+
flips `createDialogOpen` directly — showing both CTAs on an
|
|
157
|
+
empty screen makes the layout feel cluttered. -->
|
|
158
|
+
<DialogTrigger>
|
|
159
|
+
{#snippet child({ props })}
|
|
160
|
+
<Button size="sm" {...props}>
|
|
161
|
+
<Plus class="mr-1.5 h-4 w-4" />
|
|
162
|
+
Create Key
|
|
163
|
+
</Button>
|
|
164
|
+
{/snippet}
|
|
165
|
+
</DialogTrigger>
|
|
166
|
+
{/if}
|
|
167
|
+
<DialogContent class="sm:max-w-[500px]">
|
|
168
|
+
{#if createdKey}
|
|
169
|
+
<DialogHeader>
|
|
170
|
+
<DialogTitle>API Key Created</DialogTitle>
|
|
171
|
+
<DialogDescription>
|
|
172
|
+
Save this key securely - you won't be able to see it again
|
|
173
|
+
</DialogDescription>
|
|
174
|
+
</DialogHeader>
|
|
175
|
+
<div class="space-y-4 py-4">
|
|
176
|
+
<div>
|
|
177
|
+
<Label>Key Name</Label>
|
|
178
|
+
<p class="mt-1 text-sm font-medium">{createdKey.name}</p>
|
|
179
|
+
</div>
|
|
180
|
+
<div>
|
|
181
|
+
<Label>API Key</Label>
|
|
182
|
+
<div class="mt-1 flex gap-2">
|
|
183
|
+
<Input value={createdKey.key} readonly class="font-mono text-xs" />
|
|
184
|
+
<Button
|
|
185
|
+
size="sm"
|
|
186
|
+
variant="outline"
|
|
187
|
+
onclick={() => copyToClipboard(createdKey!.key)}
|
|
188
|
+
>
|
|
189
|
+
Copy
|
|
190
|
+
</Button>
|
|
191
|
+
</div>
|
|
192
|
+
</div>
|
|
193
|
+
</div>
|
|
194
|
+
<DialogFooter>
|
|
195
|
+
<Button
|
|
196
|
+
onclick={() => {
|
|
197
|
+
createdKey = null;
|
|
198
|
+
createDialogOpen = false;
|
|
199
|
+
}}
|
|
200
|
+
>
|
|
201
|
+
Done
|
|
202
|
+
</Button>
|
|
203
|
+
</DialogFooter>
|
|
204
|
+
{:else}
|
|
205
|
+
<DialogHeader>
|
|
206
|
+
<DialogTitle>Create API Key</DialogTitle>
|
|
207
|
+
<DialogDescription>
|
|
208
|
+
Generate a new API key for programmatic access
|
|
209
|
+
</DialogDescription>
|
|
210
|
+
</DialogHeader>
|
|
211
|
+
<div class="space-y-4 py-4">
|
|
212
|
+
<div>
|
|
213
|
+
<Label for="key-name">Key Name</Label>
|
|
214
|
+
<Input
|
|
215
|
+
id="key-name"
|
|
216
|
+
bind:value={newKeyName}
|
|
217
|
+
placeholder="Production API Key"
|
|
218
|
+
class="mt-1"
|
|
219
|
+
/>
|
|
220
|
+
<p class="text-muted-foreground mt-1 text-xs">
|
|
221
|
+
A descriptive name to identify this key
|
|
222
|
+
</p>
|
|
223
|
+
</div>
|
|
224
|
+
|
|
225
|
+
<div>
|
|
226
|
+
<Label>Access Level</Label>
|
|
227
|
+
<div class="mt-2 flex gap-2">
|
|
228
|
+
<Button
|
|
229
|
+
variant={newKeyMode === 'read' ? 'default' : 'outline'}
|
|
230
|
+
size="sm"
|
|
231
|
+
onclick={() => (newKeyMode = 'read')}
|
|
232
|
+
>
|
|
233
|
+
Read only
|
|
234
|
+
</Button>
|
|
235
|
+
<Button
|
|
236
|
+
variant={newKeyMode === 'write' ? 'default' : 'outline'}
|
|
237
|
+
size="sm"
|
|
238
|
+
onclick={() => (newKeyMode = 'write')}
|
|
239
|
+
>
|
|
240
|
+
Read + Write
|
|
241
|
+
</Button>
|
|
242
|
+
</div>
|
|
243
|
+
</div>
|
|
244
|
+
|
|
245
|
+
<div>
|
|
246
|
+
<Label for="expires">Expires In</Label>
|
|
247
|
+
<Select.Root
|
|
248
|
+
type="single"
|
|
249
|
+
name="expiration"
|
|
250
|
+
bind:value={newKeyExpiresValue}
|
|
251
|
+
onValueChange={(value) => {
|
|
252
|
+
if (value) {
|
|
253
|
+
newKeyExpiresInDays = value === 'never' ? undefined : parseInt(value);
|
|
254
|
+
}
|
|
255
|
+
}}
|
|
256
|
+
>
|
|
257
|
+
<Select.Trigger class="mt-1 w-[180px]">
|
|
258
|
+
{expirationTriggerContent}
|
|
259
|
+
</Select.Trigger>
|
|
260
|
+
<Select.Content>
|
|
261
|
+
<Select.Group>
|
|
262
|
+
{#each expirationOptions as option (option.value)}
|
|
263
|
+
<Select.Item value={option.value} label={option.label}>
|
|
264
|
+
{option.label}
|
|
265
|
+
</Select.Item>
|
|
266
|
+
{/each}
|
|
267
|
+
</Select.Group>
|
|
268
|
+
</Select.Content>
|
|
269
|
+
</Select.Root>
|
|
270
|
+
</div>
|
|
271
|
+
</div>
|
|
272
|
+
<DialogFooter>
|
|
273
|
+
<Button
|
|
274
|
+
variant="outline"
|
|
275
|
+
onclick={() => (createDialogOpen = false)}
|
|
276
|
+
disabled={isCreating}
|
|
277
|
+
>
|
|
278
|
+
Cancel
|
|
279
|
+
</Button>
|
|
280
|
+
<Button onclick={createApiKey} disabled={isCreating}>
|
|
281
|
+
{isCreating ? 'Creating...' : 'Create Key'}
|
|
282
|
+
</Button>
|
|
283
|
+
</DialogFooter>
|
|
284
|
+
{/if}
|
|
285
|
+
</DialogContent>
|
|
286
|
+
</Dialog>
|
|
287
|
+
{/if}
|
|
288
|
+
</div>
|
|
289
|
+
</Card.Header>
|
|
290
|
+
|
|
291
|
+
<Card.Content>
|
|
292
|
+
{#if apiKeys.length === 0}
|
|
293
|
+
<div class="flex flex-col items-center justify-center py-12 text-center">
|
|
294
|
+
<div class="bg-muted mb-4 rounded-full p-3">
|
|
295
|
+
<KeyRound class="text-muted-foreground h-6 w-6" />
|
|
296
|
+
</div>
|
|
297
|
+
<p class="text-base font-medium">No API keys yet</p>
|
|
298
|
+
<p class="text-muted-foreground mt-1 text-sm">
|
|
299
|
+
Create your first API key to access the CMS data programmatically
|
|
300
|
+
</p>
|
|
301
|
+
{#if canManageApiKeys}
|
|
302
|
+
<Button size="sm" class="mt-4" onclick={() => (createDialogOpen = true)}>
|
|
303
|
+
<Plus class="mr-1.5 h-4 w-4" />
|
|
304
|
+
Create API Key
|
|
305
|
+
</Button>
|
|
306
|
+
{/if}
|
|
307
|
+
</div>
|
|
308
|
+
{:else}
|
|
309
|
+
<div class="divide-y">
|
|
310
|
+
{#each apiKeys as apiKey, i (apiKey.id)}
|
|
311
|
+
<div
|
|
312
|
+
class="flex items-center justify-between gap-4 {i > 0 ? 'pt-4' : ''} {i <
|
|
313
|
+
apiKeys.length - 1
|
|
314
|
+
? 'pb-4'
|
|
315
|
+
: ''}"
|
|
316
|
+
>
|
|
317
|
+
<div class="min-w-0 flex-1">
|
|
318
|
+
<div class="flex items-center gap-2">
|
|
319
|
+
<KeyRound class="text-muted-foreground h-4 w-4 shrink-0" />
|
|
320
|
+
<span class="truncate font-medium">{apiKey.name}</span>
|
|
321
|
+
<div class="flex gap-1">
|
|
322
|
+
{#each apiKey.permissions as permission}
|
|
323
|
+
<Badge variant="secondary" class="text-xs capitalize">
|
|
324
|
+
{permission}
|
|
325
|
+
</Badge>
|
|
326
|
+
{/each}
|
|
327
|
+
</div>
|
|
328
|
+
</div>
|
|
329
|
+
<div class="text-muted-foreground mt-1.5 ml-6 flex flex-wrap gap-x-4 gap-y-1 text-xs">
|
|
330
|
+
<span>Created {formatDate(apiKey.createdAt)}</span>
|
|
331
|
+
<span>Last used {formatDate(apiKey.lastRequest)}</span>
|
|
332
|
+
<span>
|
|
333
|
+
Expires {apiKey.expiresAt ? formatDate(apiKey.expiresAt) : 'never'}
|
|
334
|
+
</span>
|
|
335
|
+
</div>
|
|
336
|
+
</div>
|
|
337
|
+
{#if canManageApiKeys}
|
|
338
|
+
<Tooltip.Root>
|
|
339
|
+
<Tooltip.Trigger>
|
|
340
|
+
{#snippet child({ props })}
|
|
341
|
+
<Button
|
|
342
|
+
{...props}
|
|
343
|
+
variant="ghost"
|
|
344
|
+
size="icon"
|
|
345
|
+
class="text-muted-foreground hover:text-destructive h-8 w-8 shrink-0"
|
|
346
|
+
onclick={(e) => {
|
|
347
|
+
props.onclick?.(e);
|
|
348
|
+
deleteApiKey(apiKey.id, apiKey.name ?? 'Unnamed');
|
|
349
|
+
}}
|
|
350
|
+
>
|
|
351
|
+
<Trash2 class="h-4 w-4" />
|
|
352
|
+
</Button>
|
|
353
|
+
{/snippet}
|
|
354
|
+
</Tooltip.Trigger>
|
|
355
|
+
<Tooltip.Content>Delete this API key</Tooltip.Content>
|
|
356
|
+
</Tooltip.Root>
|
|
357
|
+
{/if}
|
|
358
|
+
</div>
|
|
359
|
+
{/each}
|
|
360
|
+
</div>
|
|
361
|
+
{/if}
|
|
362
|
+
</Card.Content>
|
|
363
|
+
</Card.Root>
|
|
364
|
+
|
|
365
|
+
<!-- Quick Reference -->
|
|
366
|
+
<Card.Root class="mt-6">
|
|
367
|
+
<Card.Header class="pb-3">
|
|
368
|
+
<Card.Title class="text-sm font-medium">Quick Reference</Card.Title>
|
|
369
|
+
<Card.Description>
|
|
370
|
+
Pass your key via the <code class="bg-muted rounded px-1 py-0.5 text-xs">x-api-key</code> header
|
|
371
|
+
</Card.Description>
|
|
372
|
+
</Card.Header>
|
|
373
|
+
<Card.Content class="space-y-4">
|
|
374
|
+
<!-- Auth example -->
|
|
375
|
+
<div class="bg-muted relative rounded-md p-3">
|
|
376
|
+
<code class="block font-mono text-xs leading-relaxed break-all">
|
|
377
|
+
curl -H "x-api-key: your_key_here" \<br />
|
|
378
|
+
https://your-app.com/api/documents?type={schemaType}
|
|
379
|
+
</code>
|
|
380
|
+
<Button
|
|
381
|
+
variant="ghost"
|
|
382
|
+
size="icon"
|
|
383
|
+
class="absolute top-2 right-2 h-7 w-7"
|
|
384
|
+
onclick={() =>
|
|
385
|
+
copyToClipboard(
|
|
386
|
+
'curl -H "x-api-key: your_key_here" \\\n https://your-app.com/api/documents?type={schemaType}'
|
|
387
|
+
)}
|
|
388
|
+
>
|
|
389
|
+
<Copy class="h-3.5 w-3.5" />
|
|
390
|
+
</Button>
|
|
391
|
+
</div>
|
|
392
|
+
|
|
393
|
+
<!-- Endpoints -->
|
|
394
|
+
<Collapsible.Root>
|
|
395
|
+
<Collapsible.Trigger
|
|
396
|
+
class="flex w-full items-center justify-between text-xs font-medium [&[data-state=open]>svg]:rotate-180"
|
|
397
|
+
>
|
|
398
|
+
Endpoints
|
|
399
|
+
<ChevronDown class="text-muted-foreground h-3.5 w-3.5 transition-transform duration-200" />
|
|
400
|
+
</Collapsible.Trigger>
|
|
401
|
+
<Collapsible.Content>
|
|
402
|
+
<div class="bg-muted mt-2 overflow-hidden rounded-md font-mono text-xs">
|
|
403
|
+
<div class="divide-border divide-y">
|
|
404
|
+
<div class="flex gap-2 px-3 py-1.5">
|
|
405
|
+
<Badge variant="outline" class="w-14 justify-center font-mono text-[10px]">GET</Badge>
|
|
406
|
+
<span class="text-muted-foreground">/api/documents?type={type}</span>
|
|
407
|
+
</div>
|
|
408
|
+
<div class="flex gap-2 px-3 py-1.5">
|
|
409
|
+
<Badge variant="outline" class="w-14 justify-center font-mono text-[10px]">GET</Badge>
|
|
410
|
+
<span class="text-muted-foreground">/api/documents/{id}</span>
|
|
411
|
+
</div>
|
|
412
|
+
<div class="flex gap-2 px-3 py-1.5">
|
|
413
|
+
<Badge variant="secondary" class="w-14 justify-center font-mono text-[10px]"
|
|
414
|
+
>POST</Badge
|
|
415
|
+
>
|
|
416
|
+
<span class="text-muted-foreground">/api/documents</span>
|
|
417
|
+
</div>
|
|
418
|
+
<div class="flex gap-2 px-3 py-1.5">
|
|
419
|
+
<Badge variant="secondary" class="w-14 justify-center font-mono text-[10px]"
|
|
420
|
+
>PUT</Badge
|
|
421
|
+
>
|
|
422
|
+
<span class="text-muted-foreground">/api/documents/{id}</span>
|
|
423
|
+
</div>
|
|
424
|
+
<div class="flex gap-2 px-3 py-1.5">
|
|
425
|
+
<Badge variant="secondary" class="w-14 justify-center font-mono text-[10px]"
|
|
426
|
+
>DEL</Badge
|
|
427
|
+
>
|
|
428
|
+
<span class="text-muted-foreground">/api/documents/{id}</span>
|
|
429
|
+
</div>
|
|
430
|
+
<div class="flex gap-2 px-3 py-1.5">
|
|
431
|
+
<Badge variant="outline" class="w-14 justify-center font-mono text-[10px]">POST</Badge
|
|
432
|
+
>
|
|
433
|
+
<span class="text-muted-foreground">/api/documents/query</span>
|
|
434
|
+
</div>
|
|
435
|
+
<div class="flex gap-2 px-3 py-1.5">
|
|
436
|
+
<Badge variant="outline" class="w-14 justify-center font-mono text-[10px]">GET</Badge>
|
|
437
|
+
<span class="text-muted-foreground">/api/assets</span>
|
|
438
|
+
</div>
|
|
439
|
+
<div class="flex gap-2 px-3 py-1.5">
|
|
440
|
+
<Badge variant="secondary" class="w-14 justify-center font-mono text-[10px]"
|
|
441
|
+
>POST</Badge
|
|
442
|
+
>
|
|
443
|
+
<span class="text-muted-foreground">/api/assets</span>
|
|
444
|
+
<span class="text-muted-foreground/60 ml-auto text-[10px]">multipart/form-data</span>
|
|
445
|
+
</div>
|
|
446
|
+
<div class="flex gap-2 px-3 py-1.5">
|
|
447
|
+
<Badge variant="outline" class="w-14 justify-center font-mono text-[10px]">GET</Badge>
|
|
448
|
+
<span class="text-muted-foreground">/api/schemas</span>
|
|
449
|
+
</div>
|
|
450
|
+
</div>
|
|
451
|
+
</div>
|
|
452
|
+
</Collapsible.Content>
|
|
453
|
+
</Collapsible.Root>
|
|
454
|
+
|
|
455
|
+
<!-- Permissions note -->
|
|
456
|
+
<div class="text-muted-foreground text-xs leading-relaxed">
|
|
457
|
+
<p>
|
|
458
|
+
<Badge variant="outline" class="mr-1 font-mono text-[10px]">GET</Badge> endpoints need
|
|
459
|
+
<strong class="text-foreground">read</strong> permission.
|
|
460
|
+
<Badge variant="secondary" class="mr-1 ml-1 font-mono text-[10px]">POST</Badge>
|
|
461
|
+
<Badge variant="secondary" class="mr-1 font-mono text-[10px]">PUT</Badge>
|
|
462
|
+
<Badge variant="secondary" class="mr-1 font-mono text-[10px]">DEL</Badge> need
|
|
463
|
+
<strong class="text-foreground">write</strong>. Read-only keys get
|
|
464
|
+
<code class="bg-muted rounded px-1 py-0.5">403</code> on mutations.
|
|
465
|
+
</p>
|
|
466
|
+
<p class="mt-1">
|
|
467
|
+
<code class="bg-muted rounded px-1 py-0.5">POST /api/documents/query</code> is the exception
|
|
468
|
+
— it only needs <strong class="text-foreground">read</strong> permission.
|
|
469
|
+
</p>
|
|
470
|
+
</div>
|
|
471
|
+
</Card.Content>
|
|
472
|
+
</Card.Root>
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Button } from '@aphexcms/ui/shadcn/button';
|
|
3
|
+
import { Input } from '@aphexcms/ui/shadcn/input';
|
|
4
|
+
import { Label } from '@aphexcms/ui/shadcn/label';
|
|
5
|
+
import * as Card from '@aphexcms/ui/shadcn/card';
|
|
6
|
+
import * as Avatar from '@aphexcms/ui/shadcn/avatar';
|
|
7
|
+
import { Separator } from '@aphexcms/ui/shadcn/separator';
|
|
8
|
+
import { invalidateAll } from '$app/navigation';
|
|
9
|
+
import { toast } from 'svelte-sonner';
|
|
10
|
+
import { Copy, Users } from '@lucide/svelte';
|
|
11
|
+
import type { Organization } from '@aphexcms/cms-core';
|
|
12
|
+
import { organizations } from '@aphexcms/cms-core/client';
|
|
13
|
+
|
|
14
|
+
type Props = {
|
|
15
|
+
activeOrganization: Organization & { members: any[] };
|
|
16
|
+
currentUserId: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
let { activeOrganization, currentUserId }: Props = $props();
|
|
20
|
+
|
|
21
|
+
let editOrgName = $state(activeOrganization.name);
|
|
22
|
+
let editOrgSlug = $state(activeOrganization.slug);
|
|
23
|
+
let isUpdatingOrg = $state(false);
|
|
24
|
+
let error = $state<string | null>(null);
|
|
25
|
+
|
|
26
|
+
// Sync local state when the active organization changes (e.g. org switch)
|
|
27
|
+
$effect(() => {
|
|
28
|
+
editOrgName = activeOrganization.name;
|
|
29
|
+
editOrgSlug = activeOrganization.slug;
|
|
30
|
+
error = null;
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const orgInitials = $derived(
|
|
34
|
+
activeOrganization.name
|
|
35
|
+
.split(' ')
|
|
36
|
+
.map((w) => w[0])
|
|
37
|
+
.join('')
|
|
38
|
+
.toUpperCase()
|
|
39
|
+
.slice(0, 2)
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
const formattedDate = $derived(
|
|
43
|
+
new Date(activeOrganization.createdAt).toLocaleDateString('en-US', {
|
|
44
|
+
year: 'numeric',
|
|
45
|
+
month: 'long',
|
|
46
|
+
day: 'numeric'
|
|
47
|
+
})
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
function generateSlug(text: string): string {
|
|
51
|
+
return text
|
|
52
|
+
.toLowerCase()
|
|
53
|
+
.trim()
|
|
54
|
+
.replace(/[^\w\s-]/g, '')
|
|
55
|
+
.replace(/[\s_-]+/g, '-')
|
|
56
|
+
.replace(/^-+|-+$/g, '');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function handleSlugInput(event: Event) {
|
|
60
|
+
const target = event.target as HTMLInputElement;
|
|
61
|
+
editOrgSlug = target.value.toLowerCase().replace(/[^a-z0-9-]/g, '');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function copyToClipboard(text: string) {
|
|
65
|
+
navigator.clipboard.writeText(text);
|
|
66
|
+
toast.success('Copied to clipboard!');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function updateOrganization() {
|
|
70
|
+
if (!editOrgName.trim() || !editOrgSlug.trim()) {
|
|
71
|
+
error = 'Please enter both organization name and slug';
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
isUpdatingOrg = true;
|
|
76
|
+
error = null;
|
|
77
|
+
try {
|
|
78
|
+
const result = await organizations.update(activeOrganization.id, {
|
|
79
|
+
name: editOrgName.trim(),
|
|
80
|
+
slug: editOrgSlug.trim()
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
if (!result.success) {
|
|
84
|
+
throw new Error(result.error || 'Failed to update organization');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
toast.success('Organization updated successfully');
|
|
88
|
+
await invalidateAll();
|
|
89
|
+
} catch (err) {
|
|
90
|
+
error = err instanceof Error ? err.message : 'Failed to update organization';
|
|
91
|
+
} finally {
|
|
92
|
+
isUpdatingOrg = false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
</script>
|
|
96
|
+
|
|
97
|
+
<Card.Root>
|
|
98
|
+
<Card.Header>
|
|
99
|
+
<div class="flex items-center gap-4">
|
|
100
|
+
<Avatar.Root class="h-12 w-12 text-lg">
|
|
101
|
+
{#if activeOrganization.metadata?.logo}
|
|
102
|
+
<Avatar.Image src={activeOrganization.metadata.logo} alt={activeOrganization.name} />
|
|
103
|
+
{/if}
|
|
104
|
+
<Avatar.Fallback>{orgInitials}</Avatar.Fallback>
|
|
105
|
+
</Avatar.Root>
|
|
106
|
+
<div class="min-w-0 flex-1">
|
|
107
|
+
<Card.Title class="text-lg">{activeOrganization.name}</Card.Title>
|
|
108
|
+
<div class="text-muted-foreground mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
|
|
109
|
+
<span class="flex items-center gap-1">
|
|
110
|
+
<Users class="h-3 w-3" />
|
|
111
|
+
{activeOrganization.members.length} member{activeOrganization.members.length !== 1
|
|
112
|
+
? 's'
|
|
113
|
+
: ''}
|
|
114
|
+
</span>
|
|
115
|
+
<span>Created {formattedDate}</span>
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
118
|
+
</div>
|
|
119
|
+
</Card.Header>
|
|
120
|
+
|
|
121
|
+
<Card.Content>
|
|
122
|
+
<!-- Org ID -->
|
|
123
|
+
<div class="mb-4">
|
|
124
|
+
<Label class="text-muted-foreground text-xs">Organization ID</Label>
|
|
125
|
+
<div class="mt-1 flex items-center gap-2">
|
|
126
|
+
<code
|
|
127
|
+
class="bg-muted text-muted-foreground flex-1 truncate rounded-md px-3 py-1.5 font-mono text-xs"
|
|
128
|
+
>
|
|
129
|
+
{activeOrganization.id}
|
|
130
|
+
</code>
|
|
131
|
+
<Button
|
|
132
|
+
variant="ghost"
|
|
133
|
+
size="icon"
|
|
134
|
+
class="h-7 w-7 shrink-0"
|
|
135
|
+
onclick={() => copyToClipboard(activeOrganization.id)}
|
|
136
|
+
>
|
|
137
|
+
<Copy class="h-3.5 w-3.5" />
|
|
138
|
+
</Button>
|
|
139
|
+
</div>
|
|
140
|
+
</div>
|
|
141
|
+
|
|
142
|
+
<Separator class="mb-4" />
|
|
143
|
+
|
|
144
|
+
<div class="grid gap-4">
|
|
145
|
+
<div>
|
|
146
|
+
<Label for="org-name">Organization Name</Label>
|
|
147
|
+
<Input id="org-name" bind:value={editOrgName} placeholder="Acme Inc" class="mt-2" />
|
|
148
|
+
</div>
|
|
149
|
+
<div>
|
|
150
|
+
<Label for="org-slug">Slug</Label>
|
|
151
|
+
<div class="mt-2 flex gap-2">
|
|
152
|
+
<Input
|
|
153
|
+
id="org-slug"
|
|
154
|
+
value={editOrgSlug}
|
|
155
|
+
oninput={handleSlugInput}
|
|
156
|
+
placeholder="acme-inc"
|
|
157
|
+
class="flex-1"
|
|
158
|
+
/>
|
|
159
|
+
<Button
|
|
160
|
+
variant="outline"
|
|
161
|
+
size="sm"
|
|
162
|
+
onclick={() => (editOrgSlug = generateSlug(editOrgName))}
|
|
163
|
+
disabled={!editOrgName.trim()}
|
|
164
|
+
>
|
|
165
|
+
Generate
|
|
166
|
+
</Button>
|
|
167
|
+
</div>
|
|
168
|
+
<p class="text-muted-foreground mt-1.5 font-mono text-xs">
|
|
169
|
+
/{editOrgSlug || 'your-slug'}
|
|
170
|
+
</p>
|
|
171
|
+
</div>
|
|
172
|
+
{#if error}
|
|
173
|
+
<p class="text-destructive text-sm">{error}</p>
|
|
174
|
+
{/if}
|
|
175
|
+
</div>
|
|
176
|
+
</Card.Content>
|
|
177
|
+
<Card.Footer class="border-t px-6 py-4">
|
|
178
|
+
<Button onclick={updateOrganization} disabled={isUpdatingOrg}>
|
|
179
|
+
{isUpdatingOrg ? 'Saving...' : 'Save'}
|
|
180
|
+
</Button>
|
|
181
|
+
</Card.Footer>
|
|
182
|
+
</Card.Root>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { PageServerLoad } from './$types';
|
|
2
|
+
|
|
3
|
+
export const load: PageServerLoad = async ({ locals }) => {
|
|
4
|
+
const auth = locals.auth;
|
|
5
|
+
|
|
6
|
+
if (!auth || auth.type !== 'session') {
|
|
7
|
+
throw new Error('No session found');
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const databaseAdapter = locals.aphexCMS.databaseAdapter;
|
|
11
|
+
|
|
12
|
+
const userProfile = await databaseAdapter.findUserProfileById(auth.user.id);
|
|
13
|
+
const userPreferences = userProfile?.preferences || {};
|
|
14
|
+
|
|
15
|
+
let hasChildOrganizations = false;
|
|
16
|
+
if (auth.organizationId && databaseAdapter.hierarchyEnabled) {
|
|
17
|
+
const childOrgs = await databaseAdapter.getChildOrganizations(auth.organizationId);
|
|
18
|
+
hasChildOrganizations = childOrgs.length > 0;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
userPreferences,
|
|
23
|
+
hasChildOrganizations
|
|
24
|
+
};
|
|
25
|
+
};
|