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.
Files changed (105) hide show
  1. package/README.md +56 -0
  2. package/dist/index.js +160 -0
  3. package/package.json +40 -0
  4. package/templates/base/.dockerignore +44 -0
  5. package/templates/base/.env.example +38 -0
  6. package/templates/base/CHANGELOG.md +118 -0
  7. package/templates/base/Dockerfile +69 -0
  8. package/templates/base/README.md +147 -0
  9. package/templates/base/aphex.config.ts +45 -0
  10. package/templates/base/docker-compose.yml +31 -0
  11. package/templates/base/drizzle/0000_tiny_redwing.sql +226 -0
  12. package/templates/base/drizzle/meta/0000_snapshot.json +1379 -0
  13. package/templates/base/drizzle/meta/_journal.json +13 -0
  14. package/templates/base/drizzle.config.ts +17 -0
  15. package/templates/base/package.json +81 -0
  16. package/templates/base/prod.docker-compose.yml +61 -0
  17. package/templates/base/src/app.css +32 -0
  18. package/templates/base/src/app.d.ts +26 -0
  19. package/templates/base/src/app.html +11 -0
  20. package/templates/base/src/hooks.server.ts +52 -0
  21. package/templates/base/src/lib/assets/favicon.svg +5 -0
  22. package/templates/base/src/lib/auth-client.ts +19 -0
  23. package/templates/base/src/lib/index.ts +1 -0
  24. package/templates/base/src/lib/schemaTypes/index.ts +11 -0
  25. package/templates/base/src/lib/schemaTypes/post.ts +47 -0
  26. package/templates/base/src/lib/server/auth/better-auth/instance.ts +165 -0
  27. package/templates/base/src/lib/server/auth/index.ts +25 -0
  28. package/templates/base/src/lib/server/auth/instance.ts +7 -0
  29. package/templates/base/src/lib/server/auth/service.ts +486 -0
  30. package/templates/base/src/lib/server/cache/index.ts +10 -0
  31. package/templates/base/src/lib/server/db/auth-schema.ts +86 -0
  32. package/templates/base/src/lib/server/db/cms-schema.ts +23 -0
  33. package/templates/base/src/lib/server/db/index.ts +55 -0
  34. package/templates/base/src/lib/server/db/schema.ts +3 -0
  35. package/templates/base/src/lib/server/email/components/EmailLayout.svelte +24 -0
  36. package/templates/base/src/lib/server/email/components/EmailVerification.svelte +26 -0
  37. package/templates/base/src/lib/server/email/components/Invitation.svelte +30 -0
  38. package/templates/base/src/lib/server/email/components/PasswordReset.svelte +28 -0
  39. package/templates/base/src/lib/server/email/index.ts +28 -0
  40. package/templates/base/src/lib/server/email/invitation-hook.ts +56 -0
  41. package/templates/base/src/lib/server/email/renderer.ts +13 -0
  42. package/templates/base/src/lib/server/email/templates/email-verification.ts +8 -0
  43. package/templates/base/src/lib/server/email/templates/invitation.ts +8 -0
  44. package/templates/base/src/lib/server/email/templates/password-reset.ts +7 -0
  45. package/templates/base/src/lib/server/services/organization.ts +68 -0
  46. package/templates/base/src/lib/server/storage/index.ts +28 -0
  47. package/templates/base/src/lib/stores/activeTab.svelte.ts +14 -0
  48. package/templates/base/src/lib/utils/imageBuilder.ts +3 -0
  49. package/templates/base/src/lib/utils/slug.ts +33 -0
  50. package/templates/base/src/routes/(protected)/admin/+layout.server.ts +86 -0
  51. package/templates/base/src/routes/(protected)/admin/+layout.svelte +78 -0
  52. package/templates/base/src/routes/(protected)/admin/+page.server.ts +60 -0
  53. package/templates/base/src/routes/(protected)/admin/+page.svelte +34 -0
  54. package/templates/base/src/routes/(protected)/admin/organizations/+page.server.ts +39 -0
  55. package/templates/base/src/routes/(protected)/admin/organizations/+page.svelte +31 -0
  56. package/templates/base/src/routes/(protected)/admin/organizations/_components/CreateOrganization.svelte +141 -0
  57. package/templates/base/src/routes/(protected)/admin/organizations/_components/OrganizationsList.svelte +102 -0
  58. package/templates/base/src/routes/(protected)/admin/settings/+layout.server.ts +42 -0
  59. package/templates/base/src/routes/(protected)/admin/settings/+layout.svelte +116 -0
  60. package/templates/base/src/routes/(protected)/admin/settings/+page.server.ts +16 -0
  61. package/templates/base/src/routes/(protected)/admin/settings/+page.svelte +32 -0
  62. package/templates/base/src/routes/(protected)/admin/settings/_components/AccountSettings.svelte +177 -0
  63. package/templates/base/src/routes/(protected)/admin/settings/_components/ApiKeysSettings.svelte +472 -0
  64. package/templates/base/src/routes/(protected)/admin/settings/_components/OrganizationsSettings.svelte +182 -0
  65. package/templates/base/src/routes/(protected)/admin/settings/account/+page.server.ts +25 -0
  66. package/templates/base/src/routes/(protected)/admin/settings/account/+page.svelte +23 -0
  67. package/templates/base/src/routes/(protected)/admin/settings/api-keys/+page.server.ts +54 -0
  68. package/templates/base/src/routes/(protected)/admin/settings/api-keys/+page.svelte +14 -0
  69. package/templates/base/src/routes/(protected)/admin/settings/members/+page.server.ts +31 -0
  70. package/templates/base/src/routes/(protected)/admin/settings/members/+page.svelte +338 -0
  71. package/templates/base/src/routes/(protected)/admin/settings/roles/+page.server.ts +17 -0
  72. package/templates/base/src/routes/(protected)/admin/settings/roles/+page.svelte +339 -0
  73. package/templates/base/src/routes/+layout.svelte +12 -0
  74. package/templates/base/src/routes/+page.svelte +2 -0
  75. package/templates/base/src/routes/api/[...slug]/+server.ts +35 -0
  76. package/templates/base/src/routes/api/instance-settings/+server.ts +70 -0
  77. package/templates/base/src/routes/api/invitations/+server.ts +54 -0
  78. package/templates/base/src/routes/api/invitations/[id]/accept/+server.ts +54 -0
  79. package/templates/base/src/routes/api/invitations/[id]/reject/+server.ts +49 -0
  80. package/templates/base/src/routes/api/settings/api-keys/+server.ts +57 -0
  81. package/templates/base/src/routes/api/settings/api-keys/[id]/+server.ts +53 -0
  82. package/templates/base/src/routes/god-mode/+layout.server.ts +32 -0
  83. package/templates/base/src/routes/god-mode/+layout.svelte +155 -0
  84. package/templates/base/src/routes/god-mode/+page.server.ts +10 -0
  85. package/templates/base/src/routes/god-mode/+page.svelte +32 -0
  86. package/templates/base/src/routes/god-mode/_components/CreateOrganization.svelte +141 -0
  87. package/templates/base/src/routes/god-mode/_components/OrganizationsList.svelte +169 -0
  88. package/templates/base/src/routes/god-mode/organizations/+page.server.ts +43 -0
  89. package/templates/base/src/routes/god-mode/organizations/+page.svelte +74 -0
  90. package/templates/base/src/routes/invitations/+page.server.ts +48 -0
  91. package/templates/base/src/routes/invitations/+page.svelte +264 -0
  92. package/templates/base/src/routes/invite/[token]/+page.server.ts +59 -0
  93. package/templates/base/src/routes/invite/[token]/+page.svelte +109 -0
  94. package/templates/base/src/routes/login/+page.server.ts +14 -0
  95. package/templates/base/src/routes/login/+page.svelte +332 -0
  96. package/templates/base/src/routes/media/[id]/[filename]/+server.ts +3 -0
  97. package/templates/base/src/routes/reset-password/[token]/+page.server.ts +15 -0
  98. package/templates/base/src/routes/reset-password/[token]/+page.svelte +179 -0
  99. package/templates/base/src/routes/verify-email/+page.svelte +41 -0
  100. package/templates/base/static/images/aphex-darkmode.png +0 -0
  101. package/templates/base/static/images/aphex-lightmode.png +0 -0
  102. package/templates/base/static/robots.txt +3 -0
  103. package/templates/base/svelte.config.js +19 -0
  104. package/templates/base/tsconfig.json +20 -0
  105. package/templates/base/vite.config.ts +138 -0
@@ -0,0 +1,31 @@
1
+ <script lang="ts">
2
+ import { page } from '$app/state';
3
+ import CreateOrganization from './_components/CreateOrganization.svelte';
4
+ import OrganizationsList from './_components/OrganizationsList.svelte';
5
+ import type { PageData } from './$types';
6
+
7
+ let { data }: { data: PageData } = $props();
8
+
9
+ const userRole = $derived(page.data?.sidebarData?.user?.role);
10
+ const isSuperAdmin = $derived(userRole === 'super_admin');
11
+ </script>
12
+
13
+ <svelte:head>
14
+ <title>Aphex CMS - Organizations</title>
15
+ </svelte:head>
16
+
17
+ <div class="flex flex-1 flex-col gap-4 p-4 md:gap-8 md:p-8">
18
+ <div class="mx-auto flex w-full max-w-6xl items-center justify-between">
19
+ <div>
20
+ <h1 class="text-3xl font-semibold">Organizations</h1>
21
+ <p class="text-muted-foreground">Manage your organizations</p>
22
+ </div>
23
+ {#if isSuperAdmin}
24
+ <CreateOrganization />
25
+ {/if}
26
+ </div>
27
+
28
+ <div class="mx-auto grid w-full max-w-6xl gap-6">
29
+ <OrganizationsList orgs={data.organizations} />
30
+ </div>
31
+ </div>
@@ -0,0 +1,141 @@
1
+ <script lang="ts">
2
+ import { goto, invalidateAll } from '$app/navigation';
3
+ import { Button } from '@aphexcms/ui/shadcn/button';
4
+ import { Input } from '@aphexcms/ui/shadcn/input';
5
+ import { Label } from '@aphexcms/ui/shadcn/label';
6
+ import * as Dialog from '@aphexcms/ui/shadcn/dialog';
7
+ import { organizations } from '@aphexcms/cms-core/client';
8
+
9
+ let open = $state(false);
10
+ let name = $state('');
11
+ let slug = $state('');
12
+ let isSubmitting = $state(false);
13
+ let error = $state<string | null>(null);
14
+
15
+ function generateSlug(text: string): string {
16
+ return text
17
+ .toLowerCase()
18
+ .trim()
19
+ .replace(/[^\w\s-]/g, '')
20
+ .replace(/[\s_-]+/g, '-')
21
+ .replace(/^-+|-+$/g, '');
22
+ }
23
+
24
+ function handleNameInput(event: Event) {
25
+ const target = event.target as HTMLInputElement;
26
+ name = target.value;
27
+ if (!slug || slug === generateSlug(name.slice(0, -1))) {
28
+ slug = generateSlug(name);
29
+ }
30
+ }
31
+
32
+ function handleSlugInput(event: Event) {
33
+ const target = event.target as HTMLInputElement;
34
+ slug = target.value.toLowerCase().replace(/[^a-z0-9-]/g, '');
35
+ }
36
+
37
+ function resetForm() {
38
+ name = '';
39
+ slug = '';
40
+ error = null;
41
+ }
42
+
43
+ async function handleSubmit(event: Event) {
44
+ event.preventDefault();
45
+ error = null;
46
+
47
+ if (!name.trim() || !slug.trim()) {
48
+ error = 'Name and slug are required';
49
+ return;
50
+ }
51
+
52
+ isSubmitting = true;
53
+ try {
54
+ const result = await organizations.create({
55
+ name: name.trim(),
56
+ slug: slug.trim()
57
+ });
58
+
59
+ if (!result.success || !result.data) {
60
+ throw new Error('Failed to create organization');
61
+ }
62
+
63
+ resetForm();
64
+ open = false;
65
+ await invalidateAll();
66
+ await goto(`/admin?orgId=${result.data.id}`);
67
+ } catch (err) {
68
+ error = err instanceof Error ? err.message : 'Failed to create organization';
69
+ } finally {
70
+ isSubmitting = false;
71
+ }
72
+ }
73
+ </script>
74
+
75
+ <Dialog.Root bind:open onOpenChange={(v) => !v && resetForm()}>
76
+ <Dialog.Trigger>
77
+ {#snippet child({ props })}
78
+ <Button {...props}>Create Organization</Button>
79
+ {/snippet}
80
+ </Dialog.Trigger>
81
+ <Dialog.Content class="sm:max-w-[480px]">
82
+ <Dialog.Header>
83
+ <Dialog.Title>Create Organization</Dialog.Title>
84
+ <Dialog.Description>Create a new organization for your instance</Dialog.Description>
85
+ </Dialog.Header>
86
+ <form onsubmit={handleSubmit} class="grid gap-4 py-4">
87
+ <div>
88
+ <Label for="org-name">Organization name</Label>
89
+ <Input
90
+ id="org-name"
91
+ value={name}
92
+ oninput={handleNameInput}
93
+ placeholder="My Organization"
94
+ disabled={isSubmitting}
95
+ class="mt-1"
96
+ />
97
+ </div>
98
+ <div>
99
+ <Label for="org-slug">Slug</Label>
100
+ <div class="mt-1 flex gap-2">
101
+ <Input
102
+ id="org-slug"
103
+ value={slug}
104
+ oninput={handleSlugInput}
105
+ placeholder="my-organization"
106
+ disabled={isSubmitting}
107
+ class="flex-1"
108
+ />
109
+ <Button
110
+ type="button"
111
+ variant="outline"
112
+ size="sm"
113
+ onclick={() => (slug = generateSlug(name))}
114
+ disabled={!name.trim() || isSubmitting}
115
+ >
116
+ Generate
117
+ </Button>
118
+ </div>
119
+ <p class="text-muted-foreground mt-1 text-xs">
120
+ Lowercase letters, numbers, and hyphens only.
121
+ </p>
122
+ </div>
123
+ {#if error}
124
+ <p class="text-destructive text-sm">{error}</p>
125
+ {/if}
126
+ <Dialog.Footer>
127
+ <Button
128
+ type="button"
129
+ variant="outline"
130
+ onclick={() => (open = false)}
131
+ disabled={isSubmitting}
132
+ >
133
+ Cancel
134
+ </Button>
135
+ <Button type="submit" disabled={isSubmitting || !name.trim()}>
136
+ {isSubmitting ? 'Creating...' : 'Create'}
137
+ </Button>
138
+ </Dialog.Footer>
139
+ </form>
140
+ </Dialog.Content>
141
+ </Dialog.Root>
@@ -0,0 +1,102 @@
1
+ <script lang="ts">
2
+ import { goto, invalidateAll } from '$app/navigation';
3
+ import * as AlertDialog from '@aphexcms/ui/shadcn/alert-dialog';
4
+ import { Badge } from '@aphexcms/ui/shadcn/badge';
5
+ import { ExternalLink } from '@lucide/svelte';
6
+ import { organizations } from '@aphexcms/cms-core/client';
7
+
8
+ type OrgItem = {
9
+ id: string;
10
+ name: string;
11
+ slug: string;
12
+ role: string;
13
+ isActive: boolean;
14
+ memberCount: number;
15
+ ownerEmail?: string;
16
+ };
17
+
18
+ let { orgs = [] }: { orgs: OrgItem[] } = $props();
19
+
20
+ let switchingOrgId = $state<string | null>(null);
21
+
22
+ async function handleOrgClick(org: OrgItem) {
23
+ if (switchingOrgId) return;
24
+
25
+ if (org.isActive) {
26
+ goto('/admin');
27
+ return;
28
+ }
29
+
30
+ switchingOrgId = org.id;
31
+ try {
32
+ const result = await organizations.switch({ organizationId: org.id });
33
+
34
+ if (!result.success) {
35
+ throw new Error(result.error || 'Failed to switch organization');
36
+ }
37
+
38
+ await invalidateAll();
39
+ goto('/admin');
40
+ } catch (err) {
41
+ console.error('Failed to switch organization:', err);
42
+ } finally {
43
+ switchingOrgId = null;
44
+ }
45
+ }
46
+
47
+ function getInitials(name: string): string {
48
+ return name
49
+ .split(' ')
50
+ .map((word) => word[0])
51
+ .join('')
52
+ .toUpperCase()
53
+ .slice(0, 2);
54
+ }
55
+ </script>
56
+
57
+ {#if orgs.length === 0}
58
+ <p class="text-muted-foreground text-sm">No organizations yet</p>
59
+ {:else}
60
+ <div class="divide-y rounded-lg border">
61
+ {#each orgs as org (org.id)}
62
+ <div class="hover:bg-muted/50 flex items-center gap-4 p-4 transition-colors">
63
+ <!-- Avatar -->
64
+ <div
65
+ class="bg-sidebar-primary text-sidebar-primary-foreground flex size-10 shrink-0 items-center justify-center rounded-lg text-sm font-semibold"
66
+ >
67
+ {getInitials(org.name)}
68
+ </div>
69
+
70
+ <!-- Info -->
71
+ <div class="min-w-0 flex-1">
72
+ <div class="flex items-baseline gap-2">
73
+ <span class="font-medium">{org.name}</span>
74
+ <span class="text-muted-foreground">/</span>
75
+ <span class="text-muted-foreground text-sm">[{org.slug}]</span>
76
+ {#if org.isActive}
77
+ <Badge variant="default" class="text-xs">Active</Badge>
78
+ {/if}
79
+ </div>
80
+ <div class="text-muted-foreground text-sm">
81
+ {#if org.ownerEmail}
82
+ Owned by: {org.ownerEmail}
83
+ {/if}
84
+ </div>
85
+ <div class="text-muted-foreground text-sm">
86
+ Total members: {org.memberCount}
87
+ </div>
88
+ </div>
89
+
90
+ <!-- Actions -->
91
+ <button
92
+ class="text-muted-foreground hover:text-foreground hover:bg-muted rounded-md p-2 transition-colors"
93
+ onclick={() => handleOrgClick(org)}
94
+ disabled={switchingOrgId !== null}
95
+ title={org.isActive ? 'Go to dashboard' : 'Switch to this organization'}
96
+ >
97
+ <ExternalLink class="size-4" />
98
+ </button>
99
+ </div>
100
+ {/each}
101
+ </div>
102
+ {/if}
@@ -0,0 +1,42 @@
1
+ import type { LayoutServerLoad } from './$types';
2
+ import { organizationService } from '$lib/server/services/organization';
3
+
4
+ export const load: LayoutServerLoad = async ({ locals }) => {
5
+ const auth = locals.auth;
6
+
7
+ if (!auth || auth.type !== 'session') {
8
+ throw new Error('No session found');
9
+ }
10
+
11
+ const databaseAdapter = locals.aphexCMS.databaseAdapter;
12
+ let activeOrganization = null;
13
+ let currentUserOrgRole = null;
14
+
15
+ if (auth.organizationId) {
16
+ const orgData = await organizationService.getOrganizationWithMembers(auth.organizationId);
17
+ if (orgData) {
18
+ activeOrganization = {
19
+ ...orgData.organization,
20
+ members: orgData.members.map((m) => ({
21
+ ...m.member,
22
+ user: m.user,
23
+ invitedEmail: m.invitedEmail
24
+ }))
25
+ };
26
+
27
+ const currentMember = orgData.members.find((m) => m.user.id === auth.user.id);
28
+ currentUserOrgRole = currentMember?.member.role || null;
29
+ }
30
+ }
31
+
32
+ return {
33
+ user: {
34
+ id: auth.user.id,
35
+ email: auth.user.email,
36
+ name: auth.user.name,
37
+ role: auth.user.role,
38
+ organizationRole: currentUserOrgRole
39
+ },
40
+ activeOrganization
41
+ };
42
+ };
@@ -0,0 +1,116 @@
1
+ <script lang="ts">
2
+ import { page } from '$app/state';
3
+ import type { Capability } from '@aphexcms/cms-core';
4
+
5
+ let { children } = $props();
6
+
7
+ const basePath = '/admin/settings';
8
+
9
+ // Resolve capabilities once — exposed by /admin/+layout.server.ts and
10
+ // inherited through the layout data chain.
11
+ const capabilities = $derived(page.data.rbac?.capabilities ?? []);
12
+ function has(cap: Capability): boolean {
13
+ return capabilities.includes(cap);
14
+ }
15
+
16
+ // `requires: null` means everyone with a session (e.g. Profile), `requires`
17
+ // with a capability hides the tab from users who don't have it. Members
18
+ // stays visible to anyone in an org since viewing who's in the org is not
19
+ // privileged information, but mutation controls on the page itself are
20
+ // already capability-gated server-side.
21
+ type Tab = { label: string; href: string; requires: Capability | null };
22
+
23
+ const orgTabs: Tab[] = $derived(
24
+ (
25
+ [
26
+ { label: 'General', href: basePath, requires: 'org.settings' as Capability },
27
+ { label: 'Members', href: `${basePath}/members`, requires: null },
28
+ { label: 'Roles', href: `${basePath}/roles`, requires: 'role.manage' as Capability }
29
+ ] satisfies Tab[]
30
+ ).filter((t) => t.requires === null || has(t.requires))
31
+ );
32
+
33
+ const accountTabs: Tab[] = $derived(
34
+ (
35
+ [
36
+ { label: 'Profile', href: `${basePath}/account`, requires: null },
37
+ {
38
+ label: 'API Keys',
39
+ href: `${basePath}/api-keys`,
40
+ requires: 'apiKey.manage' as Capability
41
+ }
42
+ ] satisfies Tab[]
43
+ ).filter((t) => t.requires === null || has(t.requires))
44
+ );
45
+
46
+ function isActive(href: string) {
47
+ if (href === basePath) return page.url.pathname === basePath;
48
+ return page.url.pathname.startsWith(href);
49
+ }
50
+ </script>
51
+
52
+ <div class="flex flex-1 flex-col gap-4 overflow-y-auto p-4 md:gap-8 md:p-8">
53
+ <div class="mx-auto grid w-full max-w-6xl gap-2">
54
+ <h1 class="text-3xl font-semibold">Settings</h1>
55
+ <p class="text-muted-foreground">Manage your organization and account</p>
56
+ </div>
57
+
58
+ <!-- Mobile tabs -->
59
+ <div class="mx-auto w-full max-w-6xl md:hidden">
60
+ <div class="border-b">
61
+ <div class="flex gap-4 overflow-x-auto">
62
+ {#each [...orgTabs, ...accountTabs] as tab}
63
+ <a
64
+ href={tab.href}
65
+ class="border-b-2 px-1 pb-2 text-sm font-medium whitespace-nowrap transition-colors {isActive(
66
+ tab.href
67
+ )
68
+ ? 'border-primary text-primary'
69
+ : 'text-muted-foreground hover:text-foreground border-transparent'}"
70
+ >
71
+ {tab.label}
72
+ </a>
73
+ {/each}
74
+ </div>
75
+ </div>
76
+ </div>
77
+
78
+ <div
79
+ class="mx-auto grid w-full max-w-6xl items-start gap-6 md:grid-cols-[180px_1fr] lg:grid-cols-[250px_1fr]"
80
+ >
81
+ <!-- Desktop sidebar nav -->
82
+ <nav class="text-muted-foreground hidden gap-1 text-sm md:grid">
83
+ <p
84
+ class="text-muted-foreground/60 px-1 pt-2 pb-1 text-xs font-semibold tracking-wider uppercase"
85
+ >
86
+ Organization
87
+ </p>
88
+ {#each orgTabs as tab}
89
+ <a
90
+ href={tab.href}
91
+ class="rounded-md px-2 py-1.5 {isActive(tab.href)
92
+ ? 'text-primary bg-muted font-semibold'
93
+ : ''}"
94
+ >
95
+ {tab.label}
96
+ </a>
97
+ {/each}
98
+ <p
99
+ class="text-muted-foreground/60 px-1 pt-4 pb-1 text-xs font-semibold tracking-wider uppercase"
100
+ >
101
+ Account
102
+ </p>
103
+ {#each accountTabs as tab}
104
+ <a
105
+ href={tab.href}
106
+ class="rounded-md px-2 py-1.5 {isActive(tab.href)
107
+ ? 'text-primary bg-muted font-semibold'
108
+ : ''}"
109
+ >
110
+ {tab.label}
111
+ </a>
112
+ {/each}
113
+ </nav>
114
+ {@render children()}
115
+ </div>
116
+ </div>
@@ -0,0 +1,16 @@
1
+ import type { PageServerLoad } from './$types';
2
+ import { redirect } from '@sveltejs/kit';
3
+ import { hasCapability } from '@aphexcms/cms-core';
4
+
5
+ export const load: PageServerLoad = async ({ locals }) => {
6
+ const auth = locals.auth;
7
+
8
+ // Users without org.settings can't do anything on the General tab — route
9
+ // them to their profile (always available) so the default settings URL
10
+ // doesn't land them on a locked page.
11
+ if (auth?.type === 'session' && !hasCapability(auth, 'org.settings')) {
12
+ throw redirect(302, '/admin/settings/account');
13
+ }
14
+
15
+ return {};
16
+ };
@@ -0,0 +1,32 @@
1
+ <script lang="ts">
2
+ import * as Card from '@aphexcms/ui/shadcn/card';
3
+ import type { PageData } from './$types';
4
+ import OrganizationsSettings from './_components/OrganizationsSettings.svelte';
5
+
6
+ let { data }: { data: PageData } = $props();
7
+ </script>
8
+
9
+ <svelte:head>
10
+ <title>Aphex CMS - Organization Settings</title>
11
+ </svelte:head>
12
+
13
+ <div class="grid gap-6">
14
+ <div class="hidden sm:block">
15
+ <h2 class="text-xl font-semibold">General</h2>
16
+ <p class="text-muted-foreground text-sm">Manage your organization settings and preferences.</p>
17
+ </div>
18
+
19
+ {#if data.activeOrganization}
20
+ <OrganizationsSettings
21
+ activeOrganization={data.activeOrganization}
22
+ currentUserId={data.user.id}
23
+ />
24
+ {:else}
25
+ <Card.Root>
26
+ <Card.Content class="py-12 text-center">
27
+ <p class="text-muted-foreground text-lg">No active organization</p>
28
+ <p class="text-muted-foreground mt-2 text-sm">You need to be added to an organization</p>
29
+ </Card.Content>
30
+ </Card.Root>
31
+ {/if}
32
+ </div>
@@ -0,0 +1,177 @@
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 { Switch } from '@aphexcms/ui/shadcn/switch';
7
+ import * as Card from '@aphexcms/ui/shadcn/card';
8
+ import * as Avatar from '@aphexcms/ui/shadcn/avatar';
9
+ import { Separator } from '@aphexcms/ui/shadcn/separator';
10
+ import { invalidateAll } from '$app/navigation';
11
+ import type { CMSUser, UserSessionPreferences } from '@aphexcms/cms-core';
12
+ import { user as userApi } from '@aphexcms/cms-core/client';
13
+ import { Building2, Lock } from '@lucide/svelte';
14
+ import { toast } from 'svelte-sonner';
15
+
16
+ type Props = {
17
+ user: CMSUser;
18
+ userPreferences?: UserSessionPreferences | null;
19
+ hasChildOrganizations?: boolean;
20
+ };
21
+
22
+ let { user, userPreferences = null, hasChildOrganizations = false }: Props = $props();
23
+
24
+ let userName = $state(user.name || '');
25
+ let isUpdating = $state(false);
26
+ let includeChildOrganizations = $state(userPreferences?.includeChildOrganizations ?? false);
27
+ let isUpdatingPreferences = $state(false);
28
+
29
+ const userInitials = $derived(
30
+ user.name
31
+ ? user.name
32
+ .split(' ')
33
+ .map((w) => w[0])
34
+ .join('')
35
+ .toUpperCase()
36
+ .slice(0, 2)
37
+ : user.email[0].toUpperCase()
38
+ );
39
+
40
+ function getRoleBadgeVariant(role: string): 'default' | 'secondary' | 'outline' | 'destructive' {
41
+ switch (role) {
42
+ case 'super_admin':
43
+ return 'default';
44
+ case 'admin':
45
+ return 'secondary';
46
+ default:
47
+ return 'outline';
48
+ }
49
+ }
50
+
51
+ function formatRole(role: string): string {
52
+ return role.replace(/_/g, ' ');
53
+ }
54
+
55
+ async function updateProfile() {
56
+ if (!userName.trim()) {
57
+ toast.error('Please enter your name');
58
+ return;
59
+ }
60
+
61
+ isUpdating = true;
62
+ try {
63
+ const result = await userApi.updateProfile({ name: userName.trim() });
64
+
65
+ if (!result.success) {
66
+ throw new Error(result.error || result.message || 'Failed to update profile');
67
+ }
68
+
69
+ toast.success('Profile updated successfully');
70
+ await invalidateAll();
71
+ } catch (error) {
72
+ toast.error(error instanceof Error ? error.message : 'Failed to update profile');
73
+ } finally {
74
+ isUpdating = false;
75
+ }
76
+ }
77
+
78
+ async function updatePreferences(prefs: Partial<UserSessionPreferences>) {
79
+ isUpdatingPreferences = true;
80
+ try {
81
+ const result = await userApi.updatePreferences(prefs);
82
+
83
+ if (!result.success) {
84
+ throw new Error(result.error || result.message || 'Failed to update preferences');
85
+ }
86
+ } catch (error) {
87
+ toast.error(error instanceof Error ? error.message : 'Failed to update preferences');
88
+ // Revert on error
89
+ if (prefs.includeChildOrganizations !== undefined) {
90
+ includeChildOrganizations = !prefs.includeChildOrganizations;
91
+ }
92
+ } finally {
93
+ isUpdatingPreferences = false;
94
+ }
95
+ }
96
+ </script>
97
+
98
+ <div class="space-y-6">
99
+ <!-- Profile Information -->
100
+ <Card.Root>
101
+ <Card.Header>
102
+ <div class="flex items-center gap-4">
103
+ <Avatar.Root class="h-14 w-14 text-lg">
104
+ {#if user.image}
105
+ <Avatar.Image src={user.image} alt={user.name || user.email} />
106
+ {/if}
107
+ <Avatar.Fallback>{userInitials}</Avatar.Fallback>
108
+ </Avatar.Root>
109
+ <div class="min-w-0 flex-1">
110
+ <div class="flex items-center gap-2">
111
+ <Card.Title class="text-lg">{user.name || user.email}</Card.Title>
112
+ <Badge variant={getRoleBadgeVariant(user.role)} class="capitalize">
113
+ {formatRole(user.role)}
114
+ </Badge>
115
+ </div>
116
+ <p class="text-muted-foreground mt-0.5 text-sm">{user.email}</p>
117
+ </div>
118
+ </div>
119
+ </Card.Header>
120
+
121
+ <Card.Content>
122
+ <Separator class="mb-4" />
123
+
124
+ <div class="space-y-4">
125
+ <div>
126
+ <Label for="user-name">Display Name</Label>
127
+ <Input id="user-name" bind:value={userName} placeholder="Your name" class="mt-2" />
128
+ </div>
129
+ <div>
130
+ <Label for="user-email">Email</Label>
131
+ <div class="relative mt-1">
132
+ <Input id="user-email" type="email" value={user.email} disabled class="pr-9" />
133
+ <Lock
134
+ class="text-muted-foreground absolute top-1/2 right-3 h-3.5 w-3.5 -translate-y-1/2"
135
+ />
136
+ </div>
137
+ <p class="text-muted-foreground mt-1 text-xs">Managed by your authentication provider</p>
138
+ </div>
139
+ </div>
140
+ </Card.Content>
141
+ <Card.Footer class="border-t px-6 py-4">
142
+ <Button onclick={updateProfile} disabled={isUpdating}>
143
+ {isUpdating ? 'Saving...' : 'Save Changes'}
144
+ </Button>
145
+ </Card.Footer>
146
+ </Card.Root>
147
+
148
+ <!-- Preferences -->
149
+ {#if hasChildOrganizations}
150
+ <Card.Root>
151
+ <Card.Header>
152
+ <Card.Title>Content Preferences</Card.Title>
153
+ </Card.Header>
154
+ <Card.Content>
155
+ <div class="flex items-center justify-between">
156
+ <div class="flex items-center gap-3">
157
+ <Building2 class="text-muted-foreground h-5 w-5" />
158
+ <div>
159
+ <Label class="text-base font-medium">Include child organizations</Label>
160
+ <p class="text-muted-foreground text-sm">
161
+ Show documents from child organizations in your content lists
162
+ </p>
163
+ </div>
164
+ </div>
165
+ <Switch
166
+ checked={includeChildOrganizations}
167
+ disabled={isUpdatingPreferences}
168
+ onCheckedChange={(checked) => {
169
+ includeChildOrganizations = checked;
170
+ updatePreferences({ includeChildOrganizations: checked });
171
+ }}
172
+ />
173
+ </div>
174
+ </Card.Content>
175
+ </Card.Root>
176
+ {/if}
177
+ </div>