@commonpub/layer 0.8.3 → 0.8.5

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 (79) hide show
  1. package/components/ContentCard.vue +1 -1
  2. package/components/ImageUpload.vue +1 -1
  3. package/components/ShareToHubModal.vue +1 -1
  4. package/components/blocks/BlockCodeView.vue +26 -25
  5. package/components/contest/ContestEntries.vue +112 -0
  6. package/components/contest/ContestHero.vue +204 -0
  7. package/components/contest/ContestJudges.vue +51 -0
  8. package/components/contest/ContestPrizes.vue +82 -0
  9. package/components/contest/ContestRules.vue +34 -0
  10. package/components/contest/ContestSidebar.vue +83 -0
  11. package/components/editors/BlogEditor.vue +1 -1
  12. package/components/editors/DocsPageTree.vue +10 -0
  13. package/components/hub/HubHero.vue +1 -1
  14. package/composables/useSanitize.ts +112 -9
  15. package/composables/useTheme.ts +8 -0
  16. package/layouts/default.vue +7 -7
  17. package/middleware/feature-gate.global.ts +24 -0
  18. package/package.json +6 -6
  19. package/pages/[type]/index.vue +4 -3
  20. package/pages/admin/audit.vue +3 -2
  21. package/pages/admin/federation.vue +33 -13
  22. package/pages/admin/index.vue +7 -1
  23. package/pages/admin/reports.vue +152 -36
  24. package/pages/admin/settings.vue +17 -5
  25. package/pages/admin/theme.vue +5 -3
  26. package/pages/auth/forgot-password.vue +35 -35
  27. package/pages/auth/login.vue +6 -5
  28. package/pages/auth/reset-password.vue +44 -32
  29. package/pages/contests/[slug]/edit.vue +238 -56
  30. package/pages/contests/[slug]/index.vue +54 -450
  31. package/pages/contests/[slug]/judge.vue +141 -53
  32. package/pages/contests/[slug]/results.vue +182 -0
  33. package/pages/contests/create.vue +64 -64
  34. package/pages/contests/index.vue +2 -1
  35. package/pages/docs/[siteSlug]/[...pagePath].vue +6 -5
  36. package/pages/docs/[siteSlug]/edit.vue +58 -2
  37. package/pages/docs/[siteSlug]/index.vue +6 -5
  38. package/pages/federated-hubs/[id]/posts/[postId].vue +2 -2
  39. package/pages/hubs/index.vue +3 -2
  40. package/pages/index.vue +25 -7
  41. package/pages/learn/index.vue +1 -1
  42. package/pages/mirror/[id].vue +3 -3
  43. package/pages/notifications.vue +15 -1
  44. package/pages/products/[slug].vue +5 -2
  45. package/pages/settings/notifications.vue +7 -1
  46. package/pages/tags/[slug].vue +3 -2
  47. package/pages/tags/index.vue +3 -2
  48. package/pages/videos/[id].vue +18 -0
  49. package/server/api/admin/content/[id].patch.ts +1 -1
  50. package/server/api/admin/federation/mirrors/[id]/backfill.post.ts +1 -1
  51. package/server/api/admin/federation/refederate.post.ts +7 -3
  52. package/server/api/admin/federation/repair-types.post.ts +2 -45
  53. package/server/api/admin/federation/retry.post.ts +7 -4
  54. package/server/api/admin/reports.get.ts +1 -0
  55. package/server/api/auth/federated/login.post.ts +22 -2
  56. package/server/api/auth/sign-in-username.post.ts +42 -0
  57. package/server/api/content/[id]/products-sync.post.ts +7 -6
  58. package/server/api/contests/[slug]/entries/[entryId].delete.ts +14 -0
  59. package/server/api/contests/[slug]/entries.get.ts +6 -1
  60. package/server/api/contests/[slug]/judge.post.ts +8 -2
  61. package/server/api/docs/[siteSlug]/nav.get.ts +1 -1
  62. package/server/api/docs/[siteSlug]/pages/[pageId]/duplicate.post.ts +16 -0
  63. package/server/api/docs/[siteSlug]/pages/reorder.post.ts +4 -1
  64. package/server/api/docs/migrate-content.post.ts +1 -7
  65. package/server/api/federation/hub-follow-status.get.ts +2 -18
  66. package/server/api/federation/hub-follow.post.ts +9 -27
  67. package/server/api/federation/hub-post-like.post.ts +9 -98
  68. package/server/api/federation/hub-post-likes.get.ts +3 -13
  69. package/server/api/notifications/read.post.ts +6 -1
  70. package/server/api/profile/theme.put.ts +23 -0
  71. package/server/api/search/index.get.ts +2 -2
  72. package/server/api/search/trending.get.ts +3 -3
  73. package/server/api/users/index.get.ts +9 -2
  74. package/server/middleware/content-ap.ts +2 -2
  75. package/server/routes/.well-known/webfinger.ts +2 -2
  76. package/theme/base.css +23 -0
  77. package/components/EditorPropertiesPanel.vue +0 -393
  78. package/components/views/BlogView.vue +0 -735
  79. package/server/api/resolve-identity.post.ts +0 -34
@@ -2,8 +2,13 @@
2
2
  definePageMeta({ layout: 'admin', middleware: 'auth' });
3
3
  useSeoMeta({ title: `Reports — Admin — ${useSiteName()}` });
4
4
 
5
- const { data: reportsData, refresh } = await useFetch('/api/admin/reports');
6
5
  const toast = useToast();
6
+ const statusFilter = ref<string>('pending');
7
+
8
+ const { data: reportsData, refresh } = await useFetch(() => {
9
+ const base = '/api/admin/reports';
10
+ return statusFilter.value ? `${base}?status=${statusFilter.value}` : base;
11
+ });
7
12
 
8
13
  interface Report {
9
14
  id: string;
@@ -14,6 +19,9 @@ interface Report {
14
19
  targetType: string;
15
20
  targetId: string;
16
21
  createdAt: string;
22
+ reporter?: { id: string; username: string };
23
+ reviewer?: { id: string; username: string } | null;
24
+ resolution?: string | null;
17
25
  }
18
26
 
19
27
  const reports = computed<Report[]>(() => {
@@ -23,66 +31,174 @@ const reports = computed<Report[]>(() => {
23
31
  return data.items ?? [];
24
32
  });
25
33
 
26
- async function resolveReport(id: string, resolution: 'resolved' | 'dismissed'): Promise<void> {
34
+ // Bulk selection
35
+ const selectedIds = ref<Set<string>>(new Set());
36
+ const allSelected = computed(() => reports.value.length > 0 && reports.value.every(r => selectedIds.value.has(r.id)));
37
+
38
+ function toggleSelect(id: string): void {
39
+ const s = new Set(selectedIds.value);
40
+ if (s.has(id)) s.delete(id);
41
+ else s.add(id);
42
+ selectedIds.value = s;
43
+ }
44
+
45
+ function toggleSelectAll(): void {
46
+ if (allSelected.value) {
47
+ selectedIds.value = new Set();
48
+ } else {
49
+ selectedIds.value = new Set(reports.value.map(r => r.id));
50
+ }
51
+ }
52
+
53
+ // Single report actions
54
+ async function resolveReport(id: string, status: 'reviewed' | 'resolved' | 'dismissed', resolution?: string): Promise<void> {
55
+ const text = resolution ?? prompt(`Reason for ${status}:`);
56
+ if (!text) return;
27
57
  try {
28
58
  await $fetch(`/api/admin/reports/${id}/resolve` as string, {
29
59
  method: 'POST',
30
- body: { resolution },
60
+ body: { status, resolution: text },
31
61
  });
32
- toast.success(`Report ${resolution}`);
62
+ toast.success(`Report ${status}`);
63
+ selectedIds.value.delete(id);
33
64
  await refresh();
34
65
  } catch {
35
66
  toast.error('Failed to update report');
36
67
  }
37
68
  }
69
+
70
+ // Bulk actions
71
+ async function bulkAction(status: 'reviewed' | 'resolved' | 'dismissed'): Promise<void> {
72
+ if (selectedIds.value.size === 0) return;
73
+ const text = prompt(`Reason for bulk ${status} (${selectedIds.value.size} reports):`);
74
+ if (!text) return;
75
+ let successCount = 0;
76
+ for (const id of selectedIds.value) {
77
+ try {
78
+ await $fetch(`/api/admin/reports/${id}/resolve` as string, {
79
+ method: 'POST',
80
+ body: { status, resolution: text },
81
+ });
82
+ successCount++;
83
+ } catch {
84
+ // continue with remaining
85
+ }
86
+ }
87
+ toast.success(`${successCount} report${successCount === 1 ? '' : 's'} ${status}`);
88
+ selectedIds.value = new Set();
89
+ await refresh();
90
+ }
91
+
92
+ watch(statusFilter, () => {
93
+ selectedIds.value = new Set();
94
+ });
38
95
  </script>
39
96
 
40
97
  <template>
41
- <div class="admin-reports">
42
- <h1 class="admin-page-title">Reports</h1>
98
+ <div class="cpub-admin-reports">
99
+ <h1 class="cpub-admin-page-title">Reports</h1>
100
+
101
+ <!-- Filter bar -->
102
+ <div class="cpub-report-filters">
103
+ <button
104
+ v-for="s in ['pending', 'reviewed', 'resolved', 'dismissed', '']"
105
+ :key="s"
106
+ class="cpub-report-filter-btn"
107
+ :class="{ 'cpub-report-filter-active': statusFilter === s }"
108
+ @click="statusFilter = s"
109
+ >
110
+ {{ s || 'All' }}
111
+ </button>
112
+ </div>
113
+
114
+ <!-- Bulk actions -->
115
+ <div v-if="selectedIds.size > 0" class="cpub-report-bulk">
116
+ <span class="cpub-report-bulk-count">{{ selectedIds.size }} selected</span>
117
+ <button v-if="statusFilter === 'pending'" class="cpub-btn cpub-btn-sm" @click="bulkAction('reviewed')">
118
+ <i class="fa-solid fa-eye" /> Mark Reviewed
119
+ </button>
120
+ <button class="cpub-btn cpub-btn-sm" style="color: var(--green); border-color: var(--green-border);" @click="bulkAction('resolved')">
121
+ <i class="fa-solid fa-check" /> Resolve
122
+ </button>
123
+ <button class="cpub-btn cpub-btn-sm" @click="bulkAction('dismissed')">
124
+ <i class="fa-solid fa-xmark" /> Dismiss
125
+ </button>
126
+ </div>
43
127
 
44
128
  <template v-if="reports.length">
45
- <div class="report-card" v-for="report in reports" :key="report.id">
46
- <div class="report-header">
47
- <span class="report-status" :class="`status-${report.status}`">{{ report.status }}</span>
48
- <span class="report-type">{{ report.targetType }}</span>
49
- <time class="report-date">{{ new Date(report.createdAt).toLocaleDateString() }}</time>
129
+ <div class="cpub-report-card" v-for="report in reports" :key="report.id">
130
+ <div class="cpub-report-header">
131
+ <label class="cpub-report-checkbox" @click.stop>
132
+ <input type="checkbox" :checked="selectedIds.has(report.id)" @change="toggleSelect(report.id)" />
133
+ </label>
134
+ <span class="cpub-report-status" :class="`cpub-status-${report.status}`">{{ report.status }}</span>
135
+ <span class="cpub-report-type">{{ report.targetType }}</span>
136
+ <time class="cpub-report-date">{{ new Date(report.createdAt).toLocaleDateString() }}</time>
50
137
  </div>
51
- <p class="report-reason"><strong>{{ report.reason }}</strong></p>
52
- <p v-if="report.description" class="report-desc">{{ report.description }}</p>
53
- <div class="report-meta">
54
- <span class="report-meta-item">Reporter: <code>{{ report.reporterId }}</code></span>
55
- <span class="report-meta-item">Target: <code>{{ report.targetId }}</code></span>
138
+ <p class="cpub-report-reason"><strong>{{ report.reason }}</strong></p>
139
+ <p v-if="report.description" class="cpub-report-desc">{{ report.description }}</p>
140
+ <div class="cpub-report-meta">
141
+ <span class="cpub-report-meta-item">Reporter: <code>{{ report.reporter?.username ?? report.reporterId }}</code></span>
142
+ <span class="cpub-report-meta-item">Target: <code>{{ report.targetId.slice(0, 8) }}...</code></span>
143
+ <span v-if="report.reviewer" class="cpub-report-meta-item">Reviewed by: <code>{{ report.reviewer.username }}</code></span>
56
144
  </div>
57
- <div v-if="report.status === 'pending'" class="report-actions">
145
+ <p v-if="report.resolution" class="cpub-report-resolution">
146
+ <i class="fa-solid fa-comment-dots" /> {{ report.resolution }}
147
+ </p>
148
+ <div v-if="report.status === 'pending' || report.status === 'reviewed'" class="cpub-report-actions">
149
+ <button v-if="report.status === 'pending'" class="cpub-btn cpub-btn-sm" @click="resolveReport(report.id, 'reviewed')">
150
+ <i class="fa-solid fa-eye" /> Mark Reviewed
151
+ </button>
58
152
  <button class="cpub-btn cpub-btn-sm" style="color: var(--green); border-color: var(--green-border);" @click="resolveReport(report.id, 'resolved')">
59
- <i class="fa-solid fa-check"></i> Resolve
153
+ <i class="fa-solid fa-check" /> Resolve
60
154
  </button>
61
155
  <button class="cpub-btn cpub-btn-sm" @click="resolveReport(report.id, 'dismissed')">
62
- <i class="fa-solid fa-xmark"></i> Dismiss
156
+ <i class="fa-solid fa-xmark" /> Dismiss
63
157
  </button>
64
158
  </div>
65
159
  </div>
160
+
161
+ <!-- Select all -->
162
+ <div class="cpub-report-select-all">
163
+ <label @click.stop>
164
+ <input type="checkbox" :checked="allSelected" @change="toggleSelectAll" />
165
+ <span>Select all</span>
166
+ </label>
167
+ </div>
66
168
  </template>
67
- <p class="admin-empty" v-else>No reports to review.</p>
169
+ <p class="cpub-admin-empty" v-else>No reports{{ statusFilter ? ` with status "${statusFilter}"` : '' }}.</p>
68
170
  </div>
69
171
  </template>
70
172
 
71
173
  <style scoped>
72
- .admin-page-title { font-size: var(--text-xl); font-weight: var(--font-weight-bold); margin-bottom: var(--space-6); }
73
- .report-card { padding: 16px; border: var(--border-width-default) solid var(--border); background: var(--surface); margin-bottom: 12px; box-shadow: var(--shadow-md); }
74
- .report-header { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
75
- .report-status { font-size: 10px; font-family: var(--font-mono); font-weight: 600; text-transform: uppercase; padding: 2px 8px; }
76
- .status-pending { background: var(--yellow-bg); color: var(--yellow); border: var(--border-width-default) solid var(--yellow-border); }
77
- .status-resolved { background: var(--green-bg); color: var(--green); border: var(--border-width-default) solid var(--green-border); }
78
- .status-dismissed { background: var(--surface2); color: var(--text-faint); border: var(--border-width-default) solid var(--border2); }
79
- .report-type { font-size: 10px; font-family: var(--font-mono); color: var(--accent); background: var(--accent-bg); padding: 2px 6px; border: var(--border-width-default) solid var(--accent-border); }
80
- .report-date { font-size: 11px; color: var(--text-faint); margin-left: auto; font-family: var(--font-mono); }
81
- .report-reason { font-size: 13px; margin-bottom: 4px; }
82
- .report-desc { font-size: 12px; color: var(--text-dim); line-height: 1.5; margin-bottom: 8px; }
83
- .report-meta { display: flex; gap: 16px; margin-bottom: 10px; }
84
- .report-meta-item { font-size: 10px; font-family: var(--font-mono); color: var(--text-faint); }
85
- .report-meta-item code { background: var(--surface2); padding: 1px 4px; }
86
- .report-actions { display: flex; gap: 6px; padding-top: 8px; border-top: var(--border-width-default) solid var(--border2); }
87
- .admin-empty { color: var(--text-faint); text-align: center; padding: var(--space-8) 0; }
174
+ .cpub-admin-page-title { font-size: var(--text-xl); font-weight: var(--font-weight-bold); margin-bottom: var(--space-6); }
175
+ .cpub-report-filters { display: flex; gap: 4px; margin-bottom: var(--space-4); flex-wrap: wrap; }
176
+ .cpub-report-filter-btn { padding: 4px 10px; font-size: 11px; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: 0.04em; background: var(--surface); border: var(--border-width-default) solid var(--border); color: var(--text-dim); cursor: pointer; }
177
+ .cpub-report-filter-btn:hover { border-color: var(--accent); color: var(--accent); }
178
+ .cpub-report-filter-active { background: var(--accent-bg); border-color: var(--accent); color: var(--accent); }
179
+ .cpub-report-bulk { display: flex; align-items: center; gap: 8px; padding: 8px 12px; margin-bottom: var(--space-3); background: var(--accent-bg); border: var(--border-width-default) solid var(--accent-border); }
180
+ .cpub-report-bulk-count { font-size: 11px; font-family: var(--font-mono); color: var(--accent); font-weight: 600; }
181
+ .cpub-report-card { padding: 16px; border: var(--border-width-default) solid var(--border); background: var(--surface); margin-bottom: 12px; box-shadow: var(--shadow-md); }
182
+ .cpub-report-header { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
183
+ .cpub-report-checkbox { display: flex; align-items: center; cursor: pointer; }
184
+ .cpub-report-checkbox input { cursor: pointer; accent-color: var(--accent); }
185
+ .cpub-report-status { font-size: 10px; font-family: var(--font-mono); font-weight: 600; text-transform: uppercase; padding: 2px 8px; }
186
+ .cpub-status-pending { background: var(--yellow-bg); color: var(--yellow); border: var(--border-width-default) solid var(--yellow-border); }
187
+ .cpub-status-reviewed { background: var(--blue-bg, var(--accent-bg)); color: var(--blue, var(--accent)); border: var(--border-width-default) solid var(--blue-border, var(--accent-border)); }
188
+ .cpub-status-resolved { background: var(--green-bg); color: var(--green); border: var(--border-width-default) solid var(--green-border); }
189
+ .cpub-status-dismissed { background: var(--surface2); color: var(--text-faint); border: var(--border-width-default) solid var(--border2); }
190
+ .cpub-report-type { font-size: 10px; font-family: var(--font-mono); color: var(--accent); background: var(--accent-bg); padding: 2px 6px; border: var(--border-width-default) solid var(--accent-border); }
191
+ .cpub-report-date { font-size: 11px; color: var(--text-faint); margin-left: auto; font-family: var(--font-mono); }
192
+ .cpub-report-reason { font-size: 13px; margin-bottom: 4px; }
193
+ .cpub-report-desc { font-size: 12px; color: var(--text-dim); line-height: 1.5; margin-bottom: 8px; }
194
+ .cpub-report-meta { display: flex; gap: 16px; margin-bottom: 8px; flex-wrap: wrap; }
195
+ .cpub-report-meta-item { font-size: 10px; font-family: var(--font-mono); color: var(--text-faint); }
196
+ .cpub-report-meta-item code { background: var(--surface2); padding: 1px 4px; }
197
+ .cpub-report-resolution { font-size: 11px; color: var(--text-dim); font-style: italic; margin-bottom: 8px; }
198
+ .cpub-report-resolution i { color: var(--text-faint); margin-right: 4px; }
199
+ .cpub-report-actions { display: flex; gap: 6px; padding-top: 8px; border-top: var(--border-width-default) solid var(--border2); }
200
+ .cpub-report-select-all { display: flex; align-items: center; gap: 6px; padding: 8px 0; font-size: 11px; font-family: var(--font-mono); color: var(--text-faint); }
201
+ .cpub-report-select-all label { display: flex; align-items: center; gap: 6px; cursor: pointer; }
202
+ .cpub-report-select-all input { cursor: pointer; accent-color: var(--accent); }
203
+ .cpub-admin-empty { color: var(--text-faint); text-align: center; padding: var(--space-8) 0; }
88
204
  </style>
@@ -3,7 +3,7 @@ definePageMeta({ layout: 'admin', middleware: 'auth' });
3
3
 
4
4
  useSeoMeta({ title: `Settings — Admin — ${useSiteName()}` });
5
5
 
6
- const { data: settings, refresh } = await useFetch<Record<string, string>>('/api/admin/settings');
6
+ const { data: settings, pending, refresh } = await useFetch<Record<string, string>>('/api/admin/settings');
7
7
 
8
8
  const saving = ref(false);
9
9
  const editKey = ref('');
@@ -58,7 +58,11 @@ async function addSetting(): Promise<void> {
58
58
  <div class="admin-settings">
59
59
  <h1 class="admin-page-title">Instance Settings</h1>
60
60
 
61
- <div class="settings-list" v-if="settings">
61
+ <div v-if="pending" class="admin-loading">
62
+ <i class="fa-solid fa-circle-notch fa-spin"></i> Loading settings...
63
+ </div>
64
+ <template v-else-if="settings">
65
+ <div class="settings-list">
62
66
  <div v-for="item in knownSettings" :key="item.key" class="settings-row">
63
67
  <div class="settings-label">
64
68
  <span class="settings-key">{{ item.label }}</span>
@@ -78,7 +82,7 @@ async function addSetting(): Promise<void> {
78
82
  </div>
79
83
  </div>
80
84
 
81
- <div class="settings-custom" v-if="settings">
85
+ <div class="settings-custom">
82
86
  <h2 class="settings-section-title">Custom Settings</h2>
83
87
  <div v-for="(value, key) in (settings as Record<string, string>)" :key="key" class="settings-row">
84
88
  <template v-if="!knownSettings.some(k => k.key === key)">
@@ -105,8 +109,8 @@ async function addSetting(): Promise<void> {
105
109
  <button class="cpub-btn cpub-btn-sm" :disabled="!newKey.trim()" @click="addSetting">Add</button>
106
110
  </div>
107
111
  </div>
108
-
109
- <p class="admin-empty" v-if="!settings">No settings configured.</p>
112
+ </template>
113
+ <p v-else class="admin-empty">No settings configured.</p>
110
114
  </div>
111
115
  </template>
112
116
 
@@ -154,6 +158,14 @@ async function addSetting(): Promise<void> {
154
158
  background: var(--surface);
155
159
  }
156
160
 
161
+ .admin-loading {
162
+ display: flex;
163
+ align-items: center;
164
+ gap: 8px;
165
+ padding: var(--space-8, 32px);
166
+ color: var(--text-faint);
167
+ }
168
+
157
169
  .admin-empty {
158
170
  color: var(--text-faint);
159
171
  text-align: center;
@@ -4,7 +4,7 @@ import { BUILT_IN_THEMES } from '@commonpub/ui';
4
4
  definePageMeta({ layout: 'admin', middleware: 'auth' });
5
5
  useSeoMeta({ title: `Theme — Admin — ${useSiteName()}` });
6
6
 
7
- const { data: settings, refresh } = await useFetch<Record<string, unknown>>('/api/admin/settings');
7
+ const { data: settings, pending, refresh } = await useFetch<Record<string, unknown>>('/api/admin/settings');
8
8
 
9
9
  const saving = ref(false);
10
10
  const saveSuccess = ref(false);
@@ -156,8 +156,10 @@ function removeTokenOverride(key: string): void {
156
156
  <i class="fa-solid fa-check"></i> Saved
157
157
  </div>
158
158
 
159
+ <p v-if="pending" class="admin-empty"><i class="fa-solid fa-circle-notch fa-spin"></i> Loading theme settings...</p>
160
+
159
161
  <!-- Theme Families -->
160
- <section class="admin-theme-families">
162
+ <section v-else class="admin-theme-families">
161
163
  <div v-for="family in families" :key="family.id" class="admin-family-card" :class="{ active: activeFamily === family.id }" >
162
164
  <button
163
165
  class="admin-family-select"
@@ -285,7 +287,7 @@ function removeTokenOverride(key: string): void {
285
287
  right: var(--space-4);
286
288
  padding: var(--space-2) var(--space-4);
287
289
  background: var(--green);
288
- color: #fff;
290
+ color: var(--color-text-inverse);
289
291
  font-size: var(--text-sm);
290
292
  font-weight: var(--font-weight-semibold);
291
293
  z-index: var(--z-toast);
@@ -16,9 +16,9 @@ async function handleSubmit(): Promise<void> {
16
16
  loading.value = true;
17
17
 
18
18
  try {
19
- await $fetch('/api/auth/forgot-password', {
19
+ await $fetch('/api/auth/request-password-reset', {
20
20
  method: 'POST',
21
- body: { email: email.value },
21
+ body: { email: email.value, redirectTo: '/auth/reset-password' },
22
22
  });
23
23
  success.value = true;
24
24
  } catch (err: unknown) {
@@ -31,47 +31,47 @@ async function handleSubmit(): Promise<void> {
31
31
  </script>
32
32
 
33
33
  <template>
34
- <div class="forgot-page">
35
- <h1 class="forgot-title">Forgot Password</h1>
34
+ <div class="cpub-forgot-page">
35
+ <h1 class="cpub-forgot-title">Forgot Password</h1>
36
36
 
37
37
  <template v-if="success">
38
- <div class="forgot-success">
38
+ <div class="cpub-forgot-success">
39
39
  <i class="fa-solid fa-envelope" style="font-size: 24px; color: var(--accent); margin-bottom: 12px;"></i>
40
- <p class="forgot-success-text">
40
+ <p class="cpub-forgot-success-text">
41
41
  If an account exists for <strong>{{ email }}</strong>, we've sent a password reset link.
42
42
  Check your inbox and spam folder.
43
43
  </p>
44
44
  </div>
45
- <NuxtLink to="/auth/login" class="back-link">
45
+ <NuxtLink to="/auth/login" class="cpub-back-link">
46
46
  <i class="fa-solid fa-arrow-left"></i> Back to login
47
47
  </NuxtLink>
48
48
  </template>
49
49
 
50
50
  <template v-else>
51
- <p class="forgot-desc">Enter your email address and we'll send you a link to reset your password.</p>
51
+ <p class="cpub-forgot-desc">Enter your email address and we'll send you a link to reset your password.</p>
52
52
 
53
- <form class="forgot-form" @submit.prevent="handleSubmit" aria-label="Forgot password form">
54
- <div v-if="error" class="form-error" role="alert">{{ error }}</div>
53
+ <form class="cpub-forgot-form" @submit.prevent="handleSubmit" aria-label="Forgot password form">
54
+ <div v-if="error" class="cpub-form-error" role="alert">{{ error }}</div>
55
55
 
56
- <div class="field">
57
- <label for="email" class="field-label">Email</label>
56
+ <div class="cpub-field">
57
+ <label for="email" class="cpub-field-label">Email</label>
58
58
  <input
59
59
  id="email"
60
60
  v-model="email"
61
61
  type="email"
62
- class="field-input"
62
+ class="cpub-field-input"
63
63
  autocomplete="email"
64
64
  required
65
65
  placeholder="you@example.com"
66
66
  />
67
67
  </div>
68
68
 
69
- <button type="submit" class="submit-btn" :disabled="loading">
69
+ <button type="submit" class="cpub-submit-btn" :disabled="loading">
70
70
  {{ loading ? 'Sending...' : 'Send Reset Link' }}
71
71
  </button>
72
72
  </form>
73
73
 
74
- <p class="forgot-footer">
74
+ <p class="cpub-forgot-footer">
75
75
  Remember your password?
76
76
  <NuxtLink to="/auth/login">Log in</NuxtLink>
77
77
  </p>
@@ -80,24 +80,24 @@ async function handleSubmit(): Promise<void> {
80
80
  </template>
81
81
 
82
82
  <style scoped>
83
- .forgot-page { width: 100%; }
84
- .forgot-title { font-size: 18px; font-weight: 600; margin-bottom: var(--space-3); }
85
- .forgot-desc { font-size: 13px; color: var(--text-dim); margin-bottom: var(--space-5); line-height: 1.6; }
86
- .forgot-form { display: flex; flex-direction: column; gap: var(--space-4); }
87
- .forgot-success { text-align: center; padding: var(--space-5) 0; }
88
- .forgot-success-text { font-size: 13px; color: var(--text-dim); line-height: 1.6; }
89
- .back-link { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--accent); text-decoration: none; justify-content: center; margin-top: var(--space-4); }
90
- .back-link:hover { text-decoration: underline; }
91
- .form-error { padding: var(--space-3); background: var(--red-bg); color: var(--red); border: var(--border-width-default) solid var(--red); border-radius: var(--radius); font-size: 12px; }
92
- .field { display: flex; flex-direction: column; gap: 4px; }
93
- .field-label { font-size: 12px; font-weight: 500; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-dim); }
94
- .field-input { padding: 8px 12px; border: var(--border-width-default) solid var(--border); border-radius: var(--radius); background: var(--surface); color: var(--text); font-size: 13px; font-family: var(--font-sans); outline: none; width: 100%; transition: border-color 0.15s; }
95
- .field-input::placeholder { color: var(--text-faint); }
96
- .field-input:focus { border-color: var(--accent); }
97
- .submit-btn { padding: 7px 14px; background: var(--accent); color: var(--color-text-inverse); border: var(--border-width-default) solid var(--accent); border-radius: var(--radius); font-size: 13px; font-weight: 500; cursor: pointer; box-shadow: var(--shadow-sm); transition: all 0.15s; }
98
- .submit-btn:hover:not(:disabled) { box-shadow: var(--shadow-md); transform: translate(-1px, -1px); }
99
- .submit-btn:disabled { opacity: 0.7; cursor: not-allowed; }
100
- .forgot-footer { text-align: center; font-size: 12px; color: var(--text-dim); margin-top: var(--space-4); }
101
- .forgot-footer a { color: var(--accent); text-decoration: none; }
102
- .forgot-footer a:hover { text-decoration: underline; }
83
+ .cpub-forgot-page { width: 100%; }
84
+ .cpub-forgot-title { font-size: 18px; font-weight: 600; margin-bottom: var(--space-3); }
85
+ .cpub-forgot-desc { font-size: 13px; color: var(--text-dim); margin-bottom: var(--space-5); line-height: 1.6; }
86
+ .cpub-forgot-form { display: flex; flex-direction: column; gap: var(--space-4); }
87
+ .cpub-forgot-success { text-align: center; padding: var(--space-5) 0; }
88
+ .cpub-forgot-success-text { font-size: 13px; color: var(--text-dim); line-height: 1.6; }
89
+ .cpub-back-link { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--accent); text-decoration: none; justify-content: center; margin-top: var(--space-4); }
90
+ .cpub-back-link:hover { text-decoration: underline; }
91
+ .cpub-form-error { padding: var(--space-3); background: var(--red-bg); color: var(--red); border: var(--border-width-default) solid var(--red); border-radius: var(--radius); font-size: 12px; }
92
+ .cpub-field { display: flex; flex-direction: column; gap: 4px; }
93
+ .cpub-field-label { font-size: 12px; font-weight: 500; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-dim); }
94
+ .cpub-field-input { padding: 8px 12px; border: var(--border-width-default) solid var(--border); border-radius: var(--radius); background: var(--surface); color: var(--text); font-size: 13px; font-family: var(--font-sans); outline: none; width: 100%; transition: border-color 0.15s; }
95
+ .cpub-field-input::placeholder { color: var(--text-faint); }
96
+ .cpub-field-input:focus { border-color: var(--accent); }
97
+ .cpub-submit-btn { padding: 7px 14px; background: var(--accent); color: var(--color-text-inverse); border: var(--border-width-default) solid var(--accent); border-radius: var(--radius); font-size: 13px; font-weight: 500; cursor: pointer; box-shadow: var(--shadow-sm); transition: all 0.15s; }
98
+ .cpub-submit-btn:hover:not(:disabled) { box-shadow: var(--shadow-md); transform: translate(-1px, -1px); }
99
+ .cpub-submit-btn:disabled { opacity: 0.7; cursor: not-allowed; }
100
+ .cpub-forgot-footer { text-align: center; font-size: 12px; color: var(--text-dim); margin-top: var(--space-4); }
101
+ .cpub-forgot-footer a { color: var(--accent); text-decoration: none; }
102
+ .cpub-forgot-footer a:hover { text-decoration: underline; }
103
103
  </style>
@@ -6,7 +6,7 @@ useSeoMeta({
6
6
  description: 'Log in to your CommonPub account.',
7
7
  });
8
8
 
9
- const { signIn } = useAuth();
9
+ const { signIn, refreshSession } = useAuth();
10
10
  const { federation } = useFeatures();
11
11
  const route = useRoute();
12
12
 
@@ -51,12 +51,13 @@ async function handleSubmit(): Promise<void> {
51
51
  });
52
52
  await navigateTo('/dashboard');
53
53
  } else {
54
- // Normal login flow
55
- const { email } = await $fetch<{ email: string }>('/api/resolve-identity', {
54
+ // Normal login flow — username→email resolved server-side
55
+ await $fetch('/api/auth/sign-in-username', {
56
56
  method: 'POST',
57
- body: { identity: identity.value },
57
+ body: { identity: identity.value, password: password.value },
58
+ credentials: 'include',
58
59
  });
59
- await signIn(email, password.value);
60
+ await refreshSession();
60
61
  await navigateTo(redirectTo.value);
61
62
  }
62
63
  } catch (err: unknown) {
@@ -8,6 +8,7 @@ useSeoMeta({
8
8
 
9
9
  const route = useRoute();
10
10
  const token = computed(() => (route.query.token as string) || '');
11
+ const tokenError = computed(() => (route.query.error as string) || '');
11
12
 
12
13
  const password = ref('');
13
14
  const confirmPassword = ref('');
@@ -49,32 +50,42 @@ async function handleSubmit(): Promise<void> {
49
50
  </script>
50
51
 
51
52
  <template>
52
- <div class="reset-page">
53
- <h1 class="reset-title">Reset Password</h1>
53
+ <div class="cpub-reset-page">
54
+ <h1 class="cpub-reset-title">Reset Password</h1>
54
55
 
55
56
  <template v-if="success">
56
- <div class="reset-success">
57
+ <div class="cpub-reset-success">
57
58
  <i class="fa-solid fa-check-circle" style="font-size: 24px; color: var(--green); margin-bottom: 12px;"></i>
58
- <p class="reset-success-text">Your password has been reset successfully.</p>
59
+ <p class="cpub-reset-success-text">Your password has been reset successfully.</p>
59
60
  </div>
60
- <NuxtLink to="/auth/login" class="back-link">
61
+ <NuxtLink to="/auth/login" class="cpub-back-link">
61
62
  <i class="fa-solid fa-arrow-right"></i> Go to login
62
63
  </NuxtLink>
63
64
  </template>
64
65
 
66
+ <template v-else-if="tokenError">
67
+ <div class="cpub-reset-error-state">
68
+ <i class="fa-solid fa-circle-xmark" style="font-size: 24px; color: var(--red); margin-bottom: 12px;"></i>
69
+ <p class="cpub-reset-success-text">This reset link is invalid or has expired.</p>
70
+ </div>
71
+ <NuxtLink to="/auth/forgot-password" class="cpub-back-link">
72
+ <i class="fa-solid fa-arrow-left"></i> Request a new link
73
+ </NuxtLink>
74
+ </template>
75
+
65
76
  <template v-else>
66
- <p class="reset-desc">Enter your new password below.</p>
77
+ <p class="cpub-reset-desc">Enter your new password below.</p>
67
78
 
68
- <form class="reset-form" @submit.prevent="handleSubmit" aria-label="Reset password form">
69
- <div v-if="error" class="form-error" role="alert">{{ error }}</div>
79
+ <form class="cpub-reset-form" @submit.prevent="handleSubmit" aria-label="Reset password form">
80
+ <div v-if="error" class="cpub-form-error" role="alert">{{ error }}</div>
70
81
 
71
- <div class="field">
72
- <label for="password" class="field-label">New Password</label>
82
+ <div class="cpub-field">
83
+ <label for="password" class="cpub-field-label">New Password</label>
73
84
  <input
74
85
  id="password"
75
86
  v-model="password"
76
87
  type="password"
77
- class="field-input"
88
+ class="cpub-field-input"
78
89
  autocomplete="new-password"
79
90
  required
80
91
  placeholder="At least 8 characters"
@@ -82,20 +93,20 @@ async function handleSubmit(): Promise<void> {
82
93
  />
83
94
  </div>
84
95
 
85
- <div class="field">
86
- <label for="confirm" class="field-label">Confirm Password</label>
96
+ <div class="cpub-field">
97
+ <label for="confirm" class="cpub-field-label">Confirm Password</label>
87
98
  <input
88
99
  id="confirm"
89
100
  v-model="confirmPassword"
90
101
  type="password"
91
- class="field-input"
102
+ class="cpub-field-input"
92
103
  autocomplete="new-password"
93
104
  required
94
105
  placeholder="Confirm your password"
95
106
  />
96
107
  </div>
97
108
 
98
- <button type="submit" class="submit-btn" :disabled="loading">
109
+ <button type="submit" class="cpub-submit-btn" :disabled="loading">
99
110
  {{ loading ? 'Resetting...' : 'Reset Password' }}
100
111
  </button>
101
112
  </form>
@@ -104,21 +115,22 @@ async function handleSubmit(): Promise<void> {
104
115
  </template>
105
116
 
106
117
  <style scoped>
107
- .reset-page { width: 100%; }
108
- .reset-title { font-size: 18px; font-weight: 600; margin-bottom: var(--space-3); }
109
- .reset-desc { font-size: 13px; color: var(--text-dim); margin-bottom: var(--space-5); line-height: 1.6; }
110
- .reset-form { display: flex; flex-direction: column; gap: var(--space-4); }
111
- .reset-success { text-align: center; padding: var(--space-5) 0; }
112
- .reset-success-text { font-size: 13px; color: var(--text-dim); line-height: 1.6; }
113
- .back-link { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--accent); text-decoration: none; justify-content: center; margin-top: var(--space-4); }
114
- .back-link:hover { text-decoration: underline; }
115
- .form-error { padding: var(--space-3); background: var(--red-bg); color: var(--red); border: var(--border-width-default) solid var(--red); border-radius: var(--radius); font-size: 12px; }
116
- .field { display: flex; flex-direction: column; gap: 4px; }
117
- .field-label { font-size: 12px; font-weight: 500; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-dim); }
118
- .field-input { padding: 8px 12px; border: var(--border-width-default) solid var(--border); border-radius: var(--radius); background: var(--surface); color: var(--text); font-size: 13px; font-family: var(--font-sans); outline: none; width: 100%; transition: border-color 0.15s; }
119
- .field-input::placeholder { color: var(--text-faint); }
120
- .field-input:focus { border-color: var(--accent); }
121
- .submit-btn { padding: 7px 14px; background: var(--accent); color: var(--color-text-inverse); border: var(--border-width-default) solid var(--accent); border-radius: var(--radius); font-size: 13px; font-weight: 500; cursor: pointer; box-shadow: var(--shadow-sm); transition: all 0.15s; }
122
- .submit-btn:hover:not(:disabled) { box-shadow: var(--shadow-md); transform: translate(-1px, -1px); }
123
- .submit-btn:disabled { opacity: 0.7; cursor: not-allowed; }
118
+ .cpub-reset-page { width: 100%; }
119
+ .cpub-reset-title { font-size: 18px; font-weight: 600; margin-bottom: var(--space-3); }
120
+ .cpub-reset-desc { font-size: 13px; color: var(--text-dim); margin-bottom: var(--space-5); line-height: 1.6; }
121
+ .cpub-reset-form { display: flex; flex-direction: column; gap: var(--space-4); }
122
+ .cpub-reset-success { text-align: center; padding: var(--space-5) 0; }
123
+ .cpub-reset-success-text { font-size: 13px; color: var(--text-dim); line-height: 1.6; }
124
+ .cpub-back-link { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--accent); text-decoration: none; justify-content: center; margin-top: var(--space-4); }
125
+ .cpub-back-link:hover { text-decoration: underline; }
126
+ .cpub-form-error { padding: var(--space-3); background: var(--red-bg); color: var(--red); border: var(--border-width-default) solid var(--red); border-radius: var(--radius); font-size: 12px; }
127
+ .cpub-field { display: flex; flex-direction: column; gap: 4px; }
128
+ .cpub-field-label { font-size: 12px; font-weight: 500; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-dim); }
129
+ .cpub-field-input { padding: 8px 12px; border: var(--border-width-default) solid var(--border); border-radius: var(--radius); background: var(--surface); color: var(--text); font-size: 13px; font-family: var(--font-sans); outline: none; width: 100%; transition: border-color 0.15s; }
130
+ .cpub-field-input::placeholder { color: var(--text-faint); }
131
+ .cpub-field-input:focus { border-color: var(--accent); }
132
+ .cpub-submit-btn { padding: 7px 14px; background: var(--accent); color: var(--color-text-inverse); border: var(--border-width-default) solid var(--accent); border-radius: var(--radius); font-size: 13px; font-weight: 500; cursor: pointer; box-shadow: var(--shadow-sm); transition: all 0.15s; }
133
+ .cpub-submit-btn:hover:not(:disabled) { box-shadow: var(--shadow-md); transform: translate(-1px, -1px); }
134
+ .cpub-submit-btn:disabled { opacity: 0.7; cursor: not-allowed; }
135
+ .cpub-reset-error-state { text-align: center; padding: var(--space-5) 0; }
124
136
  </style>