@jant/core 0.3.25 → 0.3.26

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 (131) hide show
  1. package/dist/app.js +67 -562
  2. package/dist/client.js +1 -0
  3. package/dist/i18n/locales/en.js +1 -1
  4. package/dist/i18n/locales/zh-Hans.js +1 -1
  5. package/dist/i18n/locales/zh-Hant.js +1 -1
  6. package/dist/lib/avatar-upload.js +134 -0
  7. package/dist/lib/config.js +39 -0
  8. package/dist/lib/constants.js +10 -10
  9. package/dist/lib/favicon.js +102 -0
  10. package/dist/lib/image.js +13 -17
  11. package/dist/lib/media-helpers.js +2 -2
  12. package/dist/lib/navigation.js +23 -3
  13. package/dist/lib/render.js +10 -1
  14. package/dist/lib/schemas.js +31 -0
  15. package/dist/lib/timezones.js +388 -0
  16. package/dist/lib/view.js +1 -1
  17. package/dist/routes/api/posts.js +1 -1
  18. package/dist/routes/api/upload.js +3 -3
  19. package/dist/routes/auth/reset.js +221 -0
  20. package/dist/routes/auth/setup.js +194 -0
  21. package/dist/routes/auth/signin.js +176 -0
  22. package/dist/routes/dash/collections.js +23 -415
  23. package/dist/routes/dash/media.js +12 -392
  24. package/dist/routes/dash/pages.js +7 -330
  25. package/dist/routes/dash/redirects.js +18 -12
  26. package/dist/routes/dash/settings.js +198 -577
  27. package/dist/routes/feed/rss.js +2 -1
  28. package/dist/routes/feed/sitemap.js +4 -2
  29. package/dist/routes/pages/featured.js +5 -1
  30. package/dist/routes/pages/home.js +26 -1
  31. package/dist/routes/pages/latest.js +45 -0
  32. package/dist/services/post.js +30 -50
  33. package/dist/types/bindings.js +3 -0
  34. package/dist/types/config.js +147 -0
  35. package/dist/types/constants.js +27 -0
  36. package/dist/types/entities.js +3 -0
  37. package/dist/types/operations.js +3 -0
  38. package/dist/types/props.js +3 -0
  39. package/dist/types/views.js +5 -0
  40. package/dist/types.js +8 -111
  41. package/dist/ui/color-themes.js +33 -33
  42. package/dist/ui/compose/ComposeDialog.js +36 -21
  43. package/dist/ui/dash/PageForm.js +21 -15
  44. package/dist/ui/dash/PostForm.js +22 -16
  45. package/dist/ui/dash/collections/CollectionForm.js +152 -0
  46. package/dist/ui/dash/collections/CollectionsListContent.js +68 -0
  47. package/dist/ui/dash/collections/ViewCollectionContent.js +96 -0
  48. package/dist/ui/dash/media/MediaListContent.js +166 -0
  49. package/dist/ui/dash/media/ViewMediaContent.js +212 -0
  50. package/dist/ui/dash/pages/LinkFormContent.js +130 -0
  51. package/dist/ui/dash/pages/UnifiedPagesContent.js +193 -0
  52. package/dist/ui/dash/settings/AccountContent.js +209 -0
  53. package/dist/ui/dash/settings/AppearanceContent.js +259 -0
  54. package/dist/ui/dash/settings/GeneralContent.js +536 -0
  55. package/dist/ui/dash/settings/SettingsNav.js +41 -0
  56. package/dist/ui/font-themes.js +36 -0
  57. package/dist/ui/layouts/BaseLayout.js +24 -2
  58. package/dist/ui/layouts/SiteLayout.js +47 -19
  59. package/package.json +1 -1
  60. package/src/app.tsx +93 -553
  61. package/src/client.ts +1 -0
  62. package/src/i18n/locales/en.po +240 -175
  63. package/src/i18n/locales/en.ts +1 -1
  64. package/src/i18n/locales/zh-Hans.po +240 -175
  65. package/src/i18n/locales/zh-Hans.ts +1 -1
  66. package/src/i18n/locales/zh-Hant.po +240 -175
  67. package/src/i18n/locales/zh-Hant.ts +1 -1
  68. package/src/lib/__tests__/config.test.ts +192 -0
  69. package/src/lib/__tests__/favicon.test.ts +151 -0
  70. package/src/lib/__tests__/image.test.ts +2 -6
  71. package/src/lib/__tests__/timezones.test.ts +61 -0
  72. package/src/lib/__tests__/view.test.ts +2 -2
  73. package/src/lib/avatar-upload.ts +165 -0
  74. package/src/lib/config.ts +47 -0
  75. package/src/lib/constants.ts +19 -11
  76. package/src/lib/favicon.ts +115 -0
  77. package/src/lib/image.ts +13 -21
  78. package/src/lib/media-helpers.ts +2 -2
  79. package/src/lib/navigation.ts +33 -2
  80. package/src/lib/render.tsx +15 -1
  81. package/src/lib/schemas.ts +39 -0
  82. package/src/lib/timezones.ts +325 -0
  83. package/src/lib/view.ts +1 -1
  84. package/src/routes/api/posts.ts +1 -1
  85. package/src/routes/api/upload.ts +2 -3
  86. package/src/routes/auth/reset.tsx +239 -0
  87. package/src/routes/auth/setup.tsx +189 -0
  88. package/src/routes/auth/signin.tsx +163 -0
  89. package/src/routes/dash/__tests__/settings-avatar.test.ts +89 -0
  90. package/src/routes/dash/collections.tsx +17 -366
  91. package/src/routes/dash/media.tsx +12 -414
  92. package/src/routes/dash/pages.tsx +8 -348
  93. package/src/routes/dash/redirects.tsx +20 -14
  94. package/src/routes/dash/settings.tsx +243 -534
  95. package/src/routes/feed/__tests__/rss.test.ts +141 -0
  96. package/src/routes/feed/rss.ts +3 -1
  97. package/src/routes/feed/sitemap.ts +4 -2
  98. package/src/routes/pages/featured.tsx +7 -1
  99. package/src/routes/pages/home.tsx +25 -2
  100. package/src/routes/pages/latest.tsx +59 -0
  101. package/src/services/post.ts +34 -66
  102. package/src/styles/components.css +0 -65
  103. package/src/styles/tokens.css +1 -1
  104. package/src/styles/ui.css +24 -40
  105. package/src/types/bindings.ts +30 -0
  106. package/src/types/config.ts +183 -0
  107. package/src/types/constants.ts +26 -0
  108. package/src/types/entities.ts +109 -0
  109. package/src/types/operations.ts +88 -0
  110. package/src/types/props.ts +115 -0
  111. package/src/types/views.ts +172 -0
  112. package/src/types.ts +8 -644
  113. package/src/ui/__tests__/font-themes.test.ts +34 -0
  114. package/src/ui/color-themes.ts +34 -34
  115. package/src/ui/compose/ComposeDialog.tsx +40 -21
  116. package/src/ui/dash/PageForm.tsx +25 -19
  117. package/src/ui/dash/PostForm.tsx +26 -20
  118. package/src/ui/dash/collections/CollectionForm.tsx +153 -0
  119. package/src/ui/dash/collections/CollectionsListContent.tsx +85 -0
  120. package/src/ui/dash/collections/ViewCollectionContent.tsx +92 -0
  121. package/src/ui/dash/media/MediaListContent.tsx +201 -0
  122. package/src/ui/dash/media/ViewMediaContent.tsx +208 -0
  123. package/src/ui/dash/pages/LinkFormContent.tsx +119 -0
  124. package/src/ui/dash/pages/UnifiedPagesContent.tsx +203 -0
  125. package/src/ui/dash/settings/AccountContent.tsx +176 -0
  126. package/src/ui/dash/settings/AppearanceContent.tsx +254 -0
  127. package/src/ui/dash/settings/GeneralContent.tsx +533 -0
  128. package/src/ui/dash/settings/SettingsNav.tsx +56 -0
  129. package/src/ui/font-themes.ts +54 -0
  130. package/src/ui/layouts/BaseLayout.tsx +17 -0
  131. package/src/ui/layouts/SiteLayout.tsx +45 -31
@@ -0,0 +1,325 @@
1
+ /**
2
+ * Timezone List
3
+ *
4
+ * Provides a curated list of timezones for the settings UI
5
+ * and a helper to map IANA timezone names to our list entries.
6
+ */
7
+
8
+ export interface TimezoneEntry {
9
+ /** Display value stored in settings */
10
+ value: string;
11
+ /** Human-readable label for the select UI */
12
+ label: string;
13
+ /** UTC offset string, e.g. "+08:00" */
14
+ offset: string;
15
+ /** IANA timezone names that map to this entry */
16
+ iana: string[];
17
+ }
18
+
19
+ export const TIMEZONES: TimezoneEntry[] = [
20
+ {
21
+ value: "International Date Line West",
22
+ label: "(UTC-12:00) International Date Line West",
23
+ offset: "-12:00",
24
+ iana: ["Etc/GMT+12"],
25
+ },
26
+ {
27
+ value: "American Samoa",
28
+ label: "(UTC-11:00) American Samoa",
29
+ offset: "-11:00",
30
+ iana: ["Pacific/Pago_Pago", "Pacific/Midway", "Etc/GMT+11"],
31
+ },
32
+ {
33
+ value: "Hawaii",
34
+ label: "(UTC-10:00) Hawaii",
35
+ offset: "-10:00",
36
+ iana: ["Pacific/Honolulu", "Etc/GMT+10"],
37
+ },
38
+ {
39
+ value: "Alaska",
40
+ label: "(UTC-09:00) Alaska",
41
+ offset: "-09:00",
42
+ iana: ["America/Anchorage", "America/Juneau", "America/Nome"],
43
+ },
44
+ {
45
+ value: "Pacific Time (US & Canada)",
46
+ label: "(UTC-08:00) Pacific Time (US & Canada)",
47
+ offset: "-08:00",
48
+ iana: ["America/Los_Angeles", "America/Vancouver", "America/Tijuana"],
49
+ },
50
+ {
51
+ value: "Mountain Time (US & Canada)",
52
+ label: "(UTC-07:00) Mountain Time (US & Canada)",
53
+ offset: "-07:00",
54
+ iana: [
55
+ "America/Denver",
56
+ "America/Edmonton",
57
+ "America/Phoenix",
58
+ "America/Boise",
59
+ ],
60
+ },
61
+ {
62
+ value: "Central Time (US & Canada)",
63
+ label: "(UTC-06:00) Central Time (US & Canada)",
64
+ offset: "-06:00",
65
+ iana: [
66
+ "America/Chicago",
67
+ "America/Winnipeg",
68
+ "America/Mexico_City",
69
+ "America/Guatemala",
70
+ ],
71
+ },
72
+ {
73
+ value: "Eastern Time (US & Canada)",
74
+ label: "(UTC-05:00) Eastern Time (US & Canada)",
75
+ offset: "-05:00",
76
+ iana: [
77
+ "America/New_York",
78
+ "America/Toronto",
79
+ "America/Detroit",
80
+ "America/Indiana/Indianapolis",
81
+ "America/Bogota",
82
+ "America/Lima",
83
+ ],
84
+ },
85
+ {
86
+ value: "Atlantic Time (Canada)",
87
+ label: "(UTC-04:00) Atlantic Time (Canada)",
88
+ offset: "-04:00",
89
+ iana: [
90
+ "America/Halifax",
91
+ "America/Caracas",
92
+ "America/Santiago",
93
+ "America/La_Paz",
94
+ ],
95
+ },
96
+ {
97
+ value: "Newfoundland",
98
+ label: "(UTC-03:30) Newfoundland",
99
+ offset: "-03:30",
100
+ iana: ["America/St_Johns"],
101
+ },
102
+ {
103
+ value: "Buenos Aires",
104
+ label: "(UTC-03:00) Buenos Aires",
105
+ offset: "-03:00",
106
+ iana: [
107
+ "America/Argentina/Buenos_Aires",
108
+ "America/Sao_Paulo",
109
+ "America/Montevideo",
110
+ ],
111
+ },
112
+ {
113
+ value: "Mid-Atlantic",
114
+ label: "(UTC-02:00) Mid-Atlantic",
115
+ offset: "-02:00",
116
+ iana: ["Atlantic/South_Georgia", "Etc/GMT+2"],
117
+ },
118
+ {
119
+ value: "Azores",
120
+ label: "(UTC-01:00) Azores",
121
+ offset: "-01:00",
122
+ iana: ["Atlantic/Azores", "Atlantic/Cape_Verde"],
123
+ },
124
+ {
125
+ value: "UTC",
126
+ label: "(UTC+00:00) UTC",
127
+ offset: "+00:00",
128
+ iana: ["Etc/UTC", "UTC", "Etc/GMT", "Africa/Accra"],
129
+ },
130
+ {
131
+ value: "London",
132
+ label: "(UTC+00:00) London",
133
+ offset: "+00:00",
134
+ iana: ["Europe/London", "Europe/Dublin", "Europe/Lisbon"],
135
+ },
136
+ {
137
+ value: "Central European Time",
138
+ label: "(UTC+01:00) Central European Time",
139
+ offset: "+01:00",
140
+ iana: [
141
+ "Europe/Paris",
142
+ "Europe/Berlin",
143
+ "Europe/Amsterdam",
144
+ "Europe/Rome",
145
+ "Europe/Madrid",
146
+ "Europe/Brussels",
147
+ "Europe/Vienna",
148
+ "Europe/Warsaw",
149
+ "Europe/Prague",
150
+ "Europe/Stockholm",
151
+ "Europe/Oslo",
152
+ "Europe/Copenhagen",
153
+ "Europe/Zurich",
154
+ "Africa/Lagos",
155
+ ],
156
+ },
157
+ {
158
+ value: "Eastern European Time",
159
+ label: "(UTC+02:00) Eastern European Time",
160
+ offset: "+02:00",
161
+ iana: [
162
+ "Europe/Helsinki",
163
+ "Europe/Athens",
164
+ "Europe/Bucharest",
165
+ "Europe/Istanbul",
166
+ "Africa/Cairo",
167
+ "Africa/Johannesburg",
168
+ "Asia/Jerusalem",
169
+ "Asia/Beirut",
170
+ ],
171
+ },
172
+ {
173
+ value: "Moscow",
174
+ label: "(UTC+03:00) Moscow",
175
+ offset: "+03:00",
176
+ iana: [
177
+ "Europe/Moscow",
178
+ "Europe/Minsk",
179
+ "Asia/Baghdad",
180
+ "Asia/Riyadh",
181
+ "Africa/Nairobi",
182
+ "Asia/Kuwait",
183
+ ],
184
+ },
185
+ {
186
+ value: "Tehran",
187
+ label: "(UTC+03:30) Tehran",
188
+ offset: "+03:30",
189
+ iana: ["Asia/Tehran"],
190
+ },
191
+ {
192
+ value: "Dubai",
193
+ label: "(UTC+04:00) Dubai",
194
+ offset: "+04:00",
195
+ iana: ["Asia/Dubai", "Asia/Muscat", "Asia/Baku", "Asia/Tbilisi"],
196
+ },
197
+ {
198
+ value: "Kabul",
199
+ label: "(UTC+04:30) Kabul",
200
+ offset: "+04:30",
201
+ iana: ["Asia/Kabul"],
202
+ },
203
+ {
204
+ value: "Karachi",
205
+ label: "(UTC+05:00) Karachi",
206
+ offset: "+05:00",
207
+ iana: ["Asia/Karachi", "Asia/Tashkent", "Asia/Yekaterinburg"],
208
+ },
209
+ {
210
+ value: "Mumbai",
211
+ label: "(UTC+05:30) Mumbai",
212
+ offset: "+05:30",
213
+ iana: ["Asia/Kolkata", "Asia/Calcutta", "Asia/Colombo"],
214
+ },
215
+ {
216
+ value: "Kathmandu",
217
+ label: "(UTC+05:45) Kathmandu",
218
+ offset: "+05:45",
219
+ iana: ["Asia/Kathmandu"],
220
+ },
221
+ {
222
+ value: "Dhaka",
223
+ label: "(UTC+06:00) Dhaka",
224
+ offset: "+06:00",
225
+ iana: ["Asia/Dhaka", "Asia/Almaty", "Asia/Omsk"],
226
+ },
227
+ {
228
+ value: "Yangon",
229
+ label: "(UTC+06:30) Yangon",
230
+ offset: "+06:30",
231
+ iana: ["Asia/Yangon", "Asia/Rangoon"],
232
+ },
233
+ {
234
+ value: "Bangkok",
235
+ label: "(UTC+07:00) Bangkok",
236
+ offset: "+07:00",
237
+ iana: [
238
+ "Asia/Bangkok",
239
+ "Asia/Jakarta",
240
+ "Asia/Ho_Chi_Minh",
241
+ "Asia/Krasnoyarsk",
242
+ ],
243
+ },
244
+ {
245
+ value: "Beijing",
246
+ label: "(UTC+08:00) Beijing",
247
+ offset: "+08:00",
248
+ iana: [
249
+ "Asia/Shanghai",
250
+ "Asia/Hong_Kong",
251
+ "Asia/Taipei",
252
+ "Asia/Singapore",
253
+ "Asia/Kuala_Lumpur",
254
+ "Asia/Makassar",
255
+ "Asia/Irkutsk",
256
+ "Australia/Perth",
257
+ ],
258
+ },
259
+ {
260
+ value: "Tokyo",
261
+ label: "(UTC+09:00) Tokyo",
262
+ offset: "+09:00",
263
+ iana: ["Asia/Tokyo", "Asia/Seoul", "Asia/Yakutsk"],
264
+ },
265
+ {
266
+ value: "Adelaide",
267
+ label: "(UTC+09:30) Adelaide",
268
+ offset: "+09:30",
269
+ iana: ["Australia/Adelaide", "Australia/Darwin"],
270
+ },
271
+ {
272
+ value: "Sydney",
273
+ label: "(UTC+10:00) Sydney",
274
+ offset: "+10:00",
275
+ iana: [
276
+ "Australia/Sydney",
277
+ "Australia/Melbourne",
278
+ "Australia/Brisbane",
279
+ "Australia/Hobart",
280
+ "Pacific/Guam",
281
+ "Asia/Vladivostok",
282
+ ],
283
+ },
284
+ {
285
+ value: "Noumea",
286
+ label: "(UTC+11:00) Noumea",
287
+ offset: "+11:00",
288
+ iana: ["Pacific/Noumea", "Asia/Magadan", "Pacific/Guadalcanal"],
289
+ },
290
+ {
291
+ value: "Auckland",
292
+ label: "(UTC+12:00) Auckland",
293
+ offset: "+12:00",
294
+ iana: ["Pacific/Auckland", "Pacific/Fiji", "Asia/Kamchatka"],
295
+ },
296
+ {
297
+ value: "Nuku'alofa",
298
+ label: "(UTC+13:00) Nuku'alofa",
299
+ offset: "+13:00",
300
+ iana: ["Pacific/Tongatapu", "Pacific/Apia"],
301
+ },
302
+ ];
303
+
304
+ /**
305
+ * Maps an IANA timezone name (e.g. "Asia/Shanghai") to our timezone list value
306
+ * (e.g. "Beijing"). Returns "UTC" if no match found.
307
+ *
308
+ * @param iana - IANA timezone identifier from Intl.DateTimeFormat
309
+ * @returns The matching timezone value from TIMEZONES
310
+ *
311
+ * @example
312
+ * ```ts
313
+ * mapIanaToTimezone("Asia/Shanghai"); // "Beijing"
314
+ * mapIanaToTimezone("America/New_York"); // "Eastern Time (US & Canada)"
315
+ * mapIanaToTimezone("Unknown/Zone"); // "UTC"
316
+ * ```
317
+ */
318
+ export function mapIanaToTimezone(iana: string): string {
319
+ for (const tz of TIMEZONES) {
320
+ if (tz.iana.includes(iana)) {
321
+ return tz.value;
322
+ }
323
+ }
324
+ return "UTC";
325
+ }
package/src/lib/view.ts CHANGED
@@ -77,7 +77,7 @@ export function toMediaView(media: Media, ctx: MediaContext): MediaView {
77
77
  ctx.r2PublicUrl,
78
78
  ctx.s3PublicUrl,
79
79
  );
80
- const url = getMediaUrl(media.id, media.storageKey, publicUrl);
80
+ const url = getMediaUrl(media.storageKey, publicUrl);
81
81
  const thumbnailUrl = getImageUrl(url, ctx.imageTransformUrl, {
82
82
  width: 400,
83
83
  quality: 80,
@@ -36,7 +36,7 @@ function toMediaAttachment(
36
36
  r2PublicUrl,
37
37
  s3PublicUrl,
38
38
  );
39
- const url = getMediaUrl(m.id, m.storageKey, publicUrl);
39
+ const url = getMediaUrl(m.storageKey, publicUrl);
40
40
  const previewUrl = getImageUrl(url, imageTransformUrl, {
41
41
  width: 400,
42
42
  quality: 80,
@@ -40,7 +40,7 @@ function renderMediaCard(
40
40
  publicUrl?: string,
41
41
  imageTransformUrl?: string,
42
42
  ): string {
43
- const fullUrl = getMediaUrl(media.id, media.storageKey, publicUrl);
43
+ const fullUrl = getMediaUrl(media.storageKey, publicUrl);
44
44
  const thumbnailUrl = getImageUrl(fullUrl, imageTransformUrl, {
45
45
  width: 300,
46
46
  quality: 80,
@@ -229,7 +229,7 @@ uploadApiRoutes.post("/", async (c) => {
229
229
  c.env.R2_PUBLIC_URL,
230
230
  c.env.S3_PUBLIC_URL,
231
231
  );
232
- const publicUrl = getMediaUrl(media.id, storageKey, mediaPublicUrl);
232
+ const publicUrl = getMediaUrl(storageKey, mediaPublicUrl);
233
233
  return c.json({
234
234
  id: media.id,
235
235
  filename: media.filename,
@@ -263,7 +263,6 @@ uploadApiRoutes.get("/", async (c) => {
263
263
  id: m.id,
264
264
  filename: m.filename,
265
265
  url: getMediaUrl(
266
- m.id,
267
266
  m.storageKey,
268
267
  getPublicUrlForProvider(m.provider, r2PublicUrl, s3PublicUrl),
269
268
  ),
@@ -0,0 +1,239 @@
1
+ /**
2
+ * Password Reset Routes
3
+ *
4
+ * One-time token-based password reset flow.
5
+ */
6
+
7
+ import { Hono } from "hono";
8
+ import type { FC } from "hono/jsx";
9
+ import { useLingui } from "@lingui/react/macro";
10
+ import { hashPassword } from "better-auth/crypto";
11
+ import type { Bindings } from "../../types.js";
12
+ import type { AppVariables } from "../../app.js";
13
+ import { BaseLayout } from "../../ui/layouts/BaseLayout.js";
14
+ import { dsRedirect, dsToast } from "../../lib/sse.js";
15
+ import { SETTINGS_KEYS } from "../../lib/constants.js";
16
+ import { ResetPasswordSchema } from "../../lib/schemas.js";
17
+
18
+ type Env = { Bindings: Bindings; Variables: AppVariables };
19
+
20
+ const ResetContent: FC<{ token: string }> = ({ token }) => {
21
+ const { t } = useLingui();
22
+ const signals = JSON.stringify({
23
+ password: "",
24
+ confirmPassword: "",
25
+ token,
26
+ }).replace(/</g, "\\u003c");
27
+
28
+ return (
29
+ <div class="min-h-screen flex items-center justify-center">
30
+ <div class="card max-w-md w-full">
31
+ <header>
32
+ <h2>
33
+ {t({
34
+ message: "Reset Password",
35
+ comment: "@context: Password reset page heading",
36
+ })}
37
+ </h2>
38
+ <p>
39
+ {t({
40
+ message: "Enter your new password.",
41
+ comment: "@context: Password reset page description",
42
+ })}
43
+ </p>
44
+ </header>
45
+ <section>
46
+ <form
47
+ data-signals={signals}
48
+ data-on:submit__prevent="@post('/reset')"
49
+ data-indicator="_loading"
50
+ class="flex flex-col gap-4"
51
+ >
52
+ <div class="field">
53
+ <label class="label">
54
+ {t({
55
+ message: "New Password",
56
+ comment: "@context: Password reset form field",
57
+ })}
58
+ </label>
59
+ <input
60
+ type="password"
61
+ data-bind="password"
62
+ class="input"
63
+ required
64
+ minLength={8}
65
+ autocomplete="new-password"
66
+ />
67
+ </div>
68
+ <div class="field">
69
+ <label class="label">
70
+ {t({
71
+ message: "Confirm Password",
72
+ comment: "@context: Password reset form field",
73
+ })}
74
+ </label>
75
+ <input
76
+ type="password"
77
+ data-bind="confirmPassword"
78
+ class="input"
79
+ required
80
+ minLength={8}
81
+ autocomplete="new-password"
82
+ />
83
+ </div>
84
+ <button type="submit" class="btn" data-attr:disabled="$_loading">
85
+ <svg
86
+ data-show="$_loading"
87
+ style="display:none"
88
+ class="animate-spin size-4"
89
+ xmlns="http://www.w3.org/2000/svg"
90
+ viewBox="0 0 24 24"
91
+ fill="none"
92
+ stroke="currentColor"
93
+ stroke-width="2"
94
+ stroke-linecap="round"
95
+ stroke-linejoin="round"
96
+ role="status"
97
+ >
98
+ <path d="M21 12a9 9 0 1 1-6.219-8.56" />
99
+ </svg>
100
+ {t({
101
+ message: "Reset Password",
102
+ comment: "@context: Password reset form submit button",
103
+ })}
104
+ </button>
105
+ </form>
106
+ </section>
107
+ </div>
108
+ </div>
109
+ );
110
+ };
111
+
112
+ const ResetErrorContent: FC = () => {
113
+ const { t } = useLingui();
114
+
115
+ return (
116
+ <div class="min-h-screen flex items-center justify-center">
117
+ <div class="card max-w-md w-full">
118
+ <header>
119
+ <h2>
120
+ {t({
121
+ message: "Invalid or Expired Link",
122
+ comment: "@context: Password reset error heading",
123
+ })}
124
+ </h2>
125
+ </header>
126
+ <section>
127
+ <p class="text-muted-foreground">
128
+ {t({
129
+ message:
130
+ "This password reset link is invalid or has expired. Please generate a new one.",
131
+ comment: "@context: Password reset error description",
132
+ })}
133
+ </p>
134
+ </section>
135
+ </div>
136
+ </div>
137
+ );
138
+ };
139
+
140
+ /**
141
+ * Validate a password reset token against the stored value.
142
+ * Returns true if the token is valid and not expired.
143
+ */
144
+ async function validateResetToken(
145
+ settings: { get(key: string): Promise<string | null> },
146
+ token: string,
147
+ ): Promise<boolean> {
148
+ const stored = await settings.get(SETTINGS_KEYS.PASSWORD_RESET_TOKEN);
149
+ if (!stored) return false;
150
+
151
+ const separatorIndex = stored.lastIndexOf(":");
152
+ const storedToken = stored.substring(0, separatorIndex);
153
+ const expiry = parseInt(stored.substring(separatorIndex + 1), 10);
154
+ const now = Math.floor(Date.now() / 1000);
155
+
156
+ return token === storedToken && now <= expiry;
157
+ }
158
+
159
+ export const resetRoutes = new Hono<Env>();
160
+
161
+ resetRoutes.get("/reset", async (c) => {
162
+ const token = c.req.query("token");
163
+ if (!token) {
164
+ return c.html(
165
+ <BaseLayout title="Reset Password - Jant" c={c}>
166
+ <ResetErrorContent />
167
+ </BaseLayout>,
168
+ );
169
+ }
170
+
171
+ const isValid = await validateResetToken(c.var.services.settings, token);
172
+ if (!isValid) {
173
+ return c.html(
174
+ <BaseLayout title="Reset Password - Jant" c={c}>
175
+ <ResetErrorContent />
176
+ </BaseLayout>,
177
+ );
178
+ }
179
+
180
+ return c.html(
181
+ <BaseLayout title="Reset Password - Jant" c={c}>
182
+ <ResetContent token={token} />
183
+ </BaseLayout>,
184
+ );
185
+ });
186
+
187
+ resetRoutes.post("/reset", async (c) => {
188
+ const body = await c.req.json();
189
+ const parsed = ResetPasswordSchema.safeParse(body);
190
+
191
+ if (!parsed.success) {
192
+ const msg = parsed.error.errors[0]?.message ?? "Invalid input";
193
+ return dsToast(msg, "error");
194
+ }
195
+
196
+ const { password, token } = parsed.data;
197
+
198
+ // Validate token
199
+ const isValid = await validateResetToken(c.var.services.settings, token);
200
+ if (!isValid) {
201
+ return dsToast("Invalid or expired reset link.", "error");
202
+ }
203
+
204
+ try {
205
+ const hashedPassword = await hashPassword(password);
206
+ const db = c.env.DB.withSession() as unknown as D1Database;
207
+
208
+ // Get admin user
209
+ const userResult = await db
210
+ .prepare("SELECT id FROM user LIMIT 1")
211
+ .first<{ id: string }>();
212
+ if (!userResult) {
213
+ return dsToast("No user account found.", "error");
214
+ }
215
+
216
+ // Update password
217
+ await db
218
+ .prepare(
219
+ "UPDATE account SET password = ? WHERE user_id = ? AND provider_id = 'credential'",
220
+ )
221
+ .bind(hashedPassword, userResult.id)
222
+ .run();
223
+
224
+ // Delete all sessions
225
+ await db
226
+ .prepare("DELETE FROM session WHERE user_id = ?")
227
+ .bind(userResult.id)
228
+ .run();
229
+
230
+ // Delete the reset token
231
+ await c.var.services.settings.remove(SETTINGS_KEYS.PASSWORD_RESET_TOKEN);
232
+
233
+ return dsRedirect("/signin?reset");
234
+ } catch (err) {
235
+ // eslint-disable-next-line no-console -- Error logging is intentional
236
+ console.error("Password reset error:", err);
237
+ return dsToast("Failed to reset password.", "error");
238
+ }
239
+ });