@commonpub/layer 0.3.18 → 0.3.19

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.
@@ -7,6 +7,7 @@ defineProps<{
7
7
  senderAvatarUrl?: string | null;
8
8
  body: string;
9
9
  createdAt: string;
10
+ readAt?: string | null;
10
11
  }>;
11
12
  currentUserId: string;
12
13
  }>();
@@ -39,9 +40,15 @@ function handleSend(): void {
39
40
  <span v-if="msg.senderName" class="cpub-msg-name">{{ msg.senderName }}</span>
40
41
  </div>
41
42
  <div class="cpub-msg-bubble">{{ msg.body }}</div>
42
- <time class="cpub-msg-time">
43
- {{ new Date(msg.createdAt).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }) }}
44
- </time>
43
+ <div class="cpub-msg-meta">
44
+ <time class="cpub-msg-time">
45
+ {{ new Date(msg.createdAt).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }) }}
46
+ </time>
47
+ <span v-if="msg.senderId === currentUserId" class="cpub-msg-receipt" :class="{ 'cpub-msg-read': msg.readAt }" :title="msg.readAt ? `Read ${new Date(msg.readAt).toLocaleString()}` : 'Sent'">
48
+ <i :class="msg.readAt ? 'fa-solid fa-check-double' : 'fa-solid fa-check'" aria-hidden="true"></i>
49
+ <span class="sr-only">{{ msg.readAt ? 'Read' : 'Sent' }}</span>
50
+ </span>
51
+ </div>
45
52
  </div>
46
53
  <p v-if="!messages.length" class="cpub-thread-empty">No messages yet. Say hello!</p>
47
54
  </div>
@@ -125,17 +132,47 @@ function handleSend(): void {
125
132
  line-height: 1.5;
126
133
  }
127
134
 
128
- .cpub-msg.own .cpub-msg-bubble {
135
+ .cpub-msg.cpub-msg-own .cpub-msg-bubble {
129
136
  background: var(--accent-bg);
130
- border-color: var(--accent-border);
137
+ border-color: var(--accent-border, var(--border));
138
+ }
139
+
140
+ .cpub-msg-meta {
141
+ display: flex;
142
+ align-items: center;
143
+ gap: 4px;
144
+ margin-top: 2px;
145
+ }
146
+
147
+ .cpub-msg.cpub-msg-own .cpub-msg-meta {
148
+ justify-content: flex-end;
131
149
  }
132
150
 
133
151
  .cpub-msg-time {
134
152
  font-size: 9px;
135
153
  color: var(--text-faint);
136
154
  font-family: var(--font-mono);
137
- margin-top: 2px;
138
- display: block;
155
+ }
156
+
157
+ .cpub-msg-receipt {
158
+ font-size: 9px;
159
+ color: var(--text-faint);
160
+ }
161
+
162
+ .cpub-msg-receipt.cpub-msg-read {
163
+ color: var(--accent);
164
+ }
165
+
166
+ .sr-only {
167
+ position: absolute;
168
+ width: 1px;
169
+ height: 1px;
170
+ padding: 0;
171
+ margin: -1px;
172
+ overflow: hidden;
173
+ clip: rect(0, 0, 0, 0);
174
+ white-space: nowrap;
175
+ border-width: 0;
139
176
  }
140
177
 
141
178
  .cpub-thread-empty {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonpub/layer",
3
- "version": "0.3.18",
3
+ "version": "0.3.19",
4
4
  "type": "module",
5
5
  "main": "./nuxt.config.ts",
6
6
  "files": [
@@ -44,15 +44,15 @@
44
44
  "vue": "^3.4.0",
45
45
  "vue-router": "^4.3.0",
46
46
  "zod": "^4.3.6",
47
- "@commonpub/config": "0.7.0",
48
47
  "@commonpub/auth": "0.5.0",
49
48
  "@commonpub/docs": "0.5.2",
49
+ "@commonpub/config": "0.7.0",
50
50
  "@commonpub/editor": "0.5.0",
51
- "@commonpub/protocol": "0.9.4",
52
51
  "@commonpub/learning": "0.5.0",
52
+ "@commonpub/protocol": "0.9.4",
53
+ "@commonpub/server": "2.13.0",
53
54
  "@commonpub/schema": "0.8.12",
54
- "@commonpub/ui": "0.7.1",
55
- "@commonpub/server": "2.12.1"
55
+ "@commonpub/ui": "0.7.1"
56
56
  },
57
57
  "scripts": {}
58
58
  }
@@ -7,6 +7,7 @@ useSeoMeta({
7
7
  });
8
8
 
9
9
  const { signIn } = useAuth();
10
+ const { federation } = useFeatures();
10
11
  const route = useRoute();
11
12
 
12
13
  const identity = ref('');
@@ -14,6 +15,18 @@ const password = ref('');
14
15
  const error = ref('');
15
16
  const loading = ref(false);
16
17
 
18
+ // Federated login state
19
+ const federatedDomain = ref('');
20
+ const federatedLoading = ref(false);
21
+ const federatedError = ref('');
22
+
23
+ // Federated callback context — present when redirected back from OAuth callback.
24
+ // Only an opaque linkToken is passed; the verified identity stays server-side.
25
+ const federatedLinkToken = computed(() => {
26
+ if (route.query.federated !== 'true') return null;
27
+ return (route.query.linkToken as string) || null;
28
+ });
29
+
17
30
  const redirectTo = computed(() => {
18
31
  const raw = (route.query.redirect as string) || '/';
19
32
  if (raw.startsWith('/') && !raw.startsWith('//')) return raw;
@@ -25,15 +38,27 @@ async function handleSubmit(): Promise<void> {
25
38
  loading.value = true;
26
39
 
27
40
  try {
28
- // Step 1: Resolve username to email if needed (server-side)
29
- const { email } = await $fetch<{ email: string }>('/api/resolve-identity', {
30
- method: 'POST',
31
- body: { identity: identity.value },
32
- });
33
-
34
- // Step 2: Sign in with Better Auth (sets cookies directly)
35
- await signIn(email, password.value);
36
- await navigateTo(redirectTo.value);
41
+ if (federatedLinkToken.value) {
42
+ // Linking flow: authenticate + link federated account in one step
43
+ await $fetch('/api/auth/federated/link', {
44
+ method: 'POST',
45
+ body: {
46
+ identity: identity.value,
47
+ password: password.value,
48
+ linkToken: federatedLinkToken.value,
49
+ },
50
+ credentials: 'include',
51
+ });
52
+ await navigateTo('/dashboard');
53
+ } else {
54
+ // Normal login flow
55
+ const { email } = await $fetch<{ email: string }>('/api/resolve-identity', {
56
+ method: 'POST',
57
+ body: { identity: identity.value },
58
+ });
59
+ await signIn(email, password.value);
60
+ await navigateTo(redirectTo.value);
61
+ }
37
62
  } catch (err: unknown) {
38
63
  const fetchErr = err as { statusCode?: number; data?: { message?: string; statusMessage?: string } };
39
64
  if (fetchErr?.statusCode === 503) {
@@ -45,12 +70,48 @@ async function handleSubmit(): Promise<void> {
45
70
  loading.value = false;
46
71
  }
47
72
  }
73
+
74
+ async function handleFederatedLogin(): Promise<void> {
75
+ federatedError.value = '';
76
+ const domain = federatedDomain.value.trim().toLowerCase();
77
+ if (!domain) return;
78
+
79
+ federatedLoading.value = true;
80
+
81
+ try {
82
+ const result = await $fetch<{ authorizationUrl: string }>('/api/auth/federated/login', {
83
+ method: 'POST',
84
+ body: { instanceDomain: domain },
85
+ });
86
+ // Redirect to the remote instance's OAuth consent page
87
+ window.location.href = result.authorizationUrl;
88
+ } catch (err: unknown) {
89
+ const fetchErr = err as { statusCode?: number; data?: { message?: string; statusMessage?: string } };
90
+ if (fetchErr?.statusCode === 403) {
91
+ federatedError.value = `${domain} is not a trusted instance.`;
92
+ } else if (fetchErr?.statusCode === 502) {
93
+ federatedError.value = `Could not connect to ${domain}. Check the domain and try again.`;
94
+ } else {
95
+ federatedError.value = fetchErr?.data?.statusMessage || fetchErr?.data?.message || 'Failed to initiate federated login.';
96
+ }
97
+ } finally {
98
+ federatedLoading.value = false;
99
+ }
100
+ }
48
101
  </script>
49
102
 
50
103
  <template>
51
104
  <div class="login-page">
52
105
  <h1 class="login-title">Log in</h1>
53
106
 
107
+ <!-- Federated context banner — shown when linking an account -->
108
+ <div v-if="federatedLinkToken" class="cpub-federated-banner" role="status">
109
+ <p class="cpub-federated-banner-text">
110
+ Link your federated identity to a local account.
111
+ Log in below to complete the link.
112
+ </p>
113
+ </div>
114
+
54
115
  <form class="login-form" @submit.prevent="handleSubmit" aria-label="Login form">
55
116
  <div v-if="error" class="form-error" role="alert">{{ error }}</div>
56
117
 
@@ -81,12 +142,44 @@ async function handleSubmit(): Promise<void> {
81
142
  </div>
82
143
 
83
144
  <button type="submit" class="submit-btn" :disabled="loading">
84
- {{ loading ? 'Logging in...' : 'Log in' }}
145
+ {{ loading
146
+ ? 'Logging in...'
147
+ : federatedLinkToken
148
+ ? 'Log in & Link Account'
149
+ : 'Log in'
150
+ }}
85
151
  </button>
86
152
 
87
153
  <NuxtLink to="/auth/forgot-password" class="forgot-link">Forgot your password?</NuxtLink>
88
154
  </form>
89
155
 
156
+ <!-- Federated login section — only shown when federation is enabled -->
157
+ <div v-if="federation && !federatedLinkToken" class="cpub-federated-section">
158
+ <div class="cpub-federated-divider">
159
+ <span class="cpub-federated-divider-text">or</span>
160
+ </div>
161
+
162
+ <form class="cpub-federated-form" @submit.prevent="handleFederatedLogin" aria-label="Sign in with another instance">
163
+ <div v-if="federatedError" class="form-error" role="alert">{{ federatedError }}</div>
164
+
165
+ <label for="federated-domain" class="field-label">Sign in with another instance</label>
166
+ <div class="cpub-federated-input-group">
167
+ <input
168
+ id="federated-domain"
169
+ v-model="federatedDomain"
170
+ type="text"
171
+ class="field-input"
172
+ placeholder="instance.example.com"
173
+ required
174
+ autocomplete="off"
175
+ />
176
+ <button type="submit" class="cpub-federated-btn" :disabled="federatedLoading" aria-label="Sign in with remote instance">
177
+ {{ federatedLoading ? 'Connecting...' : 'Go' }}
178
+ </button>
179
+ </div>
180
+ </form>
181
+ </div>
182
+
90
183
  <p class="login-footer">
91
184
  Don't have an account?
92
185
  <NuxtLink to="/auth/register">Register</NuxtLink>
@@ -213,4 +306,91 @@ async function handleSubmit(): Promise<void> {
213
306
  color: var(--accent);
214
307
  text-decoration: underline;
215
308
  }
309
+
310
+ /* Federated login */
311
+ .cpub-federated-banner {
312
+ padding: var(--space-3);
313
+ background: var(--blue-bg, var(--surface-raised));
314
+ border: var(--border-width-default) solid var(--accent);
315
+ border-radius: var(--radius);
316
+ margin-bottom: var(--space-4);
317
+ }
318
+
319
+ .cpub-federated-banner-text {
320
+ font-size: 13px;
321
+ color: var(--text);
322
+ margin: 0;
323
+ line-height: 1.5;
324
+ }
325
+
326
+ .cpub-federated-banner-text code {
327
+ font-family: var(--font-mono);
328
+ font-size: 12px;
329
+ }
330
+
331
+ .cpub-federated-section {
332
+ margin-top: var(--space-5);
333
+ }
334
+
335
+ .cpub-federated-divider {
336
+ display: flex;
337
+ align-items: center;
338
+ gap: var(--space-3);
339
+ margin-bottom: var(--space-4);
340
+ }
341
+
342
+ .cpub-federated-divider::before,
343
+ .cpub-federated-divider::after {
344
+ content: '';
345
+ flex: 1;
346
+ height: 1px;
347
+ background: var(--border);
348
+ }
349
+
350
+ .cpub-federated-divider-text {
351
+ font-size: 11px;
352
+ font-family: var(--font-mono);
353
+ text-transform: uppercase;
354
+ letter-spacing: 0.06em;
355
+ color: var(--text-faint);
356
+ }
357
+
358
+ .cpub-federated-form {
359
+ display: flex;
360
+ flex-direction: column;
361
+ gap: var(--space-3);
362
+ }
363
+
364
+ .cpub-federated-input-group {
365
+ display: flex;
366
+ gap: var(--space-2);
367
+ }
368
+
369
+ .cpub-federated-input-group .field-input {
370
+ flex: 1;
371
+ }
372
+
373
+ .cpub-federated-btn {
374
+ padding: 7px 14px;
375
+ background: var(--surface-raised);
376
+ color: var(--text);
377
+ border: var(--border-width-default) solid var(--border);
378
+ border-radius: var(--radius);
379
+ font-size: 13px;
380
+ font-weight: 500;
381
+ font-family: var(--font-sans);
382
+ cursor: pointer;
383
+ white-space: nowrap;
384
+ transition: all 0.15s;
385
+ }
386
+
387
+ .cpub-federated-btn:hover:not(:disabled) {
388
+ border-color: var(--accent);
389
+ color: var(--accent);
390
+ }
391
+
392
+ .cpub-federated-btn:disabled {
393
+ opacity: 0.7;
394
+ cursor: not-allowed;
395
+ }
216
396
  </style>
@@ -8,9 +8,12 @@ useSeoMeta({
8
8
 
9
9
  const { user } = useAuth();
10
10
  const { learning: learningEnabled } = useFeatures();
11
+ const { enabledTypeMeta } = useContentTypes();
11
12
  const reqHeaders = import.meta.server ? useRequestHeaders(['cookie']) : {};
12
13
 
13
14
  const activeTab = ref<'content' | 'bookmarks' | 'learning'>('content');
15
+ const contentSort = ref<'newest' | 'oldest' | 'popular'>('newest');
16
+ const contentTypeFilter = ref('');
14
17
 
15
18
  // My content (all statuses)
16
19
  const { data: myContent, status: contentStatus } = await useFetch('/api/content', {
@@ -18,6 +21,11 @@ const { data: myContent, status: contentStatus } = await useFetch('/api/content'
18
21
  headers: reqHeaders,
19
22
  });
20
23
 
24
+ const contentTypeOptions = computed(() => [
25
+ { value: '', label: 'All types' },
26
+ ...enabledTypeMeta.value.map(m => ({ value: m.type, label: m.plural })),
27
+ ]);
28
+
21
29
  // Bookmarks
22
30
  const { data: bookmarkData } = await useFetch('/api/social/bookmarks', {
23
31
  query: { limit: 10 },
@@ -40,17 +48,42 @@ const { data: notifCount } = await useFetch('/api/notifications/count', {
40
48
 
41
49
  const toast = useToast();
42
50
 
51
+ function filterByType<T extends { type: string }>(items: T[]): T[] {
52
+ if (!contentTypeFilter.value) return items;
53
+ return items.filter((i) => i.type === contentTypeFilter.value);
54
+ }
55
+
56
+ function sortItems<T extends { createdAt: string; viewCount?: number; likeCount?: number }>(items: T[]): T[] {
57
+ const sorted = [...items];
58
+ if (contentSort.value === 'oldest') {
59
+ sorted.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
60
+ } else if (contentSort.value === 'popular') {
61
+ sorted.sort((a, b) => ((b.viewCount ?? 0) + (b.likeCount ?? 0)) - ((a.viewCount ?? 0) + (a.likeCount ?? 0)));
62
+ } else {
63
+ sorted.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
64
+ }
65
+ return sorted;
66
+ }
67
+
43
68
  const drafts = computed(() =>
44
- (myContent.value?.items ?? []).filter((i) => i.status === 'draft'),
69
+ sortItems(filterByType((myContent.value?.items ?? []).filter((i: { status: string }) => i.status === 'draft'))),
45
70
  );
46
71
  const published = computed(() =>
47
- (myContent.value?.items ?? []).filter((i) => i.status === 'published'),
72
+ sortItems(filterByType((myContent.value?.items ?? []).filter((i: { status: string }) => i.status === 'published'))),
73
+ );
74
+
75
+ // Stats use ALL items (unfiltered) so totals don't change with filter selection
76
+ const allPublished = computed(() =>
77
+ (myContent.value?.items ?? []).filter((i: { status: string }) => i.status === 'published'),
78
+ );
79
+ const allDrafts = computed(() =>
80
+ (myContent.value?.items ?? []).filter((i: { status: string }) => i.status === 'draft'),
48
81
  );
49
82
  const totalViews = computed(() =>
50
- published.value.reduce((sum, item) => sum + (item.viewCount ?? 0), 0),
83
+ allPublished.value.reduce((sum, item) => sum + (item.viewCount ?? 0), 0),
51
84
  );
52
85
  const totalLikes = computed(() =>
53
- published.value.reduce((sum, item) => sum + (item.likeCount ?? 0), 0),
86
+ allPublished.value.reduce((sum, item) => sum + (item.likeCount ?? 0), 0),
54
87
  );
55
88
 
56
89
  // Content actions
@@ -97,11 +130,11 @@ async function deleteItem(id: string, title: string): Promise<void> {
97
130
  <!-- Stats row -->
98
131
  <div class="cpub-dash-stats">
99
132
  <div class="cpub-dash-stat">
100
- <span class="cpub-dash-stat-n">{{ published.length }}</span>
133
+ <span class="cpub-dash-stat-n">{{ allPublished.length }}</span>
101
134
  <span class="cpub-dash-stat-l">Published</span>
102
135
  </div>
103
136
  <div class="cpub-dash-stat">
104
- <span class="cpub-dash-stat-n">{{ drafts.length }}</span>
137
+ <span class="cpub-dash-stat-n">{{ allDrafts.length }}</span>
105
138
  <span class="cpub-dash-stat-l">Drafts</span>
106
139
  </div>
107
140
  <div class="cpub-dash-stat">
@@ -149,6 +182,21 @@ async function deleteItem(id: string, title: string): Promise<void> {
149
182
 
150
183
  <!-- Content tab -->
151
184
  <div v-else-if="activeTab === 'content'" class="cpub-dash-panel">
185
+ <!-- Sort & filter controls -->
186
+ <div class="cpub-dash-controls">
187
+ <div class="cpub-dash-controls-left">
188
+ <select v-model="contentTypeFilter" class="cpub-dash-select" aria-label="Filter by content type">
189
+ <option v-for="opt in contentTypeOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
190
+ </select>
191
+ </div>
192
+ <div class="cpub-dash-controls-right">
193
+ <select v-model="contentSort" class="cpub-dash-select" aria-label="Sort order">
194
+ <option value="newest">Newest first</option>
195
+ <option value="oldest">Oldest first</option>
196
+ <option value="popular">Most popular</option>
197
+ </select>
198
+ </div>
199
+ </div>
152
200
  <!-- Drafts section -->
153
201
  <div v-if="drafts.length" class="cpub-dash-section">
154
202
  <h2 class="cpub-dash-section-title">Drafts</h2>
@@ -359,6 +407,39 @@ async function deleteItem(id: string, title: string): Promise<void> {
359
407
  border-bottom-color: var(--accent);
360
408
  }
361
409
 
410
+ /* Controls */
411
+ .cpub-dash-controls {
412
+ display: flex;
413
+ align-items: center;
414
+ justify-content: space-between;
415
+ padding: 10px 16px;
416
+ border-bottom: var(--border-width-default) solid var(--border);
417
+ gap: 8px;
418
+ }
419
+
420
+ .cpub-dash-controls-left,
421
+ .cpub-dash-controls-right {
422
+ display: flex;
423
+ align-items: center;
424
+ gap: 8px;
425
+ }
426
+
427
+ .cpub-dash-select {
428
+ font-family: var(--font-mono);
429
+ font-size: 10px;
430
+ padding: 4px 8px;
431
+ border: var(--border-width-default) solid var(--border);
432
+ background: var(--surface);
433
+ color: var(--text-dim);
434
+ cursor: pointer;
435
+ outline: none;
436
+ border-radius: var(--radius);
437
+ }
438
+
439
+ .cpub-dash-select:focus {
440
+ border-color: var(--accent);
441
+ }
442
+
362
443
  /* Panel */
363
444
  .cpub-dash-panel {
364
445
  background: var(--surface);
package/pages/explore.vue CHANGED
@@ -13,11 +13,14 @@ const activeTab = ref<'content' | 'hubs' | 'learn' | 'people'>('content');
13
13
  const contentType = ref('');
14
14
  const sort = ref('recent');
15
15
 
16
+ const CONTENT_PAGE_SIZE = 20;
17
+ const TAB_PAGE_SIZE = 12;
18
+
16
19
  const contentQuery = computed(() => ({
17
20
  status: 'published',
18
21
  type: contentType.value || undefined,
19
22
  sort: sort.value,
20
- limit: 20,
23
+ limit: CONTENT_PAGE_SIZE,
21
24
  }));
22
25
 
23
26
  const { data: content, pending: contentPending, error: contentError, refresh: refreshContent } = await useFetch<PaginatedResponse<Serialized<ContentListItem>>>('/api/content', {
@@ -25,26 +28,123 @@ const { data: content, pending: contentPending, error: contentError, refresh: re
25
28
  watch: [contentQuery],
26
29
  });
27
30
 
28
- const { data: hubsData } = await useFetch('/api/hubs', {
29
- query: { limit: 12 },
31
+ // Reset content pagination when filters change
32
+ const contentAllLoaded = ref(false);
33
+ const contentLoadingMore = ref(false);
34
+ watch([contentType, sort], () => { contentAllLoaded.value = false; });
35
+
36
+ async function loadMoreContent(): Promise<void> {
37
+ if (!content.value?.items) return;
38
+ contentLoadingMore.value = true;
39
+ try {
40
+ const more = await $fetch<PaginatedResponse<Serialized<ContentListItem>>>('/api/content', {
41
+ query: { ...contentQuery.value, offset: content.value.items.length },
42
+ });
43
+ if (more?.items?.length) {
44
+ content.value.items.push(...more.items);
45
+ }
46
+ if (!more?.items?.length || more.items.length < CONTENT_PAGE_SIZE) {
47
+ contentAllLoaded.value = true;
48
+ }
49
+ } catch {
50
+ contentAllLoaded.value = true;
51
+ } finally {
52
+ contentLoadingMore.value = false;
53
+ }
54
+ }
55
+
56
+ interface HubItem { id: string; slug: string; name: string; description: string | null; hubType: string; memberCount: number }
57
+ const { data: hubsData } = await useFetch<{ items: HubItem[]; total: number }>('/api/hubs', {
58
+ query: { limit: TAB_PAGE_SIZE },
30
59
  lazy: true,
31
60
  });
32
61
 
33
- const { data: pathsData } = await useFetch('/api/learn', {
34
- query: { status: 'published', limit: 12 },
62
+ const hubsAllLoaded = ref(false);
63
+ const hubsLoadingMore = ref(false);
64
+
65
+ async function loadMoreHubs(): Promise<void> {
66
+ if (!hubsData.value?.items) return;
67
+ hubsLoadingMore.value = true;
68
+ try {
69
+ const more = await $fetch<{ items: HubItem[]; total: number }>('/api/hubs', {
70
+ query: { limit: TAB_PAGE_SIZE, offset: hubsData.value.items.length },
71
+ });
72
+ if (more?.items?.length) {
73
+ hubsData.value.items.push(...more.items);
74
+ }
75
+ if (!more?.items?.length || more.items.length < TAB_PAGE_SIZE) {
76
+ hubsAllLoaded.value = true;
77
+ }
78
+ } catch {
79
+ hubsAllLoaded.value = true;
80
+ } finally {
81
+ hubsLoadingMore.value = false;
82
+ }
83
+ }
84
+
85
+ interface PathItem { id: string; slug: string; title: string; description: string | null; moduleCount: number; enrollmentCount: number }
86
+ const { data: pathsData } = await useFetch<{ items: PathItem[]; total: number }>('/api/learn', {
87
+ query: { status: 'published', limit: TAB_PAGE_SIZE },
35
88
  lazy: true,
36
89
  });
37
90
 
91
+ const pathsAllLoaded = ref(false);
92
+ const pathsLoadingMore = ref(false);
93
+
94
+ async function loadMorePaths(): Promise<void> {
95
+ if (!pathsData.value?.items) return;
96
+ pathsLoadingMore.value = true;
97
+ try {
98
+ const more = await $fetch<{ items: PathItem[]; total: number }>('/api/learn', {
99
+ query: { status: 'published', limit: TAB_PAGE_SIZE, offset: pathsData.value.items.length },
100
+ });
101
+ if (more?.items?.length) {
102
+ pathsData.value.items.push(...more.items);
103
+ }
104
+ if (!more?.items?.length || more.items.length < TAB_PAGE_SIZE) {
105
+ pathsAllLoaded.value = true;
106
+ }
107
+ } catch {
108
+ pathsAllLoaded.value = true;
109
+ } finally {
110
+ pathsLoadingMore.value = false;
111
+ }
112
+ }
113
+
38
114
  const { data: statsData } = await useFetch('/api/stats', {
39
115
  lazy: true,
40
116
  });
41
117
 
42
- const { data: peopleData } = await useFetch('/api/users', {
43
- query: { limit: 20 },
118
+ interface PersonItem { id: string; username: string; displayName: string | null; headline: string | null; avatarUrl: string | null; followerCount: number }
119
+ const { data: peopleData } = await useFetch<{ items: PersonItem[]; total: number }>('/api/users', {
120
+ query: { limit: TAB_PAGE_SIZE },
44
121
  lazy: true,
45
- default: () => ({ items: [] }),
122
+ default: () => ({ items: [], total: 0 }),
46
123
  });
47
124
 
125
+ const peopleAllLoaded = ref(false);
126
+ const peopleLoadingMore = ref(false);
127
+
128
+ async function loadMorePeople(): Promise<void> {
129
+ if (!peopleData.value?.items) return;
130
+ peopleLoadingMore.value = true;
131
+ try {
132
+ const more = await $fetch<{ items: PersonItem[]; total: number }>('/api/users', {
133
+ query: { limit: TAB_PAGE_SIZE, offset: peopleData.value.items.length },
134
+ });
135
+ if (more?.items?.length) {
136
+ peopleData.value.items.push(...more.items);
137
+ }
138
+ if (!more?.items?.length || more.items.length < TAB_PAGE_SIZE) {
139
+ peopleAllLoaded.value = true;
140
+ }
141
+ } catch {
142
+ peopleAllLoaded.value = true;
143
+ } finally {
144
+ peopleLoadingMore.value = false;
145
+ }
146
+ }
147
+
48
148
  const contentTypes = computed(() => [
49
149
  { value: '', label: 'All' },
50
150
  ...enabledTypeMeta.value.map(m => ({ value: m.type, label: m.plural })),
@@ -125,6 +225,11 @@ const sortOptions = [
125
225
  <div v-else class="cpub-empty-state">
126
226
  <p class="cpub-empty-state-title">No content found</p>
127
227
  </div>
228
+ <div v-if="!contentAllLoaded && (content?.items?.length ?? 0) >= CONTENT_PAGE_SIZE" class="cpub-explore-more">
229
+ <button class="cpub-btn" @click="loadMoreContent" :disabled="contentLoadingMore">
230
+ {{ contentLoadingMore ? 'Loading...' : 'Load More' }}
231
+ </button>
232
+ </div>
128
233
  </div>
129
234
 
130
235
  <!-- Hubs tab -->
@@ -152,6 +257,11 @@ const sortOptions = [
152
257
  <div v-else class="cpub-empty-state">
153
258
  <p class="cpub-empty-state-title">No hubs yet</p>
154
259
  </div>
260
+ <div v-if="!hubsAllLoaded && (hubsData?.items?.length ?? 0) >= TAB_PAGE_SIZE" class="cpub-explore-more">
261
+ <button class="cpub-btn" @click="loadMoreHubs" :disabled="hubsLoadingMore">
262
+ {{ hubsLoadingMore ? 'Loading...' : 'Load More' }}
263
+ </button>
264
+ </div>
155
265
  </div>
156
266
 
157
267
  <!-- Learn tab -->
@@ -177,6 +287,11 @@ const sortOptions = [
177
287
  <div v-else class="cpub-empty-state">
178
288
  <p class="cpub-empty-state-title">No learning paths yet</p>
179
289
  </div>
290
+ <div v-if="!pathsAllLoaded && (pathsData?.items?.length ?? 0) >= TAB_PAGE_SIZE" class="cpub-explore-more">
291
+ <button class="cpub-btn" @click="loadMorePaths" :disabled="pathsLoadingMore">
292
+ {{ pathsLoadingMore ? 'Loading...' : 'Load More' }}
293
+ </button>
294
+ </div>
180
295
  </div>
181
296
 
182
297
  <!-- People tab -->
@@ -188,7 +303,7 @@ const sortOptions = [
188
303
  :to="`/u/${person.username}`"
189
304
  class="cpub-explore-hub-card"
190
305
  >
191
- <div class="cpub-explore-hub-icon" style="border-radius: 50%">
306
+ <div class="cpub-explore-hub-icon">
192
307
  <img v-if="person.avatarUrl" :src="person.avatarUrl" :alt="person.displayName || person.username" class="cpub-explore-person-avatar-img" />
193
308
  <span v-else style="font-weight: 700; font-family: var(--font-mono);">{{ (person.displayName || person.username).charAt(0).toUpperCase() }}</span>
194
309
  </div>
@@ -204,6 +319,11 @@ const sortOptions = [
204
319
  <div v-else class="cpub-empty-state">
205
320
  <p class="cpub-empty-state-title">No makers yet</p>
206
321
  </div>
322
+ <div v-if="!peopleAllLoaded && (peopleData?.items?.length ?? 0) >= TAB_PAGE_SIZE" class="cpub-explore-more">
323
+ <button class="cpub-btn" @click="loadMorePeople" :disabled="peopleLoadingMore">
324
+ {{ peopleLoadingMore ? 'Loading...' : 'Load More' }}
325
+ </button>
326
+ </div>
207
327
  </div>
208
328
  </div>
209
329
  </template>
@@ -414,6 +534,11 @@ const sortOptions = [
414
534
  /* cpub-tag → global components.css */
415
535
  .cpub-tag-xs { font-size: 9px; }
416
536
 
537
+ .cpub-explore-more {
538
+ text-align: center;
539
+ padding: 24px 0;
540
+ }
541
+
417
542
  @media (max-width: 768px) {
418
543
  .cpub-explore-grid { grid-template-columns: 1fr; }
419
544
  .cpub-explore-hub-grid { grid-template-columns: 1fr; }
@@ -20,6 +20,7 @@ interface MessageItem {
20
20
  senderAvatarUrl?: string | null;
21
21
  body: string;
22
22
  createdAt: string;
23
+ readAt?: string | null;
23
24
  }
24
25
 
25
26
  const { data: convInfo } = useLazyFetch<{ id: string; participants: ConvParticipant[] }>(`/api/messages/${conversationId}/info`, {
@@ -128,8 +129,6 @@ async function handleSend(text: string): Promise<void> {
128
129
  }
129
130
 
130
131
  @media (max-width: 768px) {
131
- .msg-page { padding: 12px; }
132
- .msg-scroll { height: calc(100vh - 160px); }
133
- .msg-compose { padding: 8px; }
132
+ .cpub-message-view { height: calc(100vh - 100px); }
134
133
  }
135
134
  </style>
@@ -14,26 +14,48 @@ interface ConversationItem {
14
14
  lastMessage: string | null;
15
15
  lastMessageAt: string;
16
16
  createdAt: string;
17
- unread?: boolean;
17
+ unreadCount?: number;
18
18
  }
19
19
 
20
+ const { user } = useAuth();
20
21
  const { data: conversations, refresh } = await useFetch<ConversationItem[]>('/api/messages', {
21
22
  default: () => [] as ConversationItem[],
22
23
  });
23
24
 
25
+ /** Filter out the current user from participants for display */
26
+ function otherParticipants(conv: ConversationItem): ParticipantRef[] {
27
+ const others = conv.participants.filter(p => p.username !== user.value?.username);
28
+ return others.length > 0 ? others : conv.participants;
29
+ }
30
+
24
31
  const showNewDialog = ref(false);
25
- const newRecipient = ref('');
32
+ const newRecipientInput = ref('');
33
+ const newRecipients = ref<string[]>([]);
26
34
  const newMessage = ref('');
27
35
 
28
36
  const msgError = ref('');
29
37
 
38
+ function addRecipient(): void {
39
+ const val = newRecipientInput.value.trim();
40
+ if (!val) return;
41
+ if (newRecipients.value.includes(val)) return;
42
+ newRecipients.value.push(val);
43
+ newRecipientInput.value = '';
44
+ }
45
+
46
+ function removeRecipient(idx: number): void {
47
+ newRecipients.value.splice(idx, 1);
48
+ }
49
+
30
50
  async function startConversation(): Promise<void> {
31
- if (!newRecipient.value.trim()) return;
51
+ // Add any pending input as a recipient
52
+ if (newRecipientInput.value.trim()) addRecipient();
53
+ if (!newRecipients.value.length) return;
32
54
  msgError.value = '';
33
55
  try {
34
56
  const conv = await $fetch<{ id: string }>('/api/messages', {
35
57
  method: 'POST',
36
- body: { participants: [newRecipient.value.trim()] },
58
+ body: { participants: newRecipients.value },
37
59
  });
38
60
  if (newMessage.value.trim()) {
39
61
  await $fetch(`/api/messages/${conv.id}` as string, {
@@ -42,7 +64,8 @@ async function startConversation(): Promise<void> {
42
64
  });
43
65
  }
44
66
  showNewDialog.value = false;
45
- newRecipient.value = '';
67
+ newRecipients.value = [];
68
+ newRecipientInput.value = '';
46
69
  newMessage.value = '';
47
70
  await navigateTo(`/messages/${conv.id}`);
48
71
  } catch (err: unknown) {
@@ -62,7 +85,7 @@ async function startConversation(): Promise<void> {
62
85
  </div>
63
86
 
64
87
  <!-- New conversation dialog -->
65
- <div v-if="showNewDialog" class="cpub-new-msg-overlay" @click.self="showNewDialog = false">
88
+ <div v-if="showNewDialog" class="cpub-new-msg-overlay" @click.self="showNewDialog = false" @keydown.escape="showNewDialog = false">
66
89
  <div class="cpub-new-msg-dialog" role="dialog" aria-label="New message">
67
90
  <div class="cpub-new-msg-header">
68
91
  <h2 class="cpub-new-msg-title">New Conversation</h2>
@@ -71,9 +94,24 @@ async function startConversation(): Promise<void> {
71
94
  </button>
72
95
  </div>
73
96
  <div class="cpub-new-msg-body">
97
+ <div v-if="msgError" class="cpub-new-msg-error" role="alert">{{ msgError }}</div>
74
98
  <div class="cpub-new-msg-field">
75
- <label class="cpub-new-msg-label">Recipient username</label>
76
- <input v-model="newRecipient" type="text" class="cpub-new-msg-input" placeholder="username or @user@remote-instance.com" />
99
+ <label class="cpub-new-msg-label">Recipients</label>
100
+ <div v-if="newRecipients.length" class="cpub-new-msg-chips">
101
+ <span v-for="(r, idx) in newRecipients" :key="idx" class="cpub-new-msg-chip">
102
+ {{ r }}
103
+ <button class="cpub-new-msg-chip-remove" @click="removeRecipient(idx)" :aria-label="`Remove ${r}`">&times;</button>
104
+ </span>
105
+ </div>
106
+ <input
107
+ v-model="newRecipientInput"
108
+ type="text"
109
+ class="cpub-new-msg-input"
110
+ placeholder="username or @user@remote-instance.com"
111
+ @keydown.enter.prevent="addRecipient"
112
+ @keydown.,.prevent="addRecipient"
113
+ />
114
+ <p class="cpub-new-msg-hint">Press Enter to add multiple recipients</p>
77
115
  </div>
78
116
  <div class="cpub-new-msg-field">
79
117
  <label class="cpub-new-msg-label">Message (optional)</label>
@@ -84,7 +122,7 @@ async function startConversation(): Promise<void> {
84
122
  <button class="cpub-btn cpub-btn-sm" @click="showNewDialog = false">Cancel</button>
85
123
  <button
86
124
  class="cpub-btn cpub-btn-sm cpub-btn-primary"
87
- :disabled="!newRecipient.trim()"
125
+ :disabled="!newRecipients.length && !newRecipientInput.trim()"
88
126
  @click="startConversation"
89
127
  >
90
128
  Start Conversation
@@ -99,19 +137,32 @@ async function startConversation(): Promise<void> {
99
137
  :key="conv.id"
100
138
  :to="`/messages/${conv.id}`"
101
139
  class="cpub-conversation-item"
102
- :class="{ unread: conv.unread }"
140
+ :class="{ 'cpub-conv-unread': (conv.unreadCount ?? 0) > 0 }"
141
+ :aria-label="`Conversation with ${otherParticipants(conv).map(p => p.displayName || p.username).join(', ')}${(conv.unreadCount ?? 0) > 0 ? `, ${conv.unreadCount} unread` : ''}`"
103
142
  >
104
- <div class="cpub-conv-avatar">
105
- <img v-if="conv.participants?.[0]?.avatarUrl" :src="conv.participants[0].avatarUrl" :alt="conv.participants[0].displayName || conv.participants[0].username" class="cpub-conv-avatar-img" />
106
- <span v-else>{{ (conv.participants?.[0]?.displayName || conv.participants?.[0]?.username || '?').charAt(0).toUpperCase() }}</span>
143
+ <div v-if="otherParticipants(conv).length <= 1" class="cpub-conv-avatar">
144
+ <img v-if="otherParticipants(conv)[0]?.avatarUrl" :src="otherParticipants(conv)[0].avatarUrl!" :alt="otherParticipants(conv)[0].displayName ?? otherParticipants(conv)[0].username ?? ''" class="cpub-conv-avatar-img" />
145
+ <span v-else>{{ (otherParticipants(conv)[0]?.displayName || otherParticipants(conv)[0]?.username || '?').charAt(0).toUpperCase() }}</span>
146
+ </div>
147
+ <div v-else class="cpub-conv-avatar-group">
148
+ <div v-for="(p, idx) in otherParticipants(conv).slice(0, 4)" :key="idx" class="cpub-conv-avatar-mini">
149
+ <img v-if="p.avatarUrl" :src="p.avatarUrl" :alt="p.displayName || p.username" class="cpub-conv-avatar-img" />
150
+ <span v-else>{{ (p.displayName || p.username || '?').charAt(0).toUpperCase() }}</span>
151
+ </div>
107
152
  </div>
108
153
  <div class="cpub-conv-info">
109
- <div class="cpub-conv-name">{{ conv.participants?.map(p => p.displayName || p.username).join(', ') ?? 'Unknown' }}</div>
154
+ <div class="cpub-conv-name">
155
+ {{ otherParticipants(conv).map(p => p.displayName || p.username).join(', ') }}
156
+ <span v-if="otherParticipants(conv).length > 2" class="cpub-conv-group-count">({{ otherParticipants(conv).length }})</span>
157
+ </div>
110
158
  <div class="cpub-conv-preview">{{ conv.lastMessage ?? 'No messages yet' }}</div>
111
159
  </div>
112
- <time class="cpub-conv-time">
113
- {{ new Date(conv.lastMessageAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) }}
114
- </time>
160
+ <div class="cpub-conv-meta">
161
+ <time class="cpub-conv-time">
162
+ {{ new Date(conv.lastMessageAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) }}
163
+ </time>
164
+ <span v-if="(conv.unreadCount ?? 0) > 0" class="cpub-conv-badge" :aria-label="`${conv.unreadCount} unread messages`">{{ conv.unreadCount }}</span>
165
+ </div>
115
166
  </NuxtLink>
116
167
 
117
168
  <div v-if="!conversations.length" class="cpub-empty-state">
@@ -156,14 +207,13 @@ async function startConversation(): Promise<void> {
156
207
  background: var(--surface2);
157
208
  }
158
209
 
159
- .cpub-conversation-item.unread {
210
+ .cpub-conversation-item.cpub-conv-unread {
160
211
  background: var(--accent-bg);
161
212
  }
162
213
 
163
214
  .cpub-conv-avatar {
164
215
  width: 36px;
165
216
  height: 36px;
166
- border-radius: 50%;
167
217
  background: var(--surface3);
168
218
  border: var(--border-width-default) solid var(--border);
169
219
  display: flex;
@@ -177,7 +227,41 @@ async function startConversation(): Promise<void> {
177
227
  overflow: hidden;
178
228
  }
179
229
 
180
- .cpub-conv-avatar-img { width: 100%; height: 100%; object-fit: cover; border-radius: inherit; }
230
+ .cpub-conv-avatar-img { width: 100%; height: 100%; object-fit: cover; }
231
+
232
+ /* Group avatar stack */
233
+ .cpub-conv-avatar-group {
234
+ display: grid;
235
+ grid-template-columns: 1fr 1fr;
236
+ grid-template-rows: 1fr 1fr;
237
+ width: 36px;
238
+ height: 36px;
239
+ gap: 1px;
240
+ flex-shrink: 0;
241
+ border: var(--border-width-default) solid var(--border);
242
+ overflow: hidden;
243
+ }
244
+
245
+ .cpub-conv-avatar-mini {
246
+ background: var(--surface3);
247
+ display: flex;
248
+ align-items: center;
249
+ justify-content: center;
250
+ font-family: var(--font-mono);
251
+ font-size: 8px;
252
+ font-weight: 600;
253
+ color: var(--text-dim);
254
+ overflow: hidden;
255
+ }
256
+
257
+ .cpub-conv-avatar-mini .cpub-conv-avatar-img { width: 100%; height: 100%; object-fit: cover; }
258
+
259
+ .cpub-conv-group-count {
260
+ font-size: 10px;
261
+ color: var(--text-faint);
262
+ font-family: var(--font-mono);
263
+ font-weight: 400;
264
+ }
181
265
 
182
266
  .cpub-conv-info {
183
267
  flex: 1;
@@ -199,11 +283,33 @@ async function startConversation(): Promise<void> {
199
283
  white-space: nowrap;
200
284
  }
201
285
 
286
+ .cpub-conv-meta {
287
+ display: flex;
288
+ flex-direction: column;
289
+ align-items: flex-end;
290
+ gap: 4px;
291
+ flex-shrink: 0;
292
+ }
293
+
202
294
  .cpub-conv-time {
203
295
  font-size: 10px;
204
296
  color: var(--text-faint);
205
297
  font-family: var(--font-mono);
206
- flex-shrink: 0;
298
+ }
299
+
300
+ .cpub-conv-badge {
301
+ display: inline-flex;
302
+ align-items: center;
303
+ justify-content: center;
304
+ min-width: 18px;
305
+ height: 18px;
306
+ padding: 0 5px;
307
+ background: var(--accent);
308
+ color: var(--color-text-inverse);
309
+ font-family: var(--font-mono);
310
+ font-size: 10px;
311
+ font-weight: 700;
312
+ border-radius: var(--radius);
207
313
  }
208
314
 
209
315
  /* New message dialog */
@@ -288,6 +394,55 @@ async function startConversation(): Promise<void> {
288
394
  border-color: var(--accent);
289
395
  }
290
396
 
397
+ .cpub-new-msg-error {
398
+ padding: 8px 10px;
399
+ background: var(--red-bg);
400
+ color: var(--red);
401
+ border: var(--border-width-default) solid var(--red);
402
+ font-size: 12px;
403
+ border-radius: var(--radius);
404
+ }
405
+
406
+ .cpub-new-msg-chips {
407
+ display: flex;
408
+ flex-wrap: wrap;
409
+ gap: 4px;
410
+ margin-bottom: 4px;
411
+ }
412
+
413
+ .cpub-new-msg-chip {
414
+ display: inline-flex;
415
+ align-items: center;
416
+ gap: 4px;
417
+ padding: 2px 8px;
418
+ background: var(--accent-bg);
419
+ border: var(--border-width-default) solid var(--accent-border, var(--border));
420
+ color: var(--text);
421
+ font-family: var(--font-mono);
422
+ font-size: 11px;
423
+ }
424
+
425
+ .cpub-new-msg-chip-remove {
426
+ background: none;
427
+ border: none;
428
+ color: var(--text-faint);
429
+ cursor: pointer;
430
+ font-size: 14px;
431
+ padding: 0 2px;
432
+ line-height: 1;
433
+ }
434
+
435
+ .cpub-new-msg-chip-remove:hover {
436
+ color: var(--red);
437
+ }
438
+
439
+ .cpub-new-msg-hint {
440
+ font-size: 10px;
441
+ color: var(--text-faint);
442
+ font-family: var(--font-mono);
443
+ margin-top: 2px;
444
+ }
445
+
291
446
  .cpub-new-msg-footer {
292
447
  display: flex;
293
448
  justify-content: flex-end;
@@ -9,23 +9,45 @@ useSeoMeta({
9
9
  description: () => `Content tagged with "${tagSlug.value}" on CommonPub`,
10
10
  });
11
11
 
12
- const page = ref(0);
13
- const { data: results, refresh } = await useFetch<PaginatedResponse<Serialized<ContentListItem>>>('/api/content', {
12
+ const PAGE_SIZE = 20;
13
+ const loadingMore = ref(false);
14
+ const allLoaded = ref(false);
15
+
16
+ const { data: results } = await useFetch<PaginatedResponse<Serialized<ContentListItem>>>('/api/content', {
14
17
  query: computed(() => ({
15
18
  tag: tagSlug.value,
16
19
  status: 'published',
17
- limit: 20,
18
- offset: page.value * 20,
20
+ limit: PAGE_SIZE,
19
21
  })),
22
+ watch: [tagSlug],
20
23
  });
21
24
 
22
25
  const items = computed(() => results.value?.items ?? []);
23
26
  const total = computed(() => results.value?.total ?? 0);
24
- const hasMore = computed(() => items.value.length < total.value);
27
+ const hasMore = computed(() => !allLoaded.value && items.value.length < total.value);
28
+
29
+ // Reset when tag changes
30
+ watch(tagSlug, () => { allLoaded.value = false; });
25
31
 
26
32
  async function loadMore(): Promise<void> {
27
- page.value++;
28
- await refresh();
33
+ if (!results.value?.items) return;
34
+ loadingMore.value = true;
35
+ try {
36
+ const nextOffset = results.value.items.length;
37
+ const more = await $fetch<PaginatedResponse<Serialized<ContentListItem>>>('/api/content', {
38
+ query: { tag: tagSlug.value, status: 'published', limit: PAGE_SIZE, offset: nextOffset },
39
+ });
40
+ if (more?.items?.length) {
41
+ results.value.items.push(...more.items);
42
+ }
43
+ if (!more?.items?.length || more.items.length < PAGE_SIZE) {
44
+ allLoaded.value = true;
45
+ }
46
+ } catch {
47
+ allLoaded.value = true;
48
+ } finally {
49
+ loadingMore.value = false;
50
+ }
29
51
  }
30
52
  </script>
31
53
 
@@ -53,7 +75,9 @@ async function loadMore(): Promise<void> {
53
75
  </div>
54
76
 
55
77
  <div v-if="hasMore" class="cpub-tag-more">
56
- <button class="cpub-btn" @click="loadMore">Load more</button>
78
+ <button class="cpub-btn" @click="loadMore" :disabled="loadingMore">
79
+ {{ loadingMore ? 'Loading...' : 'Load more' }}
80
+ </button>
57
81
  </div>
58
82
  </div>
59
83
  </template>
@@ -15,7 +15,7 @@ export default defineEventHandler(async (event) => {
15
15
  console.error('[hub-backfill] Failed:', err);
16
16
  return { processed: 0, errors: 1 };
17
17
  }),
18
- fetchRemoteHubFollowers(db, id).catch((err) => {
18
+ fetchRemoteHubFollowers(db, id, config.instance.domain).catch((err) => {
19
19
  console.error('[hub-followers] Failed:', err);
20
20
  return { fetched: 0, errors: 1 };
21
21
  }),
@@ -1,4 +1,5 @@
1
- import { consumeOAuthState, exchangeCodeForToken, linkFederatedAccount } from '@commonpub/server';
1
+ import { consumeOAuthState, exchangeCodeForToken, linkFederatedAccount, findUserByFederatedAccount, createFederatedSession, storePendingLink } from '@commonpub/server';
2
+ import type { H3Event } from 'h3';
2
3
  import { z } from 'zod';
3
4
 
4
5
  const callbackSchema = z.object({
@@ -6,16 +7,30 @@ const callbackSchema = z.object({
6
7
  state: z.string(),
7
8
  });
8
9
 
10
+ /**
11
+ * Set the Better Auth session cookie after federated login.
12
+ */
13
+ function setSessionCookie(event: H3Event, token: string, expiresAt: Date): void {
14
+ setCookie(event, 'better-auth.session_token', token, {
15
+ httpOnly: true,
16
+ secure: process.env.NODE_ENV === 'production',
17
+ sameSite: 'lax',
18
+ path: '/',
19
+ expires: expiresAt,
20
+ });
21
+ }
22
+
9
23
  /**
10
24
  * OAuth2 callback handler for federated login.
11
- * Exchanges authorization code for token, links federated account.
25
+ * Exchanges authorization code for token, links federated account,
26
+ * creates a session, and redirects.
12
27
  */
13
28
  export default defineEventHandler(async (event) => {
14
29
  requireFeature('federation');
15
30
  const db = useDB();
16
31
  const { code, state: stateToken } = parseQueryParams(event, callbackSchema);
17
32
 
18
- // Retrieve and consume the stored OAuth state (single-use, 10min TTL)
33
+ // Retrieve and consume the stored OAuth state (single-use, atomic)
19
34
  const oauthState = await consumeOAuthState(db, stateToken);
20
35
  if (!oauthState) {
21
36
  throw createError({
@@ -33,14 +48,19 @@ export default defineEventHandler(async (event) => {
33
48
  });
34
49
  }
35
50
 
51
+ const ipAddress = getRequestHeader(event, 'x-forwarded-for')?.split(',')[0]?.trim()
52
+ ?? getRequestHeader(event, 'x-real-ip')
53
+ ?? undefined;
54
+ const userAgent = getRequestHeader(event, 'user-agent') ?? undefined;
55
+
36
56
  // Check if a local user is already linked to this federated account
37
- const { findUserByFederatedAccount } = await import('@commonpub/server');
38
57
  const existingLink = await findUserByFederatedAccount(db, tokenResult.user.actorUri);
39
58
 
40
59
  if (existingLink) {
41
- // User already linked — redirect to dashboard
42
- // In a full implementation, this would also create a Better Auth session
43
- return sendRedirect(event, `/dashboard?federated=linked&user=${existingLink.username}`, 302);
60
+ // User already linked — create session and redirect to dashboard
61
+ const session = await createFederatedSession(db, existingLink.userId, ipAddress, userAgent);
62
+ setSessionCookie(event, session.sessionToken, session.expiresAt);
63
+ return sendRedirect(event, '/dashboard', 302);
44
64
  }
45
65
 
46
66
  // Check if the current user is logged in — if so, link to their account
@@ -55,13 +75,15 @@ export default defineEventHandler(async (event) => {
55
75
  return sendRedirect(event, '/settings/account?federated=linked', 302);
56
76
  }
57
77
 
58
- // Not logged in and no existing link — redirect to login page with federated context
59
- // The user needs to either create an account or log in to link
60
- const params = new URLSearchParams({
61
- federated: 'true',
78
+ // Not logged in and no existing link — store verified identity in a server-side token.
79
+ // Only the opaque token is passed to the client; the actorUri is never exposed in the URL.
80
+ const linkToken = await storePendingLink(db, {
62
81
  actorUri: tokenResult.user.actorUri,
63
82
  username: tokenResult.user.username,
64
- instance: oauthState.instanceDomain,
83
+ instanceDomain: oauthState.instanceDomain,
84
+ displayName: tokenResult.user.displayName ?? undefined,
85
+ avatarUrl: tokenResult.user.avatarUrl ?? undefined,
65
86
  });
66
- return sendRedirect(event, `/auth/login?${params.toString()}`, 302);
87
+
88
+ return sendRedirect(event, `/auth/login?federated=true&linkToken=${linkToken}`, 302);
67
89
  });
@@ -0,0 +1,82 @@
1
+ import { linkFederatedAccount, consumePendingLink } from '@commonpub/server';
2
+ import { z } from 'zod';
3
+
4
+ const linkSchema = z.object({
5
+ /** Local credentials */
6
+ identity: z.string().min(1),
7
+ password: z.string().min(1),
8
+ /** Server-side link token from the OAuth callback — proves the federated identity was verified */
9
+ linkToken: z.string().min(1),
10
+ });
11
+
12
+ /**
13
+ * Link a verified federated identity to a local account.
14
+ * The linkToken is a server-side opaque token stored during the OAuth callback.
15
+ * It proves the federated identity was authenticated — the actorUri is never
16
+ * sent from the client, preventing identity hijacking.
17
+ */
18
+ export default defineEventHandler(async (event) => {
19
+ requireFeature('federation');
20
+ const db = useDB();
21
+ const body = await parseBody(event, linkSchema);
22
+
23
+ // Step 1: Consume the pending link token (single-use, 10min TTL)
24
+ const pendingLink = await consumePendingLink(db, body.linkToken);
25
+ if (!pendingLink) {
26
+ throw createError({
27
+ statusCode: 400,
28
+ statusMessage: 'Link token is invalid or expired. Please start the federated login again.',
29
+ });
30
+ }
31
+
32
+ // Step 2: Resolve identity to email
33
+ const { email } = await $fetch<{ email: string }>('/api/resolve-identity', {
34
+ method: 'POST',
35
+ body: { identity: body.identity },
36
+ }).catch(() => {
37
+ throw createError({ statusCode: 401, statusMessage: 'Invalid credentials.' });
38
+ });
39
+
40
+ // Step 3: Authenticate via Better Auth sign-in (also creates session + sets cookie)
41
+ const signInResponse = await $fetch<{ user?: { id: string }; session?: { token: string; expiresAt: string } }>(
42
+ '/api/auth/sign-in/email',
43
+ {
44
+ method: 'POST',
45
+ body: { email, password: body.password },
46
+ },
47
+ ).catch(() => {
48
+ throw createError({ statusCode: 401, statusMessage: 'Invalid credentials.' });
49
+ });
50
+
51
+ if (!signInResponse?.user?.id || !signInResponse?.session?.token) {
52
+ throw createError({ statusCode: 401, statusMessage: 'Invalid credentials.' });
53
+ }
54
+
55
+ const userId = signInResponse.user.id;
56
+
57
+ // Step 4: Link the verified federated identity (from server-side token, NOT client input)
58
+ try {
59
+ await linkFederatedAccount(db, userId, pendingLink.actorUri, pendingLink.instanceDomain, {
60
+ preferredUsername: pendingLink.username,
61
+ displayName: pendingLink.displayName,
62
+ avatarUrl: pendingLink.avatarUrl,
63
+ });
64
+ } catch (err: unknown) {
65
+ const msg = err instanceof Error ? err.message : 'Failed to link account';
66
+ throw createError({ statusCode: 409, statusMessage: msg });
67
+ }
68
+
69
+ // Step 5: Use the session Better Auth created — set cookie for the client
70
+ setCookie(event, 'better-auth.session_token', signInResponse.session.token, {
71
+ httpOnly: true,
72
+ secure: process.env.NODE_ENV === 'production',
73
+ sameSite: 'lax',
74
+ path: '/',
75
+ expires: new Date(signInResponse.session.expiresAt),
76
+ });
77
+
78
+ return {
79
+ success: true,
80
+ linked: true,
81
+ };
82
+ });
@@ -1,4 +1,4 @@
1
- import { listConversations } from '@commonpub/server';
1
+ import { listConversations, getConversationUnreadCounts } from '@commonpub/server';
2
2
  import { users } from '@commonpub/schema';
3
3
  import { inArray } from 'drizzle-orm';
4
4
 
@@ -6,11 +6,14 @@ export default defineEventHandler(async (event) => {
6
6
  const db = useDB();
7
7
  const user = requireAuth(event);
8
8
 
9
- const conversations = await listConversations(db, user.id);
9
+ const [conversationList, unreadCounts] = await Promise.all([
10
+ listConversations(db, user.id),
11
+ getConversationUnreadCounts(db, user.id),
12
+ ]);
10
13
 
11
14
  // Collect all unique participant IDs
12
15
  const allIds = new Set<string>();
13
- for (const conv of conversations) {
16
+ for (const conv of conversationList) {
14
17
  for (const id of (conv.participants ?? [])) {
15
18
  allIds.add(id);
16
19
  }
@@ -28,9 +31,10 @@ export default defineEventHandler(async (event) => {
28
31
  }
29
32
  }
30
33
 
31
- // Replace participant IDs with resolved user objects
32
- return conversations.map((conv) => ({
34
+ // Replace participant IDs with resolved user objects, include unread count
35
+ return conversationList.map((conv) => ({
33
36
  ...conv,
37
+ unreadCount: unreadCounts[conv.id] ?? 0,
34
38
  participants: (conv.participants ?? []).map((id: string) => {
35
39
  const u = userMap.get(id);
36
40
  return u ? { username: u.username, displayName: u.displayName, avatarUrl: u.avatarUrl } : { username: id, displayName: null, avatarUrl: null };