@acms-subir/core 0.3.9 → 0.3.11

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 (161) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/cli/common.mjs +2 -2
  3. package/cli/legacy.mjs +2 -1
  4. package/cli/main.mjs +17 -3
  5. package/cli/migrate.mjs +102 -0
  6. package/cli/migrations.generated.mjs +1044 -0
  7. package/cli/mobile-doctor.mjs +9 -0
  8. package/cli/monitor.mjs +189 -0
  9. package/cli/preview.mjs +1 -1
  10. package/deploy/Dockerfile +5 -1
  11. package/deploy/compose.yml +21 -0
  12. package/docs/live-video.md +1 -1
  13. package/docs/mobile.md +23 -2
  14. package/docs/theme-authoring.md +6 -0
  15. package/integration.mjs +74 -4
  16. package/migrate.mjs +5 -0
  17. package/package.json +9 -11
  18. package/src/actions/index.ts +11 -31
  19. package/src/components/AppAccess.tsx +117 -0
  20. package/src/components/DesignManager.tsx +29 -10
  21. package/src/components/EmailSettings.tsx +53 -11
  22. package/src/components/MonitoringSettings.tsx +112 -0
  23. package/src/components/ThemeTransfer.tsx +211 -0
  24. package/src/components/auth/ClerkSession.astro +80 -0
  25. package/src/components/auth/ReverifyForms.astro +61 -0
  26. package/src/components/auth/clerk.ts +169 -0
  27. package/src/components/import/ImportExport.tsx +16 -6
  28. package/src/components/import/runner.ts +3 -0
  29. package/src/layouts/Admin.astro +42 -12
  30. package/src/layouts/Site.astro +6 -1
  31. package/src/lib/api.ts +8 -4
  32. package/src/lib/auth/accounts.ts +94 -0
  33. package/src/lib/auth/config.ts +22 -0
  34. package/src/lib/auth/login-limit.ts +15 -0
  35. package/src/lib/auth/request.ts +161 -0
  36. package/src/lib/auth/store.ts +72 -0
  37. package/src/lib/auth.ts +3 -0
  38. package/src/lib/backup/archive.ts +26 -0
  39. package/src/lib/backup/content.ts +1 -1
  40. package/src/lib/backup/offsite.ts +173 -0
  41. package/src/lib/backup/store.ts +28 -0
  42. package/src/lib/booking/availability.ts +15 -2
  43. package/src/lib/booking/service.ts +53 -1
  44. package/src/lib/cache/config.ts +7 -0
  45. package/src/lib/cache/memory.ts +39 -0
  46. package/src/lib/cache/policy.ts +89 -0
  47. package/src/lib/cache/purge.ts +89 -0
  48. package/src/lib/calendar/admin.ts +41 -0
  49. package/src/lib/designPreview.ts +2 -2
  50. package/src/lib/dynamicBlocks.ts +1 -1
  51. package/src/lib/editor/auth.ts +35 -5
  52. package/src/lib/editor/http.ts +4 -1
  53. package/src/lib/editor/me.ts +4 -0
  54. package/src/lib/editor/openapi.ts +49 -1
  55. package/src/lib/editor/roles.ts +20 -0
  56. package/src/lib/editor/schedule.ts +340 -0
  57. package/src/lib/editor/schemas.ts +36 -1
  58. package/src/lib/editor/site-data.ts +110 -0
  59. package/src/lib/editor/store.ts +13 -7
  60. package/src/lib/editor/token-route.ts +2 -1
  61. package/src/lib/email/accounts.ts +4 -0
  62. package/src/lib/email/service.ts +9 -1
  63. package/src/lib/email/store.ts +10 -0
  64. package/src/lib/email/templates.ts +8 -1
  65. package/src/lib/email/types.ts +1 -0
  66. package/src/lib/forms/store.ts +5 -0
  67. package/src/lib/mobile/access-admin.ts +34 -0
  68. package/src/lib/mobile/access.ts +28 -0
  69. package/src/lib/mobile/calendar.ts +26 -13
  70. package/src/lib/mobile/content.ts +21 -1
  71. package/src/lib/mobile/forms.ts +77 -0
  72. package/src/lib/mobile/guest-store.ts +62 -0
  73. package/src/lib/mobile/guest.ts +160 -0
  74. package/src/lib/mobile/http.ts +6 -5
  75. package/src/lib/mobile/links.ts +69 -0
  76. package/src/lib/mobile/manage.ts +114 -0
  77. package/src/lib/mobile/openapi.ts +40 -4
  78. package/src/lib/mobile/route.ts +63 -4
  79. package/src/lib/mobile/schemas.ts +52 -1
  80. package/src/lib/mobile/search.ts +95 -0
  81. package/src/lib/mobile/site.ts +8 -2
  82. package/src/lib/monitoring.ts +171 -0
  83. package/src/lib/net.ts +82 -14
  84. package/src/lib/repo/migrate-runner.ts +96 -0
  85. package/src/lib/repo/migrations.ts +104 -0
  86. package/src/lib/repo/sql.ts +122 -44
  87. package/src/lib/repo/sqlite.ts +14 -1
  88. package/src/lib/repo/types.ts +31 -3
  89. package/src/lib/scheduler.ts +14 -0
  90. package/src/lib/schema.ts +35 -1
  91. package/src/lib/theme-transfer.ts +163 -0
  92. package/src/lib/theme.ts +5 -0
  93. package/src/lib/webhooks.ts +7 -2
  94. package/src/middleware.ts +41 -11
  95. package/src/pages/[...slug].astro +3 -2
  96. package/src/pages/account/[action].astro +6 -0
  97. package/src/pages/admin/app-auth.astro +21 -7
  98. package/src/pages/admin/calendar/resources/link.astro +2 -0
  99. package/src/pages/admin/design.astro +2 -1
  100. package/src/pages/admin/import.astro +5 -3
  101. package/src/pages/admin/index.astro +3 -2
  102. package/src/pages/admin/integrations.astro +3 -2
  103. package/src/pages/admin/login.astro +236 -25
  104. package/src/pages/admin/logout.ts +16 -2
  105. package/src/pages/admin/mobile.astro +6 -2
  106. package/src/pages/admin/settings.astro +43 -5
  107. package/src/pages/admin/users.astro +350 -59
  108. package/src/pages/api/backup.ts +10 -26
  109. package/src/pages/api/design/export.ts +72 -0
  110. package/src/pages/api/design/import.ts +41 -0
  111. package/src/pages/api/email/settings.ts +9 -5
  112. package/src/pages/api/health.ts +17 -0
  113. package/src/pages/api/live/oauth/youtube/callback.ts +5 -4
  114. package/src/pages/api/live/settings.ts +2 -2
  115. package/src/pages/api/media/upload.ts +2 -2
  116. package/src/pages/api/mobile/access.ts +22 -0
  117. package/src/pages/api/monitoring.ts +22 -0
  118. package/src/pages/api/ops/status.ts +2 -1
  119. package/src/pages/api/push/admin.ts +3 -2
  120. package/src/pages/api/restore.ts +6 -3
  121. package/src/pages/api/v1/admin/bookings/[id]/[action].ts +7 -1
  122. package/src/pages/api/v1/admin/bookings/index.ts +10 -2
  123. package/src/pages/api/v1/admin/calendars/[id].ts +16 -0
  124. package/src/pages/api/v1/admin/calendars/index.ts +12 -0
  125. package/src/pages/api/v1/admin/forms/[id]/responses.ts +7 -0
  126. package/src/pages/api/v1/admin/forms/index.ts +6 -0
  127. package/src/pages/api/v1/admin/links.ts +13 -0
  128. package/src/pages/api/v1/admin/resources/[id]/blocks/[blockId].ts +10 -0
  129. package/src/pages/api/v1/admin/resources/[id]/blocks/index.ts +12 -0
  130. package/src/pages/api/v1/admin/resources/[id]/index.ts +17 -0
  131. package/src/pages/api/v1/admin/resources/[id]/links/[linkId].ts +16 -0
  132. package/src/pages/api/v1/admin/resources/[id]/links/index.ts +12 -0
  133. package/src/pages/api/v1/admin/resources/index.ts +12 -0
  134. package/src/pages/api/v1/admin/schedule.ts +7 -0
  135. package/src/pages/api/v1/bookings/manage/availability.ts +6 -0
  136. package/src/pages/api/v1/bookings/manage/cancel.ts +6 -0
  137. package/src/pages/api/v1/bookings/manage/ics.ts +6 -0
  138. package/src/pages/api/v1/bookings/manage/index.ts +6 -0
  139. package/src/pages/api/v1/bookings/manage/reschedule.ts +6 -0
  140. package/src/pages/api/v1/calendars.ts +1 -1
  141. package/src/pages/api/v1/forms/[id]/index.ts +7 -0
  142. package/src/pages/api/v1/forms/[id]/responses.ts +6 -0
  143. package/src/pages/api/v1/guest/bookings.ts +6 -0
  144. package/src/pages/api/v1/guest/code.ts +6 -0
  145. package/src/pages/api/v1/guest/revoke.ts +6 -0
  146. package/src/pages/api/v1/guest/token.ts +6 -0
  147. package/src/pages/api/v1/links.ts +7 -0
  148. package/src/pages/api/v1/openapi.json.ts +1 -1
  149. package/src/pages/api/v1/push/topics.ts +1 -1
  150. package/src/pages/api/v1/resources.ts +1 -1
  151. package/src/pages/api/v1/search.ts +7 -0
  152. package/src/pages/api/v1/theme.ts +1 -1
  153. package/src/pages/blog/[slug].astro +3 -2
  154. package/src/pages/book/[resource]/[link].astro +3 -3
  155. package/src/pages/book/manage/[token].astro +11 -7
  156. package/src/pages/events/[slug].astro +3 -2
  157. package/src/pages/preview/[type]/[id].astro +3 -3
  158. package/src/styles/admin.css +169 -16
  159. package/src/styles/email-settings.css +6 -0
  160. package/src/virtual.d.ts +1 -0
  161. package/src/worker.ts +7 -1
@@ -2,7 +2,12 @@
2
2
  import Admin from '../../layouts/Admin.astro';
3
3
  import Modal from '../../components/Modal.astro';
4
4
  import { getRepo } from '../../lib/repo';
5
- import { hashPassword, validateSession, OWNER_ENV, SESSION_COOKIE } from '../../lib/auth';
5
+ import { hashPassword, OWNER_ENV } from '../../lib/auth';
6
+ import { getRequestAuth } from '../../lib/auth/request';
7
+ import { AUTH, clerkConfig } from '../../lib/auth/config';
8
+ import { changeAccountEmail, inviteClerkUser, linkClerkUser, revokeClerkInvitation, syncClerkEmails } from '../../lib/auth/accounts';
9
+ import type { AuthInvitation } from '../../lib/auth/store';
10
+ import ReverifyForms from '../../components/auth/ReverifyForms.astro';
6
11
  import { issueAccountToken } from '../../lib/email/accounts';
7
12
  import { kickEmail } from '../../lib/email/service';
8
13
  import { waitUntilFrom } from '../../lib/webhooks';
@@ -14,13 +19,26 @@ import { EDITOR } from '../../lib/editor/config';
14
19
  const repo = await getRepo(Astro.locals);
15
20
 
16
21
  // Only owners manage users. The env-password session is always owner.
17
- const sessionUserId = (await validateSession(Astro.cookies.get(SESSION_COOKIE)?.value, Astro.locals));
22
+ const auth = await getRequestAuth(Astro.request, Astro.cookies, Astro.locals);
23
+ const sessionUserId = auth.userId;
24
+ // Clerk mode: credentials live in Clerk; ACMS keeps accounts, roles and the
25
+ // explicit identity links. The issuer scopes invitations and links.
26
+ const clerkMode = AUTH.provider === 'clerk';
27
+ let issuer = '';
28
+ let clerkReady = !clerkMode;
29
+ if (clerkMode) {
30
+ try { issuer = clerkConfig(Astro.locals).issuer; clerkReady = true; } catch { clerkReady = false; }
31
+ }
18
32
  const sessionUser = sessionUserId && sessionUserId !== OWNER_ENV ? await repo.getUser(sessionUserId) : null;
19
33
  const isOwner = sessionUserId === OWNER_ENV || sessionUser?.role === 'owner';
20
34
 
21
35
  let error = '';
22
36
  let failedOp = '';
37
+ let failedId = '';
23
38
  let notice = '';
39
+ let needsVerification = false;
40
+ // changes that require a fresh Clerk verification (strict policy)
41
+ const SENSITIVE = new Set(['create', 'resend', 'revoke', 'update', 'delete', 'link', 'email']);
24
42
  // Signed-in editor apps: anyone signs out their own devices; owners anyone's.
25
43
  if (Astro.request.method === 'POST' && sessionUserId) {
26
44
  const form = await Astro.request.clone().formData();
@@ -46,14 +64,45 @@ if (Astro.request.method === 'POST' && sessionUserId) {
46
64
  }
47
65
  }
48
66
  if (Astro.url.searchParams.has('signedOut')) notice = 'Signed out. The app has to sign in again to make changes.';
67
+ if (Astro.url.searchParams.has('linked')) notice = 'Account linked. They can now sign in with Clerk; their apps were signed out.';
68
+ if (Astro.url.searchParams.has('emailChanged')) notice = clerkMode ? 'Email updated.' : 'Email updated. They were signed out everywhere and sign in with the new address.';
69
+ if (Astro.url.searchParams.has('invited')) notice = clerkMode ? 'Invitation sent. Clerk emails the sign-up link; it expires in seven days.' : 'Invitation queued.';
49
70
  if (Astro.request.method === 'POST' && isOwner && !error) {
50
71
  const form = await Astro.request.formData();
51
72
  const op = String(form.get('op'));
52
73
  failedOp = op;
74
+ failedId = String(form.get('id') ?? '');
53
75
  try {
54
76
  await requireSession(Astro.request,Astro.cookies,repo,true);
55
77
  if(Astro.request.headers.get('origin')!==Astro.url.origin)throw new Error('Cross-origin requests are not allowed.');
56
- if (op === 'create' || op === 'resend') {
78
+ if (clerkMode) {
79
+ if (!clerkReady) throw new Error('Clerk sign-in isn’t fully configured, so accounts can’t be changed right now.');
80
+ if (SENSITIVE.has(op) && !auth.reverified) {
81
+ needsVerification = true;
82
+ throw new Error('Changing users needs a sign-in from the last ten minutes. Sign in again, then retry.');
83
+ }
84
+ if (op === 'create' || op === 'resend') {
85
+ if (!await repo.email.rateLimit(`invite:${sessionUserId}`, 10, 60000)) throw new Error('Please wait before sending more invitations.');
86
+ let data = { email: String(form.get('email') || ''), name: String(form.get('name') || ''), role: form.get('role') as unknown };
87
+ if (op === 'resend') {
88
+ const previous = await repo.auth.invitation(issuer, String(form.get('id')));
89
+ if (!previous || previous.state === 'accepted') throw new Error('This invitation cannot be resent.');
90
+ // cancel the old one first so only the newest link can grant access
91
+ if (previous.state === 'pending' || previous.state === 'sending') await revokeClerkInvitation(repo, previous.id, Astro.locals);
92
+ data = { email: previous.email, name: previous.name, role: previous.role };
93
+ }
94
+ await inviteClerkUser(repo, data, Astro.locals);
95
+ return Astro.redirect('/admin/users?invited=1');
96
+ }
97
+ if (op === 'revoke') await revokeClerkInvitation(repo, String(form.get('id')), Astro.locals);
98
+ if (op === 'link') {
99
+ const id = String(form.get('id'));
100
+ await linkClerkUser(repo, id, String(form.get('subject') ?? '').trim(), Astro.locals);
101
+ return Astro.redirect('/admin/users?linked=1');
102
+ }
103
+ if (op === 'update' && String(form.get('password') ?? '')) throw new Error('Passwords are managed by Clerk.');
104
+ }
105
+ if (!clerkMode && (op === 'create' || op === 'resend')) {
57
106
  if(!await repo.email.rateLimit(`invite:${sessionUserId}`,10,60000))throw new Error('Please wait before sending more invitations.');
58
107
  const previous=op==='resend'?await repo.email.token(String(form.get('id'))):null;
59
108
  if(op==='resend'&&(!previous||previous.purpose!=='invite'||previous.used_by))throw new Error('This invitation cannot be resent.');
@@ -63,10 +112,15 @@ if (Astro.request.method === 'POST' && isOwner && !error) {
63
112
  role:invitedRole},Astro.locals);
64
113
  await kickEmail(repo,Astro.locals,waitUntilFrom(Astro.locals));
65
114
  }
66
- if(op==='revoke')await repo.email.revoke(String(form.get('id')));
115
+ if(!clerkMode&&op==='revoke')await repo.email.revoke(String(form.get('id')));
116
+ if (op === 'email') {
117
+ await changeAccountEmail(repo, String(form.get('id')), String(form.get('email') ?? ''), { notify: form.get('notify') === '1' }, Astro.locals);
118
+ return Astro.redirect('/admin/users?emailChanged=1');
119
+ }
67
120
  if (op === 'update') {
68
121
  const id = String(form.get('id'));
69
122
  const password = String(form.get('password') ?? '');
123
+ if (password && clerkMode) throw new Error('Passwords are managed by Clerk.');
70
124
  if (password && (password.length < 12 || password.length > 128)) throw new Error('Use a password between 12 and 128 characters.');
71
125
  await repo.updateUser(id, {
72
126
  name: String(form.get('name') ?? '').trim() || undefined,
@@ -78,6 +132,8 @@ if (Astro.request.method === 'POST' && isOwner && !error) {
78
132
  });
79
133
  }
80
134
  if (op === 'delete') {
135
+ // Clerk mode: your own account is your sign-in; another owner removes it
136
+ if (clerkMode && String(form.get('id')) === sessionUserId) throw new Error('You can’t delete your own account while signed in with it. Ask another owner to remove it.');
81
137
  await repo.deleteUser(String(form.get('id')));
82
138
  await repo.editor.revokeUserDevices(String(form.get('id')), 'user removed', Date.now());
83
139
  }
@@ -87,8 +143,20 @@ if (Astro.request.method === 'POST' && isOwner && !error) {
87
143
  }
88
144
  }
89
145
 
146
+ // Linked accounts follow their Clerk email; refresh before listing (best effort).
147
+ if (Astro.request.method === 'GET' && isOwner && clerkMode && clerkReady) {
148
+ try { await syncClerkEmails(repo, Astro.locals); } catch { /* Clerk unavailable: keep stored emails */ }
149
+ }
90
150
  const users = await repo.listUsers();
91
- const invitations = isOwner ? await repo.email.invitations() : [];
151
+ type Row = { id: string; email: string; role: AuthInvitation['role']; state: string; expiresAt: number; name?: string };
152
+ const invitations: Row[] = !isOwner ? [] : clerkMode ? (clerkReady ? await repo.auth.invitations(issuer) : []) : await repo.email.invitations();
153
+ // Clerk identity linked to each local account (owners see who can sign in)
154
+ const linked = new Map<string, string>(clerkMode && clerkReady
155
+ ? (await repo.auth.identities(issuer)).map((r) => [String(r.userId), String(r.subject)]) : []);
156
+ const stateLabel: Record<string, [string, string]> = {
157
+ sending: ['Sending', 'scheduled'], pending: ['Pending', 'draft'], accepted: ['Accepted', 'published'],
158
+ revoked: ['Revoked', 'archived'], expired: ['Expired', 'archived'], failed: ['Failed', 'edited'],
159
+ };
92
160
  const userById = new Map(users.map((u) => [u.id, u]));
93
161
  const devices = sessionUserId ? await repo.editor.listDevices(isOwner ? undefined : sessionUserId) : [];
94
162
  const showDevices = EDITOR.enabled || devices.length > 0;
@@ -101,25 +169,64 @@ const ago = (ms: number) => {
101
169
  const d = Math.round(h / 24); if (d < 45) return `${d} days ago`;
102
170
  return new Date(ms).toLocaleDateString('en', { month: 'short', day: 'numeric', year: 'numeric' });
103
171
  };
172
+ const initials = (name: string) => name.split(/\s+/).map((w) => w[0] ?? '').join('').slice(0, 2).toUpperCase() || '?';
173
+ const pendingInvites = invitations.filter((i) => i.state === 'pending' || i.state === 'sending').length;
174
+ // a failed drawer action re-renders with that person's drawer open
175
+ const drawerOpen = (id: string) => !!error && failedId === id && ['update', 'link', 'delete'].includes(failedOp);
176
+ const tab = failedOp === 'resend' || failedOp === 'revoke' ? 'invitations' : Astro.url.searchParams.has('signedOut') ? 'devices' : 'members';
104
177
  const day = (ms: number) => new Date(ms).toLocaleDateString('en', { month: 'short', day: 'numeric', year: 'numeric' });
105
178
  const deviceGroups = [...new Set(devices.map((d) => d.userId))].map((uid) => ({ user: userById.get(uid), userId: uid, list: devices.filter((d) => d.userId === uid) }));
106
179
  ---
107
180
 
108
181
  <Admin title="Users" section="users">
109
182
  <div class="page-head">
110
- <h1>Users</h1>
111
- {isOwner && <button class="btn primary" type="button" data-modal="invite-user">Invite user</button>}
183
+ <div>
184
+ <h1>Users</h1>
185
+ <p class="sub">{users.length} {users.length === 1 ? 'person' : 'people'}{clerkMode && isOwner && clerkReady && <> · {linked.size} connected to Clerk</>}</p>
186
+ </div>
187
+ {isOwner && clerkReady && <button class="btn primary" type="button" data-modal="invite-user">Invite user</button>}
112
188
  </div>
113
189
 
114
190
  {!isOwner && (
115
- <div class="flash error">Only owners can manage users. Ask an owner (or sign in with the owner password) to make changes.</div>
191
+ <div class="flash error">{clerkMode ? 'Only owners can manage users. Ask an owner to make changes.' : 'Only owners can manage users. Ask an owner (or sign in with the owner password) to make changes.'}</div>
192
+ )}
193
+ {error && (
194
+ <div class="flash error users-flash" role="alert">
195
+ <span>{error}</span>
196
+ {needsVerification && (
197
+ <span class="users-flash-actions">
198
+ <form class="inline-form" method="post" action="/admin/logout"><button class="btn sm" type="submit" data-acms-signin-again>Sign in again</button></form>
199
+ </span>
200
+ )}
201
+ </div>
116
202
  )}
117
- {error && <div class="flash error">{error}</div>}
118
- {notice && <div class="flash" role="status">{notice}</div>}
203
+ {notice && !error && <div class="flash" role="status">{notice}</div>}
204
+ {clerkMode && <ReverifyForms />}
119
205
 
120
- {isOwner && (
206
+ <section class="card auth-strip" aria-labelledby="auth-h">
207
+ <span class="auth-mark" aria-hidden="true">
208
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l7 3v5c0 4.5-3 8.3-7 10-4-1.7-7-5.5-7-10V6zM9 12l2 2 4-4" /></svg>
209
+ </span>
210
+ <div class="auth-main">
211
+ <h2 id="auth-h">{clerkMode ? 'Clerk' : 'Built-in'} sign-in
212
+ {clerkMode && <span class={`pill ${clerkReady ? 'published' : 'edited'}`}>{clerkReady ? 'Configured' : 'Needs configuration'}</span>}
213
+ </h2>
214
+ <p class="hint">
215
+ {clerkMode
216
+ ? 'Clerk handles passwords, verification and recovery. ACMS roles decide what each person can do; only invited or linked accounts get in.'
217
+ : 'People sign in with their email and a password they choose from their invitation. Password resets use your email settings.'}
218
+ {clerkMode && isOwner && clerkReady && ' Changes here ask you to verify it’s you.'}
219
+ </p>
220
+ {clerkMode && !clerkReady && (
221
+ <p class="hint">Set <code>PUBLIC_CLERK_PUBLISHABLE_KEY</code>, <code>CLERK_SECRET_KEY</code> and <code>CLERK_AUTHORIZED_PARTIES</code> in the deployment environment.</p>
222
+ )}
223
+ </div>
224
+ <p class="hint auth-where">Set in the deployment configuration</p>
225
+ </section>
226
+
227
+ {isOwner && clerkReady && (
121
228
  <Modal id="invite-user" title="Invite user" open={!!error && failedOp === 'create'}>
122
- <form method="post">
229
+ <form method="post" data-reverify>
123
230
  <input type="hidden" name="op" value="create" />
124
231
  <div class="field-row">
125
232
  <label class="field">
@@ -137,72 +244,210 @@ const deviceGroups = [...new Set(devices.map((d) => d.userId))].map((uid) => ({
137
244
  <span>Email</span>
138
245
  <input type="email" name="email" placeholder="email@example.com" required />
139
246
  </label>
140
- <p class="hint">They’ll receive a single-use invitation valid for seven days and choose their own password.</p>
247
+ <p class="hint">{clerkMode
248
+ ? 'Clerk emails them a sign-up link that’s valid for seven days. Their account gets this role once they accept.'
249
+ : 'They’ll receive a single-use invitation valid for seven days and choose their own password.'}</p>
141
250
  <div class="modal-foot">
142
251
  <button class="btn ghost" type="button" data-close>Cancel</button>
143
252
  <button class="btn primary" type="submit">Send invitation</button>
144
253
  </div>
145
- <p class="hint modal-note">They sign in at <code>/admin/login</code> with email + password. Choose a default role for their account. Owners also manage users.</p>
254
+ <p class="hint modal-note">{clerkMode
255
+ ? <>Already have an ACMS account? Open it in the list and link its Clerk user ID instead, so posts and roles stay attached.</>
256
+ : <>They sign in at <code>/admin/login</code> with email + password. Choose a default role for their account. Owners also manage users.</>}</p>
146
257
  </form>
147
258
  </Modal>
148
259
  )}
149
260
 
150
- {isOwner && <section class="card card-pad" style="margin-bottom:20px"><h2>Invitations</h2><p class="hint">Manage delivery in <a href="/admin/email">Email settings</a>. Resending revokes the previous link.</p>
151
- <table class="list"><thead><tr><th>Email</th><th>Role</th><th>Status</th><th>Actions</th></tr></thead><tbody>
152
- {invitations.map(i=><tr><td>{i.email}</td><td>{i.role}</td><td>{i.state}</td><td>{i.state!=='accepted'&&<form method="post" class="inline-form"><input type="hidden" name="id" value={i.id}/><button class="btn sm" name="op" value="resend">Resend</button>{i.state==='pending'&&<button class="btn sm ghost" name="op" value="revoke">Revoke</button>}</form>}</td></tr>)}
153
- {!invitations.length&&<tr><td colspan="4">No invitations yet.</td></tr>}
154
- </tbody></table></section>}
155
- <div class="card">
156
- <table class="list">
157
- <thead><tr><th>User</th><th>Role</th><th></th></tr></thead>
261
+ {(isOwner && clerkReady || showDevices) && (
262
+ <div class="seg users-tabs" role="tablist" aria-label="Users sections">
263
+ <button type="button" role="tab" id="tab-members" aria-controls="panel-members" aria-selected={tab === 'members' ? 'true' : 'false'} data-users-tab="members">Members <span class="count">{users.length}</span></button>
264
+ {isOwner && clerkReady && <button type="button" role="tab" id="tab-invitations" aria-controls="panel-invitations" aria-selected={tab === 'invitations' ? 'true' : 'false'} data-users-tab="invitations">Invitations{pendingInvites > 0 && <span class="count">{pendingInvites}</span>}</button>}
265
+ {showDevices && <button type="button" role="tab" id="tab-devices" aria-controls="panel-devices" aria-selected={tab === 'devices' ? 'true' : 'false'} data-users-tab="devices">Signed-in apps{devices.length > 0 && <span class="count">{devices.length}</span>}</button>}
266
+ </div>
267
+ )}
268
+
269
+ <section class="card users-panel" id="panel-members" role="tabpanel" aria-labelledby="tab-members" data-users-panel="members" hidden={tab !== 'members'}>
270
+ <table class="list users-table">
271
+ <thead><tr><th>Name</th><th>Role</th>{clerkMode && isOwner && clerkReady && <th>Sign-in</th>}<th><span class="sr-only">Actions</span></th></tr></thead>
158
272
  <tbody>
159
273
  {users.map((u) => (
160
- <tr>
274
+ <tr data-user-row={isOwner ? `user-${u.id}` : undefined}>
161
275
  <td>
162
- {isOwner ? (
163
- <form method="post" class="user-form">
164
- <input type="hidden" name="op" value="update" />
165
- <input type="hidden" name="id" value={u.id} />
166
- <input type="text" name="name" value={u.name} class="w-name" aria-label="Name" />
167
- <span class="t-sub user-email">{u.email}</span>
168
- <select name="role" class="w-role" aria-label="Role">
169
- {Role.options.map(role => <option value={role} selected={u.role === role}>{roleLabels[role]}</option>)}
170
- </select>
171
- <input type="password" name="password" placeholder="New password (optional)" class="w-pass" aria-label="New password" />
172
- <div class="user-form-row">
173
- <span class="t-sub">/author/</span>
174
- <input type="text" name="slug" value={u.slug} placeholder={authorSlug(u)} class="w-slug" aria-label="Author slug" />
175
- <input type="url" name="avatar" value={u.avatar} placeholder="Avatar URL (media library or https://…)" class="w-grow" aria-label="Avatar URL" />
176
- </div>
177
- <textarea name="bio" rows="2" placeholder="Short bio — shown on the author card under posts and on the author page" class="w-full" aria-label="Bio">{u.bio}</textarea>
178
- <button class="btn sm" type="submit">Save</button>
179
- <a class="btn sm ghost" href={`/author/${authorSlug(u)}`} target="_blank" rel="noopener">View profile ↗</a>
180
- </form>
181
- ) : (
182
- <>
183
- <span class="t-title">{u.name}</span>
184
- <div class="t-sub">{u.email}</div>
185
- </>
186
- )}
276
+ <div class="user-cell">
277
+ <span class="avatar user-av">{u.avatar ? <img src={u.avatar} alt="" loading="lazy" /> : initials(u.name)}</span>
278
+ <span class="user-who">
279
+ <span class="t-title">{u.name}{u.id === sessionUserId && <span class="user-you">You</span>}</span>
280
+ <span class="t-sub">{u.email}</span>
281
+ </span>
282
+ </div>
187
283
  </td>
188
- <td><span class="role-mark"><i class={u.role === 'owner' ? 'is-owner' : ''} aria-hidden="true"></i>{roleLabels[u.role]}</span></td>
284
+ <td><span class={`pill ${u.role === 'owner' ? 'draft' : 'scheduled'}`}>{roleLabels[u.role]}</span></td>
285
+ {clerkMode && isOwner && clerkReady && (
286
+ <td>{linked.has(u.id)
287
+ ? <span class="pill published" title={linked.get(u.id)}>Connected</span>
288
+ : <span class="pill archived">Not linked</span>}</td>
289
+ )}
189
290
  <td class="td-actions">
190
- {isOwner && (
191
- <form class="inline-form" method="post" onsubmit="return confirm('Delete this user? Their posts stay, attributed to no one.')">
192
- <input type="hidden" name="op" value="delete" />
193
- <input type="hidden" name="id" value={u.id} />
194
- <button class="btn sm ghost danger" type="submit">Delete</button>
195
- </form>
196
- )}
291
+ {isOwner && <button class="btn sm ghost" type="button" data-modal={`user-${u.id}`} aria-label={`Edit ${u.name}`}>Edit</button>}
197
292
  </td>
198
293
  </tr>
199
294
  ))}
200
- {users.length === 0 && <tr><td colspan="3" class="hint">No user accounts yet — the owner password from <code>.env</code> is the only login. Invite accounts above to give posts real author bylines.</td></tr>}
295
+ {users.length === 0 && <tr><td colspan="4" class="hint">{clerkMode
296
+ ? 'No user accounts yet. The owner set in CLERK_OWNER_USER_ID gets an account on first sign-in; invite everyone else.'
297
+ : <>No user accounts yet — the owner password from <code>.env</code> is the only login. Invite accounts to give posts real author bylines.</>}</td></tr>}
201
298
  </tbody>
202
299
  </table>
203
- </div>
300
+ </section>
301
+
302
+ {isOwner && users.map((u) => {
303
+ const isLinked = linked.has(u.id);
304
+ const self = clerkMode && u.id === sessionUserId;
305
+ const formId = `upd-${u.id}`;
306
+ return (
307
+ <>
308
+ <dialog class="modal drawer" id={`user-${u.id}`} aria-labelledby={`user-h-${u.id}`} open={drawerOpen(u.id)}>
309
+ <header class="drawer-head">
310
+ <span class="avatar drawer-av" data-avatar-preview>{u.avatar ? <img src={u.avatar} alt="" /> : initials(u.name)}</span>
311
+ <div class="drawer-title">
312
+ <h2 id={`user-h-${u.id}`}>{u.name}</h2>
313
+ <a class="t-sub" href={`/author/${authorSlug(u)}`} target="_blank" rel="noopener">/author/{authorSlug(u)} ↗</a>
314
+ </div>
315
+ <button class="modal-x" type="button" data-close aria-label="Close">✕</button>
316
+ </header>
317
+ <form id={formId} method="post" data-reverify>
318
+ <input type="hidden" name="op" value="update" />
319
+ <input type="hidden" name="id" value={u.id} />
320
+ </form>
321
+ <div class="drawer-body">
322
+ {drawerOpen(u.id) && <p class="flash error" role="alert">{error}</p>}
323
+ <section class="drawer-sec">
324
+ <h3>Account</h3>
325
+ <div class="drawer-kv">
326
+ <span class="k">Email</span>
327
+ <span class="v">
328
+ <span class="drawer-email">{u.email}</span>
329
+ {clerkMode && isLinked
330
+ ? <span class="pill archived" title="Change it in Clerk; ACMS updates it when this page loads.">Managed by Clerk</span>
331
+ : clerkReady && <button class="btn sm" type="button" data-modal={`email-${u.id}`}>Change</button>}
332
+ </span>
333
+ </div>
334
+ <label class="field">
335
+ <span>Role</span>
336
+ <select name="role" form={formId}>
337
+ {Role.options.map(role => <option value={role} selected={u.role === role}>{roleLabels[role]}</option>)}
338
+ </select>
339
+ </label>
340
+ {!clerkMode && (
341
+ <label class="field">
342
+ <span>New password <em class="hint">optional</em></span>
343
+ <input type="password" name="password" form={formId} placeholder="Leave blank to keep it" minlength="12" maxlength="128" autocomplete="new-password" />
344
+ </label>
345
+ )}
346
+ </section>
347
+
348
+ {clerkMode && clerkReady && (
349
+ <section class="drawer-sec">
350
+ <h3>Sign-in</h3>
351
+ <div class="drawer-kv">
352
+ <span class="k">Clerk account</span>
353
+ <span class="v">{isLinked
354
+ ? <span class="pill published" title={linked.get(u.id)}>Connected</span>
355
+ : <span class="pill archived">Not linked</span>}</span>
356
+ </div>
357
+ {!isLinked && (
358
+ <form method="post" class="drawer-link" data-reverify>
359
+ <input type="hidden" name="op" value="link" />
360
+ <input type="hidden" name="id" value={u.id} />
361
+ <input type="text" name="subject" placeholder="user_…" pattern="user_[A-Za-z0-9]+" required autocomplete="off" spellcheck="false" aria-label="Clerk user ID" aria-describedby={`link-hint-${u.id}`} />
362
+ <button class="btn sm" type="submit">Link</button>
363
+ <small class="hint" id={`link-hint-${u.id}`}>Paste their Clerk user ID from the Clerk dashboard. Posts and role stay with this account.</small>
364
+ </form>
365
+ )}
366
+ </section>
367
+ )}
368
+
369
+ <section class="drawer-sec">
370
+ <h3>Author profile</h3>
371
+ <label class="field">
372
+ <span>Display name</span>
373
+ <input type="text" name="name" form={formId} value={u.name} required data-avatar-name />
374
+ </label>
375
+ <label class="field">
376
+ <span>Profile URL</span>
377
+ <span class="slug-wrap"><span class="slug-prefix">/author/</span><input type="text" name="slug" form={formId} value={u.slug} placeholder={authorSlug(u)} /></span>
378
+ </label>
379
+ <label class="field">
380
+ <span>Avatar <em class="hint">image URL from the media library or https://</em></span>
381
+ <input type="url" name="avatar" form={formId} value={u.avatar} placeholder="https://…" data-avatar-input />
382
+ </label>
383
+ <label class="field">
384
+ <span>Bio</span>
385
+ <textarea name="bio" form={formId} rows="4" placeholder="Shown on the author card under posts and on the author page">{u.bio}</textarea>
386
+ </label>
387
+ </section>
388
+ </div>
389
+ <footer class="drawer-foot">
390
+ {!self && (
391
+ <form method="post" class="inline-form" data-reverify onsubmit="return confirm('Delete this user? Their posts stay, attributed to no one. Their apps are signed out.')">
392
+ <input type="hidden" name="op" value="delete" />
393
+ <input type="hidden" name="id" value={u.id} />
394
+ <button class="btn ghost danger" type="submit">Delete user</button>
395
+ </form>
396
+ )}
397
+ <span class="spacer"></span>
398
+ <button class="btn ghost" type="button" data-close>Cancel</button>
399
+ <button class="btn primary" type="submit" form={formId}>Save</button>
400
+ </footer>
401
+ </dialog>
402
+
403
+ {!(clerkMode && isLinked) && clerkReady && (
404
+ <Modal id={`email-${u.id}`} title="Change email" open={!!error && failedOp === 'email' && failedId === u.id}>
405
+ <form method="post" data-reverify>
406
+ <input type="hidden" name="op" value="email" />
407
+ <input type="hidden" name="id" value={u.id} />
408
+ {!!error && failedOp === 'email' && failedId === u.id && <p class="flash error" role="alert">{error}</p>}
409
+ <label class="field">
410
+ <span>Current</span>
411
+ <input type="email" value={u.email} readonly tabindex="-1" class="is-readonly" />
412
+ </label>
413
+ <label class="field">
414
+ <span>New email</span>
415
+ <input type="email" name="email" placeholder="name@example.com" required autocomplete="off" />
416
+ </label>
417
+ {clerkMode
418
+ ? <p class="callout">This account isn’t linked to Clerk yet, so the email is only used for notifications and the author page. Linking still uses their Clerk user ID.</p>
419
+ : <>
420
+ <p class="callout warn">This email is their login. They’ll be signed out everywhere, including the editor app, and sign in with the new address.</p>
421
+ <label class="check-row"><input type="checkbox" name="notify" value="1" checked /> <span>Let the previous address know about this change</span></label>
422
+ </>}
423
+ <div class="modal-foot">
424
+ <button class="btn ghost" type="button" data-close>Cancel</button>
425
+ <button class="btn primary" type="submit">Change email</button>
426
+ </div>
427
+ </form>
428
+ </Modal>
429
+ )}
430
+ </>
431
+ );
432
+ })}
433
+
434
+ {isOwner && clerkReady && <section class="card users-panel" id="panel-invitations" role="tabpanel" aria-labelledby="tab-invitations" data-users-panel="invitations" hidden={tab !== 'invitations'}>
435
+ <p class="hint users-panel-hint">{clerkMode
436
+ ? 'Clerk delivers these emails; your email settings aren’t needed for them. Resending cancels the previous link.'
437
+ : <>Manage delivery in <a href="/admin/email">Email settings</a>. Resending revokes the previous link.</>}</p>
438
+ <table class="list"><thead><tr><th>Email</th><th>Role</th><th>Status</th><th><span class="sr-only">Actions</span></th></tr></thead><tbody>
439
+ {invitations.map(i=>{
440
+ const [label, tone] = clerkMode ? (stateLabel[i.state] ?? [i.state, 'archived']) : [i.state, ''];
441
+ return <tr><td>{i.email}{clerkMode && i.name && i.name !== i.email && <div class="t-sub">{i.name}</div>}</td><td>{roleLabels[i.role] ?? i.role}</td>
442
+ <td>{clerkMode ? <span class={`pill ${tone}`}>{label}</span> : i.state}{clerkMode && i.state === 'pending' && <div class="t-sub">Expires {day(i.expiresAt)}</div>}</td>
443
+ <td class="td-actions">{i.state!=='accepted'&&<form method="post" class="inline-form" data-reverify><input type="hidden" name="id" value={i.id}/><button class="btn sm" name="op" value="resend">Resend</button>{(i.state==='pending'||(clerkMode&&i.state==='sending'))&&<button class="btn sm ghost" name="op" value="revoke">Revoke</button>}</form>}</td></tr>;
444
+ })}
445
+ {!invitations.length&&<tr><td colspan="4" class="hint">No invitations yet. Use <b>Invite user</b> to add someone.</td></tr>}
446
+ </tbody></table></section>}
447
+
204
448
  {showDevices && (
205
- <section class="card devices-card" id="devices" aria-labelledby="devices-h">
449
+ <section class="card devices-card users-panel" id="panel-devices" role="tabpanel" aria-labelledby="tab-devices" data-users-panel="devices" hidden={tab !== 'devices'}>
450
+ <span id="devices"></span>
206
451
  <header class="devices-head">
207
452
  <div>
208
453
  <h2 id="devices-h">Signed-in apps</h2>
@@ -252,4 +497,50 @@ const deviceGroups = [...new Set(devices.map((d) => d.userId))].map((uid) => ({
252
497
  ))}
253
498
  </section>
254
499
  )}
500
+
501
+ <script>
502
+ // Tabs: Members / Invitations / Signed-in apps (the server picks the first one).
503
+ const tabs = [...document.querySelectorAll<HTMLButtonElement>('[data-users-tab]')];
504
+ const select = (name: string, push = false) => {
505
+ if (!tabs.some((t) => t.dataset.usersTab === name)) return;
506
+ for (const t of tabs) t.setAttribute('aria-selected', String(t.dataset.usersTab === name));
507
+ for (const p of document.querySelectorAll<HTMLElement>('[data-users-panel]')) p.hidden = p.dataset.usersPanel !== name;
508
+ if (push) history.replaceState(null, '', name === 'members' ? location.pathname + location.search : `#${name}`);
509
+ };
510
+ for (const t of tabs) t.addEventListener('click', () => select(t.dataset.usersTab!, true));
511
+ if (location.hash) select(location.hash.slice(1));
512
+
513
+ // A click anywhere on a member row opens their drawer.
514
+ document.querySelectorAll<HTMLElement>('tr[data-user-row]').forEach((row) => {
515
+ row.addEventListener('click', (e) => {
516
+ if ((e.target as HTMLElement).closest('a, button, input, select, textarea')) return;
517
+ const dlg = document.getElementById(row.dataset.userRow!);
518
+ if (dlg instanceof HTMLDialogElement && !dlg.open) dlg.showModal();
519
+ });
520
+ });
521
+
522
+ for (const dlg of document.querySelectorAll<HTMLDialogElement>('dialog.drawer')) {
523
+ const preview = dlg.querySelector<HTMLElement>('[data-avatar-preview]');
524
+ const avatar = dlg.querySelector<HTMLInputElement>('[data-avatar-input]');
525
+ const name = dlg.querySelector<HTMLInputElement>('[data-avatar-name]');
526
+ const paint = () => {
527
+ if (!preview) return;
528
+ const url = avatar?.value.trim() ?? '';
529
+ if (url && avatar?.checkValidity()) {
530
+ const img = document.createElement('img');
531
+ img.src = url; img.alt = '';
532
+ preview.replaceChildren(img);
533
+ } else {
534
+ preview.textContent = (name?.value ?? '').split(/\s+/).map((w) => w[0] ?? '').join('').slice(0, 2).toUpperCase() || '?';
535
+ }
536
+ };
537
+ avatar?.addEventListener('input', paint);
538
+ name?.addEventListener('input', () => { if (!avatar?.value.trim()) paint(); });
539
+ // closing without saving drops unsaved edits
540
+ dlg.addEventListener('close', () => {
541
+ dlg.querySelectorAll<HTMLFormElement>('form').forEach((f) => f.reset());
542
+ paint();
543
+ });
544
+ }
545
+ </script>
255
546
  </Admin>
@@ -1,17 +1,14 @@
1
1
  import type { APIRoute } from 'astro';
2
- import { validateSession, OWNER_ENV, SESSION_COOKIE } from '../../lib/auth';
2
+ import { OWNER_ENV } from '../../lib/auth';
3
+ import { validateRequestSession } from '../../lib/auth/request';
3
4
  import { getRepo } from '../../lib/repo';
4
- import { getMediaStore } from '../../lib/media/store';
5
- import { exportContent } from '../../lib/backup/content';
6
- import { zipStream, type ZipEntry } from '../../lib/backup/zip';
5
+ import { backupArchive } from '../../lib/backup/archive';
7
6
 
8
- // Full-site backup as one ZIP download: content.json with every table,
9
- // fetched through the repo so sqlite and D1 both export the same shape,
10
- // plus the media binaries (originals + variants) under media/<key>.
11
- // Password hashes never leave the repo layer, so user accounts export
12
- // without them — after a restore, sign in with ADMIN_PASSWORD and reset.
13
- export const GET: APIRoute = async ({ cookies, locals }) => {
14
- const sessionUserId = (await validateSession(cookies.get(SESSION_COOKIE)?.value, locals));
7
+ // Full-site backup as one ZIP download (lib/backup/archive.ts). User
8
+ // accounts export without password hashes — after a restore, sign in with
9
+ // ADMIN_PASSWORD and reset.
10
+ export const GET: APIRoute = async ({ request, cookies, locals }) => {
11
+ const sessionUserId = (await validateRequestSession(request, cookies, locals));
15
12
  if (!sessionUserId) {
16
13
  return Response.json({ error: 'Unauthorized' }, { status: 401 });
17
14
  }
@@ -23,23 +20,10 @@ export const GET: APIRoute = async ({ cookies, locals }) => {
23
20
  }
24
21
 
25
22
  const now = Date.now();
26
- const content = await exportContent(repo, now);
27
- const { media } = content;
28
-
29
- const store = getMediaStore(locals);
30
- async function* entries(): AsyncGenerator<ZipEntry> {
31
- yield { name: 'content.json', data: new TextEncoder().encode(JSON.stringify(content, null, 2)) };
32
- for (const m of media) {
33
- for (const key of [m.key, ...m.variants.map((v) => v.key)]) {
34
- const obj = await store.get(key);
35
- if (!obj) throw new Error('Backup incomplete: a media object is missing.');
36
- yield { name: `media/${key}`, data: new Uint8Array(await new Response(obj.body).arrayBuffer()) };
37
- }
38
- }
39
- }
23
+ const archive = await backupArchive(repo, locals, now);
40
24
 
41
25
  const stamp = new Date(now).toISOString().slice(0, 10);
42
- return new Response(zipStream(entries(), now), {
26
+ return new Response(archive, {
43
27
  headers: {
44
28
  'Content-Type': 'application/zip',
45
29
  'Cache-Control': 'no-store',